@@ -16,6 +16,7 @@ use std::collections::HashSet;
1616use std:: io:: { BufRead , BufReader , Write } ;
1717use std:: net:: TcpListener ;
1818use std:: path:: { Path , PathBuf } ;
19+ use std:: sync:: OnceLock ;
1920
2021use serde:: Deserialize ;
2122use serde_json:: json;
@@ -334,15 +335,63 @@ pub fn config_dir() -> PathBuf {
334335 primary
335336}
336337
337- fn plain_credentials_path ( ) -> PathBuf {
338+ static ACTIVE_PROFILE : OnceLock < String > = OnceLock :: new ( ) ;
339+
340+ pub ( crate ) const DEFAULT_PROFILE : & str = "default" ;
341+
342+ pub ( crate ) fn validate_profile_name ( name : & str ) -> Result < ( ) , GwsError > {
343+ if name. is_empty ( )
344+ || name. starts_with ( '-' )
345+ || !name
346+ . chars ( )
347+ . all ( |c| c. is_ascii_alphanumeric ( ) || c == '-' || c == '_' )
348+ {
349+ return Err ( GwsError :: Validation (
350+ "Profile names must start with a letter, number, or '_' and may only contain letters, numbers, '-' and '_'" . to_string ( ) ,
351+ ) ) ;
352+ }
353+ Ok ( ( ) )
354+ }
355+
356+ pub ( crate ) fn set_active_profile ( profile : Option < String > ) -> Result < ( ) , GwsError > {
357+ if let Some ( profile) = profile {
358+ validate_profile_name ( & profile) ?;
359+ let _ = ACTIVE_PROFILE . set ( profile) ;
360+ }
361+ Ok ( ( ) )
362+ }
363+
364+ pub ( crate ) fn active_profile ( ) -> String {
365+ ACTIVE_PROFILE
366+ . get ( )
367+ . cloned ( )
368+ . or_else ( || std:: env:: var ( "GOOGLE_WORKSPACE_CLI_PROFILE" ) . ok ( ) )
369+ . filter ( |p| validate_profile_name ( p) . is_ok ( ) )
370+ . unwrap_or_else ( || DEFAULT_PROFILE . to_string ( ) )
371+ }
372+
373+ pub ( crate ) fn profile_dir ( ) -> PathBuf {
374+ let profile = active_profile ( ) ;
375+ if profile == DEFAULT_PROFILE {
376+ config_dir ( )
377+ } else {
378+ config_dir ( ) . join ( "profiles" ) . join ( profile)
379+ }
380+ }
381+
382+ pub ( crate ) fn plain_credentials_path ( ) -> PathBuf {
338383 if let Ok ( path) = std:: env:: var ( "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE" ) {
339384 return PathBuf :: from ( path) ;
340385 }
341- config_dir ( ) . join ( "credentials.json" )
386+ profile_dir ( ) . join ( "credentials.json" )
342387}
343388
344- fn token_cache_path ( ) -> PathBuf {
345- config_dir ( ) . join ( "token_cache.json" )
389+ pub ( crate ) fn token_cache_path ( ) -> PathBuf {
390+ profile_dir ( ) . join ( "token_cache.json" )
391+ }
392+
393+ pub ( crate ) fn service_account_token_cache_path ( ) -> PathBuf {
394+ profile_dir ( ) . join ( "sa_token_cache.json" )
346395}
347396
348397/// Which scope set to use for login.
@@ -644,9 +693,15 @@ async fn handle_login_inner(
644693 let enc_path = credential_store:: save_encrypted ( & creds_str)
645694 . map_err ( |e| GwsError :: Auth ( format ! ( "Failed to encrypt credentials: {e}" ) ) ) ?;
646695
696+ for path in [ token_cache_path ( ) , service_account_token_cache_path ( ) ] {
697+ let _ = std:: fs:: remove_file ( path) ;
698+ }
699+ crate :: timezone:: invalidate_cache ( ) ;
700+
647701 let output = json ! ( {
648702 "status" : "success" ,
649703 "message" : "Authentication successful. Encrypted credentials saved." ,
704+ "profile" : active_profile( ) ,
650705 "account" : actual_email. as_deref( ) . unwrap_or( "(unknown)" ) ,
651706 "credentials_file" : enc_path. display( ) . to_string( ) ,
652707 "encryption" : "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)" ,
@@ -1223,6 +1278,8 @@ async fn handle_status() -> Result<(), GwsError> {
12231278 } ;
12241279
12251280 let mut output = json ! ( {
1281+ "profile" : active_profile( ) ,
1282+ "profile_dir" : profile_dir( ) . display( ) . to_string( ) ,
12261283 "auth_method" : auth_method,
12271284 "storage" : storage,
12281285 "keyring_backend" : credential_store:: active_backend_name( ) ,
@@ -1457,7 +1514,7 @@ fn handle_logout() -> Result<(), GwsError> {
14571514 let plain_path = plain_credentials_path ( ) ;
14581515 let enc_path = credential_store:: encrypted_credentials_path ( ) ;
14591516 let token_cache = token_cache_path ( ) ;
1460- let sa_token_cache = config_dir ( ) . join ( "sa_token_cache.json" ) ;
1517+ let sa_token_cache = service_account_token_cache_path ( ) ;
14611518
14621519 let mut removed = Vec :: new ( ) ;
14631520
@@ -1476,11 +1533,13 @@ fn handle_logout() -> Result<(), GwsError> {
14761533 let output = if removed. is_empty ( ) {
14771534 json ! ( {
14781535 "status" : "success" ,
1536+ "profile" : active_profile( ) ,
14791537 "message" : "No credentials found to remove." ,
14801538 } )
14811539 } else {
14821540 json ! ( {
14831541 "status" : "success" ,
1542+ "profile" : active_profile( ) ,
14841543 "message" : "Logged out. All credentials and token caches removed." ,
14851544 "removed" : removed,
14861545 } )
@@ -1900,6 +1959,43 @@ mod tests {
19001959 assert ! ( path. starts_with( config_dir( ) ) ) ;
19011960 }
19021961
1962+ #[ test]
1963+ #[ serial_test:: serial]
1964+ fn profile_dir_defaults_to_config_dir ( ) {
1965+ unsafe {
1966+ std:: env:: remove_var ( "GOOGLE_WORKSPACE_CLI_PROFILE" ) ;
1967+ }
1968+ assert_eq ! ( active_profile( ) , DEFAULT_PROFILE ) ;
1969+ assert_eq ! ( profile_dir( ) , config_dir( ) ) ;
1970+ }
1971+
1972+ #[ test]
1973+ #[ serial_test:: serial]
1974+ fn named_profile_uses_isolated_paths ( ) {
1975+ unsafe {
1976+ std:: env:: set_var ( "GOOGLE_WORKSPACE_CLI_PROFILE" , "work" ) ;
1977+ }
1978+
1979+ let dir = profile_dir ( ) ;
1980+ assert ! ( dir. ends_with( "profiles/work" ) || dir. ends_with( r"profiles\work" ) ) ;
1981+ assert ! ( plain_credentials_path( ) . starts_with( & dir) ) ;
1982+ assert ! ( token_cache_path( ) . starts_with( & dir) ) ;
1983+ assert ! ( service_account_token_cache_path( ) . starts_with( & dir) ) ;
1984+
1985+ unsafe {
1986+ std:: env:: remove_var ( "GOOGLE_WORKSPACE_CLI_PROFILE" ) ;
1987+ }
1988+ }
1989+
1990+ #[ test]
1991+ fn validate_profile_name_rejects_traversal ( ) {
1992+ assert ! ( validate_profile_name( "work" ) . is_ok( ) ) ;
1993+ assert ! ( validate_profile_name( "../work" ) . is_err( ) ) ;
1994+ assert ! ( validate_profile_name( "work.profile" ) . is_err( ) ) ;
1995+ assert ! ( validate_profile_name( "--help" ) . is_err( ) ) ;
1996+ assert ! ( validate_profile_name( "" ) . is_err( ) ) ;
1997+ }
1998+
19031999 #[ tokio:: test]
19042000 async fn handle_auth_command_empty_args_prints_usage ( ) {
19052001 let args: Vec < String > = vec ! [ ] ;
0 commit comments