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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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+0000–U+001F,
|
||||
/// C1: U+0080–U+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::*;
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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+0000–U+001F,
|
||||
/// C1: U+0080–U+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
@@ -13,7 +13,7 @@
|
||||
# limitations under the License.
|
||||
|
||||
[workspace]
|
||||
members = ["cargo:."]
|
||||
members = ["cargo:crates/google-workspace-cli"]
|
||||
|
||||
# Config for 'cargo dist'
|
||||
[dist]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user