feat: extract google-workspace library crate (cargo workspace) (#613)

* feat: extract google-workspace library crate and restructure as cargo workspace

- Create crates/google-workspace/ with public modules: discovery, error, services, validate, client
- Move CLI binary to crates/cli/ (package: google-workspace-cli, binary: gws)
- Root Cargo.toml is now workspace-only
- Binary modules use thin re-exports from library (zero behavioral changes)
- Discovery fetch_discovery_document accepts cache_dir parameter for library consumers
- Break output.rs / error.rs circular dep by moving char detection to library validate module
- Update CI workflows for workspace (--workspace flags, path filters)
- Update dist-workspace.toml and policy.yml for new crate locations

Closes #386

* refactor: rename crates/cli to crates/google-workspace-cli

* chore: sync CLI version to 0.20.1 and regenerate skills

* chore: update labeler paths for workspace crate layout

* fix: update flake.nix to read version from CLI crate Cargo.toml

* fix: use tokio::fs for non-blocking I/O in library discovery cache

* docs: update AGENTS.md source layout for workspace structure

* docs: add crate-level READMEs for crates.io

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-24 14:40:03 -06:00
committed by GitHub
parent 3ac16ee595
commit 029e5def2b
62 changed files with 697 additions and 465 deletions
+16
View File
@@ -0,0 +1,16 @@
---
"@googleworkspace/cli": minor
---
Extract `google-workspace` library crate for programmatic Rust API access (closes #386)
Introduces a Cargo workspace with a new `google-workspace` library crate (`crates/google-workspace/`)
that exposes the core modules for use as a Rust dependency:
- `discovery` — Discovery Document types and fetching
- `error` — Structured `GwsError` type
- `services` — Service registry and resolution
- `validate` — Input validation and URL encoding
- `client` — HTTP client with retry logic
The `gws` binary crate re-exports all library types transparently — zero behavioral changes.
+33 -23
View File
@@ -4,40 +4,43 @@
"area: auth":
- changed-files:
- any-glob-to-any-file:
- src/auth.rs
- src/auth_commands.rs
- src/setup.rs
- src/accounts.rs
- src/credential_store.rs
- src/token_storage.rs
- src/oauth_config.rs
- crates/google-workspace-cli/src/auth.rs
- crates/google-workspace-cli/src/auth_commands.rs
- crates/google-workspace-cli/src/setup.rs
- crates/google-workspace-cli/src/accounts.rs
- crates/google-workspace-cli/src/credential_store.rs
- crates/google-workspace-cli/src/token_storage.rs
- crates/google-workspace-cli/src/oauth_config.rs
"area: discovery":
- changed-files:
- any-glob-to-any-file:
- src/discovery.rs
- src/services.rs
- crates/google-workspace-cli/src/discovery.rs
- crates/google-workspace-cli/src/services.rs
- crates/google-workspace/src/discovery.rs
- crates/google-workspace/src/services.rs
"area: http":
- changed-files:
- any-glob-to-any-file:
- src/executor.rs
- src/client.rs
- crates/google-workspace-cli/src/executor.rs
- crates/google-workspace-cli/src/client.rs
- crates/google-workspace/src/client.rs
"area: tui":
- changed-files:
- any-glob-to-any-file:
- src/setup_tui.rs
- crates/google-workspace-cli/src/setup_tui.rs
"area: mcp":
- changed-files:
- any-glob-to-any-file:
- src/mcp_server.rs
- crates/google-workspace-cli/src/mcp_server.rs
"area: skills":
- changed-files:
- any-glob-to-any-file:
- src/generate_skills.rs
- crates/google-workspace-cli/src/generate_skills.rs
- skills/**
"area: docs":
@@ -53,16 +56,23 @@
- .github/workflows/release-changesets.yml
- dist-workspace.toml
- Cargo.toml
- crates/*/Cargo.toml
"area: core":
- changed-files:
- any-glob-to-any-file:
- src/main.rs
- src/commands.rs
- src/error.rs
- src/formatter.rs
- src/fs_util.rs
- src/helpers/**
- src/text.rs
- src/validate.rs
- src/schema.rs
- crates/google-workspace-cli/src/main.rs
- crates/google-workspace-cli/src/commands.rs
- crates/google-workspace-cli/src/error.rs
- crates/google-workspace-cli/src/formatter.rs
- crates/google-workspace-cli/src/fs_util.rs
- crates/google-workspace-cli/src/helpers/**
- crates/google-workspace-cli/src/text.rs
- crates/google-workspace-cli/src/validate.rs
- crates/google-workspace-cli/src/schema.rs
"crate: google-workspace":
- changed-files:
- any-glob-to-any-file:
- crates/google-workspace/src/**
- crates/google-workspace/Cargo.toml
+3 -2
View File
@@ -45,6 +45,7 @@ jobs:
rust:
- '**/*.rs'
- 'Cargo.toml'
- 'crates/*/Cargo.toml'
- 'Cargo.lock'
- 'build.rs'
- '.cargo/**'
@@ -87,7 +88,7 @@ jobs:
key: test-${{ matrix.os }}
- name: Run tests
run: cargo test --verbose
run: cargo test --workspace --verbose
nix:
name: Nix
@@ -146,7 +147,7 @@ jobs:
fi
- name: Clippy
run: cargo clippy -- -D warnings
run: cargo clippy --workspace -- -D warnings
skills:
+4 -2
View File
@@ -34,8 +34,10 @@ jobs:
fetch-depth: 0
- name: Enforce AGENTS.md rules
run: |
if grep -qE "^google-[a-zA-Z0-9_-]+[[:space:]]*=" Cargo.toml; then
echo "::error file=Cargo.toml::Violates AGENTS.md: Adding generated google-* crates is prohibited. The CLI uses dynamic schema discovery at runtime."
# Check CLI crate for prohibited google-* registry crates.
# Path dependencies (e.g. google-workspace = { path = ... }) are allowed.
if grep -E "^google-[a-zA-Z0-9_-]+[[:space:]]*=" crates/google-workspace-cli/Cargo.toml | grep -v 'path[[:space:]]*='; then
echo "::error file=crates/google-workspace-cli/Cargo.toml::Violates AGENTS.md: Adding generated google-* crates is prohibited. The CLI uses dynamic schema discovery at runtime."
exit 1
fi
echo "Policy check passed."
+36 -20
View File
@@ -5,7 +5,7 @@
`gws` is a Rust CLI tool for interacting with Google Workspace APIs. It dynamically generates its command surface at runtime by parsing Google Discovery Service JSON documents.
> [!IMPORTANT]
> **Dynamic Discovery**: This project does NOT use generated Rust crates (e.g., `google-drive3`) for API interaction. Instead, it fetches the Discovery JSON at runtime and builds `clap` commands dynamically. When adding a new service, you only need to register it in `src/services.rs` and verify the Discovery URL pattern in `src/discovery.rs`. Do NOT add new crates to `Cargo.toml` for standard Google APIs.
> **Dynamic Discovery**: This project does NOT use generated Rust crates (e.g., `google-drive3`) for API interaction. Instead, it fetches the Discovery JSON at runtime and builds `clap` commands dynamically. When adding a new service, you only need to register it in `crates/google-workspace/src/services.rs` and verify the Discovery URL pattern in `crates/google-workspace/src/discovery.rs`. Do NOT add new crates to `Cargo.toml` for standard Google APIs.
> [!NOTE]
> **Package Manager**: Use `pnpm` instead of `npm` for Node.js package management in this repository.
@@ -42,22 +42,38 @@ The CLI uses a **two-phase argument parsing** strategy:
1. Parse argv to extract the service name (e.g., `drive`)
2. Fetch the service's Discovery Document, build a dynamic `clap::Command` tree, then re-parse
### Source Layout
### Workspace Layout
| File | Purpose |
| ------------------------- | ----------------------------------------------------------------------------------------- |
| `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 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 |
| `src/error.rs` | Structured JSON error output |
| `src/logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
| `src/timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API, 24h cache |
The repository is a Cargo workspace with two crates:
| Crate | Package | Purpose |
| ------------------------------ | ----------------------- | ------------------------------------------------- |
| `crates/google-workspace/` | `google-workspace` | Publishable library — core types and helpers |
| `crates/google-workspace-cli/` | `google-workspace-cli` | Binary crate — the `gws` CLI |
#### Library (`crates/google-workspace/src/`)
| File | Purpose |
| ---------------- | ---------------------------------------------------------- |
| `discovery.rs` | Serde models for Discovery Document + async fetch/cache |
| `services.rs` | Service alias → Discovery API name/version mapping |
| `error.rs` | `GwsError` enum, exit codes, JSON serialization |
| `validate.rs` | Path/URL/resource validators, `encode_path_segment()` |
| `client.rs` | HTTP client with retry logic |
#### CLI (`crates/google-workspace-cli/src/`)
| File | Purpose |
| ------------------- | ------------------------------------------------------------------------ |
| `main.rs` | Entrypoint, two-phase CLI parsing, method resolution |
| `auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC |
| `credential_store.rs` | AES-256-GCM encryption/decryption of credential files |
| `auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` |
| `commands.rs` | Recursive `clap::Command` builder from Discovery resources |
| `executor.rs` | HTTP request construction, response handling, schema validation |
| `schema.rs` | `gws schema` command — introspect API method schemas |
| `logging.rs` | Opt-in structured logging (stderr + file) via `tracing` |
| `timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API |
## Demo Videos
@@ -88,7 +104,7 @@ ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears th
> [!NOTE]
> **Environment variables are trusted inputs.** The validation rules above apply to **CLI arguments** that may be passed by untrusted AI agents. Environment variables (e.g. `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`) are set by the user themselves — in their shell profile, `.env` file, or deployment config — and are not subject to path traversal validation. This is consistent with standard conventions like `XDG_CONFIG_HOME`, `CARGO_HOME`, etc.
### Path Safety (`src/validate.rs`)
### Path Safety (`crates/google-workspace/src/validate.rs`)
When adding new helpers or CLI flags that accept file paths, **always validate** using the shared helpers:
@@ -106,7 +122,7 @@ if let Some(output_dir) = matches.get_one::<String>("output-dir") {
}
```
### URL Encoding (`src/helpers/mod.rs`)
### URL Encoding (`crates/google-workspace-cli/src/helpers/mod.rs`)
User-supplied values embedded in URL **path segments** must be percent-encoded. Use the shared helper:
@@ -131,7 +147,7 @@ client.get(url).query(&[("q", user_query)]).send().await?;
let url = format!("{}?q={}", base_url, user_query);
```
### Resource Name Validation (`src/helpers/mod.rs`)
### Resource Name Validation (`crates/google-workspace-cli/src/helpers/mod.rs`)
When a user-supplied string is used as a GCP resource identifier (project ID, topic name, space name, etc.) that gets embedded in a URL path, validate it first:
@@ -173,7 +189,7 @@ Helpers are handwritten commands prefixed with `+` that provide value the schema
> [!IMPORTANT]
> **Do NOT add a helper that** wraps a single API call already available via Discovery, adds flags to expose data already in the API response, or re-implements Discovery parameters as custom flags. Helper flags must control orchestration logic — use `--params` and `--format`/`jq` for API parameters and output filtering.
See [`src/helpers/README.md`](src/helpers/README.md) for full guidelines, anti-patterns, and a checklist for new helpers.
See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) for full guidelines, anti-patterns, and a checklist for new helpers.
## Environment Variables
Generated
+18 -1
View File
@@ -895,7 +895,23 @@ dependencies = [
]
[[package]]
name = "gws"
name = "google-workspace"
version = "0.1.0"
dependencies = [
"anyhow",
"percent-encoding",
"reqwest",
"serde",
"serde_json",
"serial_test",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "google-workspace-cli"
version = "0.20.1"
dependencies = [
"aes-gcm",
@@ -911,6 +927,7 @@ dependencies = [
"dirs",
"dotenvy",
"futures-util",
"google-workspace",
"hostname",
"iana-time-zone",
"keyring",
+3 -68
View File
@@ -12,76 +12,11 @@
# See the License for the specific language governing permissions and
# limitations under the License.
[package]
name = "gws"
version = "0.20.1"
edition = "2021"
description = "Google Workspace CLI — dynamic command surface from Discovery Service"
license = "Apache-2.0"
repository = "https://github.com/googleworkspace/cli"
homepage = "https://github.com/googleworkspace/cli"
readme = "README.md"
authors = ["Justin Poehnelt"]
keywords = ["cli", "google-workspace", "google", "drive", "gmail"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "gws"
path = "src/main.rs"
[dependencies]
tempfile = "3"
aes-gcm = "0.10"
anyhow = "1"
clap = { version = "4", features = ["derive", "string"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots"], default-features = false }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
yup-oauth2 = "12"
futures-util = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
base64 = "0.22.1"
derive_builder = "0.20.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
mail-builder = "0.4"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
percent-encoding = "2.3.2"
zeroize = { version = "1.8.2", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
uuid = { version = "1.22.0", features = ["v4", "v5"] }
mime_guess2 = "2.3.1"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
keyring = "3.6.3"
[workspace]
members = ["crates/google-workspace-cli", "crates/google-workspace"]
resolver = "2"
# The profile that 'cargo dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"
[dev-dependencies]
serial_test = "3.4.0"
+80
View File
@@ -0,0 +1,80 @@
# 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.
[package]
name = "google-workspace-cli"
version = "0.20.1"
edition = "2021"
description = "Google Workspace CLI — dynamic command surface from Discovery Service"
license = "Apache-2.0"
repository = "https://github.com/googleworkspace/cli"
homepage = "https://github.com/googleworkspace/cli"
readme = "README.md"
authors = ["Justin Poehnelt"]
keywords = ["cli", "google-workspace", "google", "drive", "gmail"]
categories = ["command-line-utilities", "web-programming"]
[[bin]]
name = "gws"
path = "src/main.rs"
[dependencies]
google-workspace = { path = "../google-workspace" }
tempfile = "3"
aes-gcm = "0.10"
anyhow = "1"
clap = { version = "4", features = ["derive", "string"] }
dirs = "5"
dotenvy = "0.15"
hostname = "0.4"
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots"], default-features = false }
rand = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
yup-oauth2 = "12"
futures-util = "0.3"
tokio-util = { version = "0.7", features = ["io"] }
bytes = "1"
base64 = "0.22.1"
derive_builder = "0.20.2"
ratatui = "0.30.0"
crossterm = "0.29.0"
chrono = "0.4.44"
chrono-tz = "0.10"
iana-time-zone = "0.1"
mail-builder = "0.4"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
percent-encoding = "2.3.2"
zeroize = { version = "1.8.2", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
uuid = { version = "1.22.0", features = ["v4", "v5"] }
mime_guess2 = "2.3.1"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies]
keyring = "3.6.3"
[dev-dependencies]
serial_test = "3.4.0"
+31
View File
@@ -0,0 +1,31 @@
# google-workspace-cli
**One CLI for all of Google Workspace — built for humans and AI agents.**
`gws` dynamically generates its command surface at runtime by reading Google's [Discovery Service](https://developers.google.com/discovery). Drive, Gmail, Calendar, and every Workspace API — zero boilerplate, structured JSON output, 40+ agent skills included.
## Install
```bash
npm install -g @googleworkspace/cli # npm
cargo install google-workspace-cli # crates.io
nix run github:googleworkspace/cli # nix
```
Pre-built binaries are available on the [GitHub Releases](https://github.com/googleworkspace/cli/releases) page.
## Quick Start
```bash
gws auth login
gws drive files list --params '{"pageSize": 5}'
gws gmail users.messages list --params '{"maxResults": 3}'
```
## Documentation
See the [full README](https://github.com/googleworkspace/cli#readme) for authentication setup, helper commands, agent skills, and more.
## License
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
+17
View File
@@ -0,0 +1,17 @@
// 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.
//! HTTP client — re-exports from `google_workspace` library crate.
pub use google_workspace::client::*;
@@ -0,0 +1,33 @@
// 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.
//! Discovery Document types and fetching.
//!
//! Types are re-exported from the `google_workspace` library crate.
//! The CLI wrapper provides default caching via `config_dir()`.
pub use google_workspace::discovery::*;
/// Fetches and caches a Google Discovery Document using the CLI's config directory.
///
/// This is a convenience wrapper around
/// [`google_workspace::discovery::fetch_discovery_document`] that automatically
/// uses the CLI's cache directory (`~/.config/gws/cache/`).
pub async fn fetch_discovery_document(
service: &str,
version: &str,
) -> anyhow::Result<RestDescription> {
let cache_dir = crate::auth_commands::config_dir().join("cache");
google_workspace::discovery::fetch_discovery_document(service, version, Some(&cache_dir)).await
}
+153
View File
@@ -0,0 +1,153 @@
// 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.
//! Structured error types and CLI error output.
//!
//! Core error types are re-exported from the `google_workspace` library crate.
//! CLI-specific error formatting (colored terminal output) is defined here.
pub use google_workspace::error::*;
use crate::output::{colorize, sanitize_for_terminal};
/// Human-readable exit code table, keyed by (code, description).
///
/// Used by `print_usage()` so the help text stays in sync with the
/// constants defined below without requiring manual updates in two places.
pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[
(0, "Success"),
(
GwsError::EXIT_CODE_API,
"API error — Google returned an error response",
),
(
GwsError::EXIT_CODE_AUTH,
"Auth error — credentials missing or invalid",
),
(
GwsError::EXIT_CODE_VALIDATION,
"Validation — bad arguments or input",
),
(
GwsError::EXIT_CODE_DISCOVERY,
"Discovery — could not fetch API schema",
),
(GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"),
];
/// Format a colored error label for the given error variant.
fn error_label(err: &GwsError) -> String {
match err {
GwsError::Api { .. } => colorize("error[api]:", "31"), // red
GwsError::Auth(_) => colorize("error[auth]:", "31"), // red
GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow
GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red
GwsError::Other(_) => colorize("error:", "31"), // red
}
}
/// Formats any error as a JSON object and prints to stdout.
///
/// A human-readable colored label is printed to stderr when connected to a
/// TTY. For `accessNotConfigured` errors (HTTP 403, reason
/// `accessNotConfigured`), additional guidance is printed to stderr.
/// The JSON output on stdout is unchanged (machine-readable).
pub fn print_error_json(err: &GwsError) {
let json = err.to_json();
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
// Print a colored summary to stderr. For accessNotConfigured errors,
// print specialized guidance instead of the generic message to avoid
// redundant output (the full API error already appears in the JSON).
if let GwsError::Api {
reason, enable_url, ..
} = err
{
if reason == "accessNotConfigured" {
eprintln!();
let hint = colorize("hint:", "36"); // cyan
eprintln!(
"{} {hint} API not enabled for your GCP project.",
error_label(err)
);
if let Some(url) = enable_url {
eprintln!(" Enable it at: {url}");
} else {
eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API.");
}
eprintln!(" After enabling, wait a few seconds and retry your command.");
return;
}
}
eprintln!(
"{} {}",
error_label(err),
sanitize_for_terminal(&err.to_string())
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[serial_test::serial]
fn test_colorize_respects_no_color_env() {
std::env::set_var("NO_COLOR", "1");
let result = colorize("hello", "31");
std::env::remove_var("NO_COLOR");
assert_eq!(result, "hello");
}
#[test]
fn test_error_label_contains_variant_name() {
let api_err = GwsError::Api {
code: 400,
message: "bad".to_string(),
reason: "r".to_string(),
enable_url: None,
};
let label = error_label(&api_err);
assert!(label.contains("error[api]:"));
let auth_err = GwsError::Auth("fail".to_string());
assert!(error_label(&auth_err).contains("error[auth]:"));
let val_err = GwsError::Validation("bad input".to_string());
assert!(error_label(&val_err).contains("error[validation]:"));
let disc_err = GwsError::Discovery("missing".to_string());
assert!(error_label(&disc_err).contains("error[discovery]:"));
let other_err = GwsError::Other(anyhow::anyhow!("oops"));
assert!(error_label(&other_err).contains("error:"));
}
#[test]
fn test_sanitize_for_terminal_strips_control_chars() {
let input = "normal \x1b[31mred text\x1b[0m end";
let sanitized = sanitize_for_terminal(input);
assert_eq!(sanitized, "normal [31mred text[0m end");
assert!(!sanitized.contains('\x1b'));
let input2 = "line1\nline2\ttab";
assert_eq!(sanitize_for_terminal(input2), "line1\nline2\ttab");
let input3 = "hello\x07bell\x08backspace";
assert_eq!(sanitize_for_terminal(input3), "hellobellbackspace");
}
}
@@ -24,8 +24,8 @@ use crate::services;
use clap::Command;
use std::path::Path;
const PERSONAS_YAML: &str = include_str!("../registry/personas.yaml");
const RECIPES_YAML: &str = include_str!("../registry/recipes.yaml");
const PERSONAS_YAML: &str = include_str!("../../../registry/personas.yaml");
const RECIPES_YAML: &str = include_str!("../../../registry/recipes.yaml");
/// Methods blocked from skill generation.
/// Format: (service_alias, resource, method).
@@ -430,7 +430,7 @@ fn load_preset_template(name: &str) -> Result<String, GwsError> {
// Fallback: embedded preset
eprintln!("Template file not found, using embedded '{}' preset", name);
Ok(include_str!("../../templates/modelarmor/jailbreak.json").to_string())
Ok(include_str!("../../../../templates/modelarmor/jailbreak.json").to_string())
}
#[cfg(test)]
@@ -19,28 +19,8 @@
//! these helpers to prevent escape-sequence injection, Unicode spoofing,
//! and to respect `NO_COLOR` / non-TTY environments.
use crate::error::GwsError;
// ── Dangerous character detection ─────────────────────────────────────
/// Returns `true` for Unicode characters that are dangerous in terminal
/// output but not caught by `char::is_control()`: zero-width chars, bidi
/// overrides, Unicode line/paragraph separators, and directional isolates.
///
/// Using `matches!` with char ranges gives O(1) per character instead of the
/// O(M) linear scan that a slice `.contains()` would require.
pub(crate) fn is_dangerous_unicode(c: char) -> bool {
matches!(c,
// zero-width: ZWSP, ZWNJ, ZWJ, BOM/ZWNBSP
'\u{200B}'..='\u{200D}' | '\u{FEFF}' |
// bidi: LRE, RLE, PDF, LRO, RLO
'\u{202A}'..='\u{202E}' |
// line / paragraph separators
'\u{2028}'..='\u{2029}' |
// directional isolates: LRI, RLI, FSI, PDI
'\u{2066}'..='\u{2069}'
)
}
// Import dangerous-char detection from the library crate.
pub(crate) use google_workspace::validate::is_dangerous_unicode;
// ── Sanitization ──────────────────────────────────────────────────────
@@ -62,27 +42,6 @@ pub(crate) fn sanitize_for_terminal(text: &str) -> String {
.collect()
}
/// Rejects strings containing control characters (C0: U+0000U+001F,
/// C1: U+0080U+009F, and DEL: U+007F) or dangerous Unicode characters
/// such as zero-width chars, bidi overrides, and line/paragraph separators.
///
/// Used for validating CLI argument values at the parse boundary.
pub(crate) fn reject_dangerous_chars(value: &str, flag_name: &str) -> Result<(), GwsError> {
for c in value.chars() {
if c.is_control() {
return Err(GwsError::Validation(format!(
"{flag_name} contains invalid control characters"
)));
}
if is_dangerous_unicode(c) {
return Err(GwsError::Validation(format!(
"{flag_name} contains invalid Unicode characters"
)));
}
}
Ok(())
}
// ── Color ─────────────────────────────────────────────────────────────
/// Returns true when stderr is connected to an interactive terminal and
@@ -187,80 +146,11 @@ mod tests {
assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ");
}
// ── reject_dangerous_chars ────────────────────────────────────
#[test]
fn reject_clean_string() {
assert!(reject_dangerous_chars("hello/world", "test").is_ok());
}
#[test]
fn reject_tab() {
assert!(reject_dangerous_chars("hello\tworld", "test").is_err());
}
#[test]
fn reject_newline() {
assert!(reject_dangerous_chars("hello\nworld", "test").is_err());
}
#[test]
fn reject_del() {
assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err());
}
#[test]
fn reject_zero_width_space() {
assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err());
}
#[test]
fn reject_bom() {
assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err());
}
#[test]
fn reject_rtl_override() {
assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err());
}
#[test]
fn reject_line_separator() {
assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err());
}
#[test]
fn reject_paragraph_separator() {
assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err());
}
#[test]
fn reject_zero_width_joiner() {
assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err());
}
#[test]
fn reject_preserves_normal_unicode() {
assert!(reject_dangerous_chars("日本語", "test").is_ok());
assert!(reject_dangerous_chars("café", "test").is_ok());
assert!(reject_dangerous_chars("αβγ", "test").is_ok());
}
#[test]
fn reject_c1_control_csi() {
// U+009B is the C1 "Control Sequence Introducer" — can inject
// terminal escape sequences just like ESC+[
assert!(reject_dangerous_chars("foo\u{009B}bar", "test").is_err());
}
// ── colorize ──────────────────────────────────────────────────
#[test]
fn colorize_returns_text_in_no_color_mode() {
// In test environment, stderr is typically not a TTY
let result = colorize("hello", "31");
// Either plain text (no TTY) or colored (TTY) — we just verify
// it contains the original text
assert!(result.contains("hello"));
}
}
@@ -0,0 +1,17 @@
// 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.
//! Service registry — re-exports from `google_workspace` library crate.
pub use google_workspace::services::*;
@@ -0,0 +1,17 @@
// 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.
//! Input validation — re-exports from `google_workspace` library crate.
pub use google_workspace::validate::*;
+40
View File
@@ -0,0 +1,40 @@
# 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.
[package]
name = "google-workspace"
version = "0.1.0"
edition = "2021"
description = "Google Workspace API client — Discovery Document types, service registry, and HTTP utilities"
license = "Apache-2.0"
repository = "https://github.com/googleworkspace/cli"
homepage = "https://github.com/googleworkspace/cli"
readme = "README.md"
authors = ["Justin Poehnelt"]
keywords = ["google-workspace", "google", "discovery", "api-client"]
categories = ["api-bindings", "web-programming"]
[dependencies]
anyhow = "1"
percent-encoding = "2.3.2"
reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["time", "fs"] }
tracing = "0.1"
[dev-dependencies]
serial_test = "3.4.0"
tempfile = "3"
+40
View File
@@ -0,0 +1,40 @@
# google-workspace
Core Rust library for interacting with Google Workspace APIs via the [Discovery Service](https://developers.google.com/discovery).
This crate provides the foundational types and utilities used by the [`google-workspace-cli`](https://crates.io/crates/google-workspace-cli) (`gws`) command-line tool, and can be used independently for programmatic access.
> **Dynamic Discovery** — this library fetches Google's Discovery Documents at runtime rather than relying on generated client crates. When Google adds or updates an API endpoint, your code picks it up automatically.
## Modules
| Module | Description |
|---|---|
| `discovery` | Discovery Document types (`RestDescription`, `RestMethod`, etc.) and async fetch with optional disk caching |
| `services` | Service registry mapping aliases (e.g., `drive`) to API name/version pairs |
| `error` | Structured `GwsError` enum with exit codes and JSON serialization |
| `validate` | Input validation: path safety, resource name checks, URL encoding |
| `client` | HTTP client builder with automatic retry logic |
## Usage
```rust
use google_workspace::discovery::fetch_discovery_document;
use google_workspace::services::resolve_service;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (api, version) = resolve_service("drive").unwrap();
let doc = fetch_discovery_document(api, version, None).await?;
println!("{} {}{} resources",
doc.name, doc.version,
doc.resources.len(),
);
Ok(())
}
```
## License
Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE).
@@ -1,3 +1,19 @@
// 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.
//! HTTP client with retry logic for Google API requests.
use reqwest::header::{HeaderMap, HeaderValue};
const MAX_RETRIES: u32 = 3;
@@ -184,9 +184,13 @@ pub struct JsonSchemaProperty {
}
/// Fetches and caches a Google Discovery Document.
///
/// When `cache_dir` is `Some`, the document is cached on disk with a 24-hour
/// TTL. Pass `None` to skip caching entirely.
pub async fn fetch_discovery_document(
service: &str,
version: &str,
cache_dir: Option<&std::path::Path>,
) -> anyhow::Result<RestDescription> {
// Validate service and version to prevent path traversal in cache filenames
// and injection in discovery URLs.
@@ -195,17 +199,15 @@ 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_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 Some(dir) = cache_dir {
tokio::fs::create_dir_all(dir).await?;
let cache_file = dir.join(format!("{service}_{version}.json"));
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 = std::fs::read_to_string(&cache_file)?;
let data = tokio::fs::read_to_string(&cache_file).await?;
let doc: RestDescription = serde_json::from_str(&data)?;
tracing::debug!(service = %service, version = %version, "Discovery cache hit");
return Ok(doc);
@@ -244,9 +246,11 @@ pub async fn fetch_discovery_document(
};
// Write to cache
if let Err(e) = std::fs::write(&cache_file, &body) {
// Non-fatal: just warn via stderr-safe approach
let _ = e;
if let Some(dir) = cache_dir {
let cache_file = dir.join(format!("{service}_{version}.json"));
if let Err(e) = tokio::fs::write(&cache_file, &body).await {
tracing::warn!(error = %e, "Failed to write discovery cache");
}
}
let doc: RestDescription = serde_json::from_str(&body)?;
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Structured error types for Google Workspace API operations.
use serde_json::json;
use thiserror::Error;
@@ -39,31 +41,6 @@ pub enum GwsError {
Other(#[from] anyhow::Error),
}
/// Human-readable exit code table, keyed by (code, description).
///
/// Used by `print_usage()` so the help text stays in sync with the
/// constants defined below without requiring manual updates in two places.
pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[
(0, "Success"),
(
GwsError::EXIT_CODE_API,
"API error — Google returned an error response",
),
(
GwsError::EXIT_CODE_AUTH,
"Auth error — credentials missing or invalid",
),
(
GwsError::EXIT_CODE_VALIDATION,
"Validation — bad arguments or input",
),
(
GwsError::EXIT_CODE_DISCOVERY,
"Discovery — could not fetch API schema",
),
(GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"),
];
impl GwsError {
/// Exit code for [`GwsError::Api`] variants.
pub const EXIT_CODE_API: i32 = 1;
@@ -77,15 +54,6 @@ impl GwsError {
pub const EXIT_CODE_OTHER: i32 = 5;
/// Map each error variant to a stable, documented exit code.
///
/// | Code | Meaning |
/// |------|----------------------------------------------|
/// | 0 | Success (never returned here) |
/// | 1 | API error — Google returned an error response |
/// | 2 | Auth error — credentials missing or invalid |
/// | 3 | Validation error — bad arguments or input |
/// | 4 | Discovery error — could not fetch API schema |
/// | 5 | Internal error — unexpected failure |
pub fn exit_code(&self) -> i32 {
match self {
GwsError::Api { .. } => Self::EXIT_CODE_API,
@@ -109,8 +77,6 @@ impl GwsError {
"message": message,
"reason": reason,
});
// Include enable_url in JSON output when present (accessNotConfigured errors).
// This preserves machine-readable compatibility while adding new optional field.
if let Some(url) = enable_url {
error_obj["enable_url"] = json!(url);
}
@@ -148,62 +114,6 @@ impl GwsError {
}
}
use crate::output::{colorize, sanitize_for_terminal};
/// Format a colored error label for the given error variant.
fn error_label(err: &GwsError) -> String {
match err {
GwsError::Api { .. } => colorize("error[api]:", "31"), // red
GwsError::Auth(_) => colorize("error[auth]:", "31"), // red
GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow
GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red
GwsError::Other(_) => colorize("error:", "31"), // red
}
}
/// Formats any error as a JSON object and prints to stdout.
///
/// A human-readable colored label is printed to stderr when connected to a
/// TTY. For `accessNotConfigured` errors (HTTP 403, reason
/// `accessNotConfigured`), additional guidance is printed to stderr.
/// The JSON output on stdout is unchanged (machine-readable).
pub fn print_error_json(err: &GwsError) {
let json = err.to_json();
println!(
"{}",
serde_json::to_string_pretty(&json).unwrap_or_default()
);
// Print a colored summary to stderr. For accessNotConfigured errors,
// print specialized guidance instead of the generic message to avoid
// redundant output (the full API error already appears in the JSON).
if let GwsError::Api {
reason, enable_url, ..
} = err
{
if reason == "accessNotConfigured" {
eprintln!();
let hint = colorize("hint:", "36"); // cyan
eprintln!(
"{} {hint} API not enabled for your GCP project.",
error_label(err)
);
if let Some(url) = enable_url {
eprintln!(" Enable it at: {url}");
} else {
eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API.");
}
eprintln!(" After enabling, wait a few seconds and retry your command.");
return;
}
}
eprintln!(
"{} {}",
error_label(err),
sanitize_for_terminal(&err.to_string())
);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -253,7 +163,6 @@ mod tests {
#[test]
fn test_exit_codes_are_distinct() {
// Ensure all named constants are unique (regression guard).
let codes = [
GwsError::EXIT_CODE_API,
GwsError::EXIT_CODE_AUTH,
@@ -320,13 +229,11 @@ mod tests {
assert_eq!(json["error"]["reason"], "internalError");
}
// --- accessNotConfigured tests ---
#[test]
fn test_error_to_json_access_not_configured_with_url() {
let err = GwsError::Api {
code: 403,
message: "Gmail API has not been used in project 549352339482 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 then retry.".to_string(),
message: "Gmail API has not been used in project 549352339482 before or it is disabled.".to_string(),
reason: "accessNotConfigured".to_string(),
enable_url: Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482".to_string()),
};
@@ -350,61 +257,6 @@ mod tests {
let json = err.to_json();
assert_eq!(json["error"]["code"], 403);
assert_eq!(json["error"]["reason"], "accessNotConfigured");
// enable_url key should not appear in JSON when None
assert!(json["error"]["enable_url"].is_null());
}
// --- colored output tests ---
#[test]
#[serial_test::serial]
fn test_colorize_respects_no_color_env() {
// NO_COLOR is the de-facto standard for disabling colors.
// When set, colorize() should return the plain text.
std::env::set_var("NO_COLOR", "1");
let result = colorize("hello", "31");
std::env::remove_var("NO_COLOR");
assert_eq!(result, "hello");
}
#[test]
fn test_error_label_contains_variant_name() {
let api_err = GwsError::Api {
code: 400,
message: "bad".to_string(),
reason: "r".to_string(),
enable_url: None,
};
let label = error_label(&api_err);
assert!(label.contains("error[api]:"));
let auth_err = GwsError::Auth("fail".to_string());
assert!(error_label(&auth_err).contains("error[auth]:"));
let val_err = GwsError::Validation("bad input".to_string());
assert!(error_label(&val_err).contains("error[validation]:"));
let disc_err = GwsError::Discovery("missing".to_string());
assert!(error_label(&disc_err).contains("error[discovery]:"));
let other_err = GwsError::Other(anyhow::anyhow!("oops"));
assert!(error_label(&other_err).contains("error:"));
}
#[test]
fn test_sanitize_for_terminal_strips_control_chars() {
// ANSI escape sequence should be stripped
let input = "normal \x1b[31mred text\x1b[0m end";
let sanitized = sanitize_for_terminal(input);
assert_eq!(sanitized, "normal [31mred text[0m end");
assert!(!sanitized.contains('\x1b'));
// Newlines and tabs preserved
let input2 = "line1\nline2\ttab";
assert_eq!(sanitize_for_terminal(input2), "line1\nline2\ttab");
// Other control characters stripped
let input3 = "hello\x07bell\x08backspace";
assert_eq!(sanitize_for_terminal(input3), "hellobellbackspace");
}
}
+32
View File
@@ -0,0 +1,32 @@
// 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.
//! Google Workspace API client library.
//!
//! Provides types and utilities for working with Google Workspace APIs
//! via the [Discovery Service](https://developers.google.com/discovery).
//!
//! # Modules
//!
//! - [`discovery`] — Discovery Document types and fetching
//! - [`error`] — Structured error types
//! - [`services`] — Service name registry and resolution
//! - [`validate`] — Input validation and URL encoding utilities
//! - [`client`] — HTTP client with retry logic
pub mod client;
pub mod discovery;
pub mod error;
pub mod services;
pub mod validate;
@@ -12,6 +12,10 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Google Workspace service registry.
//!
//! Maps service aliases (e.g. "drive", "gmail") to Discovery API names and versions.
use crate::error::GwsError;
/// A known service with its alias, API name, version, and description.
@@ -14,14 +14,53 @@
//! Shared input validation helpers.
//!
//! These functions harden CLI inputs against adversarial or accidentally
//! These functions harden inputs against adversarial or accidentally
//! malformed values — especially important when the CLI is invoked by an
//! LLM agent rather than a human operator.
use crate::error::GwsError;
use std::path::{Path, PathBuf};
use crate::output::reject_dangerous_chars as reject_control_chars;
// ── Dangerous character detection ─────────────────────────────────────
/// Returns `true` for Unicode characters that are dangerous in terminal
/// output but not caught by `char::is_control()`: zero-width chars, bidi
/// overrides, Unicode line/paragraph separators, and directional isolates.
pub fn is_dangerous_unicode(c: char) -> bool {
matches!(c,
// zero-width: ZWSP, ZWNJ, ZWJ, BOM/ZWNBSP
'\u{200B}'..='\u{200D}' | '\u{FEFF}' |
// bidi: LRE, RLE, PDF, LRO, RLO
'\u{202A}'..='\u{202E}' |
// line / paragraph separators
'\u{2028}'..='\u{2029}' |
// directional isolates: LRI, RLI, FSI, PDI
'\u{2066}'..='\u{2069}'
)
}
/// Rejects strings containing control characters (C0: U+0000U+001F,
/// C1: U+0080U+009F, and DEL: U+007F) or dangerous Unicode characters
/// such as zero-width chars, bidi overrides, and line/paragraph separators.
///
/// Used for validating argument values at the parse boundary.
pub fn reject_dangerous_chars(value: &str, flag_name: &str) -> Result<(), GwsError> {
for c in value.chars() {
if c.is_control() {
return Err(GwsError::Validation(format!(
"{flag_name} contains invalid control characters"
)));
}
if is_dangerous_unicode(c) {
return Err(GwsError::Validation(format!(
"{flag_name} contains invalid Unicode characters"
)));
}
}
Ok(())
}
// ── Path validators ───────────────────────────────────────────────────
/// Validates that `dir` is a safe output directory.
///
@@ -31,7 +70,7 @@ use crate::output::reject_dangerous_chars as reject_control_chars;
///
/// Returns the canonicalized path on success.
pub fn validate_safe_output_dir(dir: &str) -> Result<PathBuf, GwsError> {
reject_control_chars(dir, "--output-dir")?;
reject_dangerous_chars(dir, "--output-dir")?;
let path = Path::new(dir);
@@ -79,7 +118,7 @@ pub fn validate_safe_output_dir(dir: &str) -> Result<PathBuf, GwsError> {
/// Similar to [`validate_safe_output_dir`] but also follows symlinks
/// safely and ensures the resolved path stays under CWD.
pub fn validate_safe_dir_path(dir: &str) -> Result<PathBuf, GwsError> {
reject_control_chars(dir, "--dir")?;
reject_dangerous_chars(dir, "--dir")?;
let path = Path::new(dir);
@@ -136,7 +175,7 @@ pub fn validate_safe_dir_path(dir: &str) -> Result<PathBuf, GwsError> {
/// TOCTOU would require `openat(O_NOFOLLOW)` on each path component,
/// which is tracked as a follow-up for Unix platforms.
pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result<PathBuf, GwsError> {
reject_control_chars(path_str, flag_name)?;
reject_dangerous_chars(path_str, flag_name)?;
let path = Path::new(path_str);
let cwd = std::env::current_dir()
@@ -193,8 +232,6 @@ fn normalize_dotdot(path: &Path) -> PathBuf {
out
}
// reject_control_chars is now a re-export from crate::output (see top of file)
/// Resolves a path that may not exist yet by canonicalizing the existing
/// prefix and appending remaining components.
fn normalize_non_existing(path: &Path) -> Result<PathBuf, GwsError> {
@@ -233,6 +270,8 @@ fn normalize_non_existing(path: &Path) -> Result<PathBuf, GwsError> {
Ok(resolved)
}
// ── URL encoding ──────────────────────────────────────────────────────
/// Percent-encode a value for use as a single URL path segment (e.g., file ID,
/// calendar ID, message ID). All non-alphanumeric characters are encoded.
pub fn encode_path_segment(s: &str) -> String {
@@ -253,6 +292,8 @@ pub fn encode_path_preserving_slashes(s: &str) -> String {
.join("/")
}
// ── Resource / API validators ─────────────────────────────────────────
/// Validate a multi-segment resource name (e.g., `spaces/ABC`, `subscriptions/123`).
/// Rejects path traversal, control characters, and URL-special characters including `%`
/// to prevent URL-encoded bypasses. Returns the validated name or an error.
@@ -268,7 +309,7 @@ pub fn validate_resource_name(s: &str) -> Result<&str, GwsError> {
)));
}
if s.chars()
.any(|c| c == '\0' || c.is_control() || crate::output::is_dangerous_unicode(c))
.any(|c| c == '\0' || c.is_control() || is_dangerous_unicode(c))
{
return Err(GwsError::Validation(format!(
"Resource name contains invalid characters: {s}"
@@ -321,9 +362,7 @@ mod tests {
#[test]
#[serial]
fn test_output_dir_relative_subdir() {
// Create a real temp dir and change into it for the test
let dir = tempdir().unwrap();
// Canonicalize to handle macOS /var -> /private/var symlink
let canonical_dir = dir.path().canonicalize().unwrap();
let sub = canonical_dir.join("output");
fs::create_dir_all(&sub).unwrap();
@@ -343,21 +382,18 @@ mod tests {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
// Create a directory inside the tempdir
let allowed_dir = canonical_dir.join("allowed");
fs::create_dir(&allowed_dir).unwrap();
// Create a symlink pointing OUTSIDE the tempdir (e.g. to /tmp)
let symlink_path = canonical_dir.join("sneaky_link");
#[cfg(unix)]
std::os::unix::fs::symlink("/tmp", &symlink_path).unwrap();
#[cfg(windows)]
return; // Skip on Windows due to privilege requirements for symlinks
return;
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
// Try to validate the symlink resolving outside CWD
let result = validate_safe_output_dir("sneaky_link");
std::env::set_current_dir(&saved_cwd).unwrap();
@@ -440,26 +476,26 @@ mod tests {
assert!(validate_safe_dir_path("/usr/local").is_err());
}
// --- reject_control_chars ---
// --- reject_dangerous_chars ---
#[test]
fn test_reject_control_chars_clean() {
assert!(reject_control_chars("hello/world", "test").is_ok());
fn test_reject_dangerous_chars_clean() {
assert!(reject_dangerous_chars("hello/world", "test").is_ok());
}
#[test]
fn test_reject_control_chars_tab() {
assert!(reject_control_chars("hello\tworld", "test").is_err());
fn test_reject_dangerous_chars_tab() {
assert!(reject_dangerous_chars("hello\tworld", "test").is_err());
}
#[test]
fn test_reject_control_chars_newline() {
assert!(reject_control_chars("hello\nworld", "test").is_err());
fn test_reject_dangerous_chars_newline() {
assert!(reject_dangerous_chars("hello\nworld", "test").is_err());
}
#[test]
fn test_reject_control_chars_del() {
assert!(reject_control_chars("hello\x7Fworld", "test").is_err());
fn test_reject_dangerous_chars_del() {
assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err());
}
// -- encode_path_segment --------------------------------------------------
@@ -471,7 +507,6 @@ mod tests {
#[test]
fn test_encode_path_segment_email() {
// Calendar IDs are often email addresses
let encoded = encode_path_segment("user@gmail.com");
assert!(!encoded.contains('@'));
assert!(!encoded.contains('.'));
@@ -479,7 +514,6 @@ mod tests {
#[test]
fn test_encode_path_segment_query_injection() {
// LLM might include query params in an ID by mistake
let encoded = encode_path_segment("fileid?fields=name");
assert!(!encoded.contains('?'));
assert!(!encoded.contains('='));
@@ -493,7 +527,6 @@ mod tests {
#[test]
fn test_encode_path_segment_path_traversal() {
// Encoding makes traversal segments harmless
let encoded = encode_path_segment("../../etc/passwd");
assert!(!encoded.contains('/'));
assert!(!encoded.contains(".."));
@@ -501,7 +534,6 @@ mod tests {
#[test]
fn test_encode_path_segment_unicode() {
// LLM might pass unicode characters
let encoded = encode_path_segment("日本語ID");
assert!(!encoded.contains('日'));
}
@@ -514,10 +546,7 @@ mod tests {
#[test]
fn test_encode_path_segment_already_encoded() {
// LLM might double-encode by passing pre-encoded values
let encoded = encode_path_segment("user%40gmail.com");
// The % itself gets encoded to %25, so %40 becomes %2540
// This prevents double-encoding issues at the HTTP layer
assert!(encoded.contains("%2540"));
}
@@ -572,7 +601,6 @@ mod tests {
#[test]
fn test_validate_resource_name_query_injection() {
// LLMs might append query strings or fragments to resource names
assert!(validate_resource_name("spaces/ABC?key=val").is_err());
assert!(validate_resource_name("spaces/ABC#fragment").is_err());
}
@@ -591,64 +619,54 @@ mod tests {
#[test]
fn test_validate_resource_name_percent_bypass() {
// %2e%2e is ..
assert!(validate_resource_name("%2e%2e").is_err());
assert!(validate_resource_name("spaces/%2e%2e/etc").is_err());
// Just % should be rejected too
assert!(validate_resource_name("spaces/100%").is_err());
}
// --- reject_control_chars Unicode ---
// --- reject_dangerous_chars Unicode ---
#[test]
fn test_reject_control_chars_zero_width_space() {
// U+200B zero-width space
assert!(reject_control_chars("foo\u{200B}bar", "test").is_err());
fn test_reject_dangerous_chars_zero_width_space() {
assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err());
}
#[test]
fn test_reject_control_chars_bom() {
// U+FEFF byte-order mark / zero-width no-break space
assert!(reject_control_chars("foo\u{FEFF}bar", "test").is_err());
fn test_reject_dangerous_chars_bom() {
assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err());
}
#[test]
fn test_reject_control_chars_rtl_override() {
// U+202E RIGHT-TO-LEFT OVERRIDE
assert!(reject_control_chars("foo\u{202E}bar", "test").is_err());
fn test_reject_dangerous_chars_rtl_override() {
assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err());
}
#[test]
fn test_reject_control_chars_unicode_line_separator() {
// U+2028 LINE SEPARATOR
assert!(reject_control_chars("foo\u{2028}bar", "test").is_err());
fn test_reject_dangerous_chars_unicode_line_separator() {
assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err());
}
#[test]
fn test_reject_control_chars_paragraph_separator() {
// U+2029 PARAGRAPH SEPARATOR
assert!(reject_control_chars("foo\u{2029}bar", "test").is_err());
fn test_reject_dangerous_chars_paragraph_separator() {
assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err());
}
#[test]
fn test_reject_control_chars_zero_width_joiner() {
// U+200D ZERO WIDTH JOINER
assert!(reject_control_chars("foo\u{200D}bar", "test").is_err());
fn test_reject_dangerous_chars_zero_width_joiner() {
assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err());
}
#[test]
fn test_reject_control_chars_normal_unicode_ok() {
// CJK, accented characters and emoji should pass
assert!(reject_control_chars("日本語", "test").is_ok());
assert!(reject_control_chars("café", "test").is_ok());
assert!(reject_control_chars("αβγ", "test").is_ok());
fn test_reject_dangerous_chars_normal_unicode_ok() {
assert!(reject_dangerous_chars("日本語", "test").is_ok());
assert!(reject_dangerous_chars("café", "test").is_ok());
assert!(reject_dangerous_chars("αβγ", "test").is_ok());
}
// --- path validator Unicode (via validate_safe_output_dir) ---
// --- path validator Unicode ---
#[test]
fn test_output_dir_rejects_zero_width_chars() {
// U+200B in a path segment
assert!(validate_safe_output_dir("foo\u{200B}bar").is_err());
}
@@ -666,7 +684,6 @@ mod tests {
#[test]
fn test_validate_resource_name_zero_width_chars() {
// U+200B, U+200D, U+FEFF all rejected
assert!(validate_resource_name("foo\u{200B}bar").is_err());
assert!(validate_resource_name("foo\u{200D}bar").is_err());
assert!(validate_resource_name("foo\u{FEFF}bar").is_err());
@@ -685,21 +702,17 @@ mod tests {
#[test]
fn test_validate_resource_name_bidi_embedding() {
// U+202A LEFT-TO-RIGHT EMBEDDING, U+202B RIGHT-TO-LEFT EMBEDDING
assert!(validate_resource_name("foo\u{202A}bar").is_err());
assert!(validate_resource_name("foo\u{202B}bar").is_err());
}
#[test]
fn test_validate_resource_name_homoglyphs_pass_through() {
// Cyrillic lookalikes are intentionally allowed (homoglyph detection
// is out of scope for this validator — see validate_resource_name docs).
assert!(validate_resource_name("spaces/ΑΒС").is_ok()); // Cyrillic С
assert!(validate_resource_name("spaces/ΑΒС").is_ok());
}
#[test]
fn test_validate_resource_name_overlong_accepted() {
// No length limit — documents current behaviour.
let long = "a".repeat(10_000);
assert!(validate_resource_name(&long).is_ok());
}
@@ -790,7 +803,6 @@ mod tests {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
// Create a symlink that points outside the directory
#[cfg(unix)]
{
let link_path = canonical_dir.join("escape");
@@ -809,9 +821,6 @@ mod tests {
#[test]
#[serial]
fn test_file_path_rejects_traversal_via_nonexistent_prefix() {
// Regression: non_existent/../../etc/passwd could bypass starts_with
// because normalize_non_existing preserves ".." in the non-existent
// suffix. The normalize_dotdot fix resolves this.
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
+1 -1
View File
@@ -13,7 +13,7 @@
# limitations under the License.
[workspace]
members = ["cargo:."]
members = ["cargo:crates/google-workspace-cli"]
# Config for 'cargo dist'
[dist]
+2 -2
View File
@@ -11,8 +11,8 @@
let
pkgs = nixpkgs.legacyPackages.${system};
# Extract version from Cargo.toml
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
# Extract version from CLI crate's Cargo.toml
cargoToml = builtins.fromTOML (builtins.readFile ./crates/google-workspace-cli/Cargo.toml);
version = cargoToml.package.version;
# System dependencies