@@ -66,8 +66,8 @@ fn save_key_file_exclusive(path: &std::path::Path, b64_key: &str) -> std::io::Re
6666
6767/// Persist the base64-encoded encryption key to a local file with restrictive
6868/// permissions (0600 file, 0700 directory). Overwrites any existing file.
69- /// Persist the base64-encoded encryption key to a local file with restrictive
70- /// permissions. Uses atomic_write to prevent TOCTOU/symlink race conditions.
69+ /// Uses atomic_write to prevent TOCTOU/symlink race conditions.
70+ # [ allow ( dead_code ) ]
7171fn save_key_file ( path : & std:: path:: Path , b64_key : & str ) -> std:: io:: Result < ( ) > {
7272 crate :: fs_util:: atomic_write ( path, b64_key. as_bytes ( ) )
7373}
@@ -202,64 +202,122 @@ fn resolve_key(
202202
203203 // --- 1. Try keyring (only when backend = Keyring) --------------------
204204 if backend == KeyringBackend :: Keyring {
205- match provider. get_password ( ) {
206- Ok ( b64_key) => {
207- if let Ok ( decoded) = STANDARD . decode ( & b64_key) {
208- if decoded. len ( ) == 32 {
209- let mut arr = [ 0u8 ; 32 ] ;
210- arr. copy_from_slice ( & decoded) ;
211- // Ensure file backup stays in sync with keyring so
212- // credentials survive keyring loss (e.g. after OS
213- // upgrades, container restarts, daemon changes).
214- if let Err ( err) = save_key_file ( key_file, & b64_key) {
215- eprintln ! (
216- "Warning: failed to sync keyring backup file at '{}': {err}" ,
217- key_file. display( )
218- ) ;
205+ #[ cfg( any( target_os = "macos" , target_os = "windows" ) ) ]
206+ {
207+ match provider. get_password ( ) {
208+ Ok ( b64_key) => {
209+ if let Ok ( decoded) = STANDARD . decode ( & b64_key) {
210+ if decoded. len ( ) == 32 {
211+ let mut arr = [ 0u8 ; 32 ] ;
212+ arr. copy_from_slice ( & decoded) ;
213+ // Cleanup insecure file fallback if it still exists.
214+ // TOCTOU race condition is a known limitation.
215+ if let Err ( e) = std:: fs:: remove_file ( key_file) {
216+ if e. kind ( ) != std:: io:: ErrorKind :: NotFound {
217+ eprintln ! (
218+ "Warning: failed to remove legacy key file at '{}': {}" ,
219+ key_file. display( ) ,
220+ e
221+ ) ;
222+ }
223+ }
224+ return Ok ( arr) ;
219225 }
220- return Ok ( arr) ;
221226 }
227+ // Keyring contained invalid data — fall through to generate new.
228+ }
229+ Err ( keyring:: Error :: NoEntry ) => {
230+ // Keyring is empty — fall through to generate new.
231+ }
232+ Err ( e) => {
233+ anyhow:: bail!( "OS keyring failed: {}. Set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file to use file storage." , sanitize_for_terminal( & e. to_string( ) ) ) ;
222234 }
223- // Keyring contained invalid data — fall through to file.
224235 }
225- Err ( keyring:: Error :: NoEntry ) => {
226- // Keyring is reachable but empty — check file, then generate.
227- if let Some ( key) = read_key_file ( key_file) {
228- // Best-effort: copy file key into keyring for future runs.
229- let _ = provider. set_password ( & STANDARD . encode ( key) ) ;
230- return Ok ( key) ;
236+
237+ // Generate a new key if keyring was empty or contained invalid data.
238+ let key = generate_random_key ( ) ;
239+ let b64_key = STANDARD . encode ( key) ;
240+ if let Err ( e) = provider. set_password ( & b64_key) {
241+ anyhow:: bail!(
242+ "Failed to set key in OS keyring: {}" ,
243+ sanitize_for_terminal( & e. to_string( ) )
244+ ) ;
245+ }
246+ if let Err ( e) = std:: fs:: remove_file ( key_file) {
247+ if e. kind ( ) != std:: io:: ErrorKind :: NotFound {
248+ eprintln ! (
249+ "Warning: failed to remove legacy key file at '{}': {}" ,
250+ key_file. display( ) ,
251+ e
252+ ) ;
231253 }
254+ }
255+ return Ok ( key) ;
256+ }
232257
233- // Generate a new key.
234- let key = generate_random_key ( ) ;
235- let b64_key = STANDARD . encode ( key) ;
236-
237- // Best-effort: store in keyring.
238- let _ = provider. set_password ( & b64_key) ;
239-
240- // Atomically create the file; if another process raced us,
241- // use their key instead.
242- match save_key_file_exclusive ( key_file, & b64_key) {
243- Ok ( ( ) ) => return Ok ( key) ,
244- Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: AlreadyExists => {
245- if let Some ( winner) = read_key_file ( key_file) {
246- // Sync the winner's key into the keyring so both
247- // backends stay consistent after the race.
248- let _ = provider. set_password ( & STANDARD . encode ( winner) ) ;
249- return Ok ( winner) ;
258+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
259+ {
260+ // On Linux, keyring uses a mock store by default without C DBus dependencies,
261+ // so we continue to use the file fallback for reliability.
262+ match provider. get_password ( ) {
263+ Ok ( b64_key) => {
264+ if let Ok ( decoded) = STANDARD . decode ( & b64_key) {
265+ if decoded. len ( ) == 32 {
266+ let mut arr = [ 0u8 ; 32 ] ;
267+ arr. copy_from_slice ( & decoded) ;
268+ // Ensure file backup stays in sync with keyring so
269+ // credentials survive keyring loss (e.g. after OS
270+ // upgrades, container restarts, daemon changes).
271+ if let Err ( err) = save_key_file ( key_file, & b64_key) {
272+ eprintln ! (
273+ "Warning: failed to sync keyring backup file at '{}': {err}" ,
274+ key_file. display( )
275+ ) ;
276+ }
277+ return Ok ( arr) ;
250278 }
251- // File exists but is unreadable/corrupt — overwrite.
252- save_key_file ( key_file, & b64_key) ?;
279+ }
280+ // Keyring contained invalid data — fall through to file.
281+ }
282+ Err ( keyring:: Error :: NoEntry ) => {
283+ // Keyring is reachable but empty — check file, then generate.
284+ if let Some ( key) = read_key_file ( key_file) {
285+ // Best-effort: copy file key into keyring for future runs.
286+ let _ = provider. set_password ( & STANDARD . encode ( key) ) ;
253287 return Ok ( key) ;
254288 }
255- Err ( e) => return Err ( e. into ( ) ) ,
289+
290+ // Generate a new key.
291+ let key = generate_random_key ( ) ;
292+ let b64_key = STANDARD . encode ( key) ;
293+
294+ // Best-effort: store in keyring.
295+ let _ = provider. set_password ( & b64_key) ;
296+
297+ // Atomically create the file; if another process raced us,
298+ // use their key instead.
299+ match save_key_file_exclusive ( key_file, & b64_key) {
300+ Ok ( ( ) ) => return Ok ( key) ,
301+ Err ( e) if e. kind ( ) == std:: io:: ErrorKind :: AlreadyExists => {
302+ if let Some ( winner) = read_key_file ( key_file) {
303+ // Sync the winner's key into the keyring so both
304+ // backends stay consistent after the race.
305+ let _ = provider. set_password ( & STANDARD . encode ( winner) ) ;
306+ return Ok ( winner) ;
307+ }
308+ // File exists but is unreadable/corrupt — overwrite.
309+ save_key_file ( key_file, & b64_key) ?;
310+ return Ok ( key) ;
311+ }
312+ Err ( e) => return Err ( e. into ( ) ) ,
313+ }
314+ }
315+ Err ( e) => {
316+ eprintln ! (
317+ "Warning: keyring access failed, falling back to file storage: {}" ,
318+ sanitize_for_terminal( & e. to_string( ) )
319+ ) ;
256320 }
257- }
258- Err ( e) => {
259- eprintln ! (
260- "Warning: keyring access failed, falling back to file storage: {}" ,
261- sanitize_for_terminal( & e. to_string( ) )
262- ) ;
263321 }
264322 }
265323 }
@@ -293,7 +351,11 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> {
293351 return Ok ( * key) ;
294352 }
295353
354+ #[ cfg( not( test) ) ]
296355 let backend = KeyringBackend :: from_env ( ) ;
356+ #[ cfg( test) ]
357+ let backend = KeyringBackend :: File ; // Force file to avoid native keychain prompts during test execution
358+
297359 // Item 5: log which backend was selected
298360 eprintln ! ( "Using keyring backend: {}" , backend. as_str( ) ) ;
299361
@@ -407,6 +469,8 @@ pub fn load_encrypted() -> anyhow::Result<String> {
407469
408470#[ cfg( test) ]
409471mod tests {
472+ #![ allow( dead_code) ]
473+
410474 use super :: * ;
411475 use std:: cell:: RefCell ;
412476
@@ -513,7 +577,73 @@ mod tests {
513577 assert_eq ! ( result, expected) ;
514578 }
515579
580+ // ---- Backend::Keyring tests (macOS/Windows specific behavior) ----
581+
582+ #[ test]
583+ #[ cfg( any( target_os = "macos" , target_os = "windows" ) ) ]
584+ fn keyring_backend_cleans_up_legacy_file_on_success ( ) {
585+ use base64:: { engine:: general_purpose:: STANDARD , Engine as _} ;
586+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
587+ let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
588+
589+ // Create a legacy fallback file
590+ std:: fs:: write ( & key_file, STANDARD . encode ( [ 99u8 ; 32 ] ) ) . unwrap ( ) ;
591+ assert ! ( key_file. exists( ) ) ;
592+
593+ // Keyring has a valid key
594+ let expected = [ 7u8 ; 32 ] ;
595+ let mock = MockKeyring :: with_password ( & STANDARD . encode ( expected) ) ;
596+
597+ let result = resolve_key ( KeyringBackend :: Keyring , & mock, & key_file) . unwrap ( ) ;
598+
599+ assert_eq ! ( result, expected) ;
600+ assert ! (
601+ !key_file. exists( ) ,
602+ "Legacy file must be deleted upon successful keyring read"
603+ ) ;
604+ }
605+
606+ #[ test]
607+ #[ cfg( any( target_os = "macos" , target_os = "windows" ) ) ]
608+ fn keyring_backend_cleans_up_legacy_file_on_generation ( ) {
609+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
610+ let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
611+
612+ std:: fs:: write ( & key_file, "legacy-data" ) . unwrap ( ) ;
613+ assert ! ( key_file. exists( ) ) ;
614+
615+ let mock = MockKeyring :: no_entry ( ) ;
616+
617+ let result = resolve_key ( KeyringBackend :: Keyring , & mock, & key_file) . unwrap ( ) ;
618+
619+ assert_eq ! ( result. len( ) , 32 ) ;
620+ assert ! (
621+ !key_file. exists( ) ,
622+ "Legacy file must be deleted upon successful keyring generation"
623+ ) ;
624+ }
625+
626+ #[ test]
627+ #[ cfg( any( target_os = "macos" , target_os = "windows" ) ) ]
628+ fn keyring_backend_returns_error_on_platform_failure ( ) {
629+ let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
630+ let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
631+
632+ let mock = MockKeyring :: platform_error ( ) ;
633+
634+ let result = resolve_key ( KeyringBackend :: Keyring , & mock, & key_file) ;
635+
636+ assert ! ( result. is_err( ) ) ;
637+ assert ! ( result
638+ . unwrap_err( )
639+ . to_string( )
640+ . contains( "OS keyring failed" ) ) ;
641+ }
642+
643+ // ---- Backend::Keyring tests (Linux fallback behavior) ----
644+
516645 #[ test]
646+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
517647 fn keyring_backend_creates_file_backup_when_missing ( ) {
518648 use base64:: { engine:: general_purpose:: STANDARD , Engine as _} ;
519649 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
@@ -535,6 +665,7 @@ mod tests {
535665 }
536666
537667 #[ test]
668+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
538669 fn keyring_backend_syncs_file_when_keyring_differs ( ) {
539670 use base64:: { engine:: general_purpose:: STANDARD , Engine as _} ;
540671 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
@@ -554,6 +685,7 @@ mod tests {
554685 }
555686
556687 #[ test]
688+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
557689 fn keyring_backend_no_entry_reads_file ( ) {
558690 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
559691 let ( expected, key_file) = write_test_key ( dir. path ( ) ) ;
@@ -568,6 +700,7 @@ mod tests {
568700 }
569701
570702 #[ test]
703+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
571704 fn keyring_backend_no_entry_no_file_generates_and_saves_both ( ) {
572705 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
573706 let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
@@ -581,6 +714,7 @@ mod tests {
581714 }
582715
583716 #[ test]
717+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
584718 fn keyring_backend_no_entry_no_file_keyring_set_fails ( ) {
585719 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
586720 let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
@@ -593,6 +727,7 @@ mod tests {
593727 }
594728
595729 #[ test]
730+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
596731 fn keyring_backend_platform_error_falls_back_to_file ( ) {
597732 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
598733 let ( expected, key_file) = write_test_key ( dir. path ( ) ) ;
@@ -602,6 +737,7 @@ mod tests {
602737 }
603738
604739 #[ test]
740+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
605741 fn keyring_backend_platform_error_no_file_generates ( ) {
606742 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
607743 let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
@@ -612,6 +748,7 @@ mod tests {
612748 }
613749
614750 #[ test]
751+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
615752 fn keyring_backend_invalid_keyring_data_uses_file ( ) {
616753 use base64:: { engine:: general_purpose:: STANDARD , Engine as _} ;
617754 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
@@ -660,6 +797,7 @@ mod tests {
660797 // ---- Stability tests ----
661798
662799 #[ test]
800+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
663801 fn key_is_stable_across_calls ( ) {
664802 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
665803 let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
@@ -858,6 +996,7 @@ mod tests {
858996 // ---- Race condition tests ----
859997
860998 #[ test]
999+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
8611000 fn race_loser_syncs_winner_key_to_keyring ( ) {
8621001 use base64:: { engine:: general_purpose:: STANDARD , Engine as _} ;
8631002 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
@@ -881,6 +1020,7 @@ mod tests {
8811020 }
8821021
8831022 #[ test]
1023+ #[ cfg( not( any( target_os = "macos" , target_os = "windows" ) ) ) ]
8841024 fn race_loser_corrupt_file_overwrites ( ) {
8851025 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
8861026 let key_file = dir. path ( ) . join ( ".encryption_key" ) ;
0 commit comments