1#[cfg(not(target_os = "linux"))]
6use rustix::fs::openat;
7use rustix::fs::{
8 AtFlags, Dir, FileType, FlockOperation, Mode, OFlags, fchmod, flock, fstat, ftruncate, mkdirat,
9 open, renameat, statat, unlinkat,
10};
11#[cfg(any(target_os = "linux", target_os = "macos"))]
12use rustix::fs::{RenameFlags, renameat_with};
13#[cfg(target_os = "linux")]
14use rustix::fs::{ResolveFlags, StatxFlags, openat2};
15use rustix::process::geteuid;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256};
18use std::fmt;
19use std::fs::File;
20use std::io::{Read, Write};
21use std::os::fd::AsFd;
22use std::path::{Component, Path, PathBuf};
23use std::sync::Arc;
24#[cfg(test)]
25use std::sync::atomic::{AtomicUsize, Ordering};
26#[cfg(any(target_os = "linux", target_os = "macos"))]
27use std::time::{Duration, Instant};
28use std::time::{SystemTime, UNIX_EPOCH};
29use zeroize::Zeroizing;
30
31#[cfg(test)]
32use crate::local_credential_types::MAX_LABEL_BYTES;
33pub use crate::local_credential_types::{
34 CredentialDependencyImpact, CredentialHealthState, CredentialLifecycleApproval,
35 CredentialLifecycleOperation, CredentialLifecycleOutcome, CredentialLifecyclePreview,
36 CredentialLifecycleRequestIdentity, CredentialLifecycleState, CredentialMetadata,
37 CredentialRemovalReconciliation, CredentialRotationReconciliation, CredentialStatus,
38 CredentialStoreError, REMOVE_CONFIRMATION, ROTATE_CONFIRMATION,
39};
40use crate::local_credential_types::{
41 MAX_CREDENTIALS, MAX_CREDENTIALS_PER_PROVIDER, MAX_METADATA_BYTES, MAX_PROVIDER_BYTES,
42 MAX_SECRET_BYTES, REFERENCE_PREFIX, SCHEMA_VERSION, normalize_account_label, valid_descriptor,
43 valid_id, valid_sha256_id,
44};
45
46const ENTRIES_DIRECTORY: &str = "entries";
47const LIFECYCLE_DIRECTORY: &str = "lifecycle";
48const LOCK_FILE: &str = ".store.lock";
49const SECRET_FILE: &str = "secret";
50const METADATA_FILE: &str = "metadata.json";
51const NEXT_SECRET_FILE: &str = ".next-secret";
52const NEXT_METADATA_FILE: &str = ".next-metadata.json";
53#[cfg(any(target_os = "linux", target_os = "macos"))]
54const MAX_ACCOUNT_RECORD_BYTES: usize = 2048;
55#[cfg(target_os = "linux")]
56const MAX_LINUX_DATA_HOME_BYTES: usize = 4096;
57const MAX_PREVIEW_LIFETIME_SECONDS: u64 = 10 * 60;
58#[cfg(any(target_os = "linux", target_os = "macos"))]
59const ACCOUNT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
60#[cfg(any(target_os = "linux", target_os = "macos"))]
61const ACCOUNT_LOOKUP_POLL_INTERVAL: Duration = Duration::from_millis(5);
62#[cfg(any(target_os = "linux", target_os = "macos"))]
63const ACCOUNT_LOOKUP_CLEANUP_DEADLINE: Duration = Duration::from_millis(250);
64const FLOCK_EINTR_RETRY_BOUND: usize = 256;
65const FLOCK_EINTR_RETRY_SLEEP: Duration = Duration::from_millis(1);
66
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69struct CredentialLifecycleJournal {
70 schema_version: String,
71 operation: CredentialLifecycleOperation,
72 credential_id: String,
73 credential_ref: String,
74 expected_generation: u64,
75 target_generation: Option<u64>,
76 preview_digest: String,
77 expires_at_epoch_seconds: u64,
78 required_confirmation: String,
79 old_secret_identity: FileIdentity,
80 staged_secret_identity: Option<FileIdentity>,
81 state: CredentialLifecycleJournalState,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86enum CredentialLifecycleJournalState {
87 Prepared,
88 Committed,
89}
90
91impl CredentialLifecycleJournal {
92 fn validate(&self) -> Result<(), CredentialStoreError> {
93 if self.schema_version != SCHEMA_VERSION
94 || !valid_id(&self.credential_id)
95 || self.credential_ref != format!("{REFERENCE_PREFIX}{}", self.credential_id)
96 || self.expected_generation == 0
97 || !valid_sha256_id(&self.preview_digest)
98 || !valid_journal_secret_identity(self.old_secret_identity)
99 || self
100 .staged_secret_identity
101 .is_some_and(|identity| !valid_journal_secret_identity(identity))
102 || match self.operation {
103 CredentialLifecycleOperation::Rotate => {
104 self.required_confirmation != ROTATE_CONFIRMATION
105 || self.target_generation != self.expected_generation.checked_add(1)
106 || self.staged_secret_identity.is_none()
107 || self.staged_secret_identity == Some(self.old_secret_identity)
108 || self.staged_secret_identity.is_some_and(|identity| {
109 identity.device != self.old_secret_identity.device
110 })
111 }
112 CredentialLifecycleOperation::Remove => {
113 self.required_confirmation != REMOVE_CONFIRMATION
114 || self.target_generation.is_some()
115 || self.staged_secret_identity.is_some()
116 }
117 }
118 {
119 return Err(CredentialStoreError::CorruptState);
120 }
121 Ok(())
122 }
123}
124
125fn valid_journal_secret_identity(identity: FileIdentity) -> bool {
126 identity.device != 0
127 && identity.inode != 0
128 && identity.owner == geteuid().as_raw()
129 && identity.mode == 0o600
130 && identity.link_count == 1
131 && identity.birth_time.is_some()
132}
133
134#[derive(Clone)]
135pub struct LocalCredentialStore {
136 root_path: PathBuf,
137 root: Arc<File>,
138 root_identity: FileIdentity,
139 #[cfg(test)]
140 flock_eintr_injections: Arc<AtomicUsize>,
141}
142
143pub struct CredentialInventoryGuard {
147 store: LocalCredentialStore,
148 _locked: LockedRoot,
149 entries: File,
150 credentials: Vec<CredentialMetadata>,
151}
152
153impl CredentialInventoryGuard {
154 pub fn credentials(&self) -> &[CredentialMetadata] {
155 &self.credentials
156 }
157
158 pub fn rotate_preview_with_bindings(
159 &self,
160 credential_ref: &str,
161 bound_phases: &[String],
162 bound_routes: &[String],
163 expires_at_epoch_seconds: u64,
164 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
165 self.lifecycle_preview_with_bindings(
166 credential_ref,
167 CredentialLifecycleOperation::Rotate,
168 bound_phases,
169 bound_routes,
170 current_epoch_seconds()?,
171 expires_at_epoch_seconds,
172 )
173 }
174
175 pub fn remove_preview_with_bindings(
176 &self,
177 credential_ref: &str,
178 bound_phases: &[String],
179 bound_routes: &[String],
180 expires_at_epoch_seconds: u64,
181 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
182 self.lifecycle_preview_with_bindings(
183 credential_ref,
184 CredentialLifecycleOperation::Remove,
185 bound_phases,
186 bound_routes,
187 current_epoch_seconds()?,
188 expires_at_epoch_seconds,
189 )
190 }
191
192 fn lifecycle_preview_with_bindings(
193 &self,
194 credential_ref: &str,
195 operation: CredentialLifecycleOperation,
196 bound_phases: &[String],
197 bound_routes: &[String],
198 now_epoch_seconds: u64,
199 expires_at_epoch_seconds: u64,
200 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
201 let id = parse_reference(credential_ref)?;
202 let mut metadata = self
203 .credentials
204 .iter()
205 .find(|metadata| metadata.id == id && metadata.credential_ref == credential_ref)
206 .cloned()
207 .ok_or(CredentialStoreError::NotFound)?;
208 if metadata.lifecycle_state == CredentialLifecycleState::Removed {
209 return Err(CredentialStoreError::NotFound);
210 }
211 metadata.bound_phases = bound_phases.to_vec();
212 metadata.bound_routes = bound_routes.to_vec();
213 metadata.validate()?;
214 lifecycle_preview_for_metadata(
215 &metadata,
216 operation,
217 now_epoch_seconds,
218 expires_at_epoch_seconds,
219 )
220 }
221
222 fn metadata(&self, credential_ref: &str) -> Result<CredentialMetadata, CredentialStoreError> {
223 let id = parse_reference(credential_ref)?;
224 self.credentials
225 .iter()
226 .find(|metadata| metadata.id == id && metadata.credential_ref == credential_ref)
227 .cloned()
228 .ok_or(CredentialStoreError::NotFound)
229 }
230
231 pub fn reconcile_rotate(
232 &self,
233 credential_ref: &str,
234 request: CredentialLifecycleRequestIdentity<'_>,
235 ) -> Result<CredentialRotationReconciliation, CredentialStoreError> {
236 let id = parse_reference(credential_ref)?;
237 let lifecycle = open_or_create_private_directory(&self._locked.root, LIFECYCLE_DIRECTORY)?;
238 reconcile_rotate_request_locked(&self.entries, &lifecycle, id, credential_ref, request)
239 }
240
241 pub fn reconcile_remove(
242 &self,
243 credential_ref: &str,
244 request: CredentialLifecycleRequestIdentity<'_>,
245 ) -> Result<CredentialRemovalReconciliation, CredentialStoreError> {
246 let id = parse_reference(credential_ref)?;
247 let lifecycle = open_or_create_private_directory(&self._locked.root, LIFECYCLE_DIRECTORY)?;
248 reconcile_remove_request_locked(&self.entries, &lifecycle, id, credential_ref, request)
249 }
250
251 pub fn rotate_apply_with_bindings(
252 &self,
253 credential_ref: &str,
254 secret: &[u8],
255 approval: CredentialLifecycleApproval<'_>,
256 bound_phases: &[String],
257 bound_routes: &[String],
258 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
259 self.rotate_apply_with_bindings_at(
260 credential_ref,
261 secret,
262 approval,
263 bound_phases,
264 bound_routes,
265 current_epoch_seconds()?,
266 )
267 }
268
269 fn rotate_apply_with_bindings_at(
270 &self,
271 credential_ref: &str,
272 secret: &[u8],
273 approval: CredentialLifecycleApproval<'_>,
274 bound_phases: &[String],
275 bound_routes: &[String],
276 now_epoch_seconds: u64,
277 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
278 self.store.rotate_apply_locked(
279 &self._locked.root,
280 &self.entries,
281 credential_ref,
282 secret,
283 approval,
284 bound_phases,
285 bound_routes,
286 now_epoch_seconds,
287 )
288 }
289
290 pub fn remove_apply_with_bindings(
291 &self,
292 credential_ref: &str,
293 approval: CredentialLifecycleApproval<'_>,
294 bound_phases: &[String],
295 bound_routes: &[String],
296 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
297 self.remove_apply_with_bindings_at(
298 credential_ref,
299 approval,
300 bound_phases,
301 bound_routes,
302 current_epoch_seconds()?,
303 )
304 }
305
306 fn remove_apply_with_bindings_at(
307 &self,
308 credential_ref: &str,
309 approval: CredentialLifecycleApproval<'_>,
310 bound_phases: &[String],
311 bound_routes: &[String],
312 now_epoch_seconds: u64,
313 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
314 self.store.remove_apply_locked(
315 &self._locked.root,
316 &self.entries,
317 credential_ref,
318 approval,
319 bound_phases,
320 bound_routes,
321 now_epoch_seconds,
322 )
323 }
324
325 pub fn verify_available(&self, credential_ref: &str) -> Result<(), CredentialStoreError> {
328 let id = parse_reference(credential_ref)?;
329 let metadata = self
330 .credentials
331 .iter()
332 .find(|metadata| metadata.id == id && metadata.credential_ref == credential_ref)
333 .ok_or(CredentialStoreError::NotFound)?;
334 if metadata.lifecycle_state != CredentialLifecycleState::Active
335 || metadata.health != CredentialHealthState::LocallyVerified
336 || metadata.last_verified_at_epoch_seconds.is_none()
337 {
338 return Err(CredentialStoreError::NotFound);
339 }
340 let entry = open_private_directory(&self.entries, id)?;
341 let (_, size) = read_secret_or_tombstone_identity(&entry)?;
342 if size == 0 {
343 return Err(CredentialStoreError::NotFound);
344 }
345 Ok(())
346 }
347
348 pub fn status(&self, credential_ref: &str) -> Result<CredentialStatus, CredentialStoreError> {
350 let id = parse_reference(credential_ref)?;
351 let metadata = self
352 .credentials
353 .iter()
354 .find(|metadata| metadata.id == id && metadata.credential_ref == credential_ref)
355 .cloned()
356 .ok_or(CredentialStoreError::NotFound)?;
357 let entry = open_private_directory(&self.entries, id)?;
358 let status = match metadata.lifecycle_state {
359 CredentialLifecycleState::Active => {
360 self.verify_available(credential_ref)?;
361 "available"
362 }
363 CredentialLifecycleState::Removed => {
364 let _ = read_tombstone_identity(&entry)?;
365 "removed"
366 }
367 };
368 Ok(CredentialStatus { metadata, status })
369 }
370}
371
372impl fmt::Debug for LocalCredentialStore {
373 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
374 formatter
375 .debug_struct("LocalCredentialStore")
376 .field("root", &"[DEVICE_LOCAL]")
377 .finish()
378 }
379}
380
381impl LocalCredentialStore {
382 pub fn production_setup_root_path() -> Result<PathBuf, CredentialStoreError> {
385 #[cfg(target_os = "macos")]
386 {
387 let home = current_user_home_from_account_record()?;
388 validate_absolute_path(&home)?;
389 Ok(home
390 .join("Library")
391 .join("Application Support")
392 .join("SteadyMux")
393 .join("device-setup-v1"))
394 }
395 #[cfg(not(target_os = "macos"))]
396 {
397 Err(CredentialStoreError::UnsupportedPlatform)
398 }
399 }
400
401 pub fn ensure_production_setup_root() -> Result<PathBuf, CredentialStoreError> {
403 #[cfg(target_os = "macos")]
404 {
405 let home = current_user_home_from_account_record()?;
406 validate_absolute_path(&home)?;
407 let home_directory = open_path_directory(&home)?;
408 validate_owned_directory(
409 &fstat(&home_directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
410 )?;
411 let library = open_owned_directory(&home_directory, "Library")?;
412 let application_support = open_owned_directory(&library, "Application Support")?;
413 let product = open_or_create_private_directory(&application_support, "SteadyMux")?;
414 let _setup = open_or_create_private_directory(&product, "device-setup-v1")?;
415 Ok(home
416 .join("Library")
417 .join("Application Support")
418 .join("SteadyMux")
419 .join("device-setup-v1"))
420 }
421 #[cfg(not(target_os = "macos"))]
422 {
423 Err(CredentialStoreError::UnsupportedPlatform)
424 }
425 }
426
427 pub fn production() -> Result<Self, CredentialStoreError> {
429 #[cfg(target_os = "macos")]
430 {
431 let home = current_user_home_from_account_record()?;
432 let (root_path, root) = ensure_production_store(&home)?;
433 Self::open_verified(root_path, root)
434 }
435 #[cfg(target_os = "linux")]
436 {
437 let home = current_user_home_from_account_record()?;
438 let xdg_data_home = std::env::var_os("XDG_DATA_HOME");
439 let (root_path, root) = ensure_linux_production_store(&home, xdg_data_home.as_deref())?;
440 Self::open_verified(root_path, root)
441 }
442 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
443 {
444 Err(CredentialStoreError::UnsupportedPlatform)
445 }
446 }
447
448 pub fn production_existing() -> Result<Self, CredentialStoreError> {
450 #[cfg(target_os = "macos")]
451 {
452 let home = current_user_home_from_account_record()?;
453 let (root_path, root) = open_production_store(&home)?;
454 Self::open_verified_existing(root_path, root)
455 }
456 #[cfg(target_os = "linux")]
457 {
458 let home = current_user_home_from_account_record()?;
459 let xdg_data_home = std::env::var_os("XDG_DATA_HOME");
460 let (root_path, root) = open_linux_production_store(&home, xdg_data_home.as_deref())?;
461 Self::open_verified_existing(root_path, root)
462 }
463 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
464 {
465 Err(CredentialStoreError::UnsupportedPlatform)
466 }
467 }
468
469 #[cfg(test)]
470 fn at_test_root(root: &Path) -> Result<Self, CredentialStoreError> {
471 let temporary_root = std::env::temp_dir();
472 let relative = root
473 .strip_prefix(&temporary_root)
474 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
475 let normalized_root = temporary_root
476 .canonicalize()
477 .map_err(|_| CredentialStoreError::UnsafeStorage)?
478 .join(relative);
479 let boundary_path = temporary_root
480 .canonicalize()
481 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
482 let boundary = open_path_directory(&boundary_path)?;
483 let relative = normalized_root
484 .strip_prefix(&boundary_path)
485 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
486 let root = ensure_private_components(&boundary, relative)?;
487 Self::open_verified(normalized_root, root)
488 }
489
490 #[cfg(test)]
491 fn at_existing_test_root(root: &Path) -> Result<Self, CredentialStoreError> {
492 let temporary_root = std::env::temp_dir();
493 let relative = root
494 .strip_prefix(&temporary_root)
495 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
496 let normalized_root = temporary_root
497 .canonicalize()
498 .map_err(|_| CredentialStoreError::UnsafeStorage)?
499 .join(relative);
500 let root = open_path_directory(&normalized_root)?;
501 Self::open_verified_existing(normalized_root, root)
502 }
503
504 fn open_verified(root_path: PathBuf, root: File) -> Result<Self, CredentialStoreError> {
505 validate_absolute_path(&root_path)?;
506 let root_stat = fstat(&root).map_err(|_| CredentialStoreError::UnsafeStorage)?;
507 validate_directory(&root_stat)?;
508 let store = Self {
509 root_path,
510 root: Arc::new(root),
511 root_identity: identity(&root_stat)?,
512 #[cfg(test)]
513 flock_eintr_injections: Arc::new(AtomicUsize::new(0)),
514 };
515 let locked = store.lock()?;
516 let _ = open_or_create_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
517 Ok(store)
518 }
519
520 fn open_verified_existing(
521 root_path: PathBuf,
522 root: File,
523 ) -> Result<Self, CredentialStoreError> {
524 validate_absolute_path(&root_path)?;
525 let root_stat = fstat(&root).map_err(|_| CredentialStoreError::UnsafeStorage)?;
526 validate_directory(&root_stat)?;
527 let store = Self {
528 root_path,
529 root: Arc::new(root),
530 root_identity: identity(&root_stat)?,
531 #[cfg(test)]
532 flock_eintr_injections: Arc::new(AtomicUsize::new(0)),
533 };
534 let entries = open_private_directory(&store.root, ENTRIES_DIRECTORY)?;
535 drop(entries);
536 Ok(store)
537 }
538
539 pub fn add(
540 &self,
541 provider: &str,
542 label: &str,
543 secret: &[u8],
544 ) -> Result<CredentialMetadata, CredentialStoreError> {
545 let now_epoch_seconds = current_epoch_seconds()?;
546 self.add_at(provider, label, secret, now_epoch_seconds)
547 }
548
549 fn add_at(
550 &self,
551 provider: &str,
552 label: &str,
553 secret: &[u8],
554 now_epoch_seconds: u64,
555 ) -> Result<CredentialMetadata, CredentialStoreError> {
556 let label = normalize_account_label(label)?;
557 if !valid_descriptor(provider, MAX_PROVIDER_BYTES) || !valid_secret(secret) {
558 return Err(CredentialStoreError::InvalidInput);
559 }
560 let locked = self.lock()?;
561 let entries = open_or_create_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
562 recover_add_staging(&entries)?;
563 ensure_store_needs_no_lifecycle_recovery(&locked.root, &entries)?;
564 let existing = self.list_locked(&entries)?;
565 if existing.len() >= MAX_CREDENTIALS
566 || existing
567 .iter()
568 .filter(|metadata| metadata.provider == provider)
569 .count()
570 >= MAX_CREDENTIALS_PER_PROVIDER
571 {
572 return Err(CredentialStoreError::CapacityExhausted);
573 }
574 if existing
575 .iter()
576 .any(|metadata| metadata.provider == provider && metadata.label == label)
577 {
578 return Err(CredentialStoreError::InvalidInput);
579 }
580 let id = generate_unused_id(&entries)?;
581 let metadata = CredentialMetadata {
582 schema_version: SCHEMA_VERSION.to_owned(),
583 credential_ref: format!("{REFERENCE_PREFIX}{id}"),
584 id: id.clone(),
585 provider: provider.to_owned(),
586 label,
587 generation: 1,
588 created_at_epoch_seconds: now_epoch_seconds,
589 rotated_at_epoch_seconds: None,
590 previous_generation: None,
591 last_lifecycle_preview_digest: None,
592 lifecycle_state: CredentialLifecycleState::Active,
593 health: CredentialHealthState::LocallyVerified,
594 last_verified_at_epoch_seconds: Some(now_epoch_seconds),
595 bound_phases: Vec::new(),
596 bound_routes: Vec::new(),
597 };
598 let metadata_bytes =
599 serde_json::to_vec(&metadata).map_err(|_| CredentialStoreError::CommitAmbiguous)?;
600 let staging = format!(".staging-{id}");
601 mkdirat(&entries, &staging, Mode::from_raw_mode(0o700))
602 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
603 let staged = open_private_directory(&entries, &staging)?;
604 let mut committed = false;
605 let result = (|| {
606 write_new_file(&staged, SECRET_FILE, secret)?;
607 write_new_file(&staged, METADATA_FILE, &metadata_bytes)?;
608 durable_sync(&staged)?;
609 rename_new_entry(&entries, &staging, &id)?;
610 committed = true;
611 durable_sync(&entries)?;
612 let committed = self.read_metadata(&entries, &id)?;
613 if committed != metadata {
614 return Err(CredentialStoreError::CommitAmbiguous);
615 }
616 Ok(metadata.clone())
617 })();
618 if result.is_err() {
619 cleanup_failed_add(&entries, &staged, &staging, committed);
620 }
621 result
622 }
623
624 pub fn rotate_preview(
625 &self,
626 credential_ref: &str,
627 expires_at_epoch_seconds: u64,
628 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
629 self.rotate_preview_at(
630 credential_ref,
631 current_epoch_seconds()?,
632 expires_at_epoch_seconds,
633 )
634 }
635
636 fn rotate_preview_at(
637 &self,
638 credential_ref: &str,
639 now_epoch_seconds: u64,
640 expires_at_epoch_seconds: u64,
641 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
642 self.lifecycle_preview(
643 credential_ref,
644 CredentialLifecycleOperation::Rotate,
645 now_epoch_seconds,
646 expires_at_epoch_seconds,
647 )
648 }
649
650 pub fn remove_preview(
651 &self,
652 credential_ref: &str,
653 expires_at_epoch_seconds: u64,
654 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
655 self.remove_preview_at(
656 credential_ref,
657 current_epoch_seconds()?,
658 expires_at_epoch_seconds,
659 )
660 }
661
662 fn remove_preview_at(
663 &self,
664 credential_ref: &str,
665 now_epoch_seconds: u64,
666 expires_at_epoch_seconds: u64,
667 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
668 self.lifecycle_preview(
669 credential_ref,
670 CredentialLifecycleOperation::Remove,
671 now_epoch_seconds,
672 expires_at_epoch_seconds,
673 )
674 }
675
676 pub fn reconcile_rotate(
682 &self,
683 credential_ref: &str,
684 request: CredentialLifecycleRequestIdentity<'_>,
685 ) -> Result<CredentialRotationReconciliation, CredentialStoreError> {
686 let id = parse_reference(credential_ref)?;
687 let locked = self.lock()?;
688 let entries = open_or_create_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
689 self.ensure_normalized_label_inventory(&entries)?;
690 recover_add_staging(&entries)?;
691 let lifecycle = open_or_create_private_directory(&locked.root, LIFECYCLE_DIRECTORY)?;
692 reconcile_rotate_request_locked(&entries, &lifecycle, id, credential_ref, request)
693 }
694
695 pub fn reconcile_remove(
700 &self,
701 credential_ref: &str,
702 request: CredentialLifecycleRequestIdentity<'_>,
703 ) -> Result<CredentialRemovalReconciliation, CredentialStoreError> {
704 let id = parse_reference(credential_ref)?;
705 let locked = self.lock()?;
706 let entries = open_or_create_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
707 self.ensure_normalized_label_inventory(&entries)?;
708 recover_add_staging(&entries)?;
709 let lifecycle = open_or_create_private_directory(&locked.root, LIFECYCLE_DIRECTORY)?;
710 reconcile_remove_request_locked(&entries, &lifecycle, id, credential_ref, request)
711 }
712
713 pub fn rotate_apply(
714 &self,
715 credential_ref: &str,
716 secret: &[u8],
717 approval: CredentialLifecycleApproval<'_>,
718 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
719 self.rotate_apply_at(credential_ref, secret, approval, current_epoch_seconds()?)
720 }
721
722 fn rotate_apply_at(
723 &self,
724 credential_ref: &str,
725 secret: &[u8],
726 approval: CredentialLifecycleApproval<'_>,
727 now_epoch_seconds: u64,
728 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
729 let guard = self.lock_lifecycle_inventory()?;
730 let metadata = guard.metadata(credential_ref)?;
731 guard.rotate_apply_with_bindings_at(
732 credential_ref,
733 secret,
734 approval,
735 &metadata.bound_phases,
736 &metadata.bound_routes,
737 now_epoch_seconds,
738 )
739 }
740
741 #[allow(clippy::too_many_arguments)]
742 fn rotate_apply_locked(
743 &self,
744 root: &File,
745 entries: &File,
746 credential_ref: &str,
747 secret: &[u8],
748 approval: CredentialLifecycleApproval<'_>,
749 bound_phases: &[String],
750 bound_routes: &[String],
751 now_epoch_seconds: u64,
752 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
753 let id = parse_reference(credential_ref)?;
754 let lifecycle = open_or_create_private_directory(root, LIFECYCLE_DIRECTORY)?;
755 match reconcile_rotate_request_locked(
756 entries,
757 &lifecycle,
758 id,
759 credential_ref,
760 CredentialLifecycleRequestIdentity {
761 expected_preview: approval.expected_preview,
762 expected_generation: approval.expected_generation,
763 expires_at_epoch_seconds: approval.expires_at_epoch_seconds,
764 },
765 )? {
766 CredentialRotationReconciliation::Committed(outcome) => return Ok(outcome),
767 CredentialRotationReconciliation::RolledBackFreshPreviewRequired => {
768 return Err(CredentialStoreError::FreshPreviewRequired);
769 }
770 CredentialRotationReconciliation::NotRequired => {}
771 }
772 if !valid_secret(secret) {
773 return Err(CredentialStoreError::InvalidInput);
774 }
775 if approval.confirmation != ROTATE_CONFIRMATION {
776 return Err(CredentialStoreError::ConfirmationRequired);
777 }
778 let metadata = self.read_unique_metadata(entries, id)?;
779 if metadata.generation != approval.expected_generation {
780 return Err(CredentialStoreError::GenerationConflict);
781 }
782 let (old_secret, old_secret_identity) = read_secret_with_identity(entries, id)?;
783 drop(old_secret);
784 let mut preview_metadata = metadata.clone();
785 preview_metadata.bound_phases = bound_phases.to_vec();
786 preview_metadata.bound_routes = bound_routes.to_vec();
787 preview_metadata.validate()?;
788 let preview = lifecycle_preview_for_metadata(
789 &preview_metadata,
790 CredentialLifecycleOperation::Rotate,
791 now_epoch_seconds,
792 approval.expires_at_epoch_seconds,
793 )?;
794 validate_expected_preview(&preview, approval.expected_preview, now_epoch_seconds)?;
795
796 let next_generation = metadata
797 .generation
798 .checked_add(1)
799 .ok_or(CredentialStoreError::GenerationConflict)?;
800 let mut replacement = metadata.clone();
801 replacement.previous_generation = Some(metadata.generation);
802 replacement.last_lifecycle_preview_digest = Some(preview.preview_digest.clone());
803 replacement.generation = next_generation;
804 replacement.rotated_at_epoch_seconds = Some(now_epoch_seconds);
805 replacement.last_verified_at_epoch_seconds = Some(now_epoch_seconds);
806 replacement.lifecycle_state = CredentialLifecycleState::Active;
807 replacement.health = CredentialHealthState::LocallyVerified;
808 replacement.validate()?;
809 let metadata_bytes =
810 serde_json::to_vec(&replacement).map_err(|_| CredentialStoreError::CommitAmbiguous)?;
811 if metadata_bytes.len() > MAX_METADATA_BYTES {
812 return Err(CredentialStoreError::InvalidInput);
813 }
814 let entry = open_private_directory(entries, id)?;
815 let precommit = (|| -> Result<FileIdentity, CredentialStoreError> {
816 write_new_file(&entry, NEXT_SECRET_FILE, secret)?;
817 write_new_file(&entry, NEXT_METADATA_FILE, &metadata_bytes)?;
818 durable_sync(&entry)?;
819 verify_staged_files(&entry, &replacement, secret)
820 })();
821 let staged_secret_identity = match precommit {
822 Ok(identity) => identity,
823 Err(error) => {
824 cleanup_precommit_files(&entry);
825 return Err(error);
826 }
827 };
828 let journal = CredentialLifecycleJournal {
829 schema_version: SCHEMA_VERSION.to_owned(),
830 operation: CredentialLifecycleOperation::Rotate,
831 credential_id: id.to_owned(),
832 credential_ref: credential_ref.to_owned(),
833 expected_generation: metadata.generation,
834 target_generation: Some(next_generation),
835 preview_digest: preview.preview_digest.clone(),
836 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
837 required_confirmation: ROTATE_CONFIRMATION.to_owned(),
838 old_secret_identity,
839 staged_secret_identity: Some(staged_secret_identity),
840 state: CredentialLifecycleJournalState::Prepared,
841 };
842 if let Err(error) = write_journal_new(&lifecycle, &journal) {
843 cleanup_precommit_files(&entry);
844 return Err(error);
845 }
846 let (live_secret, verified_old_identity) = read_secret_with_identity(entries, id)
847 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
848 drop(live_secret);
849 let (staged_secret, verified_staged_identity) =
850 read_regular_file_with_identity(&entry, NEXT_SECRET_FILE, MAX_SECRET_BYTES, true)
851 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
852 drop(staged_secret);
853 if verified_old_identity != journal.old_secret_identity
854 || Some(verified_staged_identity) != journal.staged_secret_identity
855 {
856 return Err(CredentialStoreError::RecoveryRequired);
857 }
858 if lifecycle_failpoint("rotate_before_secret_replace") {
859 return Err(CredentialStoreError::CommitAmbiguous);
860 }
861
862 rename_replace(&entry, NEXT_SECRET_FILE, SECRET_FILE)?;
866 let (committed_secret, committed_identity) = read_secret_with_identity(entries, id)
867 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
868 drop(committed_secret);
869 if Some(committed_identity) != journal.staged_secret_identity {
870 return Err(CredentialStoreError::RecoveryRequired);
871 }
872 if lifecycle_failpoint("rotate_after_secret_replace") {
873 return Ok(rotation_outcome(
874 credential_ref,
875 metadata.generation,
876 next_generation,
877 true,
878 ));
879 }
880 let mut reconciliation_required = false;
881 post_commit_step(
882 "rotate_secret_sync",
883 || durable_sync(&entry),
884 &mut reconciliation_required,
885 );
886 post_commit_step(
887 "rotate_metadata_replace",
888 || rename_replace(&entry, NEXT_METADATA_FILE, METADATA_FILE),
889 &mut reconciliation_required,
890 );
891 post_commit_step(
892 "rotate_metadata_sync",
893 || durable_sync(&entry),
894 &mut reconciliation_required,
895 );
896 let committed_journal = CredentialLifecycleJournal {
897 state: CredentialLifecycleJournalState::Committed,
898 ..journal
899 };
900 post_commit_step(
901 "rotate_journal_commit",
902 || replace_journal(&lifecycle, &committed_journal),
903 &mut reconciliation_required,
904 );
905 Ok(rotation_outcome(
906 credential_ref,
907 metadata.generation,
908 next_generation,
909 reconciliation_required,
910 ))
911 }
912
913 pub fn remove_apply(
914 &self,
915 credential_ref: &str,
916 approval: CredentialLifecycleApproval<'_>,
917 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
918 self.remove_apply_at(credential_ref, approval, current_epoch_seconds()?)
919 }
920
921 fn remove_apply_at(
922 &self,
923 credential_ref: &str,
924 approval: CredentialLifecycleApproval<'_>,
925 now_epoch_seconds: u64,
926 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
927 let guard = self.lock_lifecycle_inventory()?;
928 let metadata = guard.metadata(credential_ref)?;
929 guard.remove_apply_with_bindings_at(
930 credential_ref,
931 approval,
932 &metadata.bound_phases,
933 &metadata.bound_routes,
934 now_epoch_seconds,
935 )
936 }
937
938 #[allow(clippy::too_many_arguments)]
939 fn remove_apply_locked(
940 &self,
941 root: &File,
942 entries: &File,
943 credential_ref: &str,
944 approval: CredentialLifecycleApproval<'_>,
945 bound_phases: &[String],
946 bound_routes: &[String],
947 now_epoch_seconds: u64,
948 ) -> Result<CredentialLifecycleOutcome, CredentialStoreError> {
949 let id = parse_reference(credential_ref)?;
950 let lifecycle = open_or_create_private_directory(root, LIFECYCLE_DIRECTORY)?;
951 match reconcile_remove_request_locked(
952 entries,
953 &lifecycle,
954 id,
955 credential_ref,
956 CredentialLifecycleRequestIdentity {
957 expected_preview: approval.expected_preview,
958 expected_generation: approval.expected_generation,
959 expires_at_epoch_seconds: approval.expires_at_epoch_seconds,
960 },
961 )? {
962 CredentialRemovalReconciliation::Committed(outcome) => return Ok(outcome),
963 CredentialRemovalReconciliation::RolledBackFreshPreviewRequired => {
964 return Err(CredentialStoreError::FreshPreviewRequired);
965 }
966 CredentialRemovalReconciliation::NotRequired => {}
967 }
968 if approval.confirmation != REMOVE_CONFIRMATION {
969 return Err(CredentialStoreError::ConfirmationRequired);
970 }
971 let metadata = self.read_unique_metadata(entries, id)?;
972 if metadata.generation != approval.expected_generation {
973 return Err(CredentialStoreError::GenerationConflict);
974 }
975 if metadata.lifecycle_state == CredentialLifecycleState::Removed {
976 return Err(CredentialStoreError::NotFound);
977 }
978 let mut preview_metadata = metadata.clone();
979 preview_metadata.bound_phases = bound_phases.to_vec();
980 preview_metadata.bound_routes = bound_routes.to_vec();
981 preview_metadata.validate()?;
982 let preview = lifecycle_preview_for_metadata(
983 &preview_metadata,
984 CredentialLifecycleOperation::Remove,
985 now_epoch_seconds,
986 approval.expires_at_epoch_seconds,
987 )?;
988 validate_expected_preview(&preview, approval.expected_preview, now_epoch_seconds)?;
989 if !preview.dependency_impact.apply_allowed {
990 return Err(CredentialStoreError::DependencyConflict);
991 }
992 let entry = open_private_directory(entries, id)?;
993 let (secret_file, old_secret_identity) = open_secret_for_erasure(&entry)?;
994 let journal = CredentialLifecycleJournal {
995 schema_version: SCHEMA_VERSION.to_owned(),
996 operation: CredentialLifecycleOperation::Remove,
997 credential_id: id.to_owned(),
998 credential_ref: credential_ref.to_owned(),
999 expected_generation: metadata.generation,
1000 target_generation: None,
1001 preview_digest: preview.preview_digest,
1002 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
1003 required_confirmation: REMOVE_CONFIRMATION.to_owned(),
1004 old_secret_identity,
1005 staged_secret_identity: None,
1006 state: CredentialLifecycleJournalState::Prepared,
1007 };
1008 write_journal_new(&lifecycle, &journal)?;
1009 if lifecycle_failpoint("remove_before_secret_truncate") {
1010 return Err(CredentialStoreError::CommitAmbiguous);
1011 }
1012
1013 #[cfg(test)]
1014 if lifecycle_failpoint("remove_substitute_path_after_fd_validation") {
1015 inject_remove_secret_substitution_for_test(&entry)?;
1016 }
1017
1018 let parent_stat = fstat(&entry).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1019 let before_truncate =
1020 fstat(&secret_file).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1021 let before_size = usize::try_from(before_truncate.st_size)
1022 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1023 validate_regular(&before_truncate, device(&parent_stat)?, Some(before_size))
1024 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1025 let verified_old_identity = identity_with_birth_fd(&before_truncate, &secret_file)
1026 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1027 if verified_old_identity != journal.old_secret_identity {
1028 return Err(CredentialStoreError::RecoveryRequired);
1029 }
1030
1031 ftruncate(&secret_file, 0).map_err(|_| CredentialStoreError::CommitAmbiguous)?;
1035 if lifecycle_failpoint("remove_after_secret_truncate")
1036 || lifecycle_failpoint("remove_before_secret_sync")
1037 {
1038 return Err(CredentialStoreError::RecoveryRequired);
1039 }
1040 if lifecycle_failpoint("remove_secret_sync_failure") || durable_sync(&secret_file).is_err()
1041 {
1042 return Err(CredentialStoreError::RecoveryRequired);
1043 }
1044 if lifecycle_failpoint("remove_after_secret_sync") {
1045 return Err(CredentialStoreError::RecoveryRequired);
1046 }
1047 let after_truncate =
1048 fstat(&secret_file).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1049 validate_regular(&after_truncate, device(&parent_stat)?, Some(0))
1050 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1051 if identity_with_birth_fd(&after_truncate, &secret_file)
1052 .map_err(|_| CredentialStoreError::RecoveryRequired)?
1053 != journal.old_secret_identity
1054 {
1055 return Err(CredentialStoreError::RecoveryRequired);
1056 }
1057
1058 let mut reconciliation_required = match reconcile_removal(entries, &lifecycle, &journal)? {
1059 RemovalTransactionState::RolledBack => {
1060 return Err(CredentialStoreError::RecoveryRequired);
1061 }
1062 RemovalTransactionState::Committed {
1063 reconciliation_required,
1064 } => reconciliation_required,
1065 };
1066 let committed_journal = CredentialLifecycleJournal {
1067 state: CredentialLifecycleJournalState::Committed,
1068 ..journal
1069 };
1070 post_commit_step(
1071 "remove_journal_commit",
1072 || replace_journal(&lifecycle, &committed_journal),
1073 &mut reconciliation_required,
1074 );
1075 Ok(removal_outcome(
1076 credential_ref,
1077 metadata.generation,
1078 reconciliation_required,
1079 ))
1080 }
1081
1082 fn lifecycle_preview(
1083 &self,
1084 credential_ref: &str,
1085 operation: CredentialLifecycleOperation,
1086 now_epoch_seconds: u64,
1087 expires_at_epoch_seconds: u64,
1088 ) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
1089 let id = parse_reference(credential_ref)?;
1090 let locked = self.lock()?;
1091 let entries = open_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
1092 ensure_preview_needs_no_recovery(&locked.root, &entries, id)?;
1093 let metadata = self.read_unique_metadata(&entries, id)?;
1094 if metadata.lifecycle_state == CredentialLifecycleState::Removed {
1095 return Err(CredentialStoreError::NotFound);
1096 }
1097 lifecycle_preview_for_metadata(
1098 &metadata,
1099 operation,
1100 now_epoch_seconds,
1101 expires_at_epoch_seconds,
1102 )
1103 }
1104
1105 pub fn list(&self) -> Result<Vec<CredentialMetadata>, CredentialStoreError> {
1106 let locked = self.lock()?;
1107 let entries = open_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
1108 ensure_store_needs_no_lifecycle_recovery(&locked.root, &entries)?;
1109 self.list_locked(&entries)
1110 }
1111
1112 pub fn lock_inventory(&self) -> Result<CredentialInventoryGuard, CredentialStoreError> {
1113 let locked = self.lock()?;
1114 let entries = open_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
1115 ensure_store_needs_no_lifecycle_recovery(&locked.root, &entries)?;
1116 let credentials = self.list_locked(&entries)?;
1117 Ok(CredentialInventoryGuard {
1118 store: self.clone(),
1119 _locked: locked,
1120 entries,
1121 credentials,
1122 })
1123 }
1124
1125 pub fn lock_lifecycle_inventory(
1126 &self,
1127 ) -> Result<CredentialInventoryGuard, CredentialStoreError> {
1128 let locked = self.lock()?;
1129 let entries = open_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
1130 self.ensure_normalized_label_inventory(&entries)?;
1131 recover_add_staging(&entries)?;
1132 let credentials = self.list_locked(&entries)?;
1133 Ok(CredentialInventoryGuard {
1134 store: self.clone(),
1135 _locked: locked,
1136 entries,
1137 credentials,
1138 })
1139 }
1140
1141 pub fn status(&self, credential_ref: &str) -> Result<CredentialStatus, CredentialStoreError> {
1142 let id = parse_reference(credential_ref)?;
1143 let locked = self.lock()?;
1144 let entries = open_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
1145 ensure_store_needs_no_lifecycle_recovery(&locked.root, &entries)?;
1146 let metadata = self.read_unique_metadata(&entries, id)?;
1147 let status = match metadata.lifecycle_state {
1148 CredentialLifecycleState::Active => {
1149 let secret = read_secret(&entries, id)?;
1150 drop(secret);
1151 "available"
1152 }
1153 CredentialLifecycleState::Removed => {
1154 let entry = open_private_directory(&entries, id)?;
1155 let _ = read_tombstone_identity(&entry)?;
1156 "removed"
1157 }
1158 };
1159 Ok(CredentialStatus { metadata, status })
1160 }
1161
1162 pub fn resolve(
1163 &self,
1164 credential_ref: &str,
1165 ) -> Result<Zeroizing<Vec<u8>>, CredentialStoreError> {
1166 let id = parse_reference(credential_ref)?;
1167 let locked = self.lock()?;
1168 let entries = open_private_directory(&locked.root, ENTRIES_DIRECTORY)?;
1169 ensure_store_needs_no_lifecycle_recovery(&locked.root, &entries)?;
1170 let metadata = self.read_unique_metadata(&entries, id)?;
1171 if metadata.credential_ref != credential_ref {
1172 return Err(CredentialStoreError::CorruptState);
1173 }
1174 if metadata.lifecycle_state == CredentialLifecycleState::Removed {
1175 return Err(CredentialStoreError::NotFound);
1176 }
1177 read_secret(&entries, id)
1178 }
1179
1180 fn list_locked(&self, entries: &File) -> Result<Vec<CredentialMetadata>, CredentialStoreError> {
1181 self.recheck_root()?;
1182 let mut records = Vec::new();
1183 let directory = Dir::read_from(entries).map_err(|_| CredentialStoreError::UnsafeStorage)?;
1184 for item in directory {
1185 let item = item.map_err(|_| CredentialStoreError::UnsafeStorage)?;
1186 let name = item
1187 .file_name()
1188 .to_str()
1189 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
1190 if matches!(name, "." | "..") {
1191 continue;
1192 }
1193 if !valid_id(name) {
1194 return Err(CredentialStoreError::CorruptState);
1195 }
1196 records.push(self.read_metadata(entries, name)?);
1197 if records.len() > MAX_CREDENTIALS {
1198 return Err(CredentialStoreError::CapacityExhausted);
1199 }
1200 }
1201 records.sort_by(|left, right| left.id.cmp(&right.id));
1202 for (index, record) in records.iter().enumerate() {
1203 if records[..index].iter().any(|existing| {
1204 existing.provider == record.provider && existing.label == record.label
1205 }) {
1206 return Err(CredentialStoreError::CorruptState);
1207 }
1208 }
1209 Ok(records)
1210 }
1211
1212 fn read_unique_metadata(
1213 &self,
1214 entries: &File,
1215 id: &str,
1216 ) -> Result<CredentialMetadata, CredentialStoreError> {
1217 self.list_locked(entries)?
1218 .into_iter()
1219 .find(|metadata| metadata.id == id)
1220 .ok_or(CredentialStoreError::NotFound)
1221 }
1222
1223 fn ensure_normalized_label_inventory(
1224 &self,
1225 entries: &File,
1226 ) -> Result<(), CredentialStoreError> {
1227 self.list_locked(entries).map(drop)
1228 }
1229
1230 fn read_metadata(
1231 &self,
1232 entries: &File,
1233 id: &str,
1234 ) -> Result<CredentialMetadata, CredentialStoreError> {
1235 let entry = open_private_directory(entries, id)?;
1236 let bytes = read_regular_file(&entry, METADATA_FILE, MAX_METADATA_BYTES, false)?;
1237 let mut metadata: CredentialMetadata =
1238 serde_json::from_slice(&bytes).map_err(|_| CredentialStoreError::CorruptState)?;
1239 metadata.validate()?;
1240 if metadata.id != id {
1241 return Err(CredentialStoreError::CorruptState);
1242 }
1243 metadata.label = normalize_account_label(&metadata.label)
1244 .map_err(|_| CredentialStoreError::CorruptState)?;
1245 Ok(metadata)
1246 }
1247
1248 fn lock(&self) -> Result<LockedRoot, CredentialStoreError> {
1249 self.recheck_root()?;
1250 let root = self
1251 .root
1252 .try_clone()
1253 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
1254 let flags = OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC;
1255 let lock_fd = match strict_openat(&root, LOCK_FILE, flags, Mode::empty()) {
1256 Ok(fd) => fd,
1257 Err(CredentialStoreError::NotFound) => strict_openat(
1258 &root,
1259 LOCK_FILE,
1260 flags | OFlags::CREATE | OFlags::EXCL,
1261 Mode::from_raw_mode(0o600),
1262 )?,
1263 Err(error) => return Err(error),
1264 };
1265 fchmod(&lock_fd, Mode::from_raw_mode(0o600))
1266 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
1267 let lock = File::from(lock_fd);
1268 self.lock_exclusive(&lock)?;
1269 validate_regular(
1270 &fstat(&lock).map_err(|_| CredentialStoreError::UnsafeStorage)?,
1271 self.root_identity.device,
1272 Some(0),
1273 )?;
1274 Ok(LockedRoot { root, _lock: lock })
1275 }
1276
1277 fn lock_exclusive(&self, lock: &File) -> Result<(), CredentialStoreError> {
1284 let mut interrupted_attempts = 0usize;
1285 loop {
1286 let interrupted = self.flock_attempt(lock)?;
1287 if !interrupted {
1288 return Ok(());
1289 }
1290 interrupted_attempts += 1;
1291 if interrupted_attempts > FLOCK_EINTR_RETRY_BOUND {
1292 return Err(CredentialStoreError::LockUnavailable);
1293 }
1294 std::thread::sleep(FLOCK_EINTR_RETRY_SLEEP);
1295 }
1296 }
1297
1298 fn flock_attempt(&self, lock: &File) -> Result<bool, CredentialStoreError> {
1304 #[cfg(test)]
1305 {
1306 if self.flock_eintr_injections.load(Ordering::SeqCst) > 0 {
1307 self.flock_eintr_injections.fetch_sub(1, Ordering::SeqCst);
1308 return Ok(true);
1309 }
1310 }
1311 match flock(lock, FlockOperation::NonBlockingLockExclusive) {
1312 Ok(()) => Ok(false),
1313 Err(error) if error == rustix::io::Errno::INTR => Ok(true),
1314 Err(_) => Err(CredentialStoreError::LockUnavailable),
1315 }
1316 }
1317
1318 fn recheck_root(&self) -> Result<(), CredentialStoreError> {
1319 let fresh = open_path_directory(&self.root_path)?;
1320 let fresh_stat = fstat(&fresh).map_err(|_| CredentialStoreError::UnsafeStorage)?;
1321 let held_stat = fstat(&*self.root).map_err(|_| CredentialStoreError::UnsafeStorage)?;
1322 validate_directory(&fresh_stat)?;
1323 validate_directory(&held_stat)?;
1324 if !same_filesystem_object(identity(&fresh_stat)?, self.root_identity)
1325 || !same_filesystem_object(identity(&held_stat)?, self.root_identity)
1326 {
1327 return Err(CredentialStoreError::UnsafeStorage);
1328 }
1329 Ok(())
1330 }
1331}
1332
1333struct LockedRoot {
1334 root: File,
1335 _lock: File,
1336}
1337
1338fn rotation_outcome(
1339 credential_ref: &str,
1340 previous_generation: u64,
1341 generation: u64,
1342 reconciliation_required: bool,
1343) -> CredentialLifecycleOutcome {
1344 CredentialLifecycleOutcome {
1345 operation: CredentialLifecycleOperation::Rotate,
1346 credential_ref: credential_ref.to_owned(),
1347 previous_generation,
1348 generation: Some(generation),
1349 lifecycle_state: "active",
1350 health: "locally_verified",
1351 commit_status: if reconciliation_required {
1352 "committed_reconciliation_required"
1353 } else {
1354 "committed"
1355 },
1356 reconciliation_required,
1357 }
1358}
1359
1360fn removal_outcome(
1361 credential_ref: &str,
1362 previous_generation: u64,
1363 reconciliation_required: bool,
1364) -> CredentialLifecycleOutcome {
1365 CredentialLifecycleOutcome {
1366 operation: CredentialLifecycleOperation::Remove,
1367 credential_ref: credential_ref.to_owned(),
1368 previous_generation,
1369 generation: None,
1370 lifecycle_state: "removed",
1371 health: "unavailable",
1372 commit_status: if reconciliation_required {
1373 "committed_reconciliation_required"
1374 } else {
1375 "committed"
1376 },
1377 reconciliation_required,
1378 }
1379}
1380
1381fn reconcile_rotate_request_locked(
1382 entries: &File,
1383 lifecycle: &File,
1384 id: &str,
1385 credential_ref: &str,
1386 request: CredentialLifecycleRequestIdentity<'_>,
1387) -> Result<CredentialRotationReconciliation, CredentialStoreError> {
1388 let Some(journal) = read_journal(lifecycle, id).map_err(|error| match error {
1389 CredentialStoreError::CorruptState => CredentialStoreError::RecoveryRequired,
1390 other => other,
1391 })?
1392 else {
1393 match open_private_directory(entries, id) {
1394 Ok(entry) => {
1395 if path_exists(&entry, NEXT_SECRET_FILE)?
1396 || path_exists(&entry, NEXT_METADATA_FILE)?
1397 {
1398 return Err(CredentialStoreError::RecoveryRequired);
1399 }
1400 }
1401 Err(CredentialStoreError::NotFound) => {}
1402 Err(error) => return Err(error),
1403 }
1404 return Ok(CredentialRotationReconciliation::NotRequired);
1405 };
1406 journal
1407 .validate()
1408 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1409 if journal.credential_id != id
1410 || journal.credential_ref != credential_ref
1411 || journal.operation != CredentialLifecycleOperation::Rotate
1412 || journal.required_confirmation != ROTATE_CONFIRMATION
1413 || journal.expected_generation != request.expected_generation
1414 || journal.expires_at_epoch_seconds != request.expires_at_epoch_seconds
1415 || !constant_time_eq(
1416 journal.preview_digest.as_bytes(),
1417 request.expected_preview.as_bytes(),
1418 )
1419 {
1420 return Err(CredentialStoreError::RecoveryRequired);
1421 }
1422 let mut reconciliation_required = match reconcile_rotation(entries, lifecycle, &journal)? {
1423 RotationTransactionState::RolledBack => {
1424 return Ok(CredentialRotationReconciliation::RolledBackFreshPreviewRequired);
1425 }
1426 RotationTransactionState::Committed {
1427 reconciliation_required,
1428 } => reconciliation_required,
1429 };
1430 if journal.state != CredentialLifecycleJournalState::Committed {
1431 let committed_journal = CredentialLifecycleJournal {
1432 state: CredentialLifecycleJournalState::Committed,
1433 ..journal.clone()
1434 };
1435 if replace_journal(lifecycle, &committed_journal).is_err() {
1436 reconciliation_required = true;
1437 }
1438 }
1439 Ok(CredentialRotationReconciliation::Committed(
1440 rotation_outcome(
1441 &journal.credential_ref,
1442 journal.expected_generation,
1443 journal
1444 .target_generation
1445 .ok_or(CredentialStoreError::CorruptState)?,
1446 reconciliation_required,
1447 ),
1448 ))
1449}
1450
1451fn reconcile_remove_request_locked(
1452 entries: &File,
1453 lifecycle: &File,
1454 id: &str,
1455 credential_ref: &str,
1456 request: CredentialLifecycleRequestIdentity<'_>,
1457) -> Result<CredentialRemovalReconciliation, CredentialStoreError> {
1458 let Some(journal) = read_journal(lifecycle, id).map_err(|error| match error {
1459 CredentialStoreError::CorruptState => CredentialStoreError::RecoveryRequired,
1460 other => other,
1461 })?
1462 else {
1463 return Ok(CredentialRemovalReconciliation::NotRequired);
1464 };
1465 journal
1466 .validate()
1467 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1468 if journal.credential_id != id
1469 || journal.credential_ref != credential_ref
1470 || journal.operation != CredentialLifecycleOperation::Remove
1471 || journal.required_confirmation != REMOVE_CONFIRMATION
1472 || journal.expected_generation != request.expected_generation
1473 || journal.expires_at_epoch_seconds != request.expires_at_epoch_seconds
1474 || !constant_time_eq(
1475 journal.preview_digest.as_bytes(),
1476 request.expected_preview.as_bytes(),
1477 )
1478 {
1479 return Err(CredentialStoreError::RecoveryRequired);
1480 }
1481 let mut reconciliation_required = match reconcile_removal(entries, lifecycle, &journal)? {
1482 RemovalTransactionState::RolledBack => {
1483 return Ok(CredentialRemovalReconciliation::RolledBackFreshPreviewRequired);
1484 }
1485 RemovalTransactionState::Committed {
1486 reconciliation_required,
1487 } => reconciliation_required,
1488 };
1489 if journal.state != CredentialLifecycleJournalState::Committed {
1490 let committed_journal = CredentialLifecycleJournal {
1491 state: CredentialLifecycleJournalState::Committed,
1492 ..journal.clone()
1493 };
1494 if replace_journal(lifecycle, &committed_journal).is_err() {
1495 reconciliation_required = true;
1496 }
1497 }
1498 Ok(CredentialRemovalReconciliation::Committed(removal_outcome(
1499 &journal.credential_ref,
1500 journal.expected_generation,
1501 reconciliation_required,
1502 )))
1503}
1504
1505enum RotationTransactionState {
1506 RolledBack,
1507 Committed { reconciliation_required: bool },
1508}
1509
1510fn reconcile_rotation(
1511 entries: &File,
1512 lifecycle: &File,
1513 journal: &CredentialLifecycleJournal,
1514) -> Result<RotationTransactionState, CredentialStoreError> {
1515 let entry = open_private_directory(entries, &journal.credential_id)?;
1516 let (live_secret, live_identity) = read_secret_with_identity(entries, &journal.credential_id)
1517 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1518 drop(live_secret);
1519 if live_identity == journal.old_secret_identity {
1520 if journal.state == CredentialLifecycleJournalState::Committed {
1521 return Err(CredentialStoreError::RecoveryRequired);
1522 }
1523 cleanup_precommit_files_checked(&entry)?;
1524 remove_journal(lifecycle, &journal.credential_id)?;
1525 return Ok(RotationTransactionState::RolledBack);
1526 }
1527 if Some(live_identity) != journal.staged_secret_identity {
1528 return Err(CredentialStoreError::RecoveryRequired);
1529 }
1530
1531 let metadata_bytes = read_regular_file(&entry, METADATA_FILE, MAX_METADATA_BYTES, false)
1532 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1533 let metadata: CredentialMetadata = serde_json::from_slice(&metadata_bytes)
1534 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1535 metadata
1536 .validate()
1537 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1538 let metadata_is_target = rotation_metadata_matches_journal(&metadata, journal);
1539 if !metadata_is_target {
1540 if metadata.generation != journal.expected_generation
1541 || metadata.id != journal.credential_id
1542 || !path_exists(&entry, NEXT_METADATA_FILE)?
1543 {
1544 return Err(CredentialStoreError::RecoveryRequired);
1545 }
1546 let next_bytes = read_regular_file(&entry, NEXT_METADATA_FILE, MAX_METADATA_BYTES, false)
1547 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1548 let next_metadata: CredentialMetadata = serde_json::from_slice(&next_bytes)
1549 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1550 next_metadata
1551 .validate()
1552 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1553 if !rotation_metadata_matches_journal(&next_metadata, journal) {
1554 return Err(CredentialStoreError::RecoveryRequired);
1555 }
1556 if lifecycle_failpoint("reconcile_rotate_metadata_replace")
1557 || rename_replace(&entry, NEXT_METADATA_FILE, METADATA_FILE).is_err()
1558 {
1559 return Ok(RotationTransactionState::Committed {
1560 reconciliation_required: true,
1561 });
1562 }
1563 }
1564
1565 let mut reconciliation_required = false;
1566 post_commit_step(
1567 "reconcile_rotate_staging_cleanup",
1568 || cleanup_postcommit_rotation_files(&entry),
1569 &mut reconciliation_required,
1570 );
1571 post_commit_step(
1572 "reconcile_rotate_entry_sync",
1573 || durable_sync(&entry),
1574 &mut reconciliation_required,
1575 );
1576 Ok(RotationTransactionState::Committed {
1577 reconciliation_required,
1578 })
1579}
1580
1581fn rotation_metadata_matches_journal(
1582 metadata: &CredentialMetadata,
1583 journal: &CredentialLifecycleJournal,
1584) -> bool {
1585 metadata.id == journal.credential_id
1586 && metadata.credential_ref == journal.credential_ref
1587 && Some(metadata.generation) == journal.target_generation
1588 && metadata.previous_generation == Some(journal.expected_generation)
1589 && metadata.last_lifecycle_preview_digest.as_deref()
1590 == Some(journal.preview_digest.as_str())
1591}
1592
1593enum RemovalTransactionState {
1594 RolledBack,
1595 Committed { reconciliation_required: bool },
1596}
1597
1598fn reconcile_removal(
1599 entries: &File,
1600 lifecycle: &File,
1601 journal: &CredentialLifecycleJournal,
1602) -> Result<RemovalTransactionState, CredentialStoreError> {
1603 let entry = open_private_directory(entries, &journal.credential_id)
1604 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1605 let (secret_file, live_identity, secret_size) =
1606 open_secret_or_tombstone_for_recovery(&entry)
1607 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1608 if live_identity != journal.old_secret_identity {
1609 return Err(CredentialStoreError::RecoveryRequired);
1610 }
1611 let metadata_bytes = read_regular_file(&entry, METADATA_FILE, MAX_METADATA_BYTES, false)
1612 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1613 let metadata: CredentialMetadata = serde_json::from_slice(&metadata_bytes)
1614 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1615 metadata
1616 .validate()
1617 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1618
1619 if secret_size != 0 {
1620 if journal.state == CredentialLifecycleJournalState::Committed
1621 || !active_removal_metadata_matches_journal(&metadata, journal)
1622 || path_exists(&entry, NEXT_METADATA_FILE)?
1623 {
1624 return Err(CredentialStoreError::RecoveryRequired);
1625 }
1626 remove_journal(lifecycle, &journal.credential_id)?;
1627 return Ok(RemovalTransactionState::RolledBack);
1628 }
1629
1630 if lifecycle_failpoint("remove_secret_sync_failure") || durable_sync(&secret_file).is_err() {
1631 return Err(CredentialStoreError::RecoveryRequired);
1632 }
1633 if lifecycle_failpoint("remove_after_secret_sync") {
1634 return Err(CredentialStoreError::RecoveryRequired);
1635 }
1636 let parent_stat = fstat(&entry).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1637 let descriptor_stat =
1638 fstat(&secret_file).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1639 let path_stat = statat(&entry, SECRET_FILE, AtFlags::SYMLINK_NOFOLLOW)
1640 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1641 let parent_device = device(&parent_stat).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1642 validate_regular(&descriptor_stat, parent_device, Some(0))
1643 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1644 validate_regular(&path_stat, parent_device, Some(0))
1645 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1646 let descriptor_identity = identity_with_birth(&descriptor_stat, &entry, SECRET_FILE)
1647 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1648 if descriptor_identity != journal.old_secret_identity
1649 || identity_with_birth(&path_stat, &entry, SECRET_FILE)
1650 .map_err(|_| CredentialStoreError::RecoveryRequired)?
1651 != journal.old_secret_identity
1652 {
1653 return Err(CredentialStoreError::RecoveryRequired);
1654 }
1655
1656 let reconciliation_required = finalize_removal_metadata(&entry, &metadata, journal)?;
1657 Ok(RemovalTransactionState::Committed {
1658 reconciliation_required,
1659 })
1660}
1661
1662fn active_removal_metadata_matches_journal(
1663 metadata: &CredentialMetadata,
1664 journal: &CredentialLifecycleJournal,
1665) -> bool {
1666 metadata.id == journal.credential_id
1667 && metadata.credential_ref == journal.credential_ref
1668 && metadata.generation == journal.expected_generation
1669 && metadata.lifecycle_state == CredentialLifecycleState::Active
1670 && metadata.health == CredentialHealthState::LocallyVerified
1671 && metadata.bound_phases.is_empty()
1672 && metadata.bound_routes.is_empty()
1673}
1674
1675fn removed_metadata_matches_journal(
1676 metadata: &CredentialMetadata,
1677 journal: &CredentialLifecycleJournal,
1678) -> bool {
1679 metadata.id == journal.credential_id
1680 && metadata.credential_ref == journal.credential_ref
1681 && metadata.generation == journal.expected_generation
1682 && metadata.lifecycle_state == CredentialLifecycleState::Removed
1683 && metadata.health == CredentialHealthState::Unavailable
1684 && metadata.last_lifecycle_preview_digest.as_deref()
1685 == Some(journal.preview_digest.as_str())
1686 && metadata.last_verified_at_epoch_seconds.is_none()
1687 && metadata.bound_phases.is_empty()
1688 && metadata.bound_routes.is_empty()
1689}
1690
1691fn finalize_removal_metadata(
1692 entry: &File,
1693 metadata: &CredentialMetadata,
1694 journal: &CredentialLifecycleJournal,
1695) -> Result<bool, CredentialStoreError> {
1696 if removed_metadata_matches_journal(metadata, journal) {
1697 return Ok(false);
1698 }
1699 if !active_removal_metadata_matches_journal(metadata, journal) {
1700 return Err(CredentialStoreError::RecoveryRequired);
1701 }
1702
1703 let mut tombstone = metadata.clone();
1704 tombstone.lifecycle_state = CredentialLifecycleState::Removed;
1705 tombstone.health = CredentialHealthState::Unavailable;
1706 tombstone.last_lifecycle_preview_digest = Some(journal.preview_digest.clone());
1707 tombstone.last_verified_at_epoch_seconds = None;
1708 tombstone.validate()?;
1709 let tombstone_bytes =
1710 serde_json::to_vec(&tombstone).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1711 if tombstone_bytes.len() > MAX_METADATA_BYTES {
1712 return Err(CredentialStoreError::RecoveryRequired);
1713 }
1714
1715 if path_exists(entry, NEXT_METADATA_FILE)? {
1716 let staged = read_regular_file(entry, NEXT_METADATA_FILE, MAX_METADATA_BYTES, false)
1717 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1718 let staged: CredentialMetadata =
1719 serde_json::from_slice(&staged).map_err(|_| CredentialStoreError::RecoveryRequired)?;
1720 staged
1721 .validate()
1722 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1723 if staged != tombstone {
1724 return Err(CredentialStoreError::RecoveryRequired);
1725 }
1726 } else {
1727 if lifecycle_failpoint("remove_metadata_stage") {
1728 return Ok(true);
1729 }
1730 if write_new_file(entry, NEXT_METADATA_FILE, &tombstone_bytes).is_err() {
1731 return Ok(true);
1732 }
1733 }
1734 if lifecycle_failpoint("remove_metadata_replace")
1735 || rename_replace(entry, NEXT_METADATA_FILE, METADATA_FILE).is_err()
1736 {
1737 return Ok(true);
1738 }
1739 if lifecycle_failpoint("remove_metadata_sync") || durable_sync(entry).is_err() {
1740 return Ok(true);
1741 }
1742 Ok(false)
1743}
1744
1745fn ensure_preview_needs_no_recovery(
1746 root: &File,
1747 entries: &File,
1748 id: &str,
1749) -> Result<(), CredentialStoreError> {
1750 ensure_store_needs_no_lifecycle_recovery(root, entries)?;
1751 let _ = open_private_directory(entries, id)?;
1752 Ok(())
1753}
1754
1755fn ensure_store_needs_no_lifecycle_recovery(
1756 root: &File,
1757 entries: &File,
1758) -> Result<(), CredentialStoreError> {
1759 let directory = Dir::read_from(entries).map_err(|_| CredentialStoreError::UnsafeStorage)?;
1760 for item in directory {
1761 let item = item.map_err(|_| CredentialStoreError::UnsafeStorage)?;
1762 let name = item
1763 .file_name()
1764 .to_str()
1765 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
1766 if matches!(name, "." | "..") {
1767 continue;
1768 }
1769 if valid_staging_name(name) {
1770 return Err(CredentialStoreError::RecoveryRequired);
1771 }
1772 if !valid_id(name) {
1773 return Err(CredentialStoreError::CorruptState);
1774 }
1775 let entry = open_private_directory(entries, name)?;
1776 if path_exists(&entry, NEXT_SECRET_FILE)? || path_exists(&entry, NEXT_METADATA_FILE)? {
1777 return Err(CredentialStoreError::RecoveryRequired);
1778 }
1779 }
1780 let lifecycle = match open_private_directory(root, LIFECYCLE_DIRECTORY) {
1781 Ok(directory) => directory,
1782 Err(CredentialStoreError::NotFound) => return Ok(()),
1783 Err(error) => return Err(error),
1784 };
1785 let directory = Dir::read_from(&lifecycle).map_err(|_| CredentialStoreError::UnsafeStorage)?;
1786 for item in directory {
1787 let item = item.map_err(|_| CredentialStoreError::UnsafeStorage)?;
1788 let name = item
1789 .file_name()
1790 .to_str()
1791 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
1792 if matches!(name, "." | "..") {
1793 continue;
1794 }
1795 if name.starts_with(".journal-") {
1796 return Err(CredentialStoreError::RecoveryRequired);
1797 }
1798 if valid_id(name) {
1799 let journal =
1800 read_journal(&lifecycle, name)?.ok_or(CredentialStoreError::CorruptState)?;
1801 if journal.credential_id != name {
1802 return Err(CredentialStoreError::CorruptState);
1803 }
1804 if journal.state == CredentialLifecycleJournalState::Prepared {
1805 return Err(CredentialStoreError::RecoveryRequired);
1806 }
1807 match journal.operation {
1808 CredentialLifecycleOperation::Rotate => {
1809 let entry = open_private_directory(entries, name)
1810 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1811 let bytes = read_regular_file(&entry, METADATA_FILE, MAX_METADATA_BYTES, false)
1812 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1813 let metadata: CredentialMetadata = serde_json::from_slice(&bytes)
1814 .map_err(|_| CredentialStoreError::CorruptState)?;
1815 metadata.validate()?;
1816 let (secret, live_identity) = read_secret_with_identity(entries, name)
1817 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1818 drop(secret);
1819 if metadata.id != name
1820 || metadata.generation != journal.target_generation.unwrap_or_default()
1821 || metadata.previous_generation != Some(journal.expected_generation)
1822 || metadata.last_lifecycle_preview_digest.as_deref()
1823 != Some(journal.preview_digest.as_str())
1824 || Some(live_identity) != journal.staged_secret_identity
1825 {
1826 return Err(CredentialStoreError::RecoveryRequired);
1827 }
1828 }
1829 CredentialLifecycleOperation::Remove => {
1830 let entry = open_private_directory(entries, name)
1831 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1832 let tombstone_identity = read_tombstone_identity(&entry)
1833 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1834 let bytes = read_regular_file(&entry, METADATA_FILE, MAX_METADATA_BYTES, false)
1835 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1836 let metadata: CredentialMetadata = serde_json::from_slice(&bytes)
1837 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1838 metadata
1839 .validate()
1840 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1841 if tombstone_identity != journal.old_secret_identity
1842 || !removed_metadata_matches_journal(&metadata, &journal)
1843 || path_exists(&entry, NEXT_METADATA_FILE)?
1844 {
1845 return Err(CredentialStoreError::RecoveryRequired);
1846 }
1847 }
1848 }
1849 } else {
1850 return Err(CredentialStoreError::CorruptState);
1851 }
1852 }
1853 Ok(())
1854}
1855
1856fn post_commit_step(
1857 edge: &'static str,
1858 operation: impl FnOnce() -> Result<(), CredentialStoreError>,
1859 reconciliation_required: &mut bool,
1860) {
1861 if lifecycle_failpoint(edge) || operation().is_err() {
1862 *reconciliation_required = true;
1863 }
1864}
1865
1866#[cfg(test)]
1867std::thread_local! {
1868 static LIFECYCLE_FAILPOINT: std::cell::Cell<Option<&'static str>> = const { std::cell::Cell::new(None) };
1869}
1870
1871#[cfg(test)]
1872fn lifecycle_failpoint(edge: &'static str) -> bool {
1873 LIFECYCLE_FAILPOINT.with(|configured| configured.get() == Some(edge))
1874}
1875
1876#[cfg(not(test))]
1877fn lifecycle_failpoint(_edge: &'static str) -> bool {
1878 false
1879}
1880
1881#[cfg(test)]
1882fn inject_remove_secret_substitution_for_test(entry: &File) -> Result<(), CredentialStoreError> {
1883 renameat(entry, SECRET_FILE, entry, ".test-original-secret")
1884 .map_err(|_| CredentialStoreError::RecoveryRequired)?;
1885 write_new_file(entry, SECRET_FILE, b"same-user-substitute")
1886 .map_err(|_| CredentialStoreError::RecoveryRequired)
1887}
1888
1889fn read_journal(
1890 lifecycle: &File,
1891 id: &str,
1892) -> Result<Option<CredentialLifecycleJournal>, CredentialStoreError> {
1893 match read_regular_file(lifecycle, id, MAX_METADATA_BYTES, false) {
1894 Ok(bytes) => {
1895 let journal: CredentialLifecycleJournal =
1896 serde_json::from_slice(&bytes).map_err(|_| CredentialStoreError::CorruptState)?;
1897 journal.validate()?;
1898 Ok(Some(journal))
1899 }
1900 Err(CredentialStoreError::NotFound) => Ok(None),
1901 Err(error) => Err(error),
1902 }
1903}
1904
1905fn write_journal_new(
1906 lifecycle: &File,
1907 journal: &CredentialLifecycleJournal,
1908) -> Result<(), CredentialStoreError> {
1909 journal.validate()?;
1910 let bytes = serde_json::to_vec(journal).map_err(|_| CredentialStoreError::CommitAmbiguous)?;
1911 if bytes.len() > MAX_METADATA_BYTES {
1912 return Err(CredentialStoreError::InvalidInput);
1913 }
1914 write_new_file(lifecycle, &journal.credential_id, &bytes)?;
1915 durable_sync(lifecycle)
1916}
1917
1918fn replace_journal(
1919 lifecycle: &File,
1920 journal: &CredentialLifecycleJournal,
1921) -> Result<(), CredentialStoreError> {
1922 journal.validate()?;
1923 let bytes = serde_json::to_vec(journal).map_err(|_| CredentialStoreError::CommitAmbiguous)?;
1924 if bytes.len() > MAX_METADATA_BYTES {
1925 return Err(CredentialStoreError::InvalidInput);
1926 }
1927 let staging = format!(".journal-{}", journal.credential_id);
1928 unlink_regular_if_exists(lifecycle, &staging)?;
1929 write_new_file(lifecycle, &staging, &bytes)?;
1930 durable_sync(lifecycle)?;
1931 rename_replace(lifecycle, &staging, &journal.credential_id)?;
1932 durable_sync(lifecycle)
1933}
1934
1935fn remove_journal(lifecycle: &File, id: &str) -> Result<(), CredentialStoreError> {
1936 unlink_regular_if_exists(lifecycle, &format!(".journal-{id}"))?;
1937 unlink_regular_if_exists(lifecycle, id)?;
1938 durable_sync(lifecycle)
1939}
1940
1941fn path_exists(parent: &File, name: &str) -> Result<bool, CredentialStoreError> {
1942 match statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
1943 Ok(_) => Ok(true),
1944 Err(error) if error == rustix::io::Errno::NOENT => Ok(false),
1945 Err(_) => Err(CredentialStoreError::UnsafeStorage),
1946 }
1947}
1948
1949fn unlink_regular_if_exists(parent: &File, name: &str) -> Result<(), CredentialStoreError> {
1950 let parent_device = device(&fstat(parent).map_err(|_| CredentialStoreError::UnsafeStorage)?)?;
1951 match statat(parent, name, AtFlags::SYMLINK_NOFOLLOW) {
1952 Ok(stat) => validate_regular(&stat, parent_device, None)?,
1953 Err(error) if error == rustix::io::Errno::NOENT => return Ok(()),
1954 Err(_) => return Err(CredentialStoreError::UnsafeStorage),
1955 }
1956 unlinkat(parent, name, AtFlags::empty()).map_err(|_| CredentialStoreError::UnsafeStorage)
1957}
1958
1959fn cleanup_precommit_files(entry: &File) {
1960 let _ = cleanup_precommit_files_checked(entry);
1961}
1962
1963fn cleanup_precommit_files_checked(entry: &File) -> Result<(), CredentialStoreError> {
1964 unlink_regular_if_exists(entry, NEXT_SECRET_FILE)?;
1965 unlink_regular_if_exists(entry, NEXT_METADATA_FILE)?;
1966 durable_sync(entry)
1967}
1968
1969fn cleanup_postcommit_rotation_files(entry: &File) -> Result<(), CredentialStoreError> {
1970 unlink_regular_if_exists(entry, NEXT_SECRET_FILE)?;
1971 unlink_regular_if_exists(entry, NEXT_METADATA_FILE)
1972}
1973
1974fn lifecycle_preview_for_metadata(
1975 metadata: &CredentialMetadata,
1976 operation: CredentialLifecycleOperation,
1977 now_epoch_seconds: u64,
1978 expires_at_epoch_seconds: u64,
1979) -> Result<CredentialLifecyclePreview, CredentialStoreError> {
1980 metadata.validate()?;
1981 validate_preview_window(now_epoch_seconds, expires_at_epoch_seconds)?;
1982 let binding_count = metadata
1983 .bound_phases
1984 .len()
1985 .checked_add(metadata.bound_routes.len())
1986 .ok_or(CredentialStoreError::CorruptState)?;
1987 let removal_allowed = operation != CredentialLifecycleOperation::Remove || binding_count == 0;
1988 let required_confirmation = match operation {
1989 CredentialLifecycleOperation::Rotate => ROTATE_CONFIRMATION,
1990 CredentialLifecycleOperation::Remove => REMOVE_CONFIRMATION,
1991 };
1992 let mut preview = CredentialLifecyclePreview {
1993 schema_version: SCHEMA_VERSION.to_owned(),
1994 operation,
1995 provider: metadata.provider.clone(),
1996 label: metadata.label.clone(),
1997 credential_ref: metadata.credential_ref.clone(),
1998 dependency_impact: CredentialDependencyImpact {
1999 bound_phases: metadata.bound_phases.clone(),
2000 bound_routes: metadata.bound_routes.clone(),
2001 binding_count,
2002 apply_allowed: removal_allowed,
2003 policy: if operation == CredentialLifecycleOperation::Remove && !removal_allowed {
2004 "blocked_until_zero_bindings"
2005 } else if operation == CredentialLifecycleOperation::Remove {
2006 "zero_binding_removal"
2007 } else {
2008 "bindings_preserved"
2009 },
2010 },
2011 expected_generation: metadata.generation,
2012 expires_at_epoch_seconds,
2013 required_confirmation,
2014 preview_digest: String::new(),
2015 };
2016 let encoded = serde_json::to_vec(&preview).map_err(|_| CredentialStoreError::CorruptState)?;
2017 preview.preview_digest = sha256_id(&encoded);
2018 Ok(preview)
2019}
2020
2021fn validate_preview_window(
2022 now_epoch_seconds: u64,
2023 expires_at_epoch_seconds: u64,
2024) -> Result<(), CredentialStoreError> {
2025 let lifetime = expires_at_epoch_seconds
2026 .checked_sub(now_epoch_seconds)
2027 .ok_or(CredentialStoreError::PreviewExpired)?;
2028 if lifetime == 0 || lifetime > MAX_PREVIEW_LIFETIME_SECONDS {
2029 return Err(CredentialStoreError::PreviewExpired);
2030 }
2031 Ok(())
2032}
2033
2034fn validate_expected_preview(
2035 preview: &CredentialLifecyclePreview,
2036 expected_preview: &str,
2037 now_epoch_seconds: u64,
2038) -> Result<(), CredentialStoreError> {
2039 if now_epoch_seconds >= preview.expires_at_epoch_seconds {
2040 return Err(CredentialStoreError::PreviewExpired);
2041 }
2042 if !constant_time_eq(
2043 preview.preview_digest.as_bytes(),
2044 expected_preview.as_bytes(),
2045 ) {
2046 return Err(CredentialStoreError::PreviewMismatch);
2047 }
2048 Ok(())
2049}
2050
2051fn sha256_id(bytes: &[u8]) -> String {
2052 let digest = Sha256::digest(bytes);
2053 let mut encoded = String::with_capacity(7 + digest.len() * 2);
2054 encoded.push_str("sha256:");
2055 for byte in digest {
2056 use std::fmt::Write as _;
2057 let _ = write!(encoded, "{byte:02x}");
2058 }
2059 encoded
2060}
2061
2062fn current_epoch_seconds() -> Result<u64, CredentialStoreError> {
2063 SystemTime::now()
2064 .duration_since(UNIX_EPOCH)
2065 .map(|duration| duration.as_secs())
2066 .map_err(|_| CredentialStoreError::CommitAmbiguous)
2067}
2068
2069fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
2070 if left.len() != right.len() {
2071 return false;
2072 }
2073 left.iter()
2074 .zip(right)
2075 .fold(0_u8, |difference, (left, right)| {
2076 difference | (left ^ right)
2077 })
2078 == 0
2079}
2080
2081fn verify_staged_files(
2082 entry: &File,
2083 expected_metadata: &CredentialMetadata,
2084 expected_secret: &[u8],
2085) -> Result<FileIdentity, CredentialStoreError> {
2086 let metadata_bytes = read_regular_file(entry, NEXT_METADATA_FILE, MAX_METADATA_BYTES, false)?;
2087 let metadata: CredentialMetadata =
2088 serde_json::from_slice(&metadata_bytes).map_err(|_| CredentialStoreError::CorruptState)?;
2089 metadata.validate()?;
2090 if &metadata != expected_metadata {
2091 return Err(CredentialStoreError::CommitAmbiguous);
2092 }
2093 let (secret, identity) =
2094 read_regular_file_with_identity(entry, NEXT_SECRET_FILE, MAX_SECRET_BYTES, true)?;
2095 let secret = Zeroizing::new(secret);
2096 if !valid_secret(&secret) || !constant_time_eq(&secret, expected_secret) {
2097 return Err(CredentialStoreError::CommitAmbiguous);
2098 }
2099 Ok(identity)
2100}
2101
2102fn read_secret(entries: &File, id: &str) -> Result<Zeroizing<Vec<u8>>, CredentialStoreError> {
2103 read_secret_with_identity(entries, id).map(|(secret, _)| secret)
2104}
2105
2106fn read_secret_with_identity(
2107 entries: &File,
2108 id: &str,
2109) -> Result<(Zeroizing<Vec<u8>>, FileIdentity), CredentialStoreError> {
2110 let entry = open_private_directory(entries, id)?;
2111 let (value, identity) =
2112 read_regular_file_with_identity(&entry, SECRET_FILE, MAX_SECRET_BYTES, true)?;
2113 let value = Zeroizing::new(value);
2114 if !valid_secret(&value) {
2115 return Err(CredentialStoreError::CorruptState);
2116 }
2117 Ok((value, identity))
2118}
2119
2120fn open_secret_for_erasure(entry: &File) -> Result<(File, FileIdentity), CredentialStoreError> {
2121 let (file, descriptor_identity, size) = open_secret_or_tombstone_for_recovery(entry)?;
2122 if size == 0 {
2123 return Err(CredentialStoreError::CorruptState);
2124 }
2125 Ok((file, descriptor_identity))
2126}
2127
2128fn open_secret_or_tombstone_for_recovery(
2129 entry: &File,
2130) -> Result<(File, FileIdentity, usize), CredentialStoreError> {
2131 let file = File::from(strict_openat(
2132 entry,
2133 SECRET_FILE,
2134 OFlags::RDWR | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2135 Mode::empty(),
2136 )?);
2137 let parent_stat = fstat(entry).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2138 let descriptor_stat = fstat(&file).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2139 let path_stat = statat(entry, SECRET_FILE, AtFlags::SYMLINK_NOFOLLOW)
2140 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2141 let size =
2142 usize::try_from(descriptor_stat.st_size).map_err(|_| CredentialStoreError::CorruptState)?;
2143 validate_regular(&descriptor_stat, device(&parent_stat)?, Some(size))?;
2144 validate_regular(&path_stat, device(&parent_stat)?, Some(size))?;
2145 let descriptor_identity = identity_with_birth(&descriptor_stat, entry, SECRET_FILE)?;
2146 if size > MAX_SECRET_BYTES
2147 || descriptor_identity != identity_with_birth(&path_stat, entry, SECRET_FILE)?
2148 {
2149 return Err(CredentialStoreError::CorruptState);
2150 }
2151 Ok((file, descriptor_identity, size))
2152}
2153
2154fn read_secret_or_tombstone_identity(
2155 entry: &File,
2156) -> Result<(FileIdentity, usize), CredentialStoreError> {
2157 let file = File::from(strict_openat(
2158 entry,
2159 SECRET_FILE,
2160 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2161 Mode::empty(),
2162 )?);
2163 let parent_stat = fstat(entry).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2164 let descriptor_stat = fstat(&file).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2165 let path_stat = statat(entry, SECRET_FILE, AtFlags::SYMLINK_NOFOLLOW)
2166 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2167 let size =
2168 usize::try_from(descriptor_stat.st_size).map_err(|_| CredentialStoreError::CorruptState)?;
2169 if size > MAX_SECRET_BYTES {
2170 return Err(CredentialStoreError::CorruptState);
2171 }
2172 validate_regular(&descriptor_stat, device(&parent_stat)?, Some(size))?;
2173 validate_regular(&path_stat, device(&parent_stat)?, Some(size))?;
2174 let descriptor_identity = identity_with_birth(&descriptor_stat, entry, SECRET_FILE)?;
2175 if descriptor_identity != identity_with_birth(&path_stat, entry, SECRET_FILE)? {
2176 return Err(CredentialStoreError::UnsafeStorage);
2177 }
2178 Ok((descriptor_identity, size))
2179}
2180
2181fn read_tombstone_identity(entry: &File) -> Result<FileIdentity, CredentialStoreError> {
2182 let (identity, size) = read_secret_or_tombstone_identity(entry)?;
2183 if size != 0 {
2184 return Err(CredentialStoreError::CorruptState);
2185 }
2186 Ok(identity)
2187}
2188
2189fn parse_reference(value: &str) -> Result<&str, CredentialStoreError> {
2190 let id = value
2191 .strip_prefix(REFERENCE_PREFIX)
2192 .ok_or(CredentialStoreError::InvalidInput)?;
2193 valid_id(id)
2194 .then_some(id)
2195 .ok_or(CredentialStoreError::InvalidInput)
2196}
2197
2198#[cfg(target_os = "macos")]
2199fn current_user_home_from_account_record() -> Result<PathBuf, CredentialStoreError> {
2200 use std::os::unix::process::CommandExt as _;
2201 use std::process::{Command, Stdio};
2202
2203 let uid = geteuid().as_raw();
2204 let uid_text = uid.to_string();
2205 let mut command = Command::new("/usr/bin/dscacheutil");
2206 command
2207 .args(["-q", "user", "-a", "uid", &uid_text])
2208 .env_clear()
2209 .stdin(Stdio::null())
2210 .stderr(Stdio::null())
2211 .stdout(Stdio::piped())
2212 .process_group(0);
2213 let output = run_bounded_account_lookup(
2214 &mut command,
2215 ACCOUNT_LOOKUP_TIMEOUT,
2216 MAX_ACCOUNT_RECORD_BYTES,
2217 )?;
2218 parse_account_home(&output, uid)
2219}
2220
2221#[cfg(target_os = "linux")]
2222fn current_user_home_from_account_record() -> Result<PathBuf, CredentialStoreError> {
2223 use std::os::unix::process::CommandExt as _;
2224 use std::process::{Command, Stdio};
2225
2226 let uid = geteuid().as_raw();
2227 let uid_text = uid.to_string();
2228 let mut command = Command::new("/usr/bin/getent");
2229 command
2230 .args(["passwd", &uid_text])
2231 .env_clear()
2232 .stdin(Stdio::null())
2233 .stderr(Stdio::null())
2234 .stdout(Stdio::piped())
2235 .process_group(0);
2236 let output = run_bounded_account_lookup(
2237 &mut command,
2238 ACCOUNT_LOOKUP_TIMEOUT,
2239 MAX_ACCOUNT_RECORD_BYTES,
2240 )?;
2241 parse_linux_account_home(&output, uid)
2242}
2243
2244#[cfg(any(target_os = "linux", target_os = "macos"))]
2245fn run_bounded_account_lookup(
2246 command: &mut std::process::Command,
2247 timeout: Duration,
2248 max_bytes: usize,
2249) -> Result<Vec<u8>, CredentialStoreError> {
2250 let child = command
2251 .spawn()
2252 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2253 let mut child = AccountLookupChild {
2254 child: Some(child),
2255 completed: false,
2256 };
2257 let mut stdout = child
2258 .child_mut()?
2259 .stdout
2260 .take()
2261 .ok_or(CredentialStoreError::UnsafeStorage)?;
2262 rustix::fs::fcntl_setfl(&stdout, OFlags::NONBLOCK)
2263 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2264 let started = Instant::now();
2265 let mut output = Vec::with_capacity(256);
2266 let status = loop {
2267 let _ = read_bounded_nonblocking(&mut stdout, &mut output, max_bytes)?;
2268 if let Some(status) = child
2269 .child_mut()?
2270 .try_wait()
2271 .map_err(|_| CredentialStoreError::UnsafeStorage)?
2272 {
2273 break status;
2274 }
2275 if started.elapsed() >= timeout {
2276 return Err(CredentialStoreError::UnsafeStorage);
2277 }
2278 std::thread::sleep(ACCOUNT_LOOKUP_POLL_INTERVAL.min(timeout));
2279 };
2280 let reached_eof = read_bounded_nonblocking(&mut stdout, &mut output, max_bytes)?;
2281 if !status.success() || !reached_eof {
2282 return Err(CredentialStoreError::UnsafeStorage);
2283 }
2284 child.completed = true;
2285 Ok(output)
2286}
2287
2288#[cfg(any(target_os = "linux", target_os = "macos"))]
2289fn read_bounded_nonblocking(
2290 stdout: &mut std::process::ChildStdout,
2291 output: &mut Vec<u8>,
2292 max_bytes: usize,
2293) -> Result<bool, CredentialStoreError> {
2294 let mut chunk = [0_u8; 512];
2295 loop {
2296 match stdout.read(&mut chunk) {
2297 Ok(0) => return Ok(true),
2298 Ok(length) => {
2299 if output.len().saturating_add(length) > max_bytes {
2300 return Err(CredentialStoreError::UnsafeStorage);
2301 }
2302 output.extend_from_slice(&chunk[..length]);
2303 }
2304 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => return Ok(false),
2305 Err(_) => return Err(CredentialStoreError::UnsafeStorage),
2306 }
2307 }
2308}
2309
2310#[cfg(any(target_os = "linux", target_os = "macos"))]
2311struct AccountLookupChild {
2312 child: Option<std::process::Child>,
2313 completed: bool,
2314}
2315
2316#[cfg(any(target_os = "linux", target_os = "macos"))]
2317impl AccountLookupChild {
2318 fn child_mut(&mut self) -> Result<&mut std::process::Child, CredentialStoreError> {
2319 self.child
2320 .as_mut()
2321 .ok_or(CredentialStoreError::UnsafeStorage)
2322 }
2323}
2324
2325#[cfg(any(target_os = "linux", target_os = "macos"))]
2326impl Drop for AccountLookupChild {
2327 fn drop(&mut self) {
2328 if self.completed {
2329 return;
2330 }
2331 let Some(mut child) = self.child.take() else {
2332 return;
2333 };
2334 let process_group = rustix::process::Pid::from_child(&child);
2335 let _ = rustix::process::kill_process_group(process_group, rustix::process::Signal::KILL);
2336 let _ = child.kill();
2337 let deadline = Instant::now() + ACCOUNT_LOOKUP_CLEANUP_DEADLINE;
2338 loop {
2339 match child.try_wait() {
2340 Ok(Some(_)) => return,
2341 Ok(None) if Instant::now() < deadline => {
2342 std::thread::sleep(ACCOUNT_LOOKUP_POLL_INTERVAL)
2343 }
2344 _ => break,
2345 }
2346 }
2347 let _ = std::thread::Builder::new()
2348 .name("steadymux-account-lookup-reaper".to_owned())
2349 .spawn(move || {
2350 let _ = child.wait();
2351 });
2352 }
2353}
2354
2355#[cfg(target_os = "macos")]
2356fn parse_account_home(bytes: &[u8], expected_uid: u32) -> Result<PathBuf, CredentialStoreError> {
2357 let text = std::str::from_utf8(bytes).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2358 let mut observed_uid = None;
2359 let mut home = None;
2360 for line in text.lines() {
2361 let Some((key, value)) = line.split_once(':') else {
2362 continue;
2363 };
2364 match key.trim() {
2365 "uid" if observed_uid.is_none() => {
2366 observed_uid = Some(
2367 value
2368 .trim()
2369 .parse::<u32>()
2370 .map_err(|_| CredentialStoreError::UnsafeStorage)?,
2371 );
2372 }
2373 "uid" => return Err(CredentialStoreError::UnsafeStorage),
2374 "dir" if home.is_none() => home = Some(PathBuf::from(value.trim())),
2375 "dir" => return Err(CredentialStoreError::UnsafeStorage),
2376 _ => {}
2377 }
2378 }
2379 let home = home.ok_or(CredentialStoreError::UnsafeStorage)?;
2380 if observed_uid != Some(expected_uid) {
2381 return Err(CredentialStoreError::UnsafeStorage);
2382 }
2383 validate_absolute_path(&home)?;
2384 Ok(home)
2385}
2386
2387#[cfg(target_os = "linux")]
2388fn parse_linux_account_home(
2389 bytes: &[u8],
2390 expected_uid: u32,
2391) -> Result<PathBuf, CredentialStoreError> {
2392 use std::ffi::OsStr;
2393 use std::os::unix::ffi::OsStrExt as _;
2394
2395 let mut records = bytes
2396 .split(|byte| *byte == b'\n')
2397 .filter(|record| !record.is_empty());
2398 let record = records.next().ok_or(CredentialStoreError::UnsafeStorage)?;
2399 if records.next().is_some() {
2400 return Err(CredentialStoreError::UnsafeStorage);
2401 }
2402 let fields = record.split(|byte| *byte == b':').collect::<Vec<_>>();
2403 if fields.len() != 7 {
2404 return Err(CredentialStoreError::UnsafeStorage);
2405 }
2406 let uid = std::str::from_utf8(fields[2])
2407 .map_err(|_| CredentialStoreError::UnsafeStorage)?
2408 .parse::<u32>()
2409 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2410 if uid != expected_uid || fields[5].is_empty() || fields[5].contains(&0) {
2411 return Err(CredentialStoreError::UnsafeStorage);
2412 }
2413 let home = PathBuf::from(OsStr::from_bytes(fields[5]));
2414 validate_absolute_path(&home)?;
2415 Ok(home)
2416}
2417
2418fn generate_unused_id(entries: &File) -> Result<String, CredentialStoreError> {
2419 for _ in 0..8 {
2420 let mut random = [0_u8; 16];
2421 getrandom::fill(&mut random).map_err(|_| CredentialStoreError::CommitAmbiguous)?;
2422 let id = random
2423 .iter()
2424 .fold(String::with_capacity(32), |mut output, byte| {
2425 use std::fmt::Write as _;
2426 let _ = write!(output, "{byte:02x}");
2427 output
2428 });
2429 match statat(entries, &id, AtFlags::SYMLINK_NOFOLLOW) {
2430 Ok(_) => continue,
2431 Err(error) if error == rustix::io::Errno::NOENT => return Ok(id),
2432 Err(_) => return Err(CredentialStoreError::UnsafeStorage),
2433 }
2434 }
2435 Err(CredentialStoreError::CapacityExhausted)
2436}
2437
2438fn recover_add_staging(entries: &File) -> Result<(), CredentialStoreError> {
2439 let directory = Dir::read_from(entries).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2440 let mut staging = Vec::new();
2441 for item in directory {
2442 let item = item.map_err(|_| CredentialStoreError::UnsafeStorage)?;
2443 let name = item
2444 .file_name()
2445 .to_str()
2446 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2447 if matches!(name, "." | "..") || !name.starts_with('.') {
2448 continue;
2449 }
2450 if !name.starts_with(".staging-") || !valid_staging_name(name) {
2451 if valid_staging_name(name) {
2452 return Err(CredentialStoreError::RecoveryRequired);
2453 }
2454 return Err(CredentialStoreError::CorruptState);
2455 }
2456 staging.push(name.to_owned());
2457 }
2458 for name in staging {
2459 remove_entry_directory(entries, &name)?;
2460 }
2461 if !staging_is_empty(entries)? {
2462 return Err(CredentialStoreError::CorruptState);
2463 }
2464 durable_sync(entries)
2465}
2466
2467fn cleanup_failed_add(entries: &File, staged: &File, staging: &str, committed: bool) {
2468 if committed {
2469 return;
2470 }
2471 let _ = unlinkat(staged, SECRET_FILE, AtFlags::empty());
2472 let _ = unlinkat(staged, METADATA_FILE, AtFlags::empty());
2473 let _ = unlinkat(entries, staging, AtFlags::REMOVEDIR);
2474}
2475
2476fn remove_entry_directory(entries: &File, name: &str) -> Result<(), CredentialStoreError> {
2477 let directory = open_private_directory(entries, name)?;
2478 let directory_device =
2479 device(&fstat(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?)?;
2480 let contents = Dir::read_from(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2481 for item in contents {
2482 let item = item.map_err(|_| CredentialStoreError::UnsafeStorage)?;
2483 let child = item
2484 .file_name()
2485 .to_str()
2486 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2487 if matches!(child, "." | "..") {
2488 continue;
2489 }
2490 if !matches!(child, SECRET_FILE | METADATA_FILE) {
2491 return Err(CredentialStoreError::CorruptState);
2492 }
2493 let child_stat = statat(&directory, child, AtFlags::SYMLINK_NOFOLLOW)
2494 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2495 validate_regular(&child_stat, directory_device, None)?;
2496 }
2497 for file in [SECRET_FILE, METADATA_FILE] {
2498 match unlinkat(&directory, file, AtFlags::empty()) {
2499 Ok(()) => {}
2500 Err(error) if error == rustix::io::Errno::NOENT => {}
2501 Err(_) => return Err(CredentialStoreError::UnsafeStorage),
2502 }
2503 }
2504 let mut remaining =
2505 Dir::read_from(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2506 if remaining.any(|entry| {
2507 entry
2508 .ok()
2509 .and_then(|entry| entry.file_name().to_str().ok().map(str::to_owned))
2510 .is_none_or(|entry| !matches!(entry.as_str(), "." | ".."))
2511 }) {
2512 return Err(CredentialStoreError::CorruptState);
2513 }
2514 unlinkat(entries, name, AtFlags::REMOVEDIR).map_err(|_| CredentialStoreError::UnsafeStorage)
2515}
2516
2517fn valid_staging_name(name: &str) -> bool {
2518 if let Some(id) = name.strip_prefix(".staging-") {
2519 return valid_id(id);
2520 }
2521 for prefix in [".rotate-", ".remove-"] {
2522 if let Some(remainder) = name.strip_prefix(prefix)
2523 && let Some((id, generation)) = remainder.rsplit_once('-')
2524 {
2525 return valid_id(id)
2526 && generation
2527 .parse::<u64>()
2528 .is_ok_and(|generation| generation > 0);
2529 }
2530 }
2531 false
2532}
2533
2534fn staging_is_empty(entries: &File) -> Result<bool, CredentialStoreError> {
2535 let directory = Dir::read_from(entries).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2536 for item in directory {
2537 let item = item.map_err(|_| CredentialStoreError::UnsafeStorage)?;
2538 if valid_staging_name(
2539 item.file_name()
2540 .to_str()
2541 .map_err(|_| CredentialStoreError::UnsafeStorage)?,
2542 ) {
2543 return Ok(false);
2544 }
2545 }
2546 Ok(true)
2547}
2548
2549fn valid_secret(value: &[u8]) -> bool {
2550 !value.is_empty()
2551 && value.len() <= MAX_SECRET_BYTES
2552 && !value.contains(&b'\r')
2553 && !value.contains(&b'\n')
2554}
2555
2556#[cfg(target_os = "macos")]
2557fn ensure_production_store(home: &Path) -> Result<(PathBuf, File), CredentialStoreError> {
2558 validate_absolute_path(home)?;
2559 let home_directory = open_path_directory(home)?;
2560 validate_owned_directory(
2561 &fstat(&home_directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
2562 )?;
2563 let library = open_owned_directory(&home_directory, "Library")?;
2564 let application_support = open_owned_directory(&library, "Application Support")?;
2565 let product = open_or_create_private_directory(&application_support, "SteadyMux")?;
2566 let root = open_or_create_private_directory(&product, "credentials-v1")?;
2567 Ok((
2568 home.join("Library")
2569 .join("Application Support")
2570 .join("SteadyMux")
2571 .join("credentials-v1"),
2572 root,
2573 ))
2574}
2575
2576#[cfg(target_os = "macos")]
2577fn open_production_store(home: &Path) -> Result<(PathBuf, File), CredentialStoreError> {
2578 validate_absolute_path(home)?;
2579 let home_directory = open_path_directory(home)?;
2580 validate_owned_directory(
2581 &fstat(&home_directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
2582 )?;
2583 let library = open_owned_directory(&home_directory, "Library")?;
2584 let application_support = open_owned_directory(&library, "Application Support")?;
2585 let product = open_private_directory(&application_support, "SteadyMux")?;
2586 let root = open_private_directory(&product, "credentials-v1")?;
2587 Ok((
2588 home.join("Library")
2589 .join("Application Support")
2590 .join("SteadyMux")
2591 .join("credentials-v1"),
2592 root,
2593 ))
2594}
2595
2596#[cfg(target_os = "linux")]
2597fn linux_data_home_path(
2598 home: &Path,
2599 xdg_data_home: Option<&std::ffi::OsStr>,
2600) -> Result<PathBuf, CredentialStoreError> {
2601 validate_absolute_path(home)?;
2602 let data_home = match xdg_data_home.filter(|value| !value.is_empty()) {
2603 Some(value) => PathBuf::from(value),
2604 None => home.join(".local").join("share"),
2605 };
2606 validate_absolute_path(&data_home)?;
2607 if data_home.as_os_str().as_encoded_bytes().len() > MAX_LINUX_DATA_HOME_BYTES {
2608 return Err(CredentialStoreError::UnsafeStorage);
2609 }
2610 Ok(data_home)
2611}
2612
2613#[cfg(target_os = "linux")]
2614fn linux_production_root_path(
2615 home: &Path,
2616 xdg_data_home: Option<&std::ffi::OsStr>,
2617) -> Result<PathBuf, CredentialStoreError> {
2618 Ok(linux_data_home_path(home, xdg_data_home)?
2619 .join("SteadyMux")
2620 .join("credentials-v1"))
2621}
2622
2623#[cfg(target_os = "linux")]
2624fn ensure_linux_production_store(
2625 home: &Path,
2626 xdg_data_home: Option<&std::ffi::OsStr>,
2627) -> Result<(PathBuf, File), CredentialStoreError> {
2628 let data_home_path = linux_data_home_path(home, xdg_data_home)?;
2629 let data_home = if xdg_data_home.is_some_and(|value| !value.is_empty()) {
2630 let directory = open_path_directory(&data_home_path)?;
2631 validate_owned_directory(
2632 &fstat(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
2633 )?;
2634 directory
2635 } else {
2636 let home_directory = open_path_directory(home)?;
2637 validate_owned_directory(
2638 &fstat(&home_directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
2639 )?;
2640 let local = open_or_create_owned_directory(&home_directory, ".local")?;
2641 open_or_create_owned_directory(&local, "share")?
2642 };
2643 let product = open_or_create_private_directory(&data_home, "SteadyMux")?;
2644 let root = open_or_create_private_directory(&product, "credentials-v1")?;
2645 Ok((linux_production_root_path(home, xdg_data_home)?, root))
2646}
2647
2648#[cfg(target_os = "linux")]
2649fn open_linux_production_store(
2650 home: &Path,
2651 xdg_data_home: Option<&std::ffi::OsStr>,
2652) -> Result<(PathBuf, File), CredentialStoreError> {
2653 let data_home_path = linux_data_home_path(home, xdg_data_home)?;
2654 let data_home = if xdg_data_home.is_some_and(|value| !value.is_empty()) {
2655 let directory = open_path_directory(&data_home_path)?;
2656 validate_owned_directory(
2657 &fstat(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
2658 )?;
2659 directory
2660 } else {
2661 let home_directory = open_path_directory(home)?;
2662 validate_owned_directory(
2663 &fstat(&home_directory).map_err(|_| CredentialStoreError::UnsafeStorage)?,
2664 )?;
2665 let local = open_owned_directory(&home_directory, ".local")?;
2666 open_owned_directory(&local, "share")?
2667 };
2668 let product = open_private_directory(&data_home, "SteadyMux")?;
2669 let root = open_private_directory(&product, "credentials-v1")?;
2670 Ok((linux_production_root_path(home, xdg_data_home)?, root))
2671}
2672
2673#[cfg(test)]
2674fn ensure_private_components(
2675 boundary: &File,
2676 relative: &Path,
2677) -> Result<File, CredentialStoreError> {
2678 if relative.as_os_str().is_empty()
2679 || relative
2680 .components()
2681 .any(|component| !matches!(component, Component::Normal(_)))
2682 {
2683 return Err(CredentialStoreError::UnsafeStorage);
2684 }
2685 let mut current = boundary
2686 .try_clone()
2687 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2688 for component in relative.components() {
2689 let Component::Normal(name) = component else {
2690 return Err(CredentialStoreError::UnsafeStorage);
2691 };
2692 current = open_or_create_owned_directory(¤t, name)?;
2693 }
2694 Ok(current)
2695}
2696
2697#[cfg(any(test, target_os = "linux"))]
2698fn open_or_create_owned_directory(
2699 parent: &File,
2700 name: impl AsRef<Path>,
2701) -> Result<File, CredentialStoreError> {
2702 let name = name.as_ref();
2703 match open_owned_directory(parent, name) {
2704 Ok(directory) => Ok(directory),
2705 Err(CredentialStoreError::NotFound) => {
2706 mkdirat(parent, name, Mode::from_raw_mode(0o700))
2707 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2708 let directory = open_private_directory(parent, name)?;
2709 durable_sync(parent)?;
2710 Ok(directory)
2711 }
2712 Err(error) => Err(error),
2713 }
2714}
2715
2716fn open_owned_directory(
2717 parent: &File,
2718 name: impl AsRef<Path>,
2719) -> Result<File, CredentialStoreError> {
2720 let directory = File::from(strict_openat(
2721 parent,
2722 name,
2723 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2724 Mode::empty(),
2725 )?);
2726 let parent_stat = fstat(parent).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2727 let directory_stat = fstat(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2728 validate_owned_directory(&directory_stat)?;
2729 if device(&directory_stat)? != device(&parent_stat)? {
2730 return Err(CredentialStoreError::UnsafeStorage);
2731 }
2732 Ok(directory)
2733}
2734
2735fn validate_owned_directory(stat: &rustix::fs::Stat) -> Result<(), CredentialStoreError> {
2736 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
2737 || stat.st_uid != geteuid().as_raw()
2738 || stat.st_mode & 0o022 != 0
2739 {
2740 return Err(CredentialStoreError::UnsafeStorage);
2741 }
2742 Ok(())
2743}
2744
2745fn validate_absolute_path(path: &Path) -> Result<(), CredentialStoreError> {
2746 if !path.is_absolute()
2747 || path.as_os_str().as_encoded_bytes().contains(&0)
2748 || path
2749 .components()
2750 .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
2751 {
2752 return Err(CredentialStoreError::UnsafeStorage);
2753 }
2754 Ok(())
2755}
2756
2757fn open_path_directory(path: &Path) -> Result<File, CredentialStoreError> {
2758 validate_absolute_path(path)?;
2759 let mut current = File::from(
2760 open(
2761 "/",
2762 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2763 Mode::empty(),
2764 )
2765 .map_err(|_| CredentialStoreError::UnsafeStorage)?,
2766 );
2767 for component in path.components() {
2768 let Component::Normal(name) = component else {
2769 continue;
2770 };
2771 current = File::from(strict_openat(
2772 ¤t,
2773 name,
2774 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2775 Mode::empty(),
2776 )?);
2777 }
2778 Ok(current)
2779}
2780
2781fn open_or_create_private_directory(
2782 parent: &File,
2783 name: impl AsRef<Path>,
2784) -> Result<File, CredentialStoreError> {
2785 let name = name.as_ref();
2786 match open_private_directory(parent, name) {
2787 Ok(directory) => Ok(directory),
2788 Err(CredentialStoreError::NotFound) => {
2789 mkdirat(parent, name, Mode::from_raw_mode(0o700))
2790 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2791 let directory = open_private_directory(parent, name)?;
2792 durable_sync(parent)?;
2793 Ok(directory)
2794 }
2795 Err(error) => Err(error),
2796 }
2797}
2798
2799fn open_private_directory(
2800 parent: &File,
2801 name: impl AsRef<Path>,
2802) -> Result<File, CredentialStoreError> {
2803 let directory = File::from(strict_openat(
2804 parent,
2805 name,
2806 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2807 Mode::empty(),
2808 )?);
2809 let parent_stat = fstat(parent).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2810 let directory_stat = fstat(&directory).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2811 validate_directory(&directory_stat)?;
2812 if device(&directory_stat)? != device(&parent_stat)? {
2813 return Err(CredentialStoreError::UnsafeStorage);
2814 }
2815 Ok(directory)
2816}
2817
2818fn write_new_file(parent: &File, name: &str, bytes: &[u8]) -> Result<(), CredentialStoreError> {
2819 let fd = strict_openat(
2820 parent,
2821 name,
2822 OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2823 Mode::from_raw_mode(0o600),
2824 )?;
2825 fchmod(&fd, Mode::from_raw_mode(0o600)).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2826 let mut file = File::from(fd);
2827 file.write_all(bytes)
2828 .map_err(|_| CredentialStoreError::CommitAmbiguous)?;
2829 file.flush()
2830 .map_err(|_| CredentialStoreError::CommitAmbiguous)?;
2831 durable_sync(&file)?;
2832 let parent_stat = fstat(parent).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2833 let file_stat = fstat(&file).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2834 let path_stat = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
2835 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2836 validate_regular(&file_stat, device(&parent_stat)?, Some(bytes.len()))?;
2837 validate_regular(&path_stat, device(&parent_stat)?, Some(bytes.len()))?;
2838 if identity(&file_stat)? != identity(&path_stat)? {
2839 return Err(CredentialStoreError::UnsafeStorage);
2840 }
2841 Ok(())
2842}
2843
2844fn read_regular_file(
2845 parent: &File,
2846 name: &str,
2847 limit: usize,
2848 secret: bool,
2849) -> Result<Vec<u8>, CredentialStoreError> {
2850 read_regular_file_with_identity(parent, name, limit, secret).map(|(bytes, _)| bytes)
2851}
2852
2853fn read_regular_file_with_identity(
2854 parent: &File,
2855 name: &str,
2856 limit: usize,
2857 secret: bool,
2858) -> Result<(Vec<u8>, FileIdentity), CredentialStoreError> {
2859 let file = File::from(strict_openat(
2860 parent,
2861 name,
2862 OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
2863 Mode::empty(),
2864 )?);
2865 let parent_stat = fstat(parent).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2866 let before = fstat(&file).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2867 validate_regular(&before, device(&parent_stat)?, None)?;
2868 let path_stat = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
2869 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2870 if identity(&before)? != identity(&path_stat)? {
2871 return Err(CredentialStoreError::UnsafeStorage);
2872 }
2873 let mut bytes = Vec::new();
2874 (&file)
2875 .take((limit + 1) as u64)
2876 .read_to_end(&mut bytes)
2877 .map_err(|_| CredentialStoreError::CorruptState)?;
2878 let after = fstat(&file).map_err(|_| CredentialStoreError::UnsafeStorage)?;
2879 let final_path_stat = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW)
2880 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2881 validate_regular(&after, device(&parent_stat)?, Some(bytes.len()))?;
2882 if bytes.len() > limit
2883 || bytes.is_empty()
2884 || identity(&before)? != identity(&after)?
2885 || identity(&before)? != identity(&final_path_stat)?
2886 || (secret && after.st_mode & 0o777 != 0o600)
2887 {
2888 return Err(CredentialStoreError::CorruptState);
2889 }
2890 Ok((bytes, identity_with_birth(&after, parent, name)?))
2891}
2892
2893fn rename_replace(
2894 parent: &File,
2895 source: &str,
2896 destination: &str,
2897) -> Result<(), CredentialStoreError> {
2898 renameat(parent, source, parent, destination).map_err(|_| CredentialStoreError::CommitAmbiguous)
2899}
2900
2901#[cfg(any(target_os = "linux", target_os = "macos"))]
2902fn rename_new_entry(entries: &File, staging: &str, id: &str) -> Result<(), CredentialStoreError> {
2903 renameat_with(entries, staging, entries, id, RenameFlags::NOREPLACE)
2904 .map_err(|_| CredentialStoreError::CommitAmbiguous)
2905}
2906
2907#[cfg(not(any(target_os = "linux", target_os = "macos")))]
2908fn rename_new_entry(entries: &File, staging: &str, id: &str) -> Result<(), CredentialStoreError> {
2909 match statat(entries, id, AtFlags::SYMLINK_NOFOLLOW) {
2910 Err(error) if error == rustix::io::Errno::NOENT => {}
2911 _ => return Err(CredentialStoreError::CommitAmbiguous),
2912 }
2913 renameat(entries, staging, entries, id).map_err(|_| CredentialStoreError::CommitAmbiguous)
2914}
2915
2916#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
2917#[serde(deny_unknown_fields)]
2918struct BirthTime {
2919 seconds: i64,
2920 nanoseconds: u32,
2921}
2922
2923#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
2924#[serde(deny_unknown_fields)]
2925struct FileIdentity {
2926 device: u64,
2927 inode: u64,
2928 owner: u32,
2929 mode: u32,
2930 link_count: u64,
2931 #[serde(default)]
2932 birth_time: Option<BirthTime>,
2933}
2934
2935fn identity(stat: &rustix::fs::Stat) -> Result<FileIdentity, CredentialStoreError> {
2936 Ok(FileIdentity {
2937 device: device(stat)?,
2938 inode: stat.st_ino,
2939 owner: stat.st_uid,
2940 mode: mode_bits(stat),
2941 link_count: link_count(stat),
2942 birth_time: birth_time_from_stat(stat),
2943 })
2944}
2945
2946#[cfg(target_os = "linux")]
2947fn birth_time_from_stat(_stat: &rustix::fs::Stat) -> Option<BirthTime> {
2948 None
2949}
2950
2951#[cfg(target_os = "macos")]
2952fn birth_time_from_stat(stat: &rustix::fs::Stat) -> Option<BirthTime> {
2953 Some(BirthTime {
2954 seconds: stat.st_birthtime,
2955 nanoseconds: u32::try_from(stat.st_birthtime_nsec).ok()?,
2956 })
2957}
2958
2959#[cfg(target_os = "linux")]
2964fn identity_with_birth(
2965 stat: &rustix::fs::Stat,
2966 parent: &File,
2967 name: &str,
2968) -> Result<FileIdentity, CredentialStoreError> {
2969 let mut file_identity = identity(stat)?;
2970 let statx = rustix::fs::statx(parent, name, AtFlags::SYMLINK_NOFOLLOW, StatxFlags::BTIME)
2971 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
2972 if statx.stx_ino != file_identity.inode {
2973 return Err(CredentialStoreError::UnsafeStorage);
2974 }
2975 if statx.stx_mask & StatxFlags::BTIME.bits() != 0 {
2976 file_identity.birth_time = Some(BirthTime {
2977 seconds: statx.stx_btime.tv_sec,
2978 nanoseconds: statx.stx_btime.tv_nsec,
2979 });
2980 }
2981 Ok(file_identity)
2982}
2983
2984#[cfg(target_os = "macos")]
2985fn identity_with_birth(
2986 stat: &rustix::fs::Stat,
2987 _parent: &File,
2988 _name: &str,
2989) -> Result<FileIdentity, CredentialStoreError> {
2990 identity(stat)
2991}
2992
2993#[cfg(target_os = "linux")]
2999fn identity_with_birth_fd(
3000 stat: &rustix::fs::Stat,
3001 fd: &File,
3002) -> Result<FileIdentity, CredentialStoreError> {
3003 let mut file_identity = identity(stat)?;
3004 let statx = rustix::fs::statx(fd, "", AtFlags::EMPTY_PATH, StatxFlags::BTIME)
3005 .map_err(|_| CredentialStoreError::UnsafeStorage)?;
3006 if statx.stx_ino != file_identity.inode {
3007 return Err(CredentialStoreError::UnsafeStorage);
3008 }
3009 if statx.stx_mask & StatxFlags::BTIME.bits() != 0 {
3010 file_identity.birth_time = Some(BirthTime {
3011 seconds: statx.stx_btime.tv_sec,
3012 nanoseconds: statx.stx_btime.tv_nsec,
3013 });
3014 }
3015 Ok(file_identity)
3016}
3017
3018#[cfg(target_os = "macos")]
3019fn identity_with_birth_fd(
3020 stat: &rustix::fs::Stat,
3021 _fd: &File,
3022) -> Result<FileIdentity, CredentialStoreError> {
3023 identity(stat)
3024}
3025
3026#[cfg(target_os = "linux")]
3027fn mode_bits(stat: &rustix::fs::Stat) -> u32 {
3028 stat.st_mode & 0o777
3029}
3030
3031#[cfg(not(target_os = "linux"))]
3032fn mode_bits(stat: &rustix::fs::Stat) -> u32 {
3033 u32::from(stat.st_mode & 0o777)
3034}
3035
3036#[cfg(target_os = "linux")]
3037fn link_count(stat: &rustix::fs::Stat) -> u64 {
3038 stat.st_nlink
3039}
3040
3041#[cfg(not(target_os = "linux"))]
3042fn link_count(stat: &rustix::fs::Stat) -> u64 {
3043 u64::from(stat.st_nlink)
3044}
3045
3046fn same_filesystem_object(left: FileIdentity, right: FileIdentity) -> bool {
3047 left.device == right.device && left.inode == right.inode
3048}
3049
3050fn validate_directory(stat: &rustix::fs::Stat) -> Result<(), CredentialStoreError> {
3051 if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
3052 || stat.st_uid != geteuid().as_raw()
3053 || stat.st_mode & 0o777 != 0o700
3054 {
3055 return Err(CredentialStoreError::UnsafeStorage);
3056 }
3057 Ok(())
3058}
3059
3060fn validate_regular(
3061 stat: &rustix::fs::Stat,
3062 device: u64,
3063 expected_len: Option<usize>,
3064) -> Result<(), CredentialStoreError> {
3065 if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
3066 || stat.st_uid != geteuid().as_raw()
3067 || self::device(stat)? != device
3068 || stat.st_nlink != 1
3069 || stat.st_mode & 0o777 != 0o600
3070 || expected_len.is_some_and(|length| usize::try_from(stat.st_size).ok() != Some(length))
3071 {
3072 return Err(CredentialStoreError::UnsafeStorage);
3073 }
3074 Ok(())
3075}
3076
3077fn device(stat: &rustix::fs::Stat) -> Result<u64, CredentialStoreError> {
3078 #[cfg(target_os = "linux")]
3079 {
3080 Ok(stat.st_dev)
3081 }
3082 #[cfg(not(target_os = "linux"))]
3083 {
3084 u64::try_from(stat.st_dev).map_err(|_| CredentialStoreError::UnsafeStorage)
3085 }
3086}
3087
3088#[cfg(target_os = "macos")]
3089fn durable_sync(file: &impl AsFd) -> Result<(), CredentialStoreError> {
3090 rustix::fs::fcntl_fullfsync(file).map_err(|_| CredentialStoreError::CommitAmbiguous)
3091}
3092
3093#[cfg(not(target_os = "macos"))]
3094fn durable_sync(file: &impl AsFd) -> Result<(), CredentialStoreError> {
3095 rustix::fs::fsync(file).map_err(|_| CredentialStoreError::CommitAmbiguous)
3096}
3097
3098#[cfg(target_os = "linux")]
3099fn strict_openat(
3100 parent: &File,
3101 path: impl AsRef<Path>,
3102 flags: OFlags,
3103 mode: Mode,
3104) -> Result<rustix::fd::OwnedFd, CredentialStoreError> {
3105 openat2(
3106 parent,
3107 path.as_ref(),
3108 flags,
3109 mode,
3110 ResolveFlags::NO_SYMLINKS | ResolveFlags::BENEATH,
3111 )
3112 .map_err(map_open_error)
3113}
3114
3115#[cfg(not(target_os = "linux"))]
3116fn strict_openat(
3117 parent: &File,
3118 path: impl AsRef<Path>,
3119 flags: OFlags,
3120 mode: Mode,
3121) -> Result<rustix::fd::OwnedFd, CredentialStoreError> {
3122 openat(parent, path.as_ref(), flags, mode).map_err(map_open_error)
3123}
3124
3125fn map_open_error(error: rustix::io::Errno) -> CredentialStoreError {
3126 if error == rustix::io::Errno::NOENT {
3127 CredentialStoreError::NotFound
3128 } else {
3129 CredentialStoreError::UnsafeStorage
3130 }
3131}
3132
3133#[cfg(test)]
3134mod tests {
3135 use super::*;
3136 use std::collections::BTreeMap;
3137 use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink};
3138 use std::sync::{Arc, Barrier};
3139
3140 #[test]
3141 fn explicit_test_root_reopen_enforces_owner_only_store_invariants() {
3142 let temporary = tempfile::tempdir().expect("temporary root");
3143 let root = temporary.path().join("credentials-v1");
3144 let store = LocalCredentialStore::at_test_root(&root).expect("create explicit store");
3145 let metadata = store
3146 .add("openrouter", "primary", b"secret-canary")
3147 .expect("add credential");
3148 drop(store);
3149
3150 let entry = root.join(ENTRIES_DIRECTORY).join(&metadata.id);
3151 for directory in [&root, &root.join(ENTRIES_DIRECTORY), &entry] {
3152 let mode = std::fs::metadata(directory)
3153 .expect("directory metadata")
3154 .mode()
3155 & 0o777;
3156 assert_eq!(mode, 0o700);
3157 }
3158 for file in [entry.join(SECRET_FILE), entry.join(METADATA_FILE)] {
3159 let file_metadata = std::fs::metadata(file).expect("credential file metadata");
3160 assert_eq!(file_metadata.mode() & 0o777, 0o600);
3161 assert_eq!(file_metadata.nlink(), 1);
3162 }
3163
3164 LocalCredentialStore::at_existing_test_root(&root).expect("reopen private store");
3165 std::fs::set_permissions(
3166 root.join(ENTRIES_DIRECTORY),
3167 std::fs::Permissions::from_mode(0o755),
3168 )
3169 .expect("relax entries mode");
3170 assert!(matches!(
3171 LocalCredentialStore::at_existing_test_root(&root),
3172 Err(CredentialStoreError::UnsafeStorage)
3173 ));
3174 std::fs::set_permissions(
3175 root.join(ENTRIES_DIRECTORY),
3176 std::fs::Permissions::from_mode(0o700),
3177 )
3178 .expect("restore entries mode");
3179 let linked_root = temporary.path().join("linked-credentials-v1");
3180 symlink(&root, &linked_root).expect("symlink store root");
3181 assert!(matches!(
3182 LocalCredentialStore::at_existing_test_root(&linked_root),
3183 Err(CredentialStoreError::UnsafeStorage)
3184 ));
3185 }
3186
3187 #[test]
3188 fn lock_retries_transient_flock_interruption_and_succeeds() {
3189 let temporary = tempfile::tempdir().expect("temporary root");
3190 let root = temporary.path().join("provider-credentials-v1");
3191 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3192 store.flock_eintr_injections.store(8, Ordering::SeqCst);
3193 let metadata = store
3194 .add("openrouter", "primary", b"secret-canary")
3195 .expect("add credential after transient flock interruption");
3196 assert_eq!(
3197 store.flock_eintr_injections.load(Ordering::SeqCst),
3198 0,
3199 "injected EINTR interruption was consumed by the retry loop"
3200 );
3201 assert_eq!(
3202 store
3203 .resolve(&metadata.credential_ref)
3204 .expect("resolve after interrupted flock retry")
3205 .as_slice(),
3206 b"secret-canary"
3207 );
3208 }
3209
3210 #[test]
3211 fn lock_exhausts_interrupted_retry_bound_and_fails_closed() {
3212 let temporary = tempfile::tempdir().expect("temporary root");
3213 let root = temporary.path().join("provider-credentials-v1");
3214 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3215 store
3216 .flock_eintr_injections
3217 .store(FLOCK_EINTR_RETRY_BOUND + 1, Ordering::SeqCst);
3218 assert!(matches!(
3219 store.list(),
3220 Err(CredentialStoreError::LockUnavailable)
3221 ));
3222 assert_eq!(
3223 store.flock_eintr_injections.load(Ordering::SeqCst),
3224 0,
3225 "persistent interruptions consumed the retry bound and failed closed"
3226 );
3227 }
3228
3229 #[test]
3230 fn add_list_status_and_resolve_keep_secret_out_of_metadata() {
3231 let temporary = tempfile::tempdir().expect("temporary root");
3232 let root = temporary.path().join("provider-credentials-v1");
3233 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3234 let metadata = store
3235 .add("openrouter", "primary", b"secret-canary")
3236 .expect("add credential");
3237
3238 assert!(metadata.credential_ref.starts_with(REFERENCE_PREFIX));
3239 assert_eq!(store.list().expect("list"), vec![metadata.clone()]);
3240 assert_eq!(
3241 store
3242 .status(&metadata.credential_ref)
3243 .expect("status")
3244 .status,
3245 "available"
3246 );
3247 assert_eq!(
3248 store
3249 .resolve(&metadata.credential_ref)
3250 .expect("resolve")
3251 .as_slice(),
3252 b"secret-canary"
3253 );
3254
3255 let entry = root.join(ENTRIES_DIRECTORY).join(&metadata.id);
3256 let encoded = std::fs::read(entry.join(METADATA_FILE)).expect("metadata");
3257 assert!(
3258 !encoded
3259 .windows(b"secret-canary".len())
3260 .any(|value| value == b"secret-canary")
3261 );
3262 assert_eq!(
3263 std::fs::metadata(entry.join(SECRET_FILE))
3264 .expect("secret metadata")
3265 .mode()
3266 & 0o777,
3267 0o600
3268 );
3269 assert_eq!(
3270 std::fs::metadata(root).expect("root metadata").mode() & 0o777,
3271 0o700
3272 );
3273 }
3274
3275 #[test]
3276 fn inventory_guard_keeps_status_and_availability_on_one_locked_snapshot() {
3277 let temporary = tempfile::tempdir().expect("temporary root");
3278 let root = temporary.path().join("provider-credentials-v1");
3279 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3280 let metadata = store
3281 .add("openrouter", "primary", b"secret-canary")
3282 .expect("add credential");
3283 let guard = store.lock_inventory().expect("inventory guard");
3284 guard
3285 .verify_available(&metadata.credential_ref)
3286 .expect("descriptor availability");
3287 assert_eq!(
3288 guard
3289 .status(&metadata.credential_ref)
3290 .expect("guarded status")
3291 .status,
3292 "available"
3293 );
3294 assert!(matches!(
3295 store.remove_preview(
3296 &metadata.credential_ref,
3297 current_epoch_seconds().unwrap() + 60
3298 ),
3299 Err(CredentialStoreError::LockUnavailable)
3300 ));
3301 assert!(matches!(
3302 store.rotate_preview(
3303 &metadata.credential_ref,
3304 current_epoch_seconds().unwrap() + 60
3305 ),
3306 Err(CredentialStoreError::LockUnavailable)
3307 ));
3308 drop(guard);
3309 assert!(
3310 store
3311 .remove_preview(
3312 &metadata.credential_ref,
3313 current_epoch_seconds().unwrap() + 60
3314 )
3315 .is_ok()
3316 );
3317 }
3318
3319 #[test]
3320 fn authoritative_projected_bindings_block_remove_and_are_reported_for_rotation() {
3321 let temporary = tempfile::tempdir().expect("temporary root");
3322 let root = temporary.path().join("provider-credentials-v1");
3323 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3324 let metadata = store
3325 .add("openrouter", "primary", b"secret-canary")
3326 .expect("add credential");
3327 assert!(metadata.bound_phases.is_empty());
3328 assert!(metadata.bound_routes.is_empty());
3329
3330 let guard = store
3331 .lock_lifecycle_inventory()
3332 .expect("lifecycle inventory guard");
3333 let phases = vec!["openrouter_api".to_owned()];
3334 let routes = vec!["openrouter-primary".to_owned()];
3335 let expires = current_epoch_seconds().expect("clock") + 60;
3336 let removal = guard
3337 .remove_preview_with_bindings(&metadata.credential_ref, &phases, &routes, expires)
3338 .expect("projected removal preview");
3339 assert_eq!(removal.dependency_impact.bound_phases, phases);
3340 assert_eq!(removal.dependency_impact.bound_routes, routes);
3341 assert_eq!(removal.dependency_impact.binding_count, 2);
3342 assert!(!removal.dependency_impact.apply_allowed);
3343 assert_eq!(
3344 guard.remove_apply_with_bindings(
3345 &metadata.credential_ref,
3346 lifecycle_approval(&removal, REMOVE_CONFIRMATION),
3347 &phases,
3348 &routes,
3349 ),
3350 Err(CredentialStoreError::DependencyConflict)
3351 );
3352
3353 let rotation = guard
3354 .rotate_preview_with_bindings(&metadata.credential_ref, &phases, &routes, expires)
3355 .expect("projected rotation preview");
3356 assert_eq!(rotation.dependency_impact.bound_phases, phases);
3357 assert_eq!(rotation.dependency_impact.bound_routes, routes);
3358 assert_eq!(rotation.dependency_impact.binding_count, 2);
3359 assert!(rotation.dependency_impact.apply_allowed);
3360 assert!(matches!(
3361 store.rotate_preview(&metadata.credential_ref, expires),
3362 Err(CredentialStoreError::LockUnavailable)
3363 ));
3364 guard
3365 .verify_available(&metadata.credential_ref)
3366 .expect("blocked removal preserves availability");
3367 drop(guard);
3368 assert_eq!(
3369 store
3370 .resolve(&metadata.credential_ref)
3371 .expect("blocked removal preserves secret")
3372 .as_slice(),
3373 b"secret-canary"
3374 );
3375 }
3376
3377 #[test]
3378 fn rejects_newlines_oversize_values_and_secret_symlinks() {
3379 let temporary = tempfile::tempdir().expect("temporary root");
3380 let root = temporary.path().join("provider-credentials-v1");
3381 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3382 assert_eq!(
3383 store.add("openrouter", "primary", b"secret\n"),
3384 Err(CredentialStoreError::InvalidInput)
3385 );
3386 assert_eq!(
3387 store.add("openrouter", "primary", &vec![b'x'; MAX_SECRET_BYTES + 1]),
3388 Err(CredentialStoreError::InvalidInput)
3389 );
3390
3391 let metadata = store.add("openrouter", "primary", b"safe").expect("add");
3392 let secret = root
3393 .join(ENTRIES_DIRECTORY)
3394 .join(&metadata.id)
3395 .join(SECRET_FILE);
3396 std::fs::remove_file(&secret).expect("remove secret");
3397 symlink("/etc/passwd", &secret).expect("replace with symlink");
3398 assert!(matches!(
3399 store.resolve(&metadata.credential_ref),
3400 Err(CredentialStoreError::UnsafeStorage)
3401 ));
3402 }
3403
3404 #[test]
3405 fn rejects_an_intermediate_symlink_in_the_store_root() {
3406 let temporary = tempfile::tempdir().expect("temporary root");
3407 let actual_parent = temporary.path().join("actual");
3408 std::fs::create_dir(&actual_parent).expect("actual parent");
3409 std::fs::set_permissions(&actual_parent, std::fs::Permissions::from_mode(0o700))
3410 .expect("private actual parent");
3411 let linked_parent = temporary.path().join("linked");
3412 symlink(&actual_parent, &linked_parent).expect("intermediate symlink");
3413
3414 assert!(matches!(
3415 LocalCredentialStore::at_test_root(&linked_parent.join("credentials-v1")),
3416 Err(CredentialStoreError::UnsafeStorage)
3417 ));
3418 assert!(
3419 !actual_parent.join("credentials-v1").exists(),
3420 "rejection must occur before any escaped directory mutation"
3421 );
3422 }
3423
3424 #[test]
3425 fn rejects_relaxed_permissions_and_hardlinked_secret() {
3426 let temporary = tempfile::tempdir().expect("temporary root");
3427 let root = temporary.path().join("provider-credentials-v1");
3428 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3429 let metadata = store.add("ollama", "cloud", b"safe").expect("add");
3430 let secret = root
3431 .join(ENTRIES_DIRECTORY)
3432 .join(&metadata.id)
3433 .join(SECRET_FILE);
3434 let hardlink = temporary.path().join("copied-link");
3435 std::fs::hard_link(&secret, &hardlink).expect("hardlink");
3436 assert!(matches!(
3437 store.resolve(&metadata.credential_ref),
3438 Err(CredentialStoreError::UnsafeStorage)
3439 ));
3440 std::fs::remove_file(hardlink).expect("remove hardlink");
3441 std::fs::set_permissions(&secret, std::fs::Permissions::from_mode(0o644))
3442 .expect("relax mode");
3443 assert!(store.resolve(&metadata.credential_ref).is_err());
3444 }
3445
3446 #[test]
3447 fn rejects_hardlinked_metadata() {
3448 let temporary = tempfile::tempdir().expect("temporary root");
3449 let root = temporary.path().join("credentials-v1");
3450 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3451 let metadata = store.add("ollama", "cloud", b"safe").expect("add");
3452 let metadata_path = std::env::temp_dir()
3453 .canonicalize()
3454 .expect("canonical temporary root")
3455 .join(
3456 root.strip_prefix(std::env::temp_dir())
3457 .expect("temporary relative root"),
3458 )
3459 .join(ENTRIES_DIRECTORY)
3460 .join(&metadata.id)
3461 .join(METADATA_FILE);
3462 let hardlink = temporary.path().join("metadata-link");
3463 std::fs::hard_link(&metadata_path, &hardlink).expect("hardlink metadata");
3464
3465 assert!(matches!(
3466 store.list(),
3467 Err(CredentialStoreError::UnsafeStorage)
3468 ));
3469 }
3470
3471 #[test]
3472 fn preview_reports_recovery_required_without_mutating_staging_or_store_bytes() {
3473 let temporary = tempfile::tempdir().expect("temporary root");
3474 let root = temporary.path().join("provider-credentials-v1");
3475 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3476 let metadata = store
3477 .add_at("openrouter", "primary", b"original", 90)
3478 .expect("add");
3479 let id = "0123456789abcdef0123456789abcdef";
3480 let staging = root.join(ENTRIES_DIRECTORY).join(format!(".staging-{id}"));
3481 std::fs::create_dir(&staging).expect("staging directory");
3482 std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o700))
3483 .expect("private staging mode");
3484 std::fs::write(staging.join(SECRET_FILE), b"partial").expect("partial secret");
3485 std::fs::set_permissions(
3486 staging.join(SECRET_FILE),
3487 std::fs::Permissions::from_mode(0o600),
3488 )
3489 .expect("private secret mode");
3490
3491 let before = store_tree_snapshot(&root);
3492 assert!(matches!(
3493 store.rotate_preview_at(&metadata.credential_ref, 100, 200),
3494 Err(CredentialStoreError::RecoveryRequired)
3495 ));
3496 assert_eq!(store_tree_snapshot(&root), before);
3497 assert!(staging.exists());
3498 }
3499
3500 #[test]
3501 fn post_rename_failure_cleanup_does_not_delete_committed_files() {
3502 let temporary = tempfile::tempdir().expect("temporary root");
3503 let root = temporary.path().join("credentials-v1");
3504 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3505 let locked = store.lock().expect("lock store");
3506 let entries = open_or_create_private_directory(&locked.root, ENTRIES_DIRECTORY)
3507 .expect("entries directory");
3508 let id = "0123456789abcdef0123456789abcdef";
3509 let staging = format!(".staging-{id}");
3510 mkdirat(&entries, &staging, Mode::from_raw_mode(0o700)).expect("staging directory");
3511 let staged = open_private_directory(&entries, &staging).expect("open staging");
3512 write_new_file(&staged, SECRET_FILE, b"secret-canary").expect("secret file");
3513 write_new_file(&staged, METADATA_FILE, b"metadata-canary").expect("metadata file");
3514 rename_new_entry(&entries, &staging, id).expect("commit entry");
3515
3516 cleanup_failed_add(&entries, &staged, &staging, true);
3517
3518 let committed = root.join(ENTRIES_DIRECTORY).join(id);
3519 assert_eq!(
3520 std::fs::read(committed.join(SECRET_FILE)).expect("committed secret"),
3521 b"secret-canary"
3522 );
3523 assert_eq!(
3524 std::fs::read(committed.join(METADATA_FILE)).expect("committed metadata"),
3525 b"metadata-canary"
3526 );
3527 }
3528
3529 #[test]
3530 fn duplicate_provider_label_is_rejected_without_replacing_the_secret() {
3531 let temporary = tempfile::tempdir().expect("temporary root");
3532 let root = temporary.path().join("provider-credentials-v1");
3533 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3534 let metadata = store.add("openrouter", "primary", b"first").expect("add");
3535 assert_eq!(
3536 store.add("openrouter", "primary", b"second"),
3537 Err(CredentialStoreError::InvalidInput)
3538 );
3539 assert_eq!(
3540 store
3541 .resolve(&metadata.credential_ref)
3542 .expect("resolve")
3543 .as_slice(),
3544 b"first"
3545 );
3546 }
3547
3548 #[test]
3549 fn account_label_aliases_store_one_canonical_identity() {
3550 let temporary = tempfile::tempdir().expect("temporary root");
3551 let root = temporary.path().join("provider-credentials-v1");
3552 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3553 let metadata = store
3554 .add("openrouter", " PRIMARY ", b"first")
3555 .expect("canonicalized add");
3556 assert_eq!(metadata.label, "primary");
3557 assert_eq!(store.list().expect("list")[0].label, "primary");
3558 assert_eq!(
3559 store.add("openrouter", "Primary", b"second"),
3560 Err(CredentialStoreError::InvalidInput)
3561 );
3562 assert_eq!(
3563 store
3564 .resolve(&metadata.credential_ref)
3565 .expect("original secret remains")
3566 .as_slice(),
3567 b"first"
3568 );
3569 }
3570
3571 #[test]
3572 fn malformed_account_labels_fail_before_store_mutation() {
3573 let temporary = tempfile::tempdir().expect("temporary root");
3574 let root = temporary.path().join("provider-credentials-v1");
3575 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3576 let before = store_tree_snapshot(&root);
3577 let oversized = "a".repeat(MAX_LABEL_BYTES + 1);
3578 for label in [
3579 "",
3580 " ",
3581 ".primary",
3582 "_primary",
3583 "-primary",
3584 "primary account",
3585 "primary/backup",
3586 "primary\\backup",
3587 "prÃmary",
3588 "primary\n",
3589 "primary\t",
3590 oversized.as_str(),
3591 ] {
3592 assert_eq!(
3593 store.add("openrouter", label, b"secret"),
3594 Err(CredentialStoreError::InvalidInput),
3595 "malformed label must fail closed: {label:?}"
3596 );
3597 assert_eq!(store_tree_snapshot(&root), before);
3598 }
3599 let canonical = store
3600 .add("openrouter", " A.B_C-1 ", b"secret")
3601 .expect("conservative alphabet is accepted");
3602 assert_eq!(canonical.label, "a.b_c-1");
3603 }
3604
3605 #[test]
3606 fn legacy_noncanonical_label_reads_canonically_and_reserves_aliases() {
3607 let temporary = tempfile::tempdir().expect("temporary root");
3608 let root = temporary.path().join("provider-credentials-v1");
3609 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3610 let metadata = store
3611 .add("openrouter", "legacy", b"legacy-secret")
3612 .expect("add legacy fixture");
3613 rewrite_metadata_for_test(&root, &metadata.id, |record| {
3614 record.label = " PRIMARY ".to_owned();
3615 });
3616
3617 assert_eq!(store.list().expect("legacy list")[0].label, "primary");
3618 assert_eq!(
3619 store
3620 .status(&metadata.credential_ref)
3621 .expect("legacy status")
3622 .metadata
3623 .label,
3624 "primary"
3625 );
3626 assert_eq!(
3627 store
3628 .rotate_preview_at(&metadata.credential_ref, 100, 200)
3629 .expect("legacy preview")
3630 .label,
3631 "primary"
3632 );
3633 let before_duplicate = store_tree_snapshot(&root);
3634 assert_eq!(
3635 store.add("openrouter", "primary", b"replacement"),
3636 Err(CredentialStoreError::InvalidInput)
3637 );
3638 assert_eq!(store_tree_snapshot(&root), before_duplicate);
3639 assert_eq!(
3640 store
3641 .resolve(&metadata.credential_ref)
3642 .expect("legacy secret remains")
3643 .as_slice(),
3644 b"legacy-secret"
3645 );
3646 }
3647
3648 #[test]
3649 fn ambiguous_legacy_normalized_labels_fail_closed_without_mutation() {
3650 let temporary = tempfile::tempdir().expect("temporary root");
3651 let root = temporary.path().join("provider-credentials-v1");
3652 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3653 let first = store
3654 .add("openrouter", "first", b"first-secret")
3655 .expect("first fixture");
3656 let second = store
3657 .add("openrouter", "second", b"second-secret")
3658 .expect("second fixture");
3659 rewrite_metadata_for_test(&root, &first.id, |record| {
3660 record.label = " PRIMARY ".to_owned();
3661 });
3662 rewrite_metadata_for_test(&root, &second.id, |record| {
3663 record.label = "primary".to_owned();
3664 });
3665 let before = store_tree_snapshot(&root);
3666
3667 assert_eq!(store.list(), Err(CredentialStoreError::CorruptState));
3668 assert_eq!(
3669 store.status(&first.credential_ref),
3670 Err(CredentialStoreError::CorruptState)
3671 );
3672 assert_eq!(
3673 store.rotate_preview_at(&first.credential_ref, 100, 200),
3674 Err(CredentialStoreError::CorruptState)
3675 );
3676 assert_eq!(
3677 store.remove_preview_at(&first.credential_ref, 100, 200),
3678 Err(CredentialStoreError::CorruptState)
3679 );
3680 assert_eq!(
3681 store.resolve(&first.credential_ref),
3682 Err(CredentialStoreError::CorruptState)
3683 );
3684 assert_eq!(
3685 store.add("openrouter", "third", b"third-secret"),
3686 Err(CredentialStoreError::CorruptState)
3687 );
3688 assert_eq!(store_tree_snapshot(&root), before);
3689 }
3690
3691 #[test]
3692 fn prepared_rotation_collision_scan_is_concurrent_and_byte_preserving() {
3693 let temporary = tempfile::tempdir().expect("temporary root");
3694 let root = temporary.path().join("provider-credentials-v1");
3695 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3696 let primary = store
3697 .add_at("openrouter", "primary", b"rotation-old", 90)
3698 .expect("primary fixture");
3699 let alias = store
3700 .add_at("openrouter", "secondary", b"alias-secret", 90)
3701 .expect("alias fixture");
3702 let preview = store
3703 .rotate_preview_at(&primary.credential_ref, 100, 200)
3704 .expect("rotation preview");
3705 set_lifecycle_failpoint(Some("rotate_before_secret_replace"));
3706 assert_eq!(
3707 store.rotate_apply_at(
3708 &primary.credential_ref,
3709 b"rotation-new",
3710 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
3711 110,
3712 ),
3713 Err(CredentialStoreError::CommitAmbiguous)
3714 );
3715 set_lifecycle_failpoint(None);
3716 rewrite_metadata_for_test(&root, &alias.id, |record| {
3717 record.label = " PRIMARY ".to_owned();
3718 });
3719 let before = store_tree_snapshot(&root);
3720
3721 let barrier = Arc::new(Barrier::new(3));
3722 let mut workers = Vec::new();
3723 for _ in 0..2 {
3724 let store = store.clone();
3725 let barrier = Arc::clone(&barrier);
3726 let credential_ref = primary.credential_ref.clone();
3727 let preview = preview.clone();
3728 workers.push(std::thread::spawn(move || {
3729 barrier.wait();
3730 store.reconcile_rotate(&credential_ref, lifecycle_request_identity(&preview))
3731 }));
3732 }
3733 barrier.wait();
3734 let results = workers
3735 .into_iter()
3736 .map(|worker| worker.join().expect("collision worker"))
3737 .collect::<Vec<_>>();
3738 assert!(results.contains(&Err(CredentialStoreError::CorruptState)));
3739 assert!(results.iter().all(|result| matches!(
3740 result,
3741 Err(CredentialStoreError::CorruptState | CredentialStoreError::LockUnavailable)
3742 )));
3743 assert_eq!(store_tree_snapshot(&root), before);
3744 assert_eq!(
3745 store.reconcile_rotate(
3746 &primary.credential_ref,
3747 lifecycle_request_identity(&preview),
3748 ),
3749 Err(CredentialStoreError::CorruptState)
3750 );
3751 assert_eq!(
3752 store.rotate_apply_at(
3753 &primary.credential_ref,
3754 b"must-not-stage",
3755 CredentialLifecycleApproval {
3756 expected_preview: &preview.preview_digest,
3757 expected_generation: preview.expected_generation,
3758 confirmation: "",
3759 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
3760 },
3761 500,
3762 ),
3763 Err(CredentialStoreError::CorruptState)
3764 );
3765 assert_eq!(store_tree_snapshot(&root), before);
3766 }
3767
3768 #[test]
3769 fn prepared_removal_collisions_never_reconcile_pre_or_post_truncate() {
3770 for (phase, failpoint, expected_initial) in [
3771 (
3772 "pretruncate",
3773 "remove_before_secret_truncate",
3774 CredentialStoreError::CommitAmbiguous,
3775 ),
3776 (
3777 "posttruncate",
3778 "remove_after_secret_truncate",
3779 CredentialStoreError::RecoveryRequired,
3780 ),
3781 ] {
3782 let temporary = tempfile::tempdir().expect("temporary root");
3783 let root = temporary.path().join(format!("credentials-{phase}"));
3784 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3785 let primary = store
3786 .add_at("openrouter", "primary", b"removal-primary", 90)
3787 .expect("primary fixture");
3788 let alias = store
3789 .add_at("openrouter", "secondary", b"removal-alias", 90)
3790 .expect("alias fixture");
3791 let preview = store
3792 .remove_preview_at(&primary.credential_ref, 100, 200)
3793 .expect("removal preview");
3794 set_lifecycle_failpoint(Some(failpoint));
3795 assert_eq!(
3796 store.remove_apply_at(
3797 &primary.credential_ref,
3798 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
3799 110,
3800 ),
3801 Err(expected_initial)
3802 );
3803 set_lifecycle_failpoint(None);
3804 rewrite_metadata_for_test(&root, &alias.id, |record| {
3805 record.label = " PRIMARY ".to_owned();
3806 });
3807 let before = store_tree_snapshot(&root);
3808
3809 assert_eq!(
3810 store.reconcile_remove(
3811 &primary.credential_ref,
3812 lifecycle_request_identity(&preview),
3813 ),
3814 Err(CredentialStoreError::CorruptState)
3815 );
3816 assert_eq!(store_tree_snapshot(&root), before);
3817 assert_eq!(
3818 store.remove_apply_at(
3819 &primary.credential_ref,
3820 CredentialLifecycleApproval {
3821 expected_preview: &preview.preview_digest,
3822 expected_generation: preview.expected_generation,
3823 confirmation: "",
3824 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
3825 },
3826 500,
3827 ),
3828 Err(CredentialStoreError::CorruptState)
3829 );
3830 assert_eq!(store_tree_snapshot(&root), before);
3831 }
3832 }
3833
3834 #[test]
3835 fn concurrent_duplicate_add_has_exactly_one_committed_winner() {
3836 let temporary = tempfile::tempdir().expect("temporary root");
3837 let root = temporary.path().join("credentials-v1");
3838 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3839 let barrier = Arc::new(Barrier::new(3));
3840 let mut workers = Vec::new();
3841 for (label, secret) in [
3842 ("PRIMARY", b"first".as_slice()),
3843 (" primary ", b"second".as_slice()),
3844 ] {
3845 let store = store.clone();
3846 let barrier = Arc::clone(&barrier);
3847 let secret = secret.to_vec();
3848 workers.push(std::thread::spawn(move || {
3849 barrier.wait();
3850 store.add("openrouter", label, &secret)
3851 }));
3852 }
3853 barrier.wait();
3854 let results = workers
3855 .into_iter()
3856 .map(|worker| worker.join().expect("worker result"))
3857 .collect::<Vec<_>>();
3858
3859 assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
3860 let records = store.list().expect("list committed records");
3861 assert_eq!(records.len(), 1);
3862 assert_eq!(records[0].label, "primary");
3863 let secret = store
3864 .resolve(&records[0].credential_ref)
3865 .expect("resolve winner");
3866 assert!(matches!(secret.as_slice(), b"first" | b"second"));
3867 }
3868
3869 #[test]
3870 fn provider_capacity_is_bounded_to_sixteen_credentials() {
3871 let temporary = tempfile::tempdir().expect("temporary root");
3872 let root = temporary.path().join("provider-credentials-v1");
3873 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3874 for index in 0..MAX_CREDENTIALS_PER_PROVIDER {
3875 store
3876 .add("openrouter", &format!("account-{index}"), b"secret")
3877 .expect("credential within provider capacity");
3878 }
3879 assert_eq!(
3880 store.add("openrouter", "account-overflow", b"secret"),
3881 Err(CredentialStoreError::CapacityExhausted)
3882 );
3883 }
3884
3885 #[test]
3886 fn device_capacity_is_bounded_to_sixty_four_credentials() {
3887 let temporary = tempfile::tempdir().expect("temporary root");
3888 let root = temporary.path().join("credentials-v1");
3889 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3890 let mut first = None;
3891 for provider in ["provider-a", "provider-b", "provider-c", "provider-d"] {
3892 for index in 0..MAX_CREDENTIALS_PER_PROVIDER {
3893 let metadata = store
3894 .add_at(provider, &format!("account-{index}"), b"secret", 90)
3895 .expect("credential within device capacity");
3896 first.get_or_insert(metadata);
3897 }
3898 }
3899 assert_eq!(store.list().expect("full store").len(), MAX_CREDENTIALS);
3900 assert_eq!(
3901 store.add_at("provider-e", "overflow", b"secret", 90),
3902 Err(CredentialStoreError::CapacityExhausted)
3903 );
3904 let first = first.expect("first credential");
3905 let preview = store
3906 .remove_preview_at(&first.credential_ref, 100, 200)
3907 .expect("removal preview at device capacity");
3908 store
3909 .remove_apply_at(
3910 &first.credential_ref,
3911 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
3912 110,
3913 )
3914 .expect("remove to bounded tombstone");
3915 assert_eq!(
3916 store.list().expect("tombstone-inclusive store").len(),
3917 MAX_CREDENTIALS
3918 );
3919 assert_eq!(
3920 store.add_at("provider-e", "still-overflow", b"secret", 120),
3921 Err(CredentialStoreError::CapacityExhausted)
3922 );
3923 }
3924
3925 #[test]
3926 fn lifecycle_preview_is_deterministic_complete_and_no_write() {
3927 let temporary = tempfile::tempdir().expect("temporary root");
3928 let root = temporary.path().join("credentials-v1");
3929 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
3930 let metadata = store
3931 .add_at("openrouter", "primary", b"secret-canary", 90)
3932 .expect("add");
3933 let before_metadata = std::fs::read(
3934 root.join(ENTRIES_DIRECTORY)
3935 .join(&metadata.id)
3936 .join(METADATA_FILE),
3937 )
3938 .expect("metadata before preview");
3939 let before_secret = store
3940 .resolve(&metadata.credential_ref)
3941 .expect("secret before");
3942
3943 let first = store
3944 .rotate_preview_at(&metadata.credential_ref, 100, 200)
3945 .expect("first preview");
3946 let second = store
3947 .rotate_preview_at(&metadata.credential_ref, 100, 200)
3948 .expect("second preview");
3949
3950 assert_eq!(first, second);
3951 assert_eq!(first.provider, "openrouter");
3952 assert_eq!(first.label, "primary");
3953 assert_eq!(first.credential_ref, metadata.credential_ref);
3954 assert_eq!(first.expected_generation, 1);
3955 assert_eq!(first.expires_at_epoch_seconds, 200);
3956 assert_eq!(first.required_confirmation, ROTATE_CONFIRMATION);
3957 assert_eq!(first.dependency_impact.policy, "bindings_preserved");
3958 assert_eq!(first.dependency_impact.binding_count, 0);
3959 assert!(first.preview_digest.starts_with("sha256:"));
3960 assert_eq!(
3961 std::fs::read(
3962 root.join(ENTRIES_DIRECTORY)
3963 .join(&metadata.id)
3964 .join(METADATA_FILE)
3965 )
3966 .expect("metadata after preview"),
3967 before_metadata
3968 );
3969 assert_eq!(
3970 store
3971 .resolve(&metadata.credential_ref)
3972 .expect("secret after preview")
3973 .as_slice(),
3974 before_secret.as_slice()
3975 );
3976 let encoded = serde_json::to_string(&first).expect("preview JSON");
3977 assert!(encoded.contains("\"provider\":\"openrouter\""));
3978 assert!(encoded.contains("\"label\":\"primary\""));
3979 assert!(encoded.contains("\"bound_phases\":[]"));
3980 assert!(encoded.contains("\"bound_routes\":[]"));
3981 assert!(encoded.contains("\"required_confirmation\""));
3982 assert!(!encoded.contains("secret-canary"));
3983 assert!(!encoded.contains(root.to_string_lossy().as_ref()));
3984 }
3985
3986 #[test]
3987 fn slice_a_metadata_remains_readable_with_redacted_lifecycle_defaults() {
3988 let id = "0123456789abcdef0123456789abcdef";
3989 let encoded = format!(
3990 "{{\"schema_version\":\"1.0.0\",\"id\":\"{id}\",\"credential_ref\":\"local-file-v1:{id}\",\"provider\":\"openrouter\",\"label\":\"primary\"}}"
3991 );
3992 let metadata: CredentialMetadata =
3993 serde_json::from_str(&encoded).expect("Slice A metadata remains compatible");
3994 metadata.validate().expect("defaulted metadata valid");
3995 assert_eq!(metadata.generation, 1);
3996 assert_eq!(metadata.lifecycle_state, CredentialLifecycleState::Active);
3997 assert_eq!(metadata.health, CredentialHealthState::LocallyVerified);
3998 assert_eq!(metadata.last_verified_at_epoch_seconds, None);
3999 assert!(metadata.bound_phases.is_empty());
4000 assert!(metadata.bound_routes.is_empty());
4001 }
4002
4003 #[test]
4004 fn rotate_apply_is_atomic_generation_bound_and_removes_old_secret() {
4005 let temporary = tempfile::tempdir().expect("temporary root");
4006 let root = temporary.path().join("credentials-v1");
4007 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4008 let metadata = store
4009 .add_at("ollama", "cloud", b"old-secret-canary", 90)
4010 .expect("add");
4011 let preview = store
4012 .rotate_preview_at(&metadata.credential_ref, 100, 200)
4013 .expect("preview");
4014
4015 let outcome = store
4016 .rotate_apply_at(
4017 &metadata.credential_ref,
4018 b"new-secret-canary",
4019 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4020 110,
4021 )
4022 .expect("rotate");
4023
4024 assert_eq!(outcome.credential_ref, metadata.credential_ref);
4025 assert_eq!(outcome.previous_generation, 1);
4026 assert_eq!(outcome.generation, Some(2));
4027 let current = store.status(&metadata.credential_ref).expect("status");
4028 assert_eq!(current.metadata.generation, 2);
4029 assert_eq!(current.metadata.previous_generation, Some(1));
4030 assert_eq!(current.metadata.rotated_at_epoch_seconds, Some(110));
4031 assert_eq!(
4032 store
4033 .resolve(&metadata.credential_ref)
4034 .expect("new secret")
4035 .as_slice(),
4036 b"new-secret-canary"
4037 );
4038 assert_no_file_contains(&root, b"old-secret-canary");
4039 let replay = store
4040 .rotate_apply_at(
4041 &metadata.credential_ref,
4042 b"replay-secret",
4043 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4044 120,
4045 )
4046 .expect("committed replay is idempotent");
4047 assert_eq!(replay.commit_status, "committed");
4048 assert!(!replay.reconciliation_required);
4049 assert_eq!(
4050 store
4051 .resolve(&metadata.credential_ref)
4052 .expect("replay did not replace secret")
4053 .as_slice(),
4054 b"new-secret-canary"
4055 );
4056 }
4057
4058 #[test]
4059 fn lifecycle_apply_rejects_digest_confirmation_expiry_and_storage_attacks_without_mutation() {
4060 let temporary = tempfile::tempdir().expect("temporary root");
4061 let root = temporary.path().join("credentials-v1");
4062 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4063 let metadata = store
4064 .add_at("openrouter", "primary", b"original-secret", 90)
4065 .expect("add");
4066 let preview = store
4067 .rotate_preview_at(&metadata.credential_ref, 100, 200)
4068 .expect("preview");
4069
4070 for result in [
4071 store.rotate_apply_at(
4072 &metadata.credential_ref,
4073 b"replacement",
4074 CredentialLifecycleApproval {
4075 expected_preview: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
4076 expected_generation: 1,
4077 confirmation: ROTATE_CONFIRMATION,
4078 expires_at_epoch_seconds: 200,
4079 },
4080 110,
4081 ),
4082 store.rotate_apply_at(
4083 &metadata.credential_ref,
4084 b"replacement",
4085 lifecycle_approval(&preview, "NO"),
4086 110,
4087 ),
4088 store.rotate_apply_at(
4089 &metadata.credential_ref,
4090 b"replacement",
4091 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4092 200,
4093 ),
4094 ] {
4095 let error = result.expect_err("apply rejected");
4096 assert_eq!(
4097 error.to_string(),
4098 "device-local credential operation failed"
4099 );
4100 }
4101 assert_eq!(
4102 store
4103 .resolve(&metadata.credential_ref)
4104 .expect("original preserved")
4105 .as_slice(),
4106 b"original-secret"
4107 );
4108
4109 let secret_path = root
4110 .join(ENTRIES_DIRECTORY)
4111 .join(&metadata.id)
4112 .join(SECRET_FILE);
4113 let hardlink = temporary.path().join("hardlink");
4114 std::fs::hard_link(&secret_path, &hardlink).expect("hardlink attack");
4115 assert!(matches!(
4116 store.rotate_apply_at(
4117 &metadata.credential_ref,
4118 b"replacement",
4119 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4120 110,
4121 ),
4122 Err(CredentialStoreError::UnsafeStorage)
4123 ));
4124 assert_no_file_contains(&root, b"replacement");
4125 }
4126
4127 #[test]
4128 fn remove_is_atomic_and_fails_closed_for_any_binding() {
4129 let temporary = tempfile::tempdir().expect("temporary root");
4130 let root = temporary.path().join("credentials-v1");
4131 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4132 let metadata = store
4133 .add_at("openrouter", "primary", b"remove-canary", 90)
4134 .expect("add");
4135 let before_preview = store_tree_snapshot(&root);
4136 let preview = store
4137 .remove_preview_at(&metadata.credential_ref, 100, 200)
4138 .expect("remove preview");
4139 assert_eq!(store_tree_snapshot(&root), before_preview);
4140 assert!(preview.dependency_impact.apply_allowed);
4141 assert_eq!(preview.dependency_impact.policy, "zero_binding_removal");
4142 let outcome = store
4143 .remove_apply_at(
4144 &metadata.credential_ref,
4145 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4146 110,
4147 )
4148 .expect("remove");
4149 assert_eq!(outcome.lifecycle_state, "removed");
4150 let removed = store
4151 .status(&metadata.credential_ref)
4152 .expect("removed status");
4153 assert_eq!(removed.status, "removed");
4154 assert_eq!(
4155 removed.metadata.lifecycle_state,
4156 CredentialLifecycleState::Removed
4157 );
4158 assert_eq!(removed.metadata.health, CredentialHealthState::Unavailable);
4159 assert!(matches!(
4160 store.resolve(&metadata.credential_ref),
4161 Err(CredentialStoreError::NotFound)
4162 ));
4163 assert_no_file_contains(&root, b"remove-canary");
4164
4165 let bound = store
4166 .add_at("ollama", "cloud", b"bound-canary", 120)
4167 .expect("bound add");
4168 rewrite_metadata_for_test(&root, &bound.id, |metadata| {
4169 metadata.bound_phases = vec!["phase-2".to_owned()];
4170 metadata.bound_routes = vec!["route-ollama-cloud".to_owned()];
4171 });
4172 let blocked = store
4173 .remove_preview_at(&bound.credential_ref, 130, 200)
4174 .expect("blocked preview");
4175 assert!(!blocked.dependency_impact.apply_allowed);
4176 assert_eq!(blocked.dependency_impact.binding_count, 2);
4177 assert_eq!(blocked.dependency_impact.bound_phases, ["phase-2"]);
4178 assert_eq!(
4179 blocked.dependency_impact.bound_routes,
4180 ["route-ollama-cloud"]
4181 );
4182 assert_eq!(
4183 blocked.dependency_impact.policy,
4184 "blocked_until_zero_bindings"
4185 );
4186 assert!(matches!(
4187 store.remove_apply_at(
4188 &bound.credential_ref,
4189 lifecycle_approval(&blocked, REMOVE_CONFIRMATION),
4190 140,
4191 ),
4192 Err(CredentialStoreError::DependencyConflict)
4193 ));
4194 assert_eq!(
4195 store
4196 .resolve(&bound.credential_ref)
4197 .expect("bound secret preserved")
4198 .as_slice(),
4199 b"bound-canary"
4200 );
4201 }
4202
4203 #[test]
4204 fn every_rotation_post_commit_failure_is_successful_reconcileable_and_has_no_old_secret() {
4205 for edge in [
4206 "rotate_secret_sync",
4207 "rotate_metadata_replace",
4208 "rotate_metadata_sync",
4209 "rotate_journal_commit",
4210 ] {
4211 let temporary = tempfile::tempdir().expect("temporary root");
4212 let root = temporary.path().join(format!("credentials-{edge}"));
4213 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4214 let metadata = store
4215 .add_at("openrouter", "primary", b"old-crash-canary", 90)
4216 .expect("add");
4217 let preview = store
4218 .rotate_preview_at(&metadata.credential_ref, 100, 200)
4219 .expect("preview");
4220 set_lifecycle_failpoint(Some(edge));
4221 let outcome = store
4222 .rotate_apply_at(
4223 &metadata.credential_ref,
4224 b"new-crash-canary",
4225 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4226 110,
4227 )
4228 .expect("post-commit failure is a committed outcome");
4229 set_lifecycle_failpoint(None);
4230 assert_eq!(outcome.commit_status, "committed_reconciliation_required");
4231 assert!(outcome.reconciliation_required);
4232 assert_no_file_contains(&root, b"old-crash-canary");
4233
4234 let reconciled = store
4235 .rotate_apply_at(
4236 &metadata.credential_ref,
4237 b"ignored-retry-secret",
4238 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4239 120,
4240 )
4241 .expect("restart/retry reconciles");
4242 assert_eq!(reconciled.commit_status, "committed");
4243 assert!(!reconciled.reconciliation_required);
4244 assert_eq!(
4245 store
4246 .resolve(&metadata.credential_ref)
4247 .expect("new secret survives")
4248 .as_slice(),
4249 b"new-crash-canary"
4250 );
4251 assert_no_file_contains(&root, b"old-crash-canary");
4252 assert_no_file_contains(&root, b"ignored-retry-secret");
4253 }
4254 }
4255
4256 #[test]
4257 fn rotation_recovery_classifies_exact_inode_before_and_after_replace() {
4258 let temporary = tempfile::tempdir().expect("temporary root");
4259 let root = temporary.path().join("credentials-before-replace");
4260 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4261 let metadata = store
4262 .add_at("openrouter", "primary", b"old-before-canary", 90)
4263 .expect("add");
4264 let preview = store
4265 .rotate_preview_at(&metadata.credential_ref, 100, 200)
4266 .expect("preview");
4267 set_lifecycle_failpoint(Some("rotate_before_secret_replace"));
4268 assert!(matches!(
4269 store.rotate_apply_at(
4270 &metadata.credential_ref,
4271 b"staged-before-canary",
4272 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4273 110,
4274 ),
4275 Err(CredentialStoreError::CommitAmbiguous)
4276 ));
4277 set_lifecycle_failpoint(None);
4278
4279 let journal_path = root.join(LIFECYCLE_DIRECTORY).join(&metadata.id);
4280 let journal_bytes = std::fs::read(&journal_path).expect("prepared journal");
4281 let journal: CredentialLifecycleJournal =
4282 serde_json::from_slice(&journal_bytes).expect("decode journal");
4283 assert_ne!(
4284 journal.old_secret_identity,
4285 journal.staged_secret_identity.expect("staged identity")
4286 );
4287 assert!(
4288 !journal_bytes
4289 .windows(b"old-before-canary".len())
4290 .any(|window| window == b"old-before-canary")
4291 );
4292 assert!(
4293 !journal_bytes
4294 .windows(b"staged-before-canary".len())
4295 .any(|window| window == b"staged-before-canary")
4296 );
4297
4298 let reconciled = store
4299 .reconcile_rotate(
4300 &metadata.credential_ref,
4301 lifecycle_request_identity(&preview),
4302 )
4303 .expect("precommit rollback");
4304 assert_eq!(
4305 reconciled,
4306 CredentialRotationReconciliation::RolledBackFreshPreviewRequired
4307 );
4308 assert_eq!(
4309 store
4310 .resolve(&metadata.credential_ref)
4311 .expect("old secret remains")
4312 .as_slice(),
4313 b"old-before-canary"
4314 );
4315 assert_no_file_contains(&root, b"staged-before-canary");
4316
4317 let after_root = temporary.path().join("credentials-after-replace");
4318 let after_store =
4319 LocalCredentialStore::at_test_root(&after_root).expect("open after store");
4320 let after_metadata = after_store
4321 .add_at("openrouter", "secondary", b"old-after-canary", 90)
4322 .expect("after add");
4323 let after_preview = after_store
4324 .rotate_preview_at(&after_metadata.credential_ref, 100, 101)
4325 .expect("short preview");
4326 set_lifecycle_failpoint(Some("rotate_after_secret_replace"));
4327 let pending = after_store
4328 .rotate_apply_at(
4329 &after_metadata.credential_ref,
4330 b"new-after-canary",
4331 lifecycle_approval(&after_preview, ROTATE_CONFIRMATION),
4332 100,
4333 )
4334 .expect("post-commit outcome");
4335 set_lifecycle_failpoint(None);
4336 assert!(pending.reconciliation_required);
4337
4338 let after_reconciled = after_store
4339 .reconcile_rotate(
4340 &after_metadata.credential_ref,
4341 lifecycle_request_identity(&after_preview),
4342 )
4343 .expect("expired journal authorization remains durable");
4344 let CredentialRotationReconciliation::Committed(outcome) = after_reconciled else {
4345 panic!("committed rotation reconciliation expected")
4346 };
4347 assert_eq!(outcome.generation, Some(2));
4348 assert_eq!(outcome.commit_status, "committed");
4349 assert_eq!(
4350 after_store
4351 .resolve(&after_metadata.credential_ref)
4352 .expect("new secret remains")
4353 .as_slice(),
4354 b"new-after-canary"
4355 );
4356 assert_no_file_contains(&after_root, b"old-after-canary");
4357 }
4358
4359 #[test]
4360 fn missing_staged_secret_with_old_inode_live_rolls_back_without_inference() {
4361 let temporary = tempfile::tempdir().expect("temporary root");
4362 let root = temporary.path().join("credentials-v1");
4363 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4364 let metadata = store
4365 .add_at("openrouter", "primary", b"old-live-canary", 90)
4366 .expect("add");
4367 let preview = store
4368 .rotate_preview_at(&metadata.credential_ref, 100, 200)
4369 .expect("preview");
4370 set_lifecycle_failpoint(Some("rotate_before_secret_replace"));
4371 let _ = store.rotate_apply_at(
4372 &metadata.credential_ref,
4373 b"missing-stage-canary",
4374 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4375 110,
4376 );
4377 set_lifecycle_failpoint(None);
4378 std::fs::remove_file(
4379 root.join(ENTRIES_DIRECTORY)
4380 .join(&metadata.id)
4381 .join(NEXT_SECRET_FILE),
4382 )
4383 .expect("remove staged secret");
4384
4385 assert_eq!(
4386 store
4387 .reconcile_rotate(
4388 &metadata.credential_ref,
4389 lifecycle_request_identity(&preview),
4390 )
4391 .expect("old inode proves precommit"),
4392 CredentialRotationReconciliation::RolledBackFreshPreviewRequired
4393 );
4394 assert_eq!(
4395 store
4396 .resolve(&metadata.credential_ref)
4397 .expect("old secret preserved")
4398 .as_slice(),
4399 b"old-live-canary"
4400 );
4401 }
4402
4403 #[test]
4404 fn foreign_or_missing_live_secret_identity_is_ambiguous_and_fails_closed() {
4405 for missing in [false, true] {
4406 let temporary = tempfile::tempdir().expect("temporary root");
4407 let root = temporary
4408 .path()
4409 .join(format!("credentials-missing-{missing}"));
4410 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4411 let metadata = store
4412 .add_at("openrouter", "primary", b"old-ambiguous-canary", 90)
4413 .expect("add");
4414 let preview = store
4415 .rotate_preview_at(&metadata.credential_ref, 100, 200)
4416 .expect("preview");
4417 set_lifecycle_failpoint(Some("rotate_before_secret_replace"));
4418 let _ = store.rotate_apply_at(
4419 &metadata.credential_ref,
4420 b"staged-ambiguous-canary",
4421 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
4422 110,
4423 );
4424 set_lifecycle_failpoint(None);
4425 let live_secret = root
4426 .join(ENTRIES_DIRECTORY)
4427 .join(&metadata.id)
4428 .join(SECRET_FILE);
4429 std::fs::remove_file(&live_secret).expect("remove recorded old inode");
4430 if !missing {
4431 std::fs::write(&live_secret, b"foreign-identity").expect("foreign live secret");
4432 std::fs::set_permissions(&live_secret, std::fs::Permissions::from_mode(0o600))
4433 .expect("foreign secret mode");
4434 }
4435
4436 assert!(matches!(
4437 store.reconcile_rotate(
4438 &metadata.credential_ref,
4439 lifecycle_request_identity(&preview),
4440 ),
4441 Err(CredentialStoreError::RecoveryRequired)
4442 ));
4443 assert!(root.join(LIFECYCLE_DIRECTORY).join(&metadata.id).exists());
4444 }
4445 }
4446
4447 #[test]
4448 fn every_removal_post_durability_failure_is_successful_and_replay_safe() {
4449 for edge in [
4450 "remove_metadata_stage",
4451 "remove_metadata_replace",
4452 "remove_metadata_sync",
4453 "remove_journal_commit",
4454 ] {
4455 let temporary = tempfile::tempdir().expect("temporary root");
4456 let root = temporary.path().join(format!("credentials-{edge}"));
4457 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4458 let metadata = store
4459 .add_at("openrouter", "primary", b"remove-crash-canary", 90)
4460 .expect("add");
4461 let preview = store
4462 .remove_preview_at(&metadata.credential_ref, 100, 200)
4463 .expect("preview");
4464 set_lifecycle_failpoint(Some(edge));
4465 let outcome = store
4466 .remove_apply_at(
4467 &metadata.credential_ref,
4468 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4469 110,
4470 )
4471 .expect("post-commit failure is a committed outcome");
4472 set_lifecycle_failpoint(None);
4473 assert_eq!(outcome.commit_status, "committed_reconciliation_required");
4474 assert!(outcome.reconciliation_required);
4475 assert_no_file_contains(&root, b"remove-crash-canary");
4476
4477 let reconciled = store
4478 .remove_apply_at(
4479 &metadata.credential_ref,
4480 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4481 120,
4482 )
4483 .expect("restart/retry reconciles");
4484 assert_eq!(reconciled.commit_status, "committed");
4485 assert!(!reconciled.reconciliation_required);
4486 assert_no_file_contains(&root, b"remove-crash-canary");
4487 }
4488 }
4489
4490 #[test]
4491 fn removal_never_reports_committed_before_sync_and_post_sync_verification() {
4492 for edge in [
4493 "remove_after_secret_truncate",
4494 "remove_before_secret_sync",
4495 "remove_after_secret_sync",
4496 ] {
4497 let temporary = tempfile::tempdir().expect("temporary root");
4498 let root = temporary.path().join(format!("credentials-{edge}"));
4499 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4500 let metadata = store
4501 .add_at("openrouter", "primary", b"remove-pending-canary", 90)
4502 .expect("add");
4503 let preview = store
4504 .remove_preview_at(&metadata.credential_ref, 100, 101)
4505 .expect("short preview");
4506
4507 set_lifecycle_failpoint(Some(edge));
4508 assert_eq!(
4509 store.remove_apply_at(
4510 &metadata.credential_ref,
4511 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4512 100,
4513 ),
4514 Err(CredentialStoreError::RecoveryRequired),
4515 "{edge} must not report committed"
4516 );
4517 set_lifecycle_failpoint(None);
4518
4519 let journal_path = root.join(LIFECYCLE_DIRECTORY).join(&metadata.id);
4520 let journal_bytes = std::fs::read(&journal_path).expect("prepared removal journal");
4521 let journal: CredentialLifecycleJournal =
4522 serde_json::from_slice(&journal_bytes).expect("decode journal");
4523 assert_eq!(journal.state, CredentialLifecycleJournalState::Prepared);
4524 assert_eq!(
4525 std::fs::metadata(
4526 root.join(ENTRIES_DIRECTORY)
4527 .join(&metadata.id)
4528 .join(SECRET_FILE),
4529 )
4530 .expect("pending tombstone")
4531 .len(),
4532 0
4533 );
4534
4535 let reconciled = store
4536 .remove_apply_at(
4537 &metadata.credential_ref,
4538 CredentialLifecycleApproval {
4539 expected_preview: &preview.preview_digest,
4540 expected_generation: preview.expected_generation,
4541 confirmation: "",
4542 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
4543 },
4544 500,
4545 )
4546 .expect("exact expired retry durably reconciles without confirmation");
4547 assert_eq!(reconciled.commit_status, "committed");
4548 assert!(!reconciled.reconciliation_required);
4549 assert_no_file_contains(&root, b"remove-pending-canary");
4550 }
4551 }
4552
4553 #[test]
4554 fn descriptor_sync_failure_preserves_prepared_removal_journal() {
4555 let temporary = tempfile::tempdir().expect("temporary root");
4556 let root = temporary.path().join("credentials-v1");
4557 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4558 let metadata = store
4559 .add_at("openrouter", "primary", b"remove-sync-failure-canary", 90)
4560 .expect("add");
4561 let preview = store
4562 .remove_preview_at(&metadata.credential_ref, 100, 200)
4563 .expect("preview");
4564
4565 set_lifecycle_failpoint(Some("remove_secret_sync_failure"));
4566 assert_eq!(
4567 store.remove_apply_at(
4568 &metadata.credential_ref,
4569 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4570 110,
4571 ),
4572 Err(CredentialStoreError::RecoveryRequired)
4573 );
4574 set_lifecycle_failpoint(None);
4575
4576 let journal_path = root.join(LIFECYCLE_DIRECTORY).join(&metadata.id);
4577 let journal_bytes = std::fs::read(&journal_path).expect("prepared removal journal");
4578 let journal: CredentialLifecycleJournal =
4579 serde_json::from_slice(&journal_bytes).expect("decode journal");
4580 assert_eq!(journal.state, CredentialLifecycleJournalState::Prepared);
4581 set_lifecycle_failpoint(Some("remove_secret_sync_failure"));
4582 assert_eq!(
4583 store.reconcile_remove(
4584 &metadata.credential_ref,
4585 lifecycle_request_identity(&preview),
4586 ),
4587 Err(CredentialStoreError::RecoveryRequired)
4588 );
4589 set_lifecycle_failpoint(None);
4590 assert_eq!(
4591 std::fs::read(&journal_path).expect("failed recovery preserves prepared journal"),
4592 journal_bytes
4593 );
4594 assert!(matches!(
4595 store
4596 .reconcile_remove(
4597 &metadata.credential_ref,
4598 lifecycle_request_identity(&preview),
4599 )
4600 .expect("exact retry completes descriptor sync"),
4601 CredentialRemovalReconciliation::Committed(_)
4602 ));
4603 assert_no_file_contains(&root, b"remove-sync-failure-canary");
4604 }
4605
4606 #[test]
4607 fn pending_zero_size_removal_reverted_to_nonzero_rolls_back_for_fresh_preview() {
4608 let temporary = tempfile::tempdir().expect("temporary root");
4609 let root = temporary.path().join("credentials-v1");
4610 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4611 let original_secret = b"remove-reverted-canary";
4612 let metadata = store
4613 .add_at("openrouter", "primary", original_secret, 90)
4614 .expect("add");
4615 let preview = store
4616 .remove_preview_at(&metadata.credential_ref, 100, 200)
4617 .expect("preview");
4618
4619 set_lifecycle_failpoint(Some("remove_after_secret_truncate"));
4620 assert_eq!(
4621 store.remove_apply_at(
4622 &metadata.credential_ref,
4623 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4624 110,
4625 ),
4626 Err(CredentialStoreError::RecoveryRequired)
4627 );
4628 set_lifecycle_failpoint(None);
4629
4630 let secret_path = root
4631 .join(ENTRIES_DIRECTORY)
4632 .join(&metadata.id)
4633 .join(SECRET_FILE);
4634 std::fs::write(&secret_path, original_secret).expect("simulate crash rollback bytes");
4635 assert_eq!(
4636 store.remove_apply_at(
4637 &metadata.credential_ref,
4638 CredentialLifecycleApproval {
4639 expected_preview: &preview.preview_digest,
4640 expected_generation: preview.expected_generation,
4641 confirmation: "",
4642 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
4643 },
4644 500,
4645 ),
4646 Err(CredentialStoreError::FreshPreviewRequired)
4647 );
4648 assert_eq!(
4649 store
4650 .resolve(&metadata.credential_ref)
4651 .expect("precommit secret remains active")
4652 .as_slice(),
4653 original_secret
4654 );
4655 assert!(!root.join(LIFECYCLE_DIRECTORY).join(&metadata.id).exists());
4656 }
4657
4658 #[test]
4659 fn expired_post_truncate_removal_retry_reconciles_from_durable_authorization() {
4660 let temporary = tempfile::tempdir().expect("temporary root");
4661 let root = temporary.path().join("credentials-v1");
4662 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4663 let metadata = store
4664 .add_at("openrouter", "primary", b"remove-expired-canary", 90)
4665 .expect("add");
4666 let preview = store
4667 .remove_preview_at(&metadata.credential_ref, 100, 101)
4668 .expect("short preview");
4669 set_lifecycle_failpoint(Some("remove_after_secret_truncate"));
4670 assert_eq!(
4671 store.remove_apply_at(
4672 &metadata.credential_ref,
4673 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4674 100,
4675 ),
4676 Err(CredentialStoreError::RecoveryRequired)
4677 );
4678 set_lifecycle_failpoint(None);
4679
4680 let journal_bytes = std::fs::read(root.join(LIFECYCLE_DIRECTORY).join(&metadata.id))
4681 .expect("prepared removal journal");
4682 let journal: CredentialLifecycleJournal =
4683 serde_json::from_slice(&journal_bytes).expect("decode journal");
4684 assert_eq!(journal.required_confirmation, REMOVE_CONFIRMATION);
4685 assert!(
4686 !journal_bytes
4687 .windows(b"remove-expired-canary".len())
4688 .any(|window| window == b"remove-expired-canary")
4689 );
4690
4691 let reconciled = store
4692 .reconcile_remove(
4693 &metadata.credential_ref,
4694 lifecycle_request_identity(&preview),
4695 )
4696 .expect("expired authorized removal reconciles");
4697 let CredentialRemovalReconciliation::Committed(outcome) = reconciled else {
4698 panic!("committed removal expected")
4699 };
4700 assert_eq!(outcome.commit_status, "committed");
4701 assert_eq!(outcome.lifecycle_state, "removed");
4702 assert_no_file_contains(&root, b"remove-expired-canary");
4703 }
4704
4705 #[test]
4706 fn expired_precommit_removal_with_exact_old_inode_rolls_back_for_fresh_preview() {
4707 let temporary = tempfile::tempdir().expect("temporary root");
4708 let root = temporary.path().join("credentials-v1");
4709 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4710 let metadata = store
4711 .add_at("openrouter", "primary", b"remove-old-live-canary", 90)
4712 .expect("add");
4713 let preview = store
4714 .remove_preview_at(&metadata.credential_ref, 100, 101)
4715 .expect("short preview");
4716 set_lifecycle_failpoint(Some("remove_before_secret_truncate"));
4717 assert!(matches!(
4718 store.remove_apply_at(
4719 &metadata.credential_ref,
4720 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4721 100,
4722 ),
4723 Err(CredentialStoreError::CommitAmbiguous)
4724 ));
4725 set_lifecycle_failpoint(None);
4726
4727 let result = store.remove_apply_at(
4728 &metadata.credential_ref,
4729 CredentialLifecycleApproval {
4730 expected_preview: &preview.preview_digest,
4731 expected_generation: preview.expected_generation,
4732 confirmation: "",
4733 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
4734 },
4735 500,
4736 );
4737 assert_eq!(result, Err(CredentialStoreError::FreshPreviewRequired));
4738 assert_eq!(
4739 store
4740 .resolve(&metadata.credential_ref)
4741 .expect("old secret remains live")
4742 .as_slice(),
4743 b"remove-old-live-canary"
4744 );
4745 let fresh = store
4746 .remove_preview_at(&metadata.credential_ref, 500, 600)
4747 .expect("fresh preview is available after rollback");
4748 assert_eq!(
4749 store.remove_apply_at(
4750 &metadata.credential_ref,
4751 lifecycle_approval(&fresh, REMOVE_CONFIRMATION),
4752 600,
4753 ),
4754 Err(CredentialStoreError::PreviewExpired)
4755 );
4756 }
4757
4758 #[test]
4759 fn mismatched_removal_reconciliation_fails_closed_then_exact_retry_succeeds() {
4760 let temporary = tempfile::tempdir().expect("temporary root");
4761 let root = temporary.path().join("credentials-v1");
4762 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4763 let metadata = store
4764 .add_at("openrouter", "primary", b"remove-mismatch-canary", 90)
4765 .expect("add");
4766 let preview = store
4767 .remove_preview_at(&metadata.credential_ref, 100, 200)
4768 .expect("preview");
4769 set_lifecycle_failpoint(Some("remove_after_secret_truncate"));
4770 let _ = store.remove_apply_at(
4771 &metadata.credential_ref,
4772 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4773 110,
4774 );
4775 set_lifecycle_failpoint(None);
4776
4777 let before_mismatch = store_tree_snapshot(&root);
4778 assert!(matches!(
4779 store.reconcile_remove(
4780 &metadata.credential_ref,
4781 CredentialLifecycleRequestIdentity {
4782 expected_preview: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
4783 expected_generation: preview.expected_generation,
4784 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
4785 },
4786 ),
4787 Err(CredentialStoreError::RecoveryRequired)
4788 ));
4789 assert_eq!(store_tree_snapshot(&root), before_mismatch);
4790 assert!(root.join(LIFECYCLE_DIRECTORY).join(&metadata.id).exists());
4791 assert!(matches!(
4792 store
4793 .reconcile_remove(
4794 &metadata.credential_ref,
4795 lifecycle_request_identity(&preview),
4796 )
4797 .expect("exact retry reconciles"),
4798 CredentialRemovalReconciliation::Committed(_)
4799 ));
4800 assert_no_file_contains(&root, b"remove-mismatch-canary");
4801 }
4802
4803 #[test]
4804 fn pre_truncate_removal_mismatch_preserves_store_and_journal_byte_for_byte() {
4805 let temporary = tempfile::tempdir().expect("temporary root");
4806 let root = temporary.path().join("credentials-v1");
4807 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4808 let metadata = store
4809 .add_at("openrouter", "primary", b"remove-preunlink-canary", 90)
4810 .expect("add");
4811 let preview = store
4812 .remove_preview_at(&metadata.credential_ref, 100, 200)
4813 .expect("preview");
4814 set_lifecycle_failpoint(Some("remove_before_secret_truncate"));
4815 assert_eq!(
4816 store.remove_apply_at(
4817 &metadata.credential_ref,
4818 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4819 110,
4820 ),
4821 Err(CredentialStoreError::CommitAmbiguous)
4822 );
4823 set_lifecycle_failpoint(None);
4824
4825 for mismatch in [
4826 CredentialLifecycleRequestIdentity {
4827 expected_preview: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
4828 expected_generation: preview.expected_generation,
4829 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
4830 },
4831 CredentialLifecycleRequestIdentity {
4832 expected_preview: &preview.preview_digest,
4833 expected_generation: preview.expected_generation + 1,
4834 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
4835 },
4836 CredentialLifecycleRequestIdentity {
4837 expected_preview: &preview.preview_digest,
4838 expected_generation: preview.expected_generation,
4839 expires_at_epoch_seconds: preview.expires_at_epoch_seconds + 1,
4840 },
4841 ] {
4842 let before = store_tree_snapshot(&root);
4843 assert_eq!(
4844 store.reconcile_remove(&metadata.credential_ref, mismatch),
4845 Err(CredentialStoreError::RecoveryRequired)
4846 );
4847 assert_eq!(store_tree_snapshot(&root), before);
4848 }
4849 }
4850
4851 #[test]
4852 fn durable_removal_ref_and_literal_mismatch_are_recovery_only_and_non_mutating() {
4853 for field in ["credential_ref", "required_confirmation"] {
4854 let temporary = tempfile::tempdir().expect("temporary root");
4855 let root = temporary.path().join(format!("credentials-{field}"));
4856 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4857 let metadata = store
4858 .add_at("openrouter", "primary", b"remove-binding-canary", 90)
4859 .expect("add");
4860 let preview = store
4861 .remove_preview_at(&metadata.credential_ref, 100, 200)
4862 .expect("preview");
4863 set_lifecycle_failpoint(Some("remove_before_secret_truncate"));
4864 assert_eq!(
4865 store.remove_apply_at(
4866 &metadata.credential_ref,
4867 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4868 110,
4869 ),
4870 Err(CredentialStoreError::CommitAmbiguous)
4871 );
4872 set_lifecycle_failpoint(None);
4873
4874 let journal_path = root.join(LIFECYCLE_DIRECTORY).join(&metadata.id);
4875 let mut journal: CredentialLifecycleJournal =
4876 serde_json::from_slice(&std::fs::read(&journal_path).expect("prepared journal"))
4877 .expect("decode journal");
4878 match field {
4879 "credential_ref" => {
4880 journal.credential_ref =
4881 "local-file-v1:00000000000000000000000000000000".to_owned();
4882 }
4883 "required_confirmation" => {
4884 journal.required_confirmation = ROTATE_CONFIRMATION.to_owned();
4885 }
4886 _ => unreachable!("bounded test field"),
4887 }
4888 std::fs::write(
4889 &journal_path,
4890 serde_json::to_vec(&journal).expect("encode tampered journal"),
4891 )
4892 .expect("write tampered journal");
4893 let before = store_tree_snapshot(&root);
4894
4895 assert_eq!(
4896 store.reconcile_remove(
4897 &metadata.credential_ref,
4898 lifecycle_request_identity(&preview),
4899 ),
4900 Err(CredentialStoreError::RecoveryRequired)
4901 );
4902 assert_eq!(store_tree_snapshot(&root), before);
4903 }
4904 }
4905
4906 #[test]
4907 fn same_user_substitute_path_is_untouched_by_descriptor_erasure() {
4908 let temporary = tempfile::tempdir().expect("temporary root");
4909 let root = temporary.path().join("credentials-v1");
4910 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4911 let metadata = store
4912 .add_at("openrouter", "primary", b"remove-race-original", 90)
4913 .expect("add");
4914 let preview = store
4915 .remove_preview_at(&metadata.credential_ref, 100, 200)
4916 .expect("preview");
4917
4918 set_lifecycle_failpoint(Some("remove_substitute_path_after_fd_validation"));
4919 assert_eq!(
4920 store.remove_apply_at(
4921 &metadata.credential_ref,
4922 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4923 110,
4924 ),
4925 Err(CredentialStoreError::RecoveryRequired)
4926 );
4927 set_lifecycle_failpoint(None);
4928
4929 assert_eq!(
4930 std::fs::read(
4931 root.join(ENTRIES_DIRECTORY)
4932 .join(&metadata.id)
4933 .join(SECRET_FILE),
4934 )
4935 .expect("substitute remains live"),
4936 b"same-user-substitute"
4937 );
4938 assert_eq!(
4939 std::fs::metadata(
4940 root.join(ENTRIES_DIRECTORY)
4941 .join(&metadata.id)
4942 .join(".test-original-secret"),
4943 )
4944 .expect("descriptor-bound original remains inspectable")
4945 .len(),
4946 0
4947 );
4948 assert!(root.join(LIFECYCLE_DIRECTORY).join(&metadata.id).exists());
4949 assert!(
4950 root.join(ENTRIES_DIRECTORY)
4951 .join(&metadata.id)
4952 .join(METADATA_FILE)
4953 .exists()
4954 );
4955 }
4956
4957 #[test]
4958 fn removal_recovery_rejects_missing_or_foreign_secret_identity() {
4959 for missing in [true, false] {
4960 let temporary = tempfile::tempdir().expect("temporary root");
4961 let root = temporary.path().join(format!("removal-foreign-{missing}"));
4962 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
4963 let metadata = store
4964 .add_at("openrouter", "primary", b"removal-identity-old", 90)
4965 .expect("add");
4966 let preview = store
4967 .remove_preview_at(&metadata.credential_ref, 100, 200)
4968 .expect("preview");
4969 set_lifecycle_failpoint(Some("remove_before_secret_truncate"));
4970 let _ = store.remove_apply_at(
4971 &metadata.credential_ref,
4972 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
4973 110,
4974 );
4975 set_lifecycle_failpoint(None);
4976
4977 let secret_path = root
4978 .join(ENTRIES_DIRECTORY)
4979 .join(&metadata.id)
4980 .join(SECRET_FILE);
4981 std::fs::remove_file(&secret_path).expect("remove journaled inode");
4982 if !missing {
4983 std::fs::write(&secret_path, b"same-user-foreign").expect("foreign secret");
4984 std::fs::set_permissions(&secret_path, std::fs::Permissions::from_mode(0o600))
4985 .expect("foreign secret mode");
4986 }
4987 let before = store_tree_snapshot(&root);
4988 assert_eq!(
4989 store.reconcile_remove(
4990 &metadata.credential_ref,
4991 lifecycle_request_identity(&preview),
4992 ),
4993 Err(CredentialStoreError::RecoveryRequired)
4994 );
4995 assert_eq!(store_tree_snapshot(&root), before);
4996 }
4997 }
4998
4999 #[test]
5000 fn rotation_request_mismatch_is_pre_reconciliation_and_byte_preserving() {
5001 for phase in ["precommit", "postcommit"] {
5002 for mismatch in ["digest", "generation", "expiry"] {
5003 let temporary = tempfile::tempdir().expect("temporary root");
5004 let root = temporary
5005 .path()
5006 .join(format!("rotation-{phase}-{mismatch}"));
5007 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
5008 let metadata = store
5009 .add_at("openrouter", "primary", b"rotation-old-canary", 90)
5010 .expect("add");
5011 let preview = store
5012 .rotate_preview_at(&metadata.credential_ref, 100, 200)
5013 .expect("preview");
5014 set_lifecycle_failpoint(Some(if phase == "precommit" {
5015 "rotate_before_secret_replace"
5016 } else {
5017 "rotate_after_secret_replace"
5018 }));
5019 let _ = store.rotate_apply_at(
5020 &metadata.credential_ref,
5021 b"rotation-new-canary",
5022 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
5023 110,
5024 );
5025 set_lifecycle_failpoint(None);
5026
5027 let request = match mismatch {
5028 "digest" => CredentialLifecycleRequestIdentity {
5029 expected_preview: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
5030 expected_generation: preview.expected_generation,
5031 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
5032 },
5033 "generation" => CredentialLifecycleRequestIdentity {
5034 expected_preview: &preview.preview_digest,
5035 expected_generation: preview.expected_generation + 1,
5036 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
5037 },
5038 "expiry" => CredentialLifecycleRequestIdentity {
5039 expected_preview: &preview.preview_digest,
5040 expected_generation: preview.expected_generation,
5041 expires_at_epoch_seconds: preview.expires_at_epoch_seconds + 1,
5042 },
5043 _ => unreachable!("bounded mismatch"),
5044 };
5045 let before = store_tree_snapshot(&root);
5046 assert_eq!(
5047 store.reconcile_rotate(&metadata.credential_ref, request),
5048 Err(CredentialStoreError::RecoveryRequired)
5049 );
5050 assert_eq!(store_tree_snapshot(&root), before);
5051 }
5052 }
5053 }
5054
5055 #[test]
5056 fn rotation_durable_ref_and_literal_mismatch_are_non_mutating() {
5057 for phase in ["precommit", "postcommit"] {
5058 for field in ["credential_ref", "required_confirmation"] {
5059 let temporary = tempfile::tempdir().expect("temporary root");
5060 let root = temporary.path().join(format!("rotation-{phase}-{field}"));
5061 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
5062 let metadata = store
5063 .add_at("openrouter", "primary", b"rotation-binding-old", 90)
5064 .expect("add");
5065 let preview = store
5066 .rotate_preview_at(&metadata.credential_ref, 100, 200)
5067 .expect("preview");
5068 set_lifecycle_failpoint(Some(if phase == "precommit" {
5069 "rotate_before_secret_replace"
5070 } else {
5071 "rotate_after_secret_replace"
5072 }));
5073 let _ = store.rotate_apply_at(
5074 &metadata.credential_ref,
5075 b"rotation-binding-new",
5076 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
5077 110,
5078 );
5079 set_lifecycle_failpoint(None);
5080
5081 let journal_path = root.join(LIFECYCLE_DIRECTORY).join(&metadata.id);
5082 let mut journal: CredentialLifecycleJournal = serde_json::from_slice(
5083 &std::fs::read(&journal_path).expect("prepared journal"),
5084 )
5085 .expect("decode journal");
5086 if field == "credential_ref" {
5087 journal.credential_ref =
5088 "local-file-v1:00000000000000000000000000000000".to_owned();
5089 } else {
5090 journal.required_confirmation = REMOVE_CONFIRMATION.to_owned();
5091 }
5092 std::fs::write(
5093 &journal_path,
5094 serde_json::to_vec(&journal).expect("encode tampered journal"),
5095 )
5096 .expect("write tampered journal");
5097 let before = store_tree_snapshot(&root);
5098 assert_eq!(
5099 store.reconcile_rotate(
5100 &metadata.credential_ref,
5101 lifecycle_request_identity(&preview),
5102 ),
5103 Err(CredentialStoreError::RecoveryRequired)
5104 );
5105 assert_eq!(store_tree_snapshot(&root), before);
5106 }
5107 }
5108 }
5109
5110 #[test]
5111 fn removed_tombstone_reserves_label_and_provider_capacity() {
5112 let temporary = tempfile::tempdir().expect("temporary root");
5113 let root = temporary.path().join("credentials-v1");
5114 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
5115 let removed = store
5116 .add_at("openrouter", "primary", b"tombstone-canary", 90)
5117 .expect("add");
5118 let preview = store
5119 .remove_preview_at(&removed.credential_ref, 100, 200)
5120 .expect("preview");
5121 store
5122 .remove_apply_at(
5123 &removed.credential_ref,
5124 lifecycle_approval(&preview, REMOVE_CONFIRMATION),
5125 110,
5126 )
5127 .expect("remove to tombstone");
5128
5129 assert_eq!(
5130 store.add_at("openrouter", " PRIMARY ", b"replacement", 120),
5131 Err(CredentialStoreError::InvalidInput)
5132 );
5133 for index in 1..MAX_CREDENTIALS_PER_PROVIDER {
5134 store
5135 .add_at(
5136 "openrouter",
5137 &format!("account-{index}"),
5138 b"active-secret",
5139 120,
5140 )
5141 .expect("active record within tombstone-inclusive capacity");
5142 }
5143 assert_eq!(
5144 store.add_at("openrouter", "overflow", b"secret", 120),
5145 Err(CredentialStoreError::CapacityExhausted)
5146 );
5147 let listed = store.list().expect("tombstone-visible list");
5148 assert_eq!(listed.len(), MAX_CREDENTIALS_PER_PROVIDER);
5149 assert_eq!(
5150 listed
5151 .iter()
5152 .filter(|metadata| metadata.lifecycle_state == CredentialLifecycleState::Removed)
5153 .count(),
5154 1
5155 );
5156 assert_eq!(
5157 std::fs::metadata(
5158 root.join(ENTRIES_DIRECTORY)
5159 .join(&removed.id)
5160 .join(SECRET_FILE),
5161 )
5162 .expect("zero-byte tombstone")
5163 .len(),
5164 0
5165 );
5166 assert_eq!(
5167 store.remove_preview_at(&removed.credential_ref, 120, 200),
5168 Err(CredentialStoreError::NotFound)
5169 );
5170 }
5171
5172 #[test]
5173 fn concurrent_rotation_has_one_generation_winner() {
5174 let temporary = tempfile::tempdir().expect("temporary root");
5175 let root = temporary.path().join("credentials-v1");
5176 let store = LocalCredentialStore::at_test_root(&root).expect("open store");
5177 let metadata = store
5178 .add_at("openrouter", "primary", b"original", 90)
5179 .expect("add");
5180 let preview = store
5181 .rotate_preview_at(&metadata.credential_ref, 100, 200)
5182 .expect("preview");
5183 let barrier = Arc::new(Barrier::new(3));
5184 let mut workers = Vec::new();
5185 for secret in [b"first".to_vec(), b"second".to_vec()] {
5186 let store = store.clone();
5187 let preview = preview.clone();
5188 let credential_ref = metadata.credential_ref.clone();
5189 let barrier = Arc::clone(&barrier);
5190 workers.push(std::thread::spawn(move || {
5191 barrier.wait();
5192 store.rotate_apply_at(
5193 &credential_ref,
5194 &secret,
5195 lifecycle_approval(&preview, ROTATE_CONFIRMATION),
5196 110,
5197 )
5198 }));
5199 }
5200 barrier.wait();
5201 let results = workers
5202 .into_iter()
5203 .map(|worker| worker.join().expect("worker result"))
5204 .collect::<Vec<_>>();
5205 let success_count = results.iter().filter(|result| result.is_ok()).count();
5206 assert!((1..=2).contains(&success_count));
5207 assert!(
5208 results
5209 .iter()
5210 .filter(|result| result.is_err())
5211 .all(|result| matches!(result, Err(CredentialStoreError::LockUnavailable)))
5212 );
5213 assert_eq!(
5214 store
5215 .status(&metadata.credential_ref)
5216 .expect("status")
5217 .metadata
5218 .generation,
5219 2
5220 );
5221 let secret = store
5222 .resolve(&metadata.credential_ref)
5223 .expect("winning secret");
5224 assert!(matches!(secret.as_slice(), b"first" | b"second"));
5225 }
5226
5227 fn rewrite_metadata_for_test(
5228 root: &Path,
5229 id: &str,
5230 mutate: impl FnOnce(&mut CredentialMetadata),
5231 ) {
5232 let path = root.join(ENTRIES_DIRECTORY).join(id).join(METADATA_FILE);
5233 let bytes = std::fs::read(&path).expect("read metadata");
5234 let mut metadata: CredentialMetadata =
5235 serde_json::from_slice(&bytes).expect("decode metadata");
5236 mutate(&mut metadata);
5237 metadata.validate().expect("valid mutated metadata");
5238 std::fs::write(
5239 &path,
5240 serde_json::to_vec(&metadata).expect("encode metadata"),
5241 )
5242 .expect("write metadata");
5243 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
5244 .expect("metadata mode");
5245 }
5246
5247 fn lifecycle_approval<'a>(
5248 preview: &'a CredentialLifecyclePreview,
5249 confirmation: &'a str,
5250 ) -> CredentialLifecycleApproval<'a> {
5251 CredentialLifecycleApproval {
5252 expected_preview: &preview.preview_digest,
5253 expected_generation: preview.expected_generation,
5254 confirmation,
5255 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
5256 }
5257 }
5258
5259 fn lifecycle_request_identity(
5260 preview: &CredentialLifecyclePreview,
5261 ) -> CredentialLifecycleRequestIdentity<'_> {
5262 CredentialLifecycleRequestIdentity {
5263 expected_preview: &preview.preview_digest,
5264 expected_generation: preview.expected_generation,
5265 expires_at_epoch_seconds: preview.expires_at_epoch_seconds,
5266 }
5267 }
5268
5269 fn set_lifecycle_failpoint(edge: Option<&'static str>) {
5270 LIFECYCLE_FAILPOINT.with(|configured| configured.set(edge));
5271 }
5272
5273 fn store_tree_snapshot(root: &Path) -> BTreeMap<PathBuf, (u32, Option<Vec<u8>>)> {
5274 fn visit(
5275 root: &Path,
5276 path: &Path,
5277 snapshot: &mut BTreeMap<PathBuf, (u32, Option<Vec<u8>>)>,
5278 ) {
5279 let metadata = std::fs::symlink_metadata(path).expect("snapshot metadata");
5280 let relative = path.strip_prefix(root).expect("snapshot relative path");
5281 let bytes = if metadata.is_file() {
5282 Some(std::fs::read(path).expect("snapshot file"))
5283 } else {
5284 None
5285 };
5286 snapshot.insert(relative.to_path_buf(), (metadata.mode() & 0o777, bytes));
5287 if metadata.is_dir() {
5288 for entry in std::fs::read_dir(path).expect("snapshot directory") {
5289 visit(root, &entry.expect("snapshot entry").path(), snapshot);
5290 }
5291 }
5292 }
5293
5294 let mut snapshot = BTreeMap::new();
5295 visit(root, root, &mut snapshot);
5296 snapshot
5297 }
5298
5299 fn assert_no_file_contains(root: &Path, canary: &[u8]) {
5300 fn visit(path: &Path, canary: &[u8]) {
5301 for entry in std::fs::read_dir(path).expect("read directory") {
5302 let entry = entry.expect("directory entry");
5303 let file_type = entry.file_type().expect("file type");
5304 if file_type.is_dir() {
5305 visit(&entry.path(), canary);
5306 } else if file_type.is_file() {
5307 let bytes = std::fs::read(entry.path()).expect("read file");
5308 assert!(!bytes.windows(canary.len()).any(|window| window == canary));
5309 }
5310 }
5311 }
5312 visit(root, canary);
5313 }
5314
5315 #[cfg(target_os = "linux")]
5316 #[test]
5317 fn linux_default_production_root_uses_account_home_xdg_layout() {
5318 let home = Path::new("/srv/steadymux-owner");
5319 assert_eq!(
5320 linux_production_root_path(home, None).expect("default Linux root"),
5321 home.join(".local")
5322 .join("share")
5323 .join("SteadyMux")
5324 .join("credentials-v1")
5325 );
5326 assert!(linux_production_root_path(home, Some(std::ffi::OsStr::new("relative"))).is_err());
5327 }
5328
5329 #[cfg(target_os = "linux")]
5330 #[test]
5331 fn linux_default_store_creates_private_xdg_tree_and_reopens() {
5332 let temporary = tempfile::tempdir().expect("temporary root");
5333 let home = temporary.path().join("home");
5334 std::fs::create_dir(&home).expect("account home");
5335 std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700))
5336 .expect("private account home");
5337
5338 let (root_path, root) =
5339 ensure_linux_production_store(&home, None).expect("ensure default Linux store");
5340 drop(root);
5341 assert_eq!(
5342 root_path,
5343 home.join(".local")
5344 .join("share")
5345 .join("SteadyMux")
5346 .join("credentials-v1")
5347 );
5348 for directory in [
5349 home.join(".local"),
5350 home.join(".local").join("share"),
5351 home.join(".local").join("share").join("SteadyMux"),
5352 root_path.clone(),
5353 ] {
5354 assert_eq!(
5355 std::fs::metadata(directory)
5356 .expect("Linux store directory metadata")
5357 .mode()
5358 & 0o777,
5359 0o700
5360 );
5361 }
5362 let (reopened_path, reopened) =
5363 open_linux_production_store(&home, None).expect("reopen default Linux store");
5364 drop(reopened);
5365 assert_eq!(reopened_path, root_path);
5366 }
5367
5368 #[cfg(target_os = "linux")]
5369 #[test]
5370 fn linux_xdg_override_store_is_private_and_reopens_without_creation() {
5371 let temporary = tempfile::tempdir().expect("temporary root");
5372 let home = temporary.path().join("home");
5373 let data_home = temporary.path().join("xdg-data");
5374 std::fs::create_dir(&home).expect("account home");
5375 std::fs::create_dir(&data_home).expect("XDG data home");
5376 std::fs::set_permissions(&home, std::fs::Permissions::from_mode(0o700))
5377 .expect("private account home");
5378 std::fs::set_permissions(&data_home, std::fs::Permissions::from_mode(0o700))
5379 .expect("private XDG data home");
5380
5381 let (root_path, root) = ensure_linux_production_store(&home, Some(data_home.as_os_str()))
5382 .expect("ensure Linux store");
5383 drop(root);
5384 assert_eq!(
5385 root_path,
5386 data_home.join("SteadyMux").join("credentials-v1")
5387 );
5388 assert_eq!(
5389 std::fs::metadata(data_home.join("SteadyMux"))
5390 .expect("product metadata")
5391 .mode()
5392 & 0o777,
5393 0o700
5394 );
5395 assert_eq!(
5396 std::fs::metadata(&root_path).expect("root metadata").mode() & 0o777,
5397 0o700
5398 );
5399
5400 let (reopened_path, reopened) =
5401 open_linux_production_store(&home, Some(data_home.as_os_str()))
5402 .expect("reopen Linux store");
5403 drop(reopened);
5404 assert_eq!(reopened_path, root_path);
5405
5406 std::fs::set_permissions(&data_home, std::fs::Permissions::from_mode(0o777))
5407 .expect("relax XDG data home");
5408 assert!(matches!(
5409 open_linux_production_store(&home, Some(data_home.as_os_str())),
5410 Err(CredentialStoreError::UnsafeStorage)
5411 ));
5412 }
5413
5414 #[cfg(target_os = "linux")]
5415 #[test]
5416 fn linux_account_record_home_parser_requires_matching_uid_and_absolute_home() {
5417 let record = b"owner:x:1001:1001:Owner:/srv/owner:/bin/bash\n";
5418 assert_eq!(
5419 parse_linux_account_home(record, 1001).expect("valid account record"),
5420 PathBuf::from("/srv/owner")
5421 );
5422 assert!(parse_linux_account_home(record, 1002).is_err());
5423 assert!(
5424 parse_linux_account_home(b"owner:x:1001:1001:Owner:relative:/bin/sh\n", 1001).is_err()
5425 );
5426 assert!(
5427 parse_linux_account_home(
5428 b"owner:x:1001:1001:Owner:/a:/bin/sh\nsecond:x:1001:1001:Owner:/b:/bin/sh\n",
5429 1001
5430 )
5431 .is_err()
5432 );
5433 }
5434
5435 #[cfg(target_os = "linux")]
5436 #[test]
5437 fn linux_current_user_home_resolves_from_effective_uid_account_record() {
5438 let home = current_user_home_from_account_record().expect("effective-UID account home");
5439 validate_absolute_path(&home).expect("absolute account home");
5440 let home_directory = open_path_directory(&home).expect("open account home");
5441 validate_owned_directory(
5442 &fstat(&home_directory).expect("effective-UID account home metadata"),
5443 )
5444 .expect("owned account home");
5445 }
5446
5447 #[cfg(target_os = "macos")]
5448 #[test]
5449 fn account_record_home_parser_requires_one_matching_uid_and_absolute_home() {
5450 let record = b"name: owner\nuid: 501\ndir: /Users/owner\n";
5451 assert_eq!(
5452 parse_account_home(record, 501).expect("valid account record"),
5453 PathBuf::from("/Users/owner")
5454 );
5455 assert!(parse_account_home(record, 502).is_err());
5456 assert!(parse_account_home(b"uid: 501\ndir: relative\n", 501).is_err());
5457 assert!(parse_account_home(b"uid: 501\ndir: /Users/a\ndir: /Users/b\n", 501).is_err());
5458 }
5459
5460 #[cfg(target_os = "macos")]
5461 fn account_lookup_test_command(script: &str) -> std::process::Command {
5462 use std::os::unix::process::CommandExt as _;
5463 use std::process::Stdio;
5464
5465 let mut command = std::process::Command::new("/bin/sh");
5466 command
5467 .args(["-c", script])
5468 .env_clear()
5469 .stdin(Stdio::null())
5470 .stderr(Stdio::null())
5471 .stdout(Stdio::piped())
5472 .process_group(0);
5473 command
5474 }
5475
5476 #[cfg(target_os = "macos")]
5477 #[test]
5478 fn account_lookup_runner_is_bounded_by_output_and_wall_clock() {
5479 let mut valid = account_lookup_test_command("printf 'uid: 501\\ndir: /Users/owner\\n'");
5480 assert_eq!(
5481 run_bounded_account_lookup(&mut valid, Duration::from_secs(1), 128)
5482 .expect("bounded local output"),
5483 b"uid: 501\ndir: /Users/owner\n"
5484 );
5485
5486 let mut oversized = account_lookup_test_command("printf '0123456789abcdef'");
5487 let error = run_bounded_account_lookup(&mut oversized, Duration::from_secs(1), 8)
5488 .expect_err("oversized output rejected");
5489 assert_eq!(
5490 format!("{error:?} {error}"),
5491 "UnsafeStorage device-local credential operation failed"
5492 );
5493
5494 let mut stalled = account_lookup_test_command("sleep 2; printf 'late-canary'");
5495 let started = Instant::now();
5496 let error = run_bounded_account_lookup(&mut stalled, Duration::from_millis(25), 128)
5497 .expect_err("stalled lookup rejected");
5498 assert!(started.elapsed() < Duration::from_secs(1));
5499 let diagnostic = format!("{error:?} {error}");
5500 assert_eq!(
5501 diagnostic,
5502 "UnsafeStorage device-local credential operation failed"
5503 );
5504 assert!(!diagnostic.contains("late-canary"));
5505 }
5506}