diff --git a/src/auth.rs b/src/auth.rs index ec57ac2..0594d6e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -78,8 +78,8 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { } let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok(); - let config_dir = crate::auth_commands::config_dir(); - let enc_path = credential_store::encrypted_credentials_path(); + let config_dir = crate::auth_commands::config_dir().await; + let enc_path = credential_store::encrypted_credentials_path().await; let default_path = config_dir.join("credentials.json"); let token_cache = config_dir.join("token_cache.json"); @@ -175,7 +175,7 @@ async fn load_credentials_inner( // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { let p = PathBuf::from(path); - if p.exists() { + if tokio::fs::metadata(&p).await.is_ok() { let content = tokio::fs::read_to_string(&p) .await .with_context(|| format!("Failed to read credentials from {path}"))?; @@ -187,8 +187,9 @@ async fn load_credentials_inner( } // 2. Encrypted credentials (always AuthorizedUser for now) - if enc_path.exists() { + if tokio::fs::metadata(enc_path).await.is_ok() { let json_str = credential_store::load_encrypted_from_path(enc_path) + .await .context("Failed to decrypt credentials")?; let creds: serde_json::Value = @@ -216,7 +217,7 @@ async fn load_credentials_inner( } // 3. Plaintext credentials at default path (Default to AuthorizedUser) - if default_path.exists() { + if tokio::fs::metadata(default_path).await.is_ok() { return Ok(Credential::AuthorizedUser( yup_oauth2::read_authorized_user_secret(default_path) .await @@ -229,7 +230,7 @@ async fn load_credentials_inner( // 4a. GOOGLE_APPLICATION_CREDENTIALS env var (explicit path — hard error if missing) if let Ok(adc_env) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") { let adc_path = PathBuf::from(&adc_env); - if adc_path.exists() { + if tokio::fs::metadata(&adc_path).await.is_ok() { let content = tokio::fs::read_to_string(&adc_path) .await .with_context(|| format!("Failed to read ADC from {adc_env}"))?; @@ -243,7 +244,7 @@ async fn load_credentials_inner( // 4b. Well-known ADC path: ~/.config/gcloud/application_default_credentials.json // (populated by `gcloud auth application-default login`). Silent if absent. if let Some(well_known) = adc_well_known_path() { - if well_known.exists() { + if tokio::fs::metadata(&well_known).await.is_ok() { let content = tokio::fs::read_to_string(&well_known) .await .with_context(|| format!("Failed to read ADC from {}", well_known.display()))?; @@ -539,7 +540,7 @@ mod tests { let enc_path = dir.path().join("credentials.enc"); // Encrypt and write - let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap(); + let encrypted = crate::credential_store::encrypt(json.as_bytes()).await.unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); let res = load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")) @@ -576,7 +577,7 @@ mod tests { let enc_path = dir.path().join("credentials.enc"); let plain_path = dir.path().join("credentials.json"); - let encrypted = crate::credential_store::encrypt(enc_json.as_bytes()).unwrap(); + let encrypted = crate::credential_store::encrypt(enc_json.as_bytes()).await.unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); std::fs::write(&plain_path, plain_json).unwrap(); diff --git a/src/auth_commands.rs b/src/auth_commands.rs index 47d2d4e..2c44e24 100644 --- a/src/auth_commands.rs +++ b/src/auth_commands.rs @@ -91,47 +91,155 @@ const READONLY_SCOPES: &[&str] = &[ "https://www.googleapis.com/auth/tasks.readonly", ]; -pub fn config_dir() -> PathBuf { +pub async fn base_config_dir() -> PathBuf { if let Ok(dir) = std::env::var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR") { - return PathBuf::from(dir); + let path = PathBuf::from(&dir); + + // First, do a basic check for ".." to prevent trivial traversal. + if path.components().any(|c| c.as_os_str() == "..") { + eprintln!("Warning: GOOGLE_WORKSPACE_CLI_CONFIG_DIR contains '..'. Using default."); + } else { + // Create the directory if it doesn't exist. This is the key to preventing TOCTOU. + if let Err(e) = tokio::fs::create_dir_all(&path).await { + eprintln!("Warning: Could not create GOOGLE_WORKSPACE_CLI_CONFIG_DIR '{}': {}. Using default.", path.display(), e); + } else { + // Now that the path is guaranteed to exist, we can safely canonicalize it. + match tokio::fs::canonicalize(&path).await { + Ok(canonical_path) => { + if is_suspicious_path(&canonical_path) { + eprintln!("Warning: GOOGLE_WORKSPACE_CLI_CONFIG_DIR resolves to a restricted or sensitive path ({}). Using default.", canonical_path.display()); + } else { + return canonical_path; + } + } + Err(e) => { + eprintln!("Warning: Could not resolve GOOGLE_WORKSPACE_CLI_CONFIG_DIR path '{}': {}. Using default.", path.display(), e); + } + } + } + } } // Use ~/.config/gws on all platforms for a consistent, user-friendly path. let primary = dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".config") - .join("gws"); - if primary.exists() { - return primary; + .join(".config") + .join("gws"); + + if tokio::fs::metadata(&primary).await.is_ok() { + primary + } else { + // Backward compat: fall back to OS-specific config dir for existing installs + // (e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows). + let legacy = dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("gws"); + if tokio::fs::metadata(&legacy).await.is_ok() { + legacy + } else { + primary + } + } +} + +fn is_suspicious_path(path_to_check: &std::path::Path) -> bool { + path_to_check.parent().is_none() + || path_to_check.components().any(|c| c.as_os_str() == ".." || c.as_os_str() == ".ssh") + || (cfg!(unix) + && (path_to_check.starts_with("/etc") + || path_to_check.starts_with("/usr") + || path_to_check.starts_with("/var") + || path_to_check.starts_with("/bin") + || path_to_check.starts_with("/sbin"))) +} + +pub static OVERRIDE_PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub async fn get_active_profile() -> Option { + // 1. Check globally injected CLI argument (thread-safe, avoids env data races) + if let Some(cli_profile) = OVERRIDE_PROFILE.get().cloned() { + return Some(cli_profile); } - // Backward compat: fall back to OS-specific config dir for existing installs - // (e.g. ~/Library/Application Support/gws on macOS, %APPDATA%\gws on Windows). - let legacy = dirs::config_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("gws"); - if legacy.exists() { - return legacy; + // 2. Fallback to reading the environment variable natively without local mutations + if let Some(s) = std::env::var("GOOGLE_WORKSPACE_CLI_PROFILE") + .ok() + .filter(|s| !s.is_empty()) + { + match validate_profile_name(&s) { + Ok(_) => return Some(s), + Err(e) => { + eprintln!( + "Error: Invalid profile name '{}' from GOOGLE_WORKSPACE_CLI_PROFILE: {}. Using default profile.", + s, e + ); + return None; + } + } + } + + let base_dir = base_config_dir().await; + match tokio::fs::read_to_string(base_dir.join("active_profile")).await { + Ok(s) => { + let trimmed = s.trim(); + if trimmed.is_empty() { + return None; + } + match validate_profile_name(trimmed) { + Ok(_) => Some(trimmed.to_string()), + Err(e) => { + eprintln!( + "Error: Invalid profile name '{}' found in active_profile file: {}. Please fix it or remove the file. Defaulting to 'default' profile.", + trimmed, e + ); + None + } + } + } + Err(_) => None, + } +} + +pub fn validate_profile_name(profile_name: &str) -> Result<(), GwsError> { + if profile_name.is_empty() + || profile_name == "." + || profile_name == ".." + || profile_name.starts_with('-') + || profile_name.chars().any(|c| { + !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' && c != '_' + }) + { + return Err(GwsError::Validation( + "Invalid profile name. It must not be empty, '.', or '..', start with a dash (-), and can only contain lowercase alphanumeric characters, dashes, and underscores (_).".to_string(), + )); } + Ok(()) +} - primary +pub async fn config_dir() -> PathBuf { + let base_dir = base_config_dir().await; + + match get_active_profile().await.as_deref() { + Some("default") | None => base_dir, + Some(name) => base_dir.join("profiles").join(name), + } } -fn plain_credentials_path() -> PathBuf { +pub async fn plain_credentials_path() -> PathBuf { if let Ok(path) = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE") { return PathBuf::from(path); } - config_dir().join("credentials.json") + config_dir().await.join("credentials.json") } -fn token_cache_path() -> PathBuf { - config_dir().join("token_cache.json") +pub async fn token_cache_path() -> PathBuf { + config_dir().await.join("token_cache.json") } /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { const USAGE: &str = concat!( - "Usage: gws auth [options]\n\n", + "Usage: gws auth [options]\n\n", " login Authenticate via OAuth2 (opens browser)\n", " --readonly Request read-only scopes\n", " --full Request all scopes incl. pubsub + cloud-platform\n", @@ -142,6 +250,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { " setup Configure GCP project + OAuth client (requires gcloud)\n", " --project Use a specific GCP project\n", " status Show current authentication state\n", + " switch Switch the active configuration profile\n", " export Print decrypted credentials to stdout\n", " logout Clear saved credentials and token cache", ); @@ -156,16 +265,57 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { "login" => handle_login(&args[1..]).await, "setup" => crate::setup::run_setup(&args[1..]).await, "status" => handle_status().await, + "switch" => handle_switch(&args[1..]).await, "export" => { let unmasked = args.len() > 1 && args[1] == "--unmasked"; handle_export(unmasked).await } - "logout" => handle_logout(), + "logout" => handle_logout().await, other => Err(GwsError::Validation(format!( - "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" + "Unknown auth subcommand: '{other}'. Use: login, setup, status, switch, export, logout" ))), } } + +async fn handle_switch(args: &[String]) -> Result<(), GwsError> { + if args.is_empty() { + return Err(GwsError::Validation( + "Missing profile name. Usage: gws auth switch ".to_string(), + )); + } + + let profile_name = &args[0]; + + validate_profile_name(profile_name)?; + + // Read the base directory without applying the current active profile + let base_dir = base_config_dir().await; + + if tokio::fs::metadata(&base_dir).await.is_err() { + tokio::fs::create_dir_all(&base_dir).await.map_err(|e| { + GwsError::Validation(format!("Failed to create base config dir: {e}")) + })?; + } + + let active_profile_path = base_dir.join("active_profile"); + + if profile_name == "default" { + if let Err(e) = tokio::fs::remove_file(&active_profile_path).await { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(GwsError::Validation(format!("Failed to remove active_profile file: {e}"))); + } + } + println!("Switched to default profile."); + } else { + tokio::fs::write(&active_profile_path, profile_name).await.map_err(|e| { + GwsError::Validation(format!("Failed to write active_profile: {e}")) + })?; + println!("Switched to profile: {profile_name}"); + } + + Ok(()) +} + /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. struct CliFlowDelegate { @@ -235,11 +385,11 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Resolve client_id and client_secret: // 1. Env vars (highest priority) // 2. Saved client_secret.json from `gws auth setup` or manual download - let (client_id, client_secret, project_id) = resolve_client_credentials()?; + let (client_id, client_secret, project_id) = resolve_client_credentials().await?; // Persist credentials to client_secret.json if not already saved, // so they survive env var removal or shell session changes. - if !crate::oauth_config::client_config_path().exists() { + if !crate::oauth_config::client_config_path().await.exists() { let _ = crate::oauth_config::save_client_config( &client_id, &client_secret, @@ -277,14 +427,14 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } // Use a temp file for yup-oauth2's token persistence, then encrypt it - let temp_path = config_dir().join("credentials.tmp"); + let temp_path = config_dir().await.join("credentials.tmp"); // Always start fresh — delete any stale temp cache from prior login attempts. - let _ = std::fs::remove_file(&temp_path); + let _ = tokio::fs::remove_file(&temp_path).await; // Ensure config directory exists if let Some(parent) = temp_path.parent() { - std::fs::create_dir_all(parent) + tokio::fs::create_dir_all(parent).await .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; } @@ -310,12 +460,13 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { if token.token().is_some() { // Read yup-oauth2's token cache to extract the refresh_token. - // EncryptedTokenStorage stores data encrypted, so we must decrypt first. - let token_data = std::fs::read(&temp_path) - .ok() - .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) - .and_then(|decrypted| String::from_utf8(decrypted).ok()) - .unwrap_or_default(); + let token_data = async { + let bytes = tokio::fs::read(&temp_path).await.ok()?; + let decrypted = crate::credential_store::decrypt(&bytes).await.ok()?; + String::from_utf8(decrypted).ok() + } + .await + .unwrap_or_default(); let refresh_token = extract_refresh_token(&token_data).ok_or_else(|| { GwsError::Auth( "OAuth flow completed but no refresh token was returned. \ @@ -341,10 +492,11 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { // Save encrypted credentials let enc_path = credential_store::save_encrypted(&creds_str) + .await .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; // Clean up temp file - let _ = std::fs::remove_file(&temp_path); + let _ = tokio::fs::remove_file(&temp_path).await; let output = json!({ "status": "success", @@ -361,7 +513,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { Ok(()) } else { // Clean up temp file on failure - let _ = std::fs::remove_file(&temp_path); + let _ = tokio::fs::remove_file(&temp_path).await; Err(GwsError::Auth( "OAuth flow completed but no token was returned.".to_string(), )) @@ -390,14 +542,14 @@ async fn fetch_userinfo_email(access_token: &str) -> Option { } async fn handle_export(unmasked: bool) -> Result<(), GwsError> { - let enc_path = credential_store::encrypted_credentials_path(); - if !enc_path.exists() { + let enc_path = credential_store::encrypted_credentials_path().await; + if tokio::fs::metadata(&enc_path).await.is_err() { return Err(GwsError::Auth( "No encrypted credentials found. Run 'gws auth login' first.".to_string(), )); } - match credential_store::load_encrypted() { + match credential_store::load_encrypted().await { Ok(contents) => { if unmasked { println!("{contents}"); @@ -424,7 +576,7 @@ async fn handle_export(unmasked: bool) -> Result<(), GwsError> { } /// Resolve OAuth client credentials from env vars or saved config file. -fn resolve_client_credentials() -> Result<(String, String, Option), GwsError> { +async fn resolve_client_credentials() -> Result<(String, String, Option), GwsError> { // 1. Try env vars first let env_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID").ok(); let env_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET").ok(); @@ -432,13 +584,14 @@ fn resolve_client_credentials() -> Result<(String, String, Option), GwsE if let (Some(id), Some(secret)) = (env_id, env_secret) { // Still try to load project_id from config file for the scope picker let project_id = crate::oauth_config::load_client_config() + .await .ok() .map(|c| c.project_id); return Ok((id, secret, project_id)); } // 2. Try saved client_secret.json - match crate::oauth_config::load_client_config() { + match crate::oauth_config::load_client_config().await { Ok(config) => Ok(( config.client_id, config.client_secret, @@ -452,7 +605,7 @@ fn resolve_client_credentials() -> Result<(String, String, Option), GwsE 2. Download client_secret.json from Google Cloud Console and save it to:\n \ {}\n \ 3. Set env vars: GOOGLE_WORKSPACE_CLI_CLIENT_ID and GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", - crate::oauth_config::client_config_path().display() + crate::oauth_config::client_config_path().await.display() ), )), } @@ -899,9 +1052,9 @@ fn run_simple_scope_picker(services_filter: Option<&HashSet>) -> Option< } async fn handle_status() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); + let plain_path = plain_credentials_path().await; + let enc_path = credential_store::encrypted_credentials_path().await; + let token_cache = token_cache_path().await; let has_encrypted = enc_path.exists(); let has_plain = plain_path.exists(); @@ -932,13 +1085,13 @@ async fn handle_status() -> Result<(), GwsError> { }); // Show client config (client_secret.json) status - let config_path = crate::oauth_config::client_config_path(); + let config_path = crate::oauth_config::client_config_path().await; let has_config = config_path.exists(); output["client_config"] = json!(config_path.display().to_string()); output["client_config_exists"] = json!(has_config); if has_config { - match crate::oauth_config::load_client_config() { + match crate::oauth_config::load_client_config().await { Ok(config) => { output["project_id"] = json!(config.project_id); let masked_id = if config.client_id.len() > 12 { @@ -968,7 +1121,7 @@ async fn handle_status() -> Result<(), GwsError> { output["token_env_var"] = json!(true); "token_env_var" } else { - match resolve_client_credentials() { + match resolve_client_credentials().await { Ok((_, _, _)) => { let has_env_id = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_ID").is_ok(); let has_env_secret = std::env::var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET").is_ok(); @@ -987,7 +1140,7 @@ async fn handle_status() -> Result<(), GwsError> { // Skip real credential/network access in test builds if !cfg!(test) { if has_encrypted { - match credential_store::load_encrypted() { + match credential_store::load_encrypted().await { Ok(contents) => { if let Ok(creds) = serde_json::from_str::(&contents) { if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) { @@ -1045,7 +1198,7 @@ async fn handle_status() -> Result<(), GwsError> { // Skip all network calls and subprocess spawning in test builds if !cfg!(test) { let creds_json_str = if has_encrypted { - credential_store::load_encrypted().ok() + credential_store::load_encrypted().await.ok() } else if has_plain { tokio::fs::read_to_string(&plain_path).await.ok() } else { @@ -1141,24 +1294,29 @@ async fn handle_status() -> Result<(), GwsError> { } } // end !cfg!(test) + // Determine the active profile + let profile = get_active_profile().await.unwrap_or_else(|| "default".to_string()); + output["active_profile"] = json!(profile); + println!( "{}", serde_json::to_string_pretty(&output).unwrap_or_default() ); + Ok(()) } -fn handle_logout() -> Result<(), GwsError> { - let plain_path = plain_credentials_path(); - let enc_path = credential_store::encrypted_credentials_path(); - let token_cache = token_cache_path(); - let sa_token_cache = config_dir().join("sa_token_cache.json"); +async fn handle_logout() -> Result<(), GwsError> { + let plain_path = plain_credentials_path().await; + let enc_path = credential_store::encrypted_credentials_path().await; + let token_cache = token_cache_path().await; + let sa_token_cache = config_dir().await.join("sa_token_cache.json"); let mut removed = Vec::new(); for path in [&enc_path, &plain_path, &token_cache, &sa_token_cache] { - if path.exists() { - std::fs::remove_file(path).map_err(|e| { + if tokio::fs::metadata(path).await.is_ok() { + tokio::fs::remove_file(path).await.map_err(|e| { GwsError::Validation(format!("Failed to remove {}: {e}", path.display())) })?; removed.push(path.display().to_string()); @@ -1392,14 +1550,14 @@ mod tests { assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); } - #[test] + #[tokio::test] #[serial_test::serial] - fn resolve_client_credentials_from_env_vars() { + async fn resolve_client_credentials_from_env_vars() { unsafe { std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", "test-id"); std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret"); } - let result = resolve_client_credentials(); + let result = resolve_client_credentials().await; unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); @@ -1410,16 +1568,16 @@ mod tests { // project_id may be Some if client_secret.json exists on the machine } - #[test] + #[tokio::test] #[serial_test::serial] - fn resolve_client_credentials_missing_env_vars_uses_config() { + async fn resolve_client_credentials_missing_env_vars_uses_config() { unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); } // Result depends on whether client_secret.json exists on the machine - let result = resolve_client_credentials(); - if crate::oauth_config::client_config_path().exists() { + let result = resolve_client_credentials().await; + if crate::oauth_config::client_config_path().await.exists() { assert!( result.is_ok(), "Should succeed when client_secret.json exists" @@ -1431,9 +1589,9 @@ mod tests { } } - #[test] - fn config_dir_returns_gws_subdir() { - let path = config_dir(); + #[tokio::test] + async fn config_dir_returns_gws_subdir() { + let path = config_dir().await; assert!(path.ends_with("gws")); } @@ -1446,9 +1604,9 @@ mod tests { assert!(primary.ends_with(".config/gws") || primary.ends_with(r".config\gws")); } - #[test] + #[tokio::test] #[serial_test::serial] - fn config_dir_fallback_to_legacy() { + async fn config_dir_fallback_to_legacy() { // When GOOGLE_WORKSPACE_CLI_CONFIG_DIR points to a legacy-style dir, // config_dir() should return it (simulating the test env override). let dir = tempfile::tempdir().unwrap(); @@ -1458,46 +1616,54 @@ mod tests { unsafe { std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", legacy.to_str().unwrap()); } - let path = config_dir(); - assert_eq!(path, legacy); + let path = config_dir().await; + let expected = tokio::fs::canonicalize(&legacy).await.unwrap_or(legacy); + assert_eq!(path, expected); unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); } } - #[test] + #[tokio::test] #[serial_test::serial] - fn plain_credentials_path_defaults_to_config_dir() { + async fn plain_credentials_path_defaults_to_config_dir() { // Without env var, should be in config dir unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); } - let path = plain_credentials_path(); + let path = plain_credentials_path().await; assert!(path.ends_with("credentials.json")); - assert!(path.starts_with(config_dir())); + assert!(path.starts_with(config_dir().await)); } - #[test] + #[tokio::test] #[serial_test::serial] - fn plain_credentials_path_respects_env_var() { + async fn plain_credentials_path_respects_env_var() { unsafe { std::env::set_var( "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE", "/tmp/test-creds.json", ); } - let path = plain_credentials_path(); + let path = plain_credentials_path().await; assert_eq!(path, PathBuf::from("/tmp/test-creds.json")); unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); } } - #[test] - fn token_cache_path_is_in_config_dir() { - let path = token_cache_path(); + #[tokio::test] + #[serial_test::serial] + async fn token_cache_path_is_in_config_dir() { + let temp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", temp.path()); } + + let c_dir = config_dir().await; + let path = token_cache_path().await; assert!(path.ends_with("token_cache.json")); - assert!(path.starts_with(config_dir())); + assert!(path.starts_with(&c_dir)); + + unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR"); } } #[tokio::test] @@ -1533,16 +1699,16 @@ mod tests { } } - #[test] + #[tokio::test] #[serial_test::serial] - fn resolve_credentials_fails_without_env_vars_or_config() { + async fn resolve_credentials_fails_without_env_vars_or_config() { unsafe { std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID"); std::env::remove_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET"); } // When no env vars AND no client_secret.json on disk, should fail - let result = resolve_client_credentials(); - if !crate::oauth_config::client_config_path().exists() { + let result = resolve_client_credentials().await; + if !crate::oauth_config::client_config_path().await.exists() { assert!(result.is_err()); match result.unwrap_err() { GwsError::Auth(msg) => assert!(msg.contains("No OAuth client configured")), @@ -1553,15 +1719,15 @@ mod tests { // successfully — that's correct behavior, not a test failure. } - #[test] + #[tokio::test] #[serial_test::serial] - fn resolve_credentials_uses_env_vars_when_present() { + async fn resolve_credentials_uses_env_vars_when_present() { unsafe { std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_ID", "test-id"); std::env::set_var("GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", "test-secret"); } - let result = resolve_client_credentials(); + let result = resolve_client_credentials().await; // Clean up immediately unsafe { @@ -1582,12 +1748,12 @@ mod tests { assert!(result.is_ok()); } - #[test] - fn credential_store_save_load_round_trip() { + #[tokio::test] + async fn credential_store_save_load_round_trip() { // Use encrypt/decrypt directly to avoid writing to the real config dir let json = r#"{"client_id":"test","client_secret":"secret","refresh_token":"tok"}"#; - let encrypted = credential_store::encrypt(json.as_bytes()).expect("encrypt should succeed"); - let decrypted = credential_store::decrypt(&encrypted).expect("decrypt should succeed"); + let encrypted = credential_store::encrypt(json.as_bytes()).await.expect("encrypt should succeed"); + let decrypted = credential_store::decrypt(&encrypted).await.expect("decrypt should succeed"); assert_eq!(String::from_utf8(decrypted).unwrap(), json); } diff --git a/src/commands.rs b/src/commands.rs index ecfe991..eddfa53 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -46,6 +46,13 @@ pub fn build_cli(doc: &RestDescription) -> Command { .help("Output format: json (default), table, yaml, csv") .value_name("FORMAT") .global(true), + ) + .arg( + clap::Arg::new("profile") + .long("profile") + .help("The configuration profile to use (e.g. default, work, personal)") + .value_name("PROFILE") + .global(true), ); // Inject helper commands diff --git a/src/credential_store.rs b/src/credential_store.rs index 867d5bc..7480b9f 100644 --- a/src/credential_store.rs +++ b/src/credential_store.rs @@ -19,35 +19,31 @@ use aes_gcm::{AeadCore, Aes256Gcm, Nonce}; use keyring::Entry; use rand::RngCore; -use std::sync::OnceLock; -/// Returns the encryption key derived from the OS keyring, or falls back to a local file. -/// Generates a random 256-bit key and stores it securely if it doesn't exist. -fn get_or_create_key() -> anyhow::Result<[u8; 32]> { - static KEY: OnceLock<[u8; 32]> = OnceLock::new(); - if let Some(key) = KEY.get() { - return Ok(*key); - } +static KEY: tokio::sync::OnceCell<[u8; 32]> = tokio::sync::OnceCell::const_new(); - let cache_key = |candidate: [u8; 32]| -> [u8; 32] { - if KEY.set(candidate).is_ok() { - candidate - } else { - // If set() fails, another thread already initialized the key. .get() is - // guaranteed to return Some at this point. - *KEY.get() - .expect("key must be initialized if OnceLock::set() failed") - } - }; +/// Returns the encryption key derived from the OS keyring, or falls back to a local file. +/// Generates a random 256-bit key and stores it securely if it doesn't exist. +async fn get_or_create_key() -> anyhow::Result<[u8; 32]> { + let key = KEY.get_or_try_init(generate_key_logic).await?; + Ok(*key) +} +async fn generate_key_logic() -> anyhow::Result<[u8; 32]> { let username = std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) .unwrap_or_else(|_| "unknown-user".to_string()); - let key_file = crate::auth_commands::config_dir().join(".encryption_key"); + let key_file = crate::auth_commands::config_dir().await.join(".encryption_key"); + + let profile = crate::auth_commands::get_active_profile().await; + let service_name = match profile.as_deref() { + Some("default") | None => "gws-cli".to_string(), + Some(name) => format!("gws-cli-{}", name), + }; - let entry = Entry::new("gws-cli", &username); + let entry = Entry::new(&service_name, &username); if let Ok(entry) = entry { match entry.get_password() { @@ -57,7 +53,7 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { if decoded.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&decoded); - return Ok(cache_key(arr)); + return Ok(arr); } } } @@ -65,15 +61,15 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { use base64::{engine::general_purpose::STANDARD, Engine as _}; // If keyring is empty, prefer a persisted local key first. - if key_file.exists() { - if let Ok(b64_key) = std::fs::read_to_string(&key_file) { + if tokio::fs::metadata(&key_file).await.is_ok() { + if let Ok(b64_key) = tokio::fs::read_to_string(&key_file).await { if let Ok(decoded) = STANDARD.decode(b64_key.trim()) { if decoded.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&decoded); // Best effort: repopulate keyring for future runs. let _ = entry.set_password(&b64_key); - return Ok(cache_key(arr)); + return Ok(arr); } } } @@ -85,13 +81,17 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { let b64_key = STANDARD.encode(key); if let Some(parent) = key_file.parent() { - let _ = std::fs::create_dir_all(parent); + let _ = tokio::fs::create_dir_all(parent).await; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Err(e) = - std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - { + let perms_result = async { + let mut perms = tokio::fs::metadata(parent).await?.permissions(); + perms.set_mode(0o700); + tokio::fs::set_permissions(parent, perms).await + } + .await; + if let Err(e) = perms_result { eprintln!( "Warning: failed to set secure permissions on key directory: {e}" ); @@ -102,22 +102,23 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true).mode(0o600); - if let Ok(mut file) = options.open(&key_file) { - use std::io::Write; - let _ = file.write_all(b64_key.as_bytes()); + let mut std_options = std::fs::OpenOptions::new(); + std_options.write(true).create(true).truncate(true).mode(0o600); + let options: tokio::fs::OpenOptions = std_options.into(); + if let Ok(mut file) = options.open(&key_file).await { + use tokio::io::AsyncWriteExt; + let _ = file.write_all(b64_key.as_bytes()).await; } } #[cfg(not(unix))] { - let _ = std::fs::write(&key_file, &b64_key); + let _ = tokio::fs::write(&key_file, &b64_key).await; } // Best effort: also store in keyring when available. let _ = entry.set_password(&b64_key); - return Ok(cache_key(key)); + return Ok(key); } Err(e) => { eprintln!("Warning: keyring access failed, falling back to file storage: {e}"); @@ -127,14 +128,14 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { // Fallback: Local file `.encryption_key` - if key_file.exists() { - if let Ok(b64_key) = std::fs::read_to_string(&key_file) { + if tokio::fs::metadata(&key_file).await.is_ok() { + if let Ok(b64_key) = tokio::fs::read_to_string(&key_file).await { use base64::{engine::general_purpose::STANDARD, Engine as _}; if let Ok(decoded) = STANDARD.decode(b64_key.trim()) { if decoded.len() == 32 { let mut arr = [0u8; 32]; arr.copy_from_slice(&decoded); - return Ok(cache_key(arr)); + return Ok(arr); } } } @@ -148,12 +149,17 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { let b64_key = STANDARD.encode(key); if let Some(parent) = key_file.parent() { - let _ = std::fs::create_dir_all(parent); + let _ = tokio::fs::create_dir_all(parent).await; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) - { + let perms_result = async { + let mut perms = tokio::fs::metadata(parent).await?.permissions(); + perms.set_mode(0o700); + tokio::fs::set_permissions(parent, perms).await + } + .await; + if let Err(e) = perms_result { eprintln!("Warning: failed to set secure permissions on key directory: {e}"); } } @@ -162,25 +168,26 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true).mode(0o600); - if let Ok(mut file) = options.open(&key_file) { - use std::io::Write; - let _ = file.write_all(b64_key.as_bytes()); + let mut std_options = std::fs::OpenOptions::new(); + std_options.write(true).create(true).truncate(true).mode(0o600); + let options: tokio::fs::OpenOptions = std_options.into(); + if let Ok(mut file) = options.open(&key_file).await { + use tokio::io::AsyncWriteExt; + let _ = file.write_all(b64_key.as_bytes()).await; } } #[cfg(not(unix))] { - let _ = std::fs::write(&key_file, b64_key); + let _ = tokio::fs::write(&key_file, b64_key).await; } - Ok(cache_key(key)) + Ok(key) } /// Encrypts plaintext bytes using AES-256-GCM with a machine-derived key. /// Returns nonce (12 bytes) || ciphertext. -pub fn encrypt(plaintext: &[u8]) -> anyhow::Result> { - let key = get_or_create_key()?; +pub async fn encrypt(plaintext: &[u8]) -> anyhow::Result> { + let key = get_or_create_key().await?; let cipher = Aes256Gcm::new_from_slice(&key) .map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?; @@ -196,12 +203,12 @@ pub fn encrypt(plaintext: &[u8]) -> anyhow::Result> { } /// Decrypts data produced by `encrypt()`. -pub fn decrypt(data: &[u8]) -> anyhow::Result> { +pub async fn decrypt(data: &[u8]) -> anyhow::Result> { if data.len() < 12 { anyhow::bail!("Encrypted data too short"); } - let key = get_or_create_key()?; + let key = get_or_create_key().await?; let cipher = Aes256Gcm::new_from_slice(&key) .map_err(|e| anyhow::anyhow!("Failed to create cipher: {e}"))?; @@ -217,19 +224,21 @@ pub fn decrypt(data: &[u8]) -> anyhow::Result> { } /// Returns the path for encrypted credentials. -pub fn encrypted_credentials_path() -> PathBuf { - crate::auth_commands::config_dir().join("credentials.enc") +pub async fn encrypted_credentials_path() -> PathBuf { + crate::auth_commands::config_dir().await.join("credentials.enc") } /// Saves credentials JSON to an encrypted file. -pub fn save_encrypted(json: &str) -> anyhow::Result { - let path = encrypted_credentials_path(); +pub async fn save_encrypted(json: &str) -> anyhow::Result { + let path = encrypted_credentials_path().await; if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + tokio::fs::create_dir_all(parent).await?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + let mut perms = tokio::fs::metadata(parent).await?.permissions(); + perms.set_mode(0o700); + if let Err(e) = tokio::fs::set_permissions(parent, perms).await { eprintln!( "Warning: failed to set directory permissions on {}: {e}", @@ -239,18 +248,20 @@ pub fn save_encrypted(json: &str) -> anyhow::Result { } } - let encrypted = encrypt(json.as_bytes())?; + let encrypted = encrypt(json.as_bytes()).await?; // Write atomically via a sibling .tmp file + rename so the credentials // file is never left in a corrupt partial-write state on crash/Ctrl-C. - crate::fs_util::atomic_write(&path, &encrypted) + crate::fs_util::atomic_write_async(&path, &encrypted).await .map_err(|e| anyhow::anyhow!("Failed to write credentials: {e}"))?; // Set permissions to 600 on Unix (contains secrets) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { + let mut perms = tokio::fs::metadata(&path).await?.permissions(); + perms.set_mode(0o600); + if let Err(e) = tokio::fs::set_permissions(&path, perms).await { eprintln!( "Warning: failed to set file permissions on {}: {e}", path.display() @@ -262,78 +273,79 @@ pub fn save_encrypted(json: &str) -> anyhow::Result { } /// Loads and decrypts credentials JSON from a specific path. -pub fn load_encrypted_from_path(path: &std::path::Path) -> anyhow::Result { - let data = std::fs::read(path)?; - let plaintext = decrypt(&data)?; +pub async fn load_encrypted_from_path(path: &std::path::Path) -> anyhow::Result { + let data = tokio::fs::read(path).await?; + let plaintext = decrypt(&data).await?; Ok(String::from_utf8(plaintext)?) } /// Loads and decrypts credentials JSON from the default encrypted file. -pub fn load_encrypted() -> anyhow::Result { - load_encrypted_from_path(&encrypted_credentials_path()) +pub async fn load_encrypted() -> anyhow::Result { + let path = encrypted_credentials_path().await; + load_encrypted_from_path(&path).await } #[cfg(test)] mod tests { use super::*; - #[test] - fn get_or_create_key_is_deterministic() { - let key1 = get_or_create_key().unwrap(); - let key2 = get_or_create_key().unwrap(); + #[tokio::test] + async fn get_or_create_key_is_deterministic() { + let key1 = get_or_create_key().await.unwrap(); + let key2 = get_or_create_key().await.unwrap(); assert_eq!(key1, key2); } - #[test] - fn get_or_create_key_produces_256_bits() { - let key = get_or_create_key().unwrap(); + #[tokio::test] + async fn get_or_create_key_produces_256_bits() { + let key = get_or_create_key().await.unwrap(); assert_eq!(key.len(), 32); } - #[test] - fn encrypt_decrypt_round_trip() { + #[tokio::test] + async fn encrypt_decrypt_round_trip() { let plaintext = b"hello, world!"; - let encrypted = encrypt(plaintext).expect("encryption should succeed"); + let encrypted = encrypt(plaintext).await.expect("encryption should succeed"); assert_ne!(&encrypted, plaintext); assert_eq!(encrypted.len(), 12 + plaintext.len() + 16); - let decrypted = decrypt(&encrypted).expect("decryption should succeed"); + let decrypted = decrypt(&encrypted).await.expect("decryption should succeed"); assert_eq!(decrypted, plaintext); } - #[test] - fn encrypt_decrypt_empty() { + #[tokio::test] + async fn encrypt_decrypt_empty() { let plaintext = b""; - let encrypted = encrypt(plaintext).expect("encryption should succeed"); - let decrypted = decrypt(&encrypted).expect("decryption should succeed"); + let encrypted = encrypt(plaintext).await.expect("encryption should succeed"); + let decrypted = decrypt(&encrypted).await.expect("decryption should succeed"); assert_eq!(decrypted, plaintext); } - #[test] - fn decrypt_rejects_short_data() { - let result = decrypt(&[0u8; 11]); + #[tokio::test] + async fn decrypt_rejects_short_data() { + let result = decrypt(&[0u8; 11]).await; assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("too short")); } - #[test] - fn decrypt_rejects_tampered_ciphertext() { - let encrypted = encrypt(b"secret data").expect("encryption should succeed"); + #[tokio::test] + async fn decrypt_rejects_tampered_ciphertext() { + let encrypted = encrypt(b"secret data").await.expect("encryption should succeed"); let mut tampered = encrypted.clone(); if tampered.len() > 12 { tampered[12] ^= 0xFF; } - let result = decrypt(&tampered); + let result = decrypt(&tampered).await; assert!(result.is_err()); } - #[test] - fn each_encryption_produces_different_output() { + #[tokio::test] + async fn each_encryption_produces_different_output() { let plaintext = b"same input"; - let enc1 = encrypt(plaintext).expect("encryption should succeed"); - let enc2 = encrypt(plaintext).expect("encryption should succeed"); + let enc1 = encrypt(plaintext).await.expect("encryption should succeed"); + let enc2 = encrypt(plaintext).await.expect("encryption should succeed"); assert_ne!(enc1, enc2); - let dec1 = decrypt(&enc1).unwrap(); - let dec2 = decrypt(&enc2).unwrap(); + let dec1 = decrypt(&enc1).await.unwrap(); + let dec2 = decrypt(&enc2).await.unwrap(); assert_eq!(dec1, dec2); assert_eq!(dec1, plaintext); } diff --git a/src/discovery.rs b/src/discovery.rs index b4fa9ff..9832bcc 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -195,20 +195,18 @@ pub async fn fetch_discovery_document( let version = crate::validate::validate_api_identifier(version).map_err(|e| anyhow::anyhow!("{e}"))?; - let cache_dir = crate::auth_commands::config_dir().join("cache"); - std::fs::create_dir_all(&cache_dir)?; + let cache_dir = crate::auth_commands::config_dir().await.join("cache"); + tokio::fs::create_dir_all(&cache_dir).await?; let cache_file = cache_dir.join(format!("{service}_{version}.json")); // Check cache (24hr TTL) - if cache_file.exists() { - if let Ok(metadata) = std::fs::metadata(&cache_file) { - if let Ok(modified) = metadata.modified() { - if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) { - let data = std::fs::read_to_string(&cache_file)?; - let doc: RestDescription = serde_json::from_str(&data)?; - return Ok(doc); - } + if let Ok(metadata) = tokio::fs::metadata(&cache_file).await { + if let Ok(modified) = metadata.modified() { + if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) { + let data = tokio::fs::read_to_string(&cache_file).await?; + let doc: RestDescription = serde_json::from_str(&data)?; + return Ok(doc); } } } @@ -242,7 +240,7 @@ pub async fn fetch_discovery_document( }; // Write to cache - if let Err(e) = std::fs::write(&cache_file, &body) { + if let Err(e) = tokio::fs::write(&cache_file, &body).await { // Non-fatal: just warn via stderr-safe approach let _ = e; } diff --git a/src/main.rs b/src/main.rs index 89d73d9..b19f501 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,76 +64,81 @@ async fn run() -> Result<(), GwsError> { )); } - // Find the first non-flag arg (skip --api-version and its value) - let mut first_arg: Option = None; - { - let mut skip_next = false; - for a in args.iter().skip(1) { - if skip_next { - skip_next = false; - continue; - } - if a == "--api-version" { - skip_next = true; - continue; - } - if a.starts_with("--api-version=") { - continue; - } - if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" { - first_arg = Some(a.clone()); - break; - } - } + let globals_cli = clap::Command::new("gws") + .ignore_errors(true) + .allow_external_subcommands(true) + .arg(clap::Arg::new("profile").long("profile").num_args(1).global(true)) + .arg(clap::Arg::new("api-version").long("api-version").num_args(1).global(true)) + .arg(clap::Arg::new("format").long("format").num_args(1).global(true)) + .arg(clap::Arg::new("sanitize").long("sanitize").num_args(1).global(true)); + + let global_matches = globals_cli.try_get_matches_from(&args).unwrap_or_default(); + + if let Some(profile) = global_matches.get_one::("profile") { + crate::auth_commands::validate_profile_name(profile)?; + let _ = crate::auth_commands::OVERRIDE_PROFILE.set(profile.clone()); } - let first_arg = first_arg.ok_or_else(|| { + + let first_arg = if let Some((cmd, _)) = global_matches.subcommand() { + Some(cmd.to_string()) + } else { + args.iter() + .skip(1) + .find(|a| *a == "--help" || *a == "--version" || *a == "-h" || *a == "-V") + .cloned() + }; + + let first_arg_val = first_arg.ok_or_else(|| { GwsError::Validation( "No service specified. Usage: gws [sub-resource] [flags]" .to_string(), ) })?; - // Handle --help and --version at top level - if is_help_flag(&first_arg) { + if is_help_flag(&first_arg_val) { print_usage(); return Ok(()); } - if is_version_flag(&first_arg) { + if is_version_flag(&first_arg_val) { println!("gws {}", env!("CARGO_PKG_VERSION")); println!("This is not an officially supported Google product."); return Ok(()); } - // Handle the `schema` command - if first_arg == "schema" { - if args.len() < 3 { + let parse_external_args = || -> Vec { + global_matches + .subcommand() + .and_then(|(_, sub)| sub.get_many::("")) + .map(|vals| vals.map(|s| s.to_string_lossy().to_string()).collect()) + .unwrap_or_default() + }; + + if first_arg_val == "schema" { + let sub_args = parse_external_args(); + if sub_args.is_empty() { return Err(GwsError::Validation( "Usage: gws schema (e.g., gws schema drive.files.list) [--resolve-refs]" .to_string(), )); } - let resolve_refs = args.iter().any(|arg| arg == "--resolve-refs"); - // Remove the flag if it exists so it doesn't mess up path parsing, or just pass the path - // The path is args[2], flags might follow. - let path = &args[2]; + let resolve_refs = sub_args.iter().any(|arg| arg == "--resolve-refs"); + let path = &sub_args[0]; return schema::handle_schema_command(path, resolve_refs).await; } - // Handle the `generate-skills` command - if first_arg == "generate-skills" { - let gen_args: Vec = args.iter().skip(2).cloned().collect(); + if first_arg_val == "generate-skills" { + let gen_args = parse_external_args(); return generate_skills::handle_generate_skills(&gen_args).await; } - // Handle the `auth` command - if first_arg == "auth" { - let auth_args: Vec = args.iter().skip(2).cloned().collect(); + if first_arg_val == "auth" { + let auth_args = parse_external_args(); return auth_commands::handle_auth_command(&auth_args).await; } // Parse service name and optional version override - let (api_name, version) = parse_service_and_version(&args, &first_arg)?; + let (api_name, version) = parse_service_and_version(&args, &first_arg_val)?; // For synthetic services (no Discovery doc), use an empty RestDescription let doc = if api_name == "workflow" { @@ -155,7 +160,7 @@ async fn run() -> Result<(), GwsError> { // Re-parse args (skip argv[0] which is the binary, and argv[1] which is the service name) // Filter out --api-version and its value // Prepend "gws" as the program name since try_get_matches_from expects argv[0] - let sub_args = filter_args_for_subcommand(&args, &first_arg); + let sub_args = filter_args_for_subcommand(&args, &first_arg_val); let matches = cli.try_get_matches_from(&sub_args).map_err(|e| { // If it's a help or version display, print it and exit cleanly @@ -423,6 +428,7 @@ fn print_usage() { println!(" --upload Local file to upload as media content (multipart)"); println!(" --output Output file path for binary responses"); println!(" --format Output format: json (default), table, yaml, csv"); + println!(" --profile Use a specific configuration profile"); println!(" --api-version Override the API version (e.g., v2, v3)"); println!(" --page-all Auto-paginate, one JSON line per page (NDJSON)"); println!(" --page-limit Max pages to fetch with --page-all (default: 10)"); @@ -449,6 +455,7 @@ fn print_usage() { println!( " GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)" ); + println!(" GOOGLE_WORKSPACE_CLI_PROFILE Configuration profile to use (e.g., 'work', 'personal')"); println!(" GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE Default Model Armor template"); println!( " GOOGLE_WORKSPACE_CLI_SANITIZE_MODE Sanitization mode: warn (default) or block" @@ -689,3 +696,4 @@ mod tests { assert_eq!(select_scope(&scopes), None); } } + diff --git a/src/oauth_config.rs b/src/oauth_config.rs index 02154b5..e442554 100644 --- a/src/oauth_config.rs +++ b/src/oauth_config.rs @@ -53,12 +53,12 @@ pub struct ClientSecretFile { } /// Returns the path for the client secret config file. -pub fn client_config_path() -> PathBuf { - crate::auth_commands::config_dir().join("client_secret.json") +pub async fn client_config_path() -> PathBuf { + crate::auth_commands::config_dir().await.join("client_secret.json") } /// Saves OAuth client configuration in the standard Google Cloud Console format. -pub fn save_client_config( +pub async fn save_client_config( client_id: &str, client_secret: &str, project_id: &str, @@ -75,29 +75,32 @@ pub fn save_client_config( }, }; - let path = client_config_path(); + let path = client_config_path().await; if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; + tokio::fs::create_dir_all(parent).await?; } let json = serde_json::to_string_pretty(&config)?; - crate::fs_util::atomic_write(&path, json.as_bytes()) + crate::fs_util::atomic_write_async(&path, json.as_bytes()).await .map_err(|e| anyhow::anyhow!("Failed to write client config: {e}"))?; // Set file permissions to 600 on Unix (contains secrets) #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; + let mut perms = tokio::fs::metadata(&path).await?.permissions(); + perms.set_mode(0o600); + tokio::fs::set_permissions(&path, perms).await?; } Ok(path) } /// Loads OAuth client configuration from the standard Google Cloud Console format. -pub fn load_client_config() -> anyhow::Result { - let path = client_config_path(); - let data = std::fs::read_to_string(&path) +pub async fn load_client_config() -> anyhow::Result { + let path = client_config_path().await; + let data = tokio::fs::read_to_string(&path) + .await .map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?; let file: ClientSecretFile = serde_json::from_str(&data) .map_err(|e| anyhow::anyhow!("Invalid client_secret.json format: {e}"))?; @@ -228,9 +231,9 @@ mod tests { } } - #[test] + #[tokio::test] #[serial_test::serial] - fn test_load_client_config() { + async fn test_load_client_config() { let dir = tempfile::tempdir().unwrap(); let _env_guard = EnvGuard::new( "GOOGLE_WORKSPACE_CLI_CONFIG_DIR", @@ -238,24 +241,24 @@ mod tests { ); // Initially no config file exists - let result = load_client_config(); + let result = load_client_config().await; let err = result.unwrap_err(); assert!(err.to_string().contains("Cannot read")); // Create a valid config file - save_client_config("test-id", "test-secret", "test-project").unwrap(); + save_client_config("test-id", "test-secret", "test-project").await.unwrap(); // Now loading should succeed - let config = load_client_config().unwrap(); + let config = load_client_config().await.unwrap(); assert_eq!(config.client_id, "test-id"); assert_eq!(config.client_secret, "test-secret"); assert_eq!(config.project_id, "test-project"); // Create an invalid config file - let path = client_config_path(); - std::fs::write(&path, "invalid json").unwrap(); + let path = client_config_path().await; + tokio::fs::write(&path, "invalid json").await.unwrap(); - let result = load_client_config(); + let result = load_client_config().await; let err = result.unwrap_err(); assert!(err .to_string() diff --git a/src/setup.rs b/src/setup.rs index a2730ec..755bf10 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1265,7 +1265,7 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result String { +async fn manual_oauth_instructions(project_id: &str) -> String { let consent_url = if project_id.is_empty() { "https://console.cloud.google.com/apis/credentials/consent".to_string() } else { @@ -1316,7 +1316,7 @@ fn manual_oauth_instructions(project_id: &str) -> String { ), consent_url = consent_url, creds_url = creds_url, - config_path = crate::oauth_config::client_config_path().display() + config_path = crate::oauth_config::client_config_path().await.display().to_string() ) } @@ -1332,14 +1332,14 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result = crate::credential_store::load_encrypted() - .ok() + .await.ok() .and_then(|s| serde_json::from_str(&s).ok()); w.show_message(&format!( @@ -1424,7 +1424,7 @@ async fn stage_configure_oauth(ctx: &mut SetupContext) -> Result Result<(), GwsError> { "apis_enabled": ctx.enabled.len(), "apis_skipped": ctx.skipped.len(), "apis_failed": ctx.failed.iter().map(|(api, err)| json!({"api": api, "error": err})).collect::>(), - "client_config": crate::oauth_config::client_config_path().display().to_string(), + "client_config": crate::oauth_config::client_config_path().await.display().to_string(), }); println!( "{}", diff --git a/src/token_storage.rs b/src/token_storage.rs index d6c5c47..c8c17e3 100644 --- a/src/token_storage.rs +++ b/src/token_storage.rs @@ -40,7 +40,7 @@ impl EncryptedTokenStorage { Err(_) => return HashMap::new(), // File doesn't exist yet — normal on first run }; - let decrypted = match crate::credential_store::decrypt(&data) { + let decrypted = match crate::credential_store::decrypt(&data).await { Ok(d) => d, Err(e) => { eprintln!( @@ -71,14 +71,22 @@ impl EncryptedTokenStorage { async fn save_to_disk(&self, map: &HashMap) -> anyhow::Result<()> { let json = serde_json::to_string(map)?; - let encrypted = crate::credential_store::encrypt(json.as_bytes())?; + let encrypted = crate::credential_store::encrypt(json.as_bytes()).await?; if let Some(parent) = self.file_path.parent() { let _ = tokio::fs::create_dir_all(parent).await; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + let perms_result = async { + let mut perms = tokio::fs::metadata(parent).await?.permissions(); + perms.set_mode(0o700); + tokio::fs::set_permissions(parent, perms).await + } + .await; + if let Err(e) = perms_result { + eprintln!("warning: failed to set secure permissions on token storage directory: {e}"); + } } }