mirror of
https://github.com/googleworkspace/cli.git
synced 2026-09-14 16:47:13 +08:00
feat!: remove multi-account, DWD, and impersonation support (#253)
* feat!: remove multi-account, DWD, and impersonation support BREAKING CHANGE: Remove domain-wide delegation, multi-account support, and impersonation from the CLI authentication flow. Removed: - `gws auth list` and `gws auth default` commands - `--account` flag from `gws auth login` and `gws auth logout` - `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var - `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env var - Per-account credential storage (accounts.json registry) - Service account impersonation (subject/DWD) Preserved: - `GOOGLE_WORKSPACE_CLI_TOKEN` (raw access token) - `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` (SA key path) - `GOOGLE_WORKSPACE_CLI_CLIENT_ID` / `CLIENT_SECRET` (OAuth config) - `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` (config dir override) * chore: update changeset description * docs: remove multi-account and DWD references from docs --------- Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
@@ -2,4 +2,4 @@
|
||||
"@googleworkspace/cli": minor
|
||||
---
|
||||
|
||||
Remove domain wide delegation support
|
||||
Remove multi-account, domain-wide delegation, and impersonation support. Removes `gws auth list`, `gws auth default`, `--account` flag, `GOOGLE_WORKSPACE_CLI_ACCOUNT` and `GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER` env vars.
|
||||
|
||||
@@ -9,12 +9,6 @@
|
||||
# Path to OAuth credentials JSON (user or service account)
|
||||
# GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE=
|
||||
|
||||
# Default account email for multi-account usage (overridden by --account flag)
|
||||
# GOOGLE_WORKSPACE_CLI_ACCOUNT=
|
||||
|
||||
# Email of user to impersonate via Domain-Wide Delegation (service accounts only)
|
||||
# GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER=
|
||||
|
||||
# ── OAuth Client ──────────────────────────────────────────────────
|
||||
# OAuth client ID and secret (alternative to saving client_secret.json)
|
||||
# GOOGLE_WORKSPACE_CLI_CLIENT_ID=
|
||||
|
||||
@@ -46,13 +46,12 @@ The CLI uses a **two-phase argument parsing** strategy:
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `src/main.rs` | Entrypoint, two-phase CLI parsing, `--account` global flag extraction, method resolution |
|
||||
| `src/main.rs` | Entrypoint, two-phase CLI parsing, method resolution |
|
||||
| `src/discovery.rs` | Serde models for Discovery Document + fetch/cache |
|
||||
| `src/services.rs` | Service alias → Discovery API name/version mapping |
|
||||
| `src/auth.rs` | OAuth2 token acquisition with multi-account support via `accounts.json` registry |
|
||||
| `src/accounts.rs` | Multi-account registry (`accounts.json`), email normalisation, base64 encoding |
|
||||
| `src/credential_store.rs` | AES-256-GCM encryption/decryption, per-account credential file paths |
|
||||
| `src/auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `list`, `default`, `setup`, `status`, `export` |
|
||||
| `src/auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC |
|
||||
| `src/credential_store.rs` | AES-256-GCM encryption/decryption of credential files |
|
||||
| `src/auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` |
|
||||
| `src/commands.rs` | Recursive `clap::Command` builder from Discovery resources |
|
||||
| `src/executor.rs` | HTTP request construction, response handling, schema validation |
|
||||
| `src/schema.rs` | `gws schema` command — introspect API method schemas |
|
||||
@@ -174,7 +173,7 @@ Use these labels to categorize pull requests and issues:
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority; bypasses all credential file loading) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (no default; if unset, falls back to credentials secured by the OS Keyring and encrypted in `~/.config/gws/`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_ACCOUNT` | Default account email for multi-account usage (overridden by `--account` flag) |
|
||||
|
||||
| `GOOGLE_APPLICATION_CREDENTIALS` | Standard Google ADC path; used as fallback when no gws-specific credentials are configured |
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -137,22 +137,6 @@ gws auth login # subsequent scope selection and login
|
||||
> gws auth login -s drive,gmail,sheets
|
||||
> ```
|
||||
|
||||
### Multiple accounts
|
||||
|
||||
You can authenticate with more than one Google account and switch between them:
|
||||
|
||||
```bash
|
||||
gws auth login --account work@corp.com # login and register an account
|
||||
gws auth login --account personal@gmail.com
|
||||
|
||||
gws auth list # list registered accounts
|
||||
gws auth default work@corp.com. # set the default
|
||||
|
||||
gws --account personal@gmail.com drive files list # one-off override
|
||||
export GOOGLE_WORKSPACE_CLI_ACCOUNT=personal@gmail.com # env var override
|
||||
```
|
||||
|
||||
Credentials are stored per-account as `credentials.<b64-email>.enc` in `~/.config/gws/`, with an `accounts.json` registry tracking defaults.
|
||||
|
||||
### Manual OAuth setup (Google Cloud Console)
|
||||
|
||||
@@ -222,14 +206,12 @@ export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token)
|
||||
|
||||
### Precedence
|
||||
|
||||
| Priority | Source | Set via |
|
||||
| -------- | --------------------------------- | --------------------------------------- |
|
||||
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
|
||||
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
|
||||
| 3 | Per-account encrypted credentials | `gws auth login --account EMAIL` |
|
||||
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
|
||||
|
||||
Account resolution: `--account` flag > `GOOGLE_WORKSPACE_CLI_ACCOUNT` env var > default in `accounts.json`.
|
||||
| Priority | Source | Set via |
|
||||
| -------- | ---------------------- | --------------------------------------- |
|
||||
| 1 | Access token | `GOOGLE_WORKSPACE_CLI_TOKEN` |
|
||||
| 2 | Credentials file | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` |
|
||||
| 3 | Encrypted credentials | `gws auth login` |
|
||||
| 4 | Plaintext credentials | `~/.config/gws/credentials.json` |
|
||||
|
||||
Environment variables can also live in a `.env` file.
|
||||
|
||||
@@ -362,7 +344,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste
|
||||
|---|---|
|
||||
| `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) |
|
||||
| `GOOGLE_WORKSPACE_CLI_ACCOUNT` | Default account email (overridden by `--account` flag) |
|
||||
|
||||
| `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) |
|
||||
| `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) |
|
||||
|
||||
-259
@@ -1,259 +0,0 @@
|
||||
// Copyright 2026 Google LLC
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Multi-account registry for `gws`.
|
||||
//!
|
||||
//! Manages `~/.config/gws/accounts.json` which maps email addresses to
|
||||
//! credential files and tracks the default account.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// On-disk representation of `accounts.json`.
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct AccountsRegistry {
|
||||
/// Email of the default account, or `None` if no default is set.
|
||||
pub default: Option<String>,
|
||||
/// Map from normalised email → account metadata.
|
||||
pub accounts: BTreeMap<String, AccountMeta>,
|
||||
}
|
||||
|
||||
/// Per-account metadata stored in the registry.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AccountMeta {
|
||||
/// ISO-8601 timestamp of when this account was added.
|
||||
pub added: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Email normalisation & base64 helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalise an email address: trim whitespace and lowercase.
|
||||
///
|
||||
/// Google treats email addresses as case-insensitive, so
|
||||
/// `User@Gmail.COM` and `user@gmail.com` must map to the same
|
||||
/// credential file and registry entry.
|
||||
pub fn normalize_email(email: &str) -> String {
|
||||
email.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Encode a normalised email to a URL-safe Base64 string (no padding).
|
||||
///
|
||||
/// This is used as the unique key in credential/token-cache filenames
|
||||
/// (e.g. `credentials.<b64>.enc`) to avoid filesystem issues with `@`
|
||||
/// and `.` characters across operating systems.
|
||||
pub fn email_to_b64(email: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(email.as_bytes())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Path to `accounts.json` inside the config directory.
|
||||
pub fn accounts_path() -> PathBuf {
|
||||
crate::auth_commands::config_dir().join("accounts.json")
|
||||
}
|
||||
|
||||
/// Load the accounts registry from disk. Returns `None` if the file does not
|
||||
/// exist, and an error if it exists but cannot be parsed.
|
||||
pub fn load_accounts() -> anyhow::Result<Option<AccountsRegistry>> {
|
||||
let path = accounts_path();
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = std::fs::read_to_string(&path)?;
|
||||
let registry: AccountsRegistry = serde_json::from_str(&data)?;
|
||||
Ok(Some(registry))
|
||||
}
|
||||
|
||||
/// Persist the accounts registry to disk with `0o600` permissions.
|
||||
pub fn save_accounts(registry: &AccountsRegistry) -> anyhow::Result<()> {
|
||||
let path = accounts_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
|
||||
{
|
||||
eprintln!(
|
||||
"Warning: failed to set directory permissions on {}: {e}",
|
||||
parent.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(registry)?;
|
||||
crate::fs_util::atomic_write(&path, json.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write accounts.json: {e}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
|
||||
eprintln!(
|
||||
"Warning: failed to set file permissions on {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Return the default account email, if one is set.
|
||||
pub fn get_default(registry: &AccountsRegistry) -> Option<&str> {
|
||||
registry.default.as_deref()
|
||||
}
|
||||
|
||||
/// Set the default account. Returns an error if the email is not registered.
|
||||
pub fn set_default(registry: &mut AccountsRegistry, email: &str) -> anyhow::Result<()> {
|
||||
let normalised = normalize_email(email);
|
||||
if !registry.accounts.contains_key(&normalised) {
|
||||
anyhow::bail!(
|
||||
"Account '{}' not found. Run 'gws auth login' to add it.",
|
||||
normalised
|
||||
);
|
||||
}
|
||||
registry.default = Some(normalised);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a new account (or update its metadata if it already exists).
|
||||
/// If this is the first account, it becomes the default automatically.
|
||||
pub fn add_account(registry: &mut AccountsRegistry, email: &str) {
|
||||
let normalised = normalize_email(email);
|
||||
let meta = AccountMeta {
|
||||
added: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
registry.accounts.insert(normalised.clone(), meta);
|
||||
if registry.default.is_none() || registry.accounts.len() == 1 {
|
||||
registry.default = Some(normalised);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove an account from the registry.
|
||||
///
|
||||
/// If the removed account was the default, the default is auto-promoted to
|
||||
/// the next available account (or set to `None` if no accounts remain).
|
||||
pub fn remove_account(registry: &mut AccountsRegistry, email: &str) {
|
||||
let normalised = normalize_email(email);
|
||||
registry.accounts.remove(&normalised);
|
||||
|
||||
// Handle dangling default
|
||||
if registry.default.as_deref() == Some(&normalised) {
|
||||
registry.default = registry.accounts.keys().next().cloned();
|
||||
}
|
||||
}
|
||||
|
||||
/// List all registered account emails.
|
||||
pub fn list_accounts(registry: &AccountsRegistry) -> Vec<&str> {
|
||||
registry.accounts.keys().map(|s| s.as_str()).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_email_normalization() {
|
||||
assert_eq!(normalize_email(" User@Gmail.COM "), "user@gmail.com");
|
||||
assert_eq!(normalize_email("WORK@Corp.com"), "work@corp.com");
|
||||
assert_eq!(normalize_email("simple@example.com"), "simple@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_to_b64_no_pad() {
|
||||
let encoded = email_to_b64("user@gmail.com");
|
||||
// Must not contain +, /, or =
|
||||
assert!(!encoded.contains('+'));
|
||||
assert!(!encoded.contains('/'));
|
||||
assert!(!encoded.contains('='));
|
||||
// Must be non-empty and deterministic
|
||||
assert!(!encoded.is_empty());
|
||||
assert_eq!(encoded, email_to_b64("user@gmail.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_case_produces_same_b64() {
|
||||
let a = email_to_b64(&normalize_email("User@Gmail.COM"));
|
||||
let b = email_to_b64(&normalize_email("user@gmail.com"));
|
||||
assert_eq!(
|
||||
a, b,
|
||||
"Case-different emails should produce the same b64 after normalization"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accounts_json_round_trip() {
|
||||
let mut registry = AccountsRegistry::default();
|
||||
assert!(registry.accounts.is_empty());
|
||||
assert!(registry.default.is_none());
|
||||
|
||||
// Add first account → auto-default
|
||||
add_account(&mut registry, "first@example.com");
|
||||
assert_eq!(registry.default.as_deref(), Some("first@example.com"));
|
||||
assert_eq!(list_accounts(®istry), vec!["first@example.com"]);
|
||||
|
||||
// Add second account → default unchanged
|
||||
add_account(&mut registry, "second@example.com");
|
||||
assert_eq!(registry.default.as_deref(), Some("first@example.com"));
|
||||
assert_eq!(list_accounts(®istry).len(), 2);
|
||||
|
||||
// Set default
|
||||
set_default(&mut registry, "second@example.com").unwrap();
|
||||
assert_eq!(registry.default.as_deref(), Some("second@example.com"));
|
||||
|
||||
// Set default to unknown → error
|
||||
let err = set_default(&mut registry, "unknown@example.com");
|
||||
assert!(err.is_err());
|
||||
|
||||
// Remove default account → auto-promote
|
||||
remove_account(&mut registry, "second@example.com");
|
||||
assert!(registry.default.is_some()); // promoted to first
|
||||
assert_eq!(list_accounts(®istry), vec!["first@example.com"]);
|
||||
|
||||
// Remove last account → default is None
|
||||
remove_account(&mut registry, "first@example.com");
|
||||
assert!(registry.default.is_none());
|
||||
assert!(registry.accounts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_round_trip() {
|
||||
let mut registry = AccountsRegistry::default();
|
||||
add_account(&mut registry, "test@example.com");
|
||||
|
||||
let json = serde_json::to_string_pretty(®istry).unwrap();
|
||||
let parsed: AccountsRegistry = serde_json::from_str(&json).unwrap();
|
||||
|
||||
assert_eq!(parsed.default.as_deref(), Some("test@example.com"));
|
||||
assert!(parsed.accounts.contains_key("test@example.com"));
|
||||
}
|
||||
}
|
||||
+7
-95
@@ -63,16 +63,13 @@ enum Credential {
|
||||
/// Tries credentials in order:
|
||||
/// 0. `GOOGLE_WORKSPACE_CLI_TOKEN` env var (raw access token, highest priority)
|
||||
/// 1. `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` env var (plaintext JSON, can be User or Service Account)
|
||||
/// 2. Per-account encrypted credentials via `accounts.json` registry
|
||||
/// 2. Encrypted credentials at `~/.config/gws/credentials.enc`
|
||||
/// 3. Plaintext credentials at `~/.config/gws/credentials.json` (User only)
|
||||
/// 4. Application Default Credentials (ADC):
|
||||
/// - `GOOGLE_APPLICATION_CREDENTIALS` env var (path to a JSON credentials file), then
|
||||
/// - Well-known ADC path: `~/.config/gcloud/application_default_credentials.json`
|
||||
/// (populated by `gcloud auth application-default login`)
|
||||
///
|
||||
/// When `account` is `Some`, a specific registered account is used.
|
||||
/// When `account` is `None`, the default account from `accounts.json` is used.
|
||||
pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result<String> {
|
||||
pub async fn get_token(scopes: &[&str]) -> anyhow::Result<String> {
|
||||
// 0. Direct token from env var (highest priority, bypasses all credential loading)
|
||||
if let Ok(token) = std::env::var("GOOGLE_WORKSPACE_CLI_TOKEN") {
|
||||
if !token.is_empty() {
|
||||
@@ -82,85 +79,12 @@ pub async fn get_token(scopes: &[&str], account: Option<&str>) -> anyhow::Result
|
||||
|
||||
let creds_file = std::env::var("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE").ok();
|
||||
let config_dir = crate::auth_commands::config_dir();
|
||||
|
||||
// If env var credentials are specified, skip account resolution entirely
|
||||
if creds_file.is_some() {
|
||||
let enc_path = credential_store::encrypted_credentials_path();
|
||||
let default_path = config_dir.join("credentials.json");
|
||||
let token_cache = config_dir.join("token_cache.json");
|
||||
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
|
||||
return get_token_inner(scopes, creds, &token_cache).await;
|
||||
}
|
||||
|
||||
// Resolve account from registry
|
||||
let resolved_account = resolve_account(account)?;
|
||||
|
||||
let enc_path = match &resolved_account {
|
||||
Some(email) => credential_store::encrypted_credentials_path_for(email),
|
||||
None => credential_store::encrypted_credentials_path(),
|
||||
};
|
||||
|
||||
// Per-account token cache: token_cache.<b64-email>.json
|
||||
let token_cache_name = resolved_account
|
||||
.as_ref()
|
||||
.map(|email| {
|
||||
let b64 = crate::accounts::email_to_b64(&crate::accounts::normalize_email(email));
|
||||
format!("token_cache.{b64}.json")
|
||||
})
|
||||
.unwrap_or_else(|| "token_cache.json".to_string());
|
||||
let token_cache_path = config_dir.join(token_cache_name);
|
||||
|
||||
let enc_path = credential_store::encrypted_credentials_path();
|
||||
let default_path = config_dir.join("credentials.json");
|
||||
let creds = load_credentials_inner(None, &enc_path, &default_path).await?;
|
||||
get_token_inner(scopes, creds, &token_cache_path).await
|
||||
}
|
||||
let token_cache = config_dir.join("token_cache.json");
|
||||
|
||||
/// Resolve which account to use:
|
||||
/// 1. Explicit `account` parameter takes priority.
|
||||
/// 2. Fall back to `accounts.json` default.
|
||||
/// 3. If no registry exists, return None to allow legacy `credentials.enc` fallthrough.
|
||||
fn resolve_account(account: Option<&str>) -> anyhow::Result<Option<String>> {
|
||||
let registry = crate::accounts::load_accounts()?;
|
||||
|
||||
match (account, ®istry) {
|
||||
// Explicit account requested — validate it exists in registry
|
||||
(Some(email), Some(reg)) => {
|
||||
let normalised = crate::accounts::normalize_email(email);
|
||||
if !reg.accounts.contains_key(&normalised) {
|
||||
anyhow::bail!(
|
||||
"Account '{}' not found. Run 'gws auth login' to add it.",
|
||||
normalised
|
||||
);
|
||||
}
|
||||
Ok(Some(normalised))
|
||||
}
|
||||
// Explicit account but no registry
|
||||
(Some(email), None) => {
|
||||
anyhow::bail!(
|
||||
"Account '{}' not found. No accounts registered. Run 'gws auth login'.",
|
||||
crate::accounts::normalize_email(email)
|
||||
);
|
||||
}
|
||||
// No explicit account — use default from registry
|
||||
(None, Some(reg)) => {
|
||||
if let Some(default) = crate::accounts::get_default(reg) {
|
||||
Ok(Some(default.to_string()))
|
||||
} else if reg.accounts.len() == 1 {
|
||||
// Auto-select the only account
|
||||
Ok(reg.accounts.keys().next().cloned())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"No default account set. Use --account or run 'gws auth default <email>'."
|
||||
);
|
||||
}
|
||||
}
|
||||
// No account, no registry — use legacy credentials if they exist
|
||||
(None, None) => {
|
||||
// Fall through to standard credential loading which will pick up
|
||||
// the legacy credentials.enc file if it exists.
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
let creds = load_credentials_inner(creds_file.as_deref(), &enc_path, &default_path).await?;
|
||||
get_token_inner(scopes, creds, &token_cache).await
|
||||
}
|
||||
|
||||
async fn get_token_inner(
|
||||
@@ -595,24 +519,12 @@ mod tests {
|
||||
async fn test_get_token_from_env_var() {
|
||||
let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "my-test-token");
|
||||
|
||||
let result = get_token(&["https://www.googleapis.com/auth/drive"], None).await;
|
||||
let result = get_token(&["https://www.googleapis.com/auth/drive"]).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "my-test-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_account_no_registry_no_account_returns_none() {
|
||||
// When there is no accounts.json and no explicit account,
|
||||
// resolve_account should return Ok(None) to allow legacy
|
||||
// credentials.enc to be picked up by load_credentials_inner.
|
||||
let result = resolve_account(None);
|
||||
// This will return Ok(None) if accounts.json doesn't exist,
|
||||
// or Ok(Some(...)) if it does with a default. Either way, it
|
||||
// should NOT error for the no-registry case.
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_credentials_encrypted_file() {
|
||||
// Simulate an encrypted credentials file
|
||||
|
||||
+35
-272
@@ -131,9 +131,8 @@ fn token_cache_path() -> PathBuf {
|
||||
/// Handle `gws auth <subcommand>`.
|
||||
pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
|
||||
const USAGE: &str = concat!(
|
||||
"Usage: gws auth <login|setup|status|export|logout|list|default> [options]\n\n",
|
||||
"Usage: gws auth <login|setup|status|export|logout> [options]\n\n",
|
||||
" login Authenticate via OAuth2 (opens browser)\n",
|
||||
" --account EMAIL Associate credentials with a specific account\n",
|
||||
" --readonly Request read-only scopes\n",
|
||||
" --full Request all scopes incl. pubsub + cloud-platform\n",
|
||||
" (may trigger restricted_client for unverified apps)\n",
|
||||
@@ -144,11 +143,7 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
|
||||
" --project Use a specific GCP project\n",
|
||||
" status Show current authentication state\n",
|
||||
" export Print decrypted credentials to stdout\n",
|
||||
" logout Clear saved credentials and token cache\n",
|
||||
" --account EMAIL Logout a specific account (otherwise: all)\n",
|
||||
" list List all registered accounts\n",
|
||||
" default Set the default account\n",
|
||||
" --account EMAIL Account to set as default",
|
||||
" logout Clear saved credentials and token cache",
|
||||
);
|
||||
|
||||
// Honour --help / -h before treating the first arg as a subcommand.
|
||||
@@ -165,11 +160,9 @@ pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> {
|
||||
let unmasked = args.len() > 1 && args[1] == "--unmasked";
|
||||
handle_export(unmasked).await
|
||||
}
|
||||
"logout" => handle_logout(&args[1..]),
|
||||
"list" => handle_list(),
|
||||
"default" => handle_default(&args[1..]),
|
||||
"logout" => handle_logout(),
|
||||
other => Err(GwsError::Validation(format!(
|
||||
"Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout, list, default"
|
||||
"Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -210,8 +203,7 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega
|
||||
}
|
||||
|
||||
async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
// Extract --account and -s/--services from args
|
||||
let mut account_email: Option<String> = None;
|
||||
// Extract -s/--services from args
|
||||
let mut services_filter: Option<HashSet<String>> = None;
|
||||
let mut filtered_args: Vec<String> = Vec::new();
|
||||
let mut skip_next = false;
|
||||
@@ -220,15 +212,6 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if args[i] == "--account" && i + 1 < args.len() {
|
||||
account_email = Some(args[i + 1].clone());
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = args[i].strip_prefix("--account=") {
|
||||
account_email = Some(value.to_string());
|
||||
continue;
|
||||
}
|
||||
let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() {
|
||||
skip_next = true;
|
||||
Some(args[i + 1].as_str())
|
||||
@@ -273,9 +256,6 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
.await;
|
||||
|
||||
// Remove restrictive scopes when broader alternatives are present.
|
||||
// gmail.metadata blocks query parameters like `q`, and is redundant
|
||||
// when broader scopes (gmail.modify, gmail.readonly, mail.google.com)
|
||||
// are already included.
|
||||
let mut scopes = filter_redundant_restrictive_scopes(scopes);
|
||||
|
||||
let secret = yup_oauth2::ApplicationSecret {
|
||||
@@ -300,8 +280,6 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
let temp_path = config_dir().join("credentials.tmp");
|
||||
|
||||
// Always start fresh — delete any stale temp cache from prior login attempts.
|
||||
// Without this, yup-oauth2 finds a cached access token and skips the browser flow,
|
||||
// which means no refresh_token is returned.
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
|
||||
// Ensure config directory exists
|
||||
@@ -318,9 +296,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
temp_path.clone(),
|
||||
)))
|
||||
.force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token
|
||||
.flow_delegate(Box::new(CliFlowDelegate {
|
||||
login_hint: account_email.clone(),
|
||||
}))
|
||||
.flow_delegate(Box::new(CliFlowDelegate { login_hint: None }))
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?;
|
||||
@@ -359,63 +335,13 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
let creds_str = serde_json::to_string_pretty(&creds_json)
|
||||
.map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?;
|
||||
|
||||
// Fetch the user's email from Google userinfo to validate and register
|
||||
// Fetch the user's email from Google userinfo
|
||||
let access_token = token.token().unwrap_or_default();
|
||||
let actual_email = fetch_userinfo_email(access_token).await;
|
||||
|
||||
// If --account was specified, validate the email matches
|
||||
if let Some(ref requested) = account_email {
|
||||
if let Some(ref actual) = actual_email {
|
||||
let normalized_requested = crate::accounts::normalize_email(requested);
|
||||
let normalized_actual = crate::accounts::normalize_email(actual);
|
||||
if normalized_requested != normalized_actual {
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(GwsError::Auth(format!(
|
||||
"Login account mismatch: requested '{}' but authenticated as '{}'. \
|
||||
Please try again and select the correct account in the browser.",
|
||||
requested, actual
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which email to use for the account
|
||||
let resolved_email = account_email.or(actual_email);
|
||||
|
||||
// Save encrypted credentials
|
||||
let enc_path = if let Some(ref email) = resolved_email {
|
||||
// Per-account save
|
||||
credential_store::save_encrypted_for(&creds_str, email)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
|
||||
|
||||
// Register in accounts.json
|
||||
let mut registry = crate::accounts::load_accounts()
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))?
|
||||
.unwrap_or_default();
|
||||
crate::accounts::add_account(&mut registry, email);
|
||||
// If this is the first account, set it as default
|
||||
if registry.default.is_none() || registry.accounts.len() == 1 {
|
||||
crate::accounts::set_default(&mut registry, email)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to set default: {e}")))?;
|
||||
}
|
||||
crate::accounts::save_accounts(®istry)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to save accounts: {e}")))?;
|
||||
|
||||
credential_store::encrypted_credentials_path_for(email)
|
||||
} else {
|
||||
// Legacy single-account save (no email available)
|
||||
credential_store::save_encrypted(&creds_str)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?
|
||||
};
|
||||
|
||||
// Clean up old legacy credentials.enc if we now have an account-keyed one
|
||||
if resolved_email.is_some() {
|
||||
let legacy = credential_store::encrypted_credentials_path();
|
||||
if legacy.exists() && legacy != enc_path {
|
||||
let _ = std::fs::remove_file(&legacy);
|
||||
}
|
||||
}
|
||||
let enc_path = credential_store::save_encrypted(&creds_str)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?;
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
@@ -423,7 +349,7 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> {
|
||||
let output = json!({
|
||||
"status": "success",
|
||||
"message": "Authentication successful. Encrypted credentials saved.",
|
||||
"account": resolved_email.as_deref().unwrap_or("(unknown)"),
|
||||
"account": actual_email.as_deref().unwrap_or("(unknown)"),
|
||||
"credentials_file": enc_path.display().to_string(),
|
||||
"encryption": "AES-256-GCM (key secured by OS Keyring or local `.encryption_key`)",
|
||||
"scopes": scopes,
|
||||
@@ -1222,198 +1148,35 @@ async fn handle_status() -> Result<(), GwsError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_logout(args: &[String]) -> Result<(), GwsError> {
|
||||
// Extract --account from args
|
||||
let mut account_email: Option<String> = None;
|
||||
for i in 0..args.len() {
|
||||
if args[i] == "--account" && i + 1 < args.len() {
|
||||
account_email = Some(args[i + 1].clone());
|
||||
} else if let Some(value) = args[i].strip_prefix("--account=") {
|
||||
account_email = Some(value.to_string());
|
||||
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");
|
||||
|
||||
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| {
|
||||
GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
|
||||
})?;
|
||||
removed.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref email) = account_email {
|
||||
// Per-account logout: remove credentials and token caches
|
||||
let enc_path = credential_store::encrypted_credentials_path_for(email);
|
||||
let b64 = crate::accounts::email_to_b64(&crate::accounts::normalize_email(email));
|
||||
let config = config_dir();
|
||||
let token_cache = config.join(format!("token_cache.{b64}.json"));
|
||||
let sa_token_cache = config.join(format!("sa_token_cache.{b64}.json"));
|
||||
let mut removed = Vec::new();
|
||||
|
||||
for path in [&enc_path, &token_cache, &sa_token_cache] {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
|
||||
})?;
|
||||
removed.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from accounts.json registry
|
||||
let mut registry = crate::accounts::load_accounts()
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))?
|
||||
.unwrap_or_default();
|
||||
crate::accounts::remove_account(&mut registry, email);
|
||||
crate::accounts::save_accounts(®istry)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to save accounts: {e}")))?;
|
||||
|
||||
let output = if removed.is_empty() {
|
||||
json!({
|
||||
"status": "success",
|
||||
"message": format!("No credentials found for account '{email}'."),
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"status": "success",
|
||||
"message": format!("Logged out account '{email}'. Credentials removed."),
|
||||
"removed": removed,
|
||||
})
|
||||
};
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&output).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
// Full logout: remove all credentials
|
||||
let plain_path = plain_credentials_path();
|
||||
let enc_path = credential_store::encrypted_credentials_path();
|
||||
let token_cache = token_cache_path();
|
||||
let accounts_path = crate::accounts::accounts_path();
|
||||
|
||||
let mut removed = Vec::new();
|
||||
|
||||
// Load accounts BEFORE deleting accounts.json so we can clean up per-account files
|
||||
let registry = crate::accounts::load_accounts()
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))?
|
||||
.unwrap_or_default();
|
||||
|
||||
for path in [&enc_path, &plain_path, &token_cache, &accounts_path] {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
|
||||
})?;
|
||||
removed.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove any per-account credential and token cache files
|
||||
for email in registry.accounts.keys() {
|
||||
let b64 = crate::accounts::email_to_b64(&crate::accounts::normalize_email(email));
|
||||
let cred_path = credential_store::encrypted_credentials_path_for(email);
|
||||
let tc_path = config_dir().join(format!("token_cache.{b64}.json"));
|
||||
let sa_tc_path = config_dir().join(format!("sa_token_cache.{b64}.json"));
|
||||
for path in [&cred_path, &tc_path, &sa_tc_path] {
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path).map_err(|e| {
|
||||
GwsError::Validation(format!("Failed to remove {}: {e}", path.display()))
|
||||
})?;
|
||||
removed.push(path.display().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = if removed.is_empty() {
|
||||
json!({
|
||||
"status": "success",
|
||||
"message": "No credentials found to remove.",
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"status": "success",
|
||||
"message": "Logged out. All credentials and token caches removed.",
|
||||
"removed": removed,
|
||||
})
|
||||
};
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&output).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all registered accounts.
|
||||
fn handle_list() -> Result<(), GwsError> {
|
||||
let registry = crate::accounts::load_accounts()
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))?
|
||||
.unwrap_or_default();
|
||||
let account_emails = crate::accounts::list_accounts(®istry);
|
||||
let accounts: Vec<serde_json::Value> = account_emails
|
||||
.iter()
|
||||
.map(|email| {
|
||||
let meta = registry.accounts.get(*email);
|
||||
json!({
|
||||
"email": email,
|
||||
"is_default": registry.default.as_deref() == Some(*email),
|
||||
"added": meta.map(|m| m.added.as_str()).unwrap_or(""),
|
||||
})
|
||||
let output = if removed.is_empty() {
|
||||
json!({
|
||||
"status": "success",
|
||||
"message": "No credentials found to remove.",
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = json!({
|
||||
"accounts": accounts,
|
||||
"default": registry.default.unwrap_or_default(),
|
||||
"count": accounts.len(),
|
||||
});
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&output).unwrap_or_default()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the default account.
|
||||
fn handle_default(args: &[String]) -> Result<(), GwsError> {
|
||||
// Extract --account from args
|
||||
let mut account_email: Option<String> = None;
|
||||
for i in 0..args.len() {
|
||||
if args[i] == "--account" && i + 1 < args.len() {
|
||||
account_email = Some(args[i + 1].clone());
|
||||
} else if let Some(value) = args[i].strip_prefix("--account=") {
|
||||
account_email = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// If no --account flag, check if the first arg is the email directly
|
||||
let email = account_email
|
||||
.or_else(|| args.first().filter(|a| !a.starts_with('-')).cloned())
|
||||
.ok_or_else(|| {
|
||||
GwsError::Validation(
|
||||
"Usage: gws auth default <email> or gws auth default --account <email>".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut registry = crate::accounts::load_accounts()
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to load accounts: {e}")))?
|
||||
.unwrap_or_default();
|
||||
|
||||
// Verify the account exists
|
||||
if !registry
|
||||
.accounts
|
||||
.keys()
|
||||
.any(|k| crate::accounts::normalize_email(k) == crate::accounts::normalize_email(&email))
|
||||
{
|
||||
return Err(GwsError::Validation(format!(
|
||||
"Account '{}' not found. Run `gws auth list` to see registered accounts.",
|
||||
email
|
||||
)));
|
||||
}
|
||||
|
||||
crate::accounts::set_default(&mut registry, &email)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to set default: {e}")))?;
|
||||
crate::accounts::save_accounts(®istry)
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to save accounts: {e}")))?;
|
||||
|
||||
let output = json!({
|
||||
"status": "success",
|
||||
"message": format!("Default account set to '{email}'."),
|
||||
"default": email,
|
||||
});
|
||||
} else {
|
||||
json!({
|
||||
"status": "success",
|
||||
"message": "Logged out. All credentials and token caches removed.",
|
||||
"removed": removed,
|
||||
})
|
||||
};
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
|
||||
+39
-160
@@ -273,147 +273,10 @@ pub fn load_encrypted() -> anyhow::Result<String> {
|
||||
load_encrypted_from_path(&encrypted_credentials_path())
|
||||
}
|
||||
|
||||
/// Returns the path for per-account encrypted credentials.
|
||||
///
|
||||
/// The filename is `credentials.<b64-email>.enc` where `<b64-email>` is the
|
||||
/// URL-safe, no-pad base64 encoding of the normalised email address.
|
||||
pub fn encrypted_credentials_path_for(account: &str) -> PathBuf {
|
||||
let normalised = crate::accounts::normalize_email(account);
|
||||
let b64 = crate::accounts::email_to_b64(&normalised);
|
||||
crate::auth_commands::config_dir().join(format!("credentials.{b64}.enc"))
|
||||
}
|
||||
|
||||
/// Saves credentials JSON to a per-account encrypted file.
|
||||
pub fn save_encrypted_for(json: &str, account: &str) -> anyhow::Result<PathBuf> {
|
||||
let path = encrypted_credentials_path_for(account);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
|
||||
{
|
||||
eprintln!(
|
||||
"Warning: failed to set directory permissions on {}: {e}",
|
||||
parent.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let encrypted = encrypt(json.as_bytes())?;
|
||||
crate::fs_util::atomic_write(&path, &encrypted)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write credentials for {account}: {e}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) {
|
||||
eprintln!(
|
||||
"Warning: failed to set file permissions on {}: {e}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let plaintext = b"hello, world!";
|
||||
let encrypted = encrypt(plaintext).expect("encryption should succeed");
|
||||
|
||||
// Encrypted data should be different from plaintext
|
||||
assert_ne!(&encrypted, plaintext);
|
||||
|
||||
// Should be nonce (12) + ciphertext (plaintext + 16 byte tag)
|
||||
assert_eq!(encrypted.len(), 12 + plaintext.len() + 16);
|
||||
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_empty() {
|
||||
let plaintext = b"";
|
||||
let encrypted = encrypt(plaintext).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_json_credentials() {
|
||||
let json = r#"{"type":"authorized_user","client_id":"test.apps.googleusercontent.com","client_secret":"secret","refresh_token":"1//token"}"#;
|
||||
let encrypted = encrypt(json.as_bytes()).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(String::from_utf8(decrypted).unwrap(), json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_large_payload() {
|
||||
let plaintext: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
|
||||
let encrypted = encrypt(&plaintext).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_short_data() {
|
||||
let result = decrypt(&[0u8; 11]);
|
||||
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");
|
||||
|
||||
// Tamper with the ciphertext (after the 12-byte nonce)
|
||||
let mut tampered = encrypted.clone();
|
||||
if tampered.len() > 12 {
|
||||
tampered[12] ^= 0xFF;
|
||||
}
|
||||
|
||||
let result = decrypt(&tampered);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Decryption failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_rejects_tampered_nonce() {
|
||||
let encrypted = encrypt(b"secret data").expect("encryption should succeed");
|
||||
|
||||
let mut tampered = encrypted.clone();
|
||||
tampered[0] ^= 0xFF;
|
||||
|
||||
let result = decrypt(&tampered);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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");
|
||||
|
||||
// Different nonces should produce different ciphertext
|
||||
assert_ne!(enc1, enc2);
|
||||
|
||||
// But both should decrypt to the same plaintext
|
||||
let dec1 = decrypt(&enc1).unwrap();
|
||||
let dec2 = decrypt(&enc2).unwrap();
|
||||
assert_eq!(dec1, dec2);
|
||||
assert_eq!(dec1, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_or_create_key_is_deterministic() {
|
||||
let key1 = get_or_create_key().unwrap();
|
||||
@@ -428,34 +291,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_credentials_path_for_uses_b64() {
|
||||
let path = encrypted_credentials_path_for("user@gmail.com");
|
||||
let filename = path.file_name().unwrap().to_str().unwrap();
|
||||
// Should start with "credentials." and end with ".enc"
|
||||
assert!(filename.starts_with("credentials."));
|
||||
assert!(filename.ends_with(".enc"));
|
||||
// Should NOT contain the raw email
|
||||
assert!(!filename.contains('@'));
|
||||
assert!(!filename.contains("user@gmail.com"));
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let plaintext = b"hello, world!";
|
||||
let encrypted = encrypt(plaintext).expect("encryption should succeed");
|
||||
assert_ne!(&encrypted, plaintext);
|
||||
assert_eq!(encrypted.len(), 12 + plaintext.len() + 16);
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_credentials_path_for_case_insensitive() {
|
||||
let path1 = encrypted_credentials_path_for("User@Gmail.COM");
|
||||
let path2 = encrypted_credentials_path_for("user@gmail.com");
|
||||
assert_eq!(
|
||||
path1, path2,
|
||||
"Case-different emails should map to same path"
|
||||
);
|
||||
fn encrypt_decrypt_empty() {
|
||||
let plaintext = b"";
|
||||
let encrypted = encrypt(plaintext).expect("encryption should succeed");
|
||||
let decrypted = decrypt(&encrypted).expect("decryption should succeed");
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypted_credentials_path_for_different_emails_differ() {
|
||||
let path1 = encrypted_credentials_path_for("alice@example.com");
|
||||
let path2 = encrypted_credentials_path_for("bob@example.com");
|
||||
assert_ne!(
|
||||
path1, path2,
|
||||
"Different emails should map to different paths"
|
||||
);
|
||||
fn decrypt_rejects_short_data() {
|
||||
let result = decrypt(&[0u8; 11]);
|
||||
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");
|
||||
let mut tampered = encrypted.clone();
|
||||
if tampered.len() > 12 {
|
||||
tampered[12] ^= 0xFF;
|
||||
}
|
||||
let result = decrypt(&tampered);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
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");
|
||||
assert_ne!(enc1, enc2);
|
||||
let dec1 = decrypt(&enc1).unwrap();
|
||||
let dec2 = decrypt(&enc2).unwrap();
|
||||
assert_eq!(dec1, dec2);
|
||||
assert_eq!(dec1, plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ TIPS:
|
||||
let (params_str, body_str, scopes) = build_insert_request(matches, doc)?;
|
||||
|
||||
let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes_str, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scopes_str).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
@@ -191,7 +191,7 @@ TIPS:
|
||||
}
|
||||
async fn handle_agenda(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let token = auth::get_token(&[cal_scope], None)
|
||||
let token = auth::get_token(&[cal_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Calendar auth failed: {e}")))?;
|
||||
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ TIPS:
|
||||
let (params_str, body_str, scopes) = build_send_request(&config, doc)?;
|
||||
|
||||
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ TIPS:
|
||||
let (params_str, body_str, scopes) = build_write_request(matches, doc)?;
|
||||
|
||||
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
@@ -96,7 +96,7 @@ TIPS:
|
||||
let body_str = metadata.to_string();
|
||||
|
||||
let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scopes).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ pub(super) async fn handle_renew(
|
||||
) -> Result<(), GwsError> {
|
||||
let config = parse_renew_args(matches)?;
|
||||
let client = crate::client::build_client()?;
|
||||
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], None)
|
||||
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?;
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ pub(super) async fn handle_subscribe(
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
// Get Pub/Sub token
|
||||
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], None)
|
||||
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?;
|
||||
|
||||
@@ -184,7 +184,7 @@ pub(super) async fn handle_subscribe(
|
||||
|
||||
// 3. Create Workspace Events subscription
|
||||
eprintln!("Creating Workspace Events subscription...");
|
||||
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE], None)
|
||||
let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE])
|
||||
.await
|
||||
.map_err(|e| {
|
||||
GwsError::Auth(format!("Failed to get Workspace Events token: {e}"))
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(super) async fn handle_send(
|
||||
let params_str = params.to_string();
|
||||
|
||||
let scopes: Vec<&str> = send_method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scopes).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
.unwrap_or(crate::formatter::OutputFormat::Table);
|
||||
|
||||
// Authenticate
|
||||
let token = auth::get_token(&[GMAIL_SCOPE], None)
|
||||
let token = auth::get_token(&[GMAIL_SCOPE])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?;
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ pub(super) async fn handle_watch(
|
||||
let client = crate::client::build_client()?;
|
||||
|
||||
// Get tokens
|
||||
let gmail_token = auth::get_token(&[GMAIL_SCOPE], None)
|
||||
let gmail_token = auth::get_token(&[GMAIL_SCOPE])
|
||||
.await
|
||||
.context("Failed to get Gmail token")?;
|
||||
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE], None)
|
||||
let pubsub_token = auth::get_token(&[PUBSUB_SCOPE])
|
||||
.await
|
||||
.context("Failed to get Pub/Sub token")?;
|
||||
|
||||
|
||||
@@ -250,7 +250,7 @@ pub const CLOUD_PLATFORM_SCOPE: &str = "https://www.googleapis.com/auth/cloud-pl
|
||||
pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationResult, GwsError> {
|
||||
let (body, url) = build_sanitize_request_data(template, text, "sanitizeUserPrompt")?;
|
||||
|
||||
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], None)
|
||||
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
|
||||
.await
|
||||
.context("Failed to get auth token for Model Armor")?;
|
||||
|
||||
@@ -281,7 +281,7 @@ pub async fn sanitize_text(template: &str, text: &str) -> Result<SanitizationRes
|
||||
|
||||
/// Make a POST request to Model Armor's regional API endpoint.
|
||||
async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> {
|
||||
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE], None)
|
||||
let token = auth::get_token(&[CLOUD_PLATFORM_SCOPE])
|
||||
.await
|
||||
.context("Failed to get auth token")?;
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ TIPS:
|
||||
let body_str = body.to_string();
|
||||
|
||||
let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scopes, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scopes).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
@@ -106,7 +106,7 @@ TIPS:
|
||||
let (params_str, body_str, scopes) = build_append_request(&config, doc)?;
|
||||
|
||||
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
@@ -164,7 +164,7 @@ TIPS:
|
||||
})?;
|
||||
|
||||
let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs, None).await {
|
||||
let (token, auth_method) = match auth::get_token(&scope_strs).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(_) => (None, executor::AuthMethod::None),
|
||||
};
|
||||
|
||||
@@ -269,7 +269,7 @@ fn format_and_print(value: &Value, matches: &ArgMatches) {
|
||||
async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let tasks_scope = "https://www.googleapis.com/auth/tasks.readonly";
|
||||
let token = auth::get_token(&[cal_scope, tasks_scope], None)
|
||||
let token = auth::get_token(&[cal_scope, tasks_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
@@ -364,7 +364,7 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
|
||||
async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let token = auth::get_token(&[cal_scope], None)
|
||||
let token = auth::get_token(&[cal_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
@@ -446,7 +446,7 @@ async fn handle_meeting_prep(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
|
||||
let tasks_scope = "https://www.googleapis.com/auth/tasks";
|
||||
let token = auth::get_token(&[gmail_scope, tasks_scope], None)
|
||||
let token = auth::get_token(&[gmail_scope, tasks_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
@@ -536,7 +536,7 @@ async fn handle_email_to_task(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let cal_scope = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
let gmail_scope = "https://www.googleapis.com/auth/gmail.readonly";
|
||||
let token = auth::get_token(&[cal_scope, gmail_scope], None)
|
||||
let token = auth::get_token(&[cal_scope, gmail_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
@@ -618,7 +618,7 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
async fn handle_file_announce(matches: &ArgMatches) -> Result<(), GwsError> {
|
||||
let drive_scope = "https://www.googleapis.com/auth/drive.readonly";
|
||||
let chat_scope = "https://www.googleapis.com/auth/chat.messages.create";
|
||||
let token = auth::get_token(&[drive_scope, chat_scope], None)
|
||||
let token = auth::get_token(&[drive_scope, chat_scope])
|
||||
.await
|
||||
.map_err(|e| GwsError::Auth(format!("Auth failed: {e}")))?;
|
||||
|
||||
|
||||
+13
-184
@@ -19,7 +19,6 @@
|
||||
//! It supports deep schema validation, OAuth / Service Account authentication,
|
||||
//! interactive prompts, and integration with Model Armor.
|
||||
|
||||
mod accounts;
|
||||
mod auth;
|
||||
pub(crate) mod auth_commands;
|
||||
mod client;
|
||||
@@ -58,20 +57,15 @@ async fn main() {
|
||||
async fn run() -> Result<(), GwsError> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Extract --account flag from anywhere in args (global flag)
|
||||
// Priority: --account flag > GOOGLE_WORKSPACE_CLI_ACCOUNT env var
|
||||
let account = extract_global_account(&args)
|
||||
.or_else(|| std::env::var("GOOGLE_WORKSPACE_CLI_ACCOUNT").ok());
|
||||
|
||||
if args.len() < 2 {
|
||||
print_usage();
|
||||
return Err(GwsError::Validation(
|
||||
"No service specified. Usage: gws [--account EMAIL] <service> <resource> [sub-resource] <method> [flags]"
|
||||
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Find the first non-flag arg (skip --account/--api-version and their values)
|
||||
// Find the first non-flag arg (skip --api-version and its value)
|
||||
let mut first_arg: Option<String> = None;
|
||||
{
|
||||
let mut skip_next = false;
|
||||
@@ -80,11 +74,11 @@ async fn run() -> Result<(), GwsError> {
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if a == "--account" || a == "--api-version" {
|
||||
if a == "--api-version" {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if a.starts_with("--account=") || a.starts_with("--api-version=") {
|
||||
if a.starts_with("--api-version=") {
|
||||
continue;
|
||||
}
|
||||
if !a.starts_with("--") || a.as_str() == "--help" || a.as_str() == "--version" {
|
||||
@@ -93,10 +87,12 @@ async fn run() -> Result<(), GwsError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let first_arg = first_arg.ok_or_else(|| GwsError::Validation(
|
||||
"No service specified. Usage: gws [--account EMAIL] <service> <resource> [sub-resource] <method> [flags]"
|
||||
.to_string(),
|
||||
))?;
|
||||
let first_arg = first_arg.ok_or_else(|| {
|
||||
GwsError::Validation(
|
||||
"No service specified. Usage: gws <service> <resource> [sub-resource] <method> [flags]"
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Handle --help and --version at top level
|
||||
if is_help_flag(&first_arg) {
|
||||
@@ -238,7 +234,7 @@ async fn run() -> Result<(), GwsError> {
|
||||
let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect();
|
||||
|
||||
// Authenticate: try OAuth, fail with error if credentials exist but are broken
|
||||
let (token, auth_method) = match auth::get_token(&scopes, account.as_deref()).await {
|
||||
let (token, auth_method) = match auth::get_token(&scopes).await {
|
||||
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
|
||||
Err(e) => {
|
||||
// If credentials were found but failed (e.g. decryption error, invalid token),
|
||||
@@ -330,11 +326,11 @@ pub fn filter_args_for_subcommand(args: &[String], service_name: &str) -> Vec<St
|
||||
skip_next = false;
|
||||
continue;
|
||||
}
|
||||
if arg == "--api-version" || arg == "--account" {
|
||||
if arg == "--api-version" {
|
||||
skip_next = true;
|
||||
continue;
|
||||
}
|
||||
if arg.starts_with("--account=") || arg.starts_with("--api-version=") {
|
||||
if arg.starts_with("--api-version=") {
|
||||
continue;
|
||||
}
|
||||
if !service_skipped && arg == service_name {
|
||||
@@ -456,9 +452,6 @@ fn print_usage() {
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_CLIENT_SECRET OAuth client secret (for gws auth login)"
|
||||
);
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_ACCOUNT Default account email for multi-account"
|
||||
);
|
||||
println!(
|
||||
" GOOGLE_WORKSPACE_CLI_CONFIG_DIR Override config directory (default: ~/.config/gws)"
|
||||
);
|
||||
@@ -479,20 +472,6 @@ fn print_usage() {
|
||||
println!(" This is not an officially supported Google product.");
|
||||
}
|
||||
|
||||
/// Extract --account value from raw CLI args (before clap parsing).
|
||||
/// Supports both `--account EMAIL` and `--account=EMAIL` syntax.
|
||||
fn extract_global_account(args: &[String]) -> Option<String> {
|
||||
for i in 0..args.len() {
|
||||
if args[i] == "--account" && i + 1 < args.len() {
|
||||
return Some(args[i + 1].clone());
|
||||
}
|
||||
if let Some(value) = args[i].strip_prefix("--account=") {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_help_flag(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
@@ -662,50 +641,6 @@ mod tests {
|
||||
assert_eq!(method.id.as_deref(), Some("drive.files.permissions.get"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_global_account_present() {
|
||||
let args = vec![
|
||||
"gws".into(),
|
||||
"--account".into(),
|
||||
"user@corp.com".into(),
|
||||
"drive".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
extract_global_account(&args),
|
||||
Some("user@corp.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_global_account_absent() {
|
||||
let args = vec!["gws".into(), "drive".into(), "files".into(), "list".into()];
|
||||
assert_eq!(extract_global_account(&args), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_global_account_at_end_missing_value() {
|
||||
let args = vec!["gws".into(), "drive".into(), "--account".into()];
|
||||
assert_eq!(extract_global_account(&args), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_strips_account() {
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"drive".into(),
|
||||
"--account".into(),
|
||||
"user@corp.com".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
assert!(!filtered.contains(&"--account".to_string()));
|
||||
assert!(!filtered.contains(&"user@corp.com".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_strips_api_version() {
|
||||
let args: Vec<String> = vec![
|
||||
@@ -720,22 +655,6 @@ mod tests {
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_strips_both_account_and_api_version() {
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"drive".into(),
|
||||
"--account".into(),
|
||||
"a@b.com".into(),
|
||||
"--api-version".into(),
|
||||
"v2".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_no_special_flags() {
|
||||
let args: Vec<String> = vec![
|
||||
@@ -750,96 +669,6 @@ mod tests {
|
||||
assert_eq!(filtered, vec!["gws", "files", "list", "--format", "table"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_global_account_before_service() {
|
||||
// --account appears before the service name — email should be extracted, not treated as service
|
||||
let args = vec![
|
||||
"gws".into(),
|
||||
"--account".into(),
|
||||
"work@corp.com".into(),
|
||||
"drive".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
extract_global_account(&args),
|
||||
Some("work@corp.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_account_after_service() {
|
||||
// --account appears AFTER the service name (the normal position for global flags
|
||||
// that weren't consumed by extract_global_account)
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"drive".into(),
|
||||
"--account".into(),
|
||||
"work@corp.com".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert!(!filtered.contains(&"--account".to_string()));
|
||||
assert!(!filtered.contains(&"work@corp.com".to_string()));
|
||||
assert!(filtered.contains(&"files".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_global_account_equals_syntax() {
|
||||
let args = vec![
|
||||
"gws".into(),
|
||||
"--account=work@corp.com".into(),
|
||||
"drive".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
extract_global_account(&args),
|
||||
Some("work@corp.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_account_before_service() {
|
||||
// --account appears BEFORE the service name (issue #181)
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"--account".into(),
|
||||
"work@corp.com".into(),
|
||||
"drive".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_account_equals_before_service() {
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"--account=work@corp.com".into(),
|
||||
"drive".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_args_strips_account_equals() {
|
||||
let args: Vec<String> = vec![
|
||||
"gws".into(),
|
||||
"drive".into(),
|
||||
"--account=a@b.com".into(),
|
||||
"files".into(),
|
||||
"list".into(),
|
||||
];
|
||||
let filtered = filter_args_for_subcommand(&args, "drive");
|
||||
assert!(!filtered.iter().any(|a| a.contains("account")));
|
||||
assert_eq!(filtered, vec!["gws", "files", "list"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_scope_picks_first() {
|
||||
let scopes = vec![
|
||||
|
||||
+1
-2
@@ -814,8 +814,7 @@ async fn execute_mcp_method(
|
||||
};
|
||||
|
||||
let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect();
|
||||
let account = std::env::var("GOOGLE_WORKSPACE_CLI_ACCOUNT").ok();
|
||||
let (token, auth_method) = match crate::auth::get_token(&scopes, account.as_deref()).await {
|
||||
let (token, auth_method) = match crate::auth::get_token(&scopes).await {
|
||||
Ok(t) => (Some(t), crate::executor::AuthMethod::OAuth),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
|
||||
Reference in New Issue
Block a user