chore: import upstream snapshot with attribution
Publish OpenClaw Skills / publish (push) Has been cancelled
Audit / Security Audit (push) Has been cancelled
Automation / Gemini Review (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Nix (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Cargo Deny (push) Has been cancelled
CI / Verify Skills (push) Has been cancelled
CI / Lint Skills (push) Has been cancelled
CI / Build (Linux x86_64) (push) Has been cancelled
CI / Build (macos-latest, aarch64-apple-darwin) (push) Has been cancelled
CI / Build (macos-latest, x86_64-apple-darwin) (push) Has been cancelled
CI / Build (ubuntu-latest, aarch64-unknown-linux-gnu) (push) Has been cancelled
CI / Build (windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
CI / API Smoketest (push) Has been cancelled
Policy / Policy Check (push) Has been cancelled
Release (Changeset) / Release (push) Has been cancelled
Automation / Gemini Reviewed (push) Has been cancelled
Coverage / Coverage (push) Has been cancelled
Automation / Format (push) Has been cancelled
Automation / File Labeler (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:06 +08:00
commit 76a9e7a0cc
222 changed files with 47091 additions and 0 deletions
+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.22.5"
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).
+164
View File
@@ -0,0 +1,164 @@
// 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 std::sync::OnceLock;
use reqwest::header::{HeaderMap, HeaderValue};
const MAX_RETRIES: u32 = 3;
/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile
/// or misconfigured server from hanging the process indefinitely.
const MAX_RETRY_DELAY_SECS: u64 = 60;
const CONNECT_TIMEOUT_SECS: u64 = 10;
fn build_client_inner() -> Result<reqwest::Client, String> {
let mut headers = HeaderMap::new();
let name = env!("CARGO_PKG_NAME");
let version = env!("CARGO_PKG_VERSION");
// Format: gl-rust/name-version (the gl-rust/ prefix is fixed)
let client_header = format!("gl-rust/{}-{}", name, version);
if let Ok(header_value) = HeaderValue::from_str(&client_header) {
headers.insert("x-goog-api-client", header_value);
}
reqwest::Client::builder()
.default_headers(headers)
.connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
pub fn build_client() -> Result<reqwest::Client, crate::error::GwsError> {
build_client_inner().map_err(|message| crate::error::GwsError::Other(anyhow::anyhow!(message)))
}
/// Returns a shared reqwest client clone backed by a single global connection pool.
///
/// `reqwest::Client` is cheap to clone, so callers can take ownership of the
/// returned value while still sharing pooled connections underneath.
pub fn shared_client() -> Result<reqwest::Client, crate::error::GwsError> {
static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
match CLIENT.get_or_init(build_client_inner) {
Ok(client) => Ok(client.clone()),
Err(message) => Err(crate::error::GwsError::Other(anyhow::anyhow!(
message.clone()
))),
}
}
/// Send an HTTP request with automatic retry on 429 (rate limit) responses
/// and transient connection/timeout errors.
/// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s).
pub async fn send_with_retry(
build_request: impl Fn() -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, reqwest::Error> {
let mut last_err: Option<reqwest::Error> = None;
for attempt in 0..MAX_RETRIES {
match build_request().send().await {
Ok(resp) => {
if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS {
return Ok(resp);
}
let header_value = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok());
let retry_after = compute_retry_delay(header_value, attempt);
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
}
Err(e) if e.is_connect() || e.is_timeout() => {
// Transient network error — retry with exponential backoff
let delay = compute_retry_delay(None, attempt);
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
last_err = Some(e);
}
Err(e) => return Err(e),
}
}
// Final attempt — return whatever we get
match build_request().send().await {
Ok(resp) => Ok(resp),
Err(e) => Err(last_err.unwrap_or(e)),
}
}
/// Compute the retry delay from a Retry-After header value and attempt number.
/// Falls back to exponential backoff (1, 2, 4s) when the header is absent or
/// unparseable. Always caps the result at MAX_RETRY_DELAY_SECS.
fn compute_retry_delay(header_value: Option<&str>, attempt: u32) -> u64 {
header_value
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(2u64.saturating_pow(attempt))
.min(MAX_RETRY_DELAY_SECS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_client_succeeds() {
assert!(build_client().is_ok());
}
#[test]
fn shared_client_succeeds() {
assert!(shared_client().is_ok());
}
#[test]
fn shared_client_can_be_reused() {
let client_a = shared_client().unwrap();
let client_b = shared_client().unwrap();
let request_a = client_a.get("https://example.com").build().unwrap();
let request_b = client_b.get("https://example.com").build().unwrap();
assert_eq!(request_a.url(), request_b.url());
}
#[test]
fn retry_delay_caps_large_header_value() {
assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS);
}
#[test]
fn retry_delay_passes_through_small_header_value() {
assert_eq!(compute_retry_delay(Some("5"), 0), 5);
}
#[test]
fn retry_delay_falls_back_to_exponential_on_missing_header() {
assert_eq!(compute_retry_delay(None, 0), 1); // 2^0
assert_eq!(compute_retry_delay(None, 1), 2); // 2^1
assert_eq!(compute_retry_delay(None, 2), 4); // 2^2
}
#[test]
fn retry_delay_falls_back_on_unparseable_header() {
assert_eq!(compute_retry_delay(Some("not-a-number"), 1), 2);
assert_eq!(compute_retry_delay(Some(""), 0), 1);
}
#[test]
fn retry_delay_caps_at_boundary() {
assert_eq!(compute_retry_delay(Some("60"), 0), 60);
assert_eq!(compute_retry_delay(Some("61"), 0), MAX_RETRY_DELAY_SECS);
}
}
+329
View File
@@ -0,0 +1,329 @@
#![allow(dead_code)]
// 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 Parsing and Management
//!
//! Handles fetching, caching, and parsing Google API Discovery Documents.
//! These JSON schemas define the shapes of API requests and responses, forming
//! the foundation of the dynamically generated CLI commands.
use std::collections::HashMap;
use serde::Deserialize;
/// Top-level Discovery REST Description document.
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RestDescription {
pub name: String,
pub version: String,
pub title: Option<String>,
pub description: Option<String>,
pub root_url: String,
#[serde(default)]
pub service_path: String,
pub base_url: Option<String>,
#[serde(default)]
pub schemas: HashMap<String, JsonSchema>,
#[serde(default)]
pub resources: HashMap<String, RestResource>,
#[serde(default)]
pub parameters: HashMap<String, MethodParameter>,
pub auth: Option<AuthDescription>,
}
#[derive(Debug, Deserialize, Default)]
pub struct AuthDescription {
pub oauth2: Option<OAuth2Description>,
}
#[derive(Debug, Deserialize, Default)]
pub struct OAuth2Description {
pub scopes: Option<HashMap<String, ScopeDescription>>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ScopeDescription {
pub description: Option<String>,
}
/// A resource in the Discovery Document, which can contain methods and nested sub-resources.
#[derive(Debug, Deserialize, Default)]
pub struct RestResource {
#[serde(default)]
pub methods: HashMap<String, RestMethod>,
#[serde(default)]
pub resources: HashMap<String, RestResource>,
}
/// A single API method.
#[derive(Debug, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct RestMethod {
pub id: Option<String>,
pub description: Option<String>,
pub http_method: String,
pub path: String,
#[serde(default)]
pub parameters: HashMap<String, MethodParameter>,
#[serde(default)]
pub parameter_order: Vec<String>,
pub request: Option<SchemaRef>,
pub response: Option<SchemaRef>,
#[serde(default)]
pub scopes: Vec<String>,
pub flat_path: Option<String>,
#[serde(default)]
pub supports_media_download: bool,
#[serde(default)]
pub supports_media_upload: bool,
pub media_upload: Option<MediaUpload>,
}
/// Media upload metadata from the Discovery Document.
#[derive(Debug, Deserialize, Default)]
pub struct MediaUpload {
pub protocols: Option<MediaUploadProtocols>,
pub accept: Option<Vec<String>>,
}
/// Upload protocol details.
#[derive(Debug, Deserialize, Default)]
pub struct MediaUploadProtocols {
pub simple: Option<MediaUploadProtocol>,
}
/// A single upload protocol entry.
#[derive(Debug, Deserialize, Default)]
pub struct MediaUploadProtocol {
pub path: String,
pub multipart: Option<bool>,
}
/// A reference to a schema (e.g., `{ "$ref": "File" }`).
#[derive(Debug, Deserialize, Default)]
pub struct SchemaRef {
#[serde(rename = "$ref")]
pub schema_ref: Option<String>,
#[serde(rename = "parameterName")]
pub parameter_name: Option<String>,
}
/// A parameter definition for a method.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct MethodParameter {
#[serde(rename = "type")]
pub param_type: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
#[serde(default)]
pub required: bool,
pub format: Option<String>,
pub default: Option<String>,
#[serde(rename = "enum")]
pub enum_values: Option<Vec<String>>,
pub enum_descriptions: Option<Vec<String>>,
#[serde(default)]
pub repeated: bool,
pub minimum: Option<String>,
pub maximum: Option<String>,
#[serde(default)]
pub deprecated: bool,
}
/// JSON Schema definition for request/response bodies.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JsonSchema {
pub id: Option<String>,
#[serde(rename = "type")]
pub schema_type: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub properties: HashMap<String, JsonSchemaProperty>,
#[serde(rename = "$ref")]
pub schema_ref: Option<String>,
pub items: Option<Box<JsonSchemaProperty>>,
#[serde(default)]
pub required: Vec<String>,
pub additional_properties: Option<Box<JsonSchemaProperty>>,
}
/// A property within a JSON Schema.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JsonSchemaProperty {
#[serde(rename = "type")]
pub prop_type: Option<String>,
pub description: Option<String>,
#[serde(rename = "$ref")]
pub schema_ref: Option<String>,
pub format: Option<String>,
pub items: Option<Box<JsonSchemaProperty>>,
#[serde(default)]
pub properties: HashMap<String, JsonSchemaProperty>,
#[serde(default)]
pub read_only: bool,
pub default: Option<String>,
#[serde(rename = "enum")]
pub enum_values: Option<Vec<String>>,
pub additional_properties: Option<Box<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.
let service =
crate::validate::validate_api_identifier(service).map_err(|e| anyhow::anyhow!("{e}"))?;
let version =
crate::validate::validate_api_identifier(version).map_err(|e| anyhow::anyhow!("{e}"))?;
// Check cache (24hr TTL)
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 = 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);
}
}
}
}
let url = format!(
"https://www.googleapis.com/discovery/v1/apis/{}/{}/rest",
crate::validate::encode_path_segment(service),
crate::validate::encode_path_segment(version),
);
tracing::debug!(service = %service, version = %version, "Fetching discovery document");
let client = crate::client::build_client()?;
let resp = client.get(&url).send().await?;
let body = if resp.status().is_success() {
resp.text().await?
} else {
// Try the $discovery/rest URL pattern used by newer APIs (Forms, Keep, Meet, etc.)
let alt_url = format!("https://{service}.googleapis.com/$discovery/rest");
let alt_resp = client
.get(&alt_url)
.query(&[("version", version)])
.send()
.await?;
if !alt_resp.status().is_success() {
anyhow::bail!(
"Failed to fetch Discovery Document for {service}/{version}: HTTP {} (tried both standard and $discovery URLs)",
alt_resp.status()
);
}
alt_resp.text().await?
};
// Write to cache
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)?;
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_rest_description() {
let json = r#"{
"name": "drive",
"version": "v3",
"rootUrl": "https://www.googleapis.com/",
"servicePath": "drive/v3/",
"resources": {
"files": {
"methods": {
"list": {
"httpMethod": "GET",
"path": "files",
"response": { "$ref": "FileList" }
}
}
}
},
"schemas": {
"FileList": {
"id": "FileList",
"type": "object",
"properties": {
"files": {
"type": "array",
"items": { "$ref": "File" }
}
}
}
}
}"#;
let doc: RestDescription = serde_json::from_str(json).unwrap();
assert_eq!(doc.name, "drive");
assert_eq!(doc.version, "v3");
assert_eq!(doc.root_url, "https://www.googleapis.com/");
assert_eq!(doc.service_path, "drive/v3/");
// precise resource checking
let files = doc.resources.get("files").expect("files resource missing");
let list = files.methods.get("list").expect("list method missing");
assert_eq!(list.http_method, "GET");
assert_eq!(list.path, "files");
// schema checking
let file_list = doc
.schemas
.get("FileList")
.expect("FileList schema missing");
assert_eq!(file_list.id.as_deref(), Some("FileList"));
}
#[test]
fn test_deserialize_defaults() {
let json = r#"{
"name": "admin",
"version": "directory_v1",
"rootUrl": "https://admin.googleapis.com/"
}"#;
let doc: RestDescription = serde_json::from_str(json).unwrap();
assert_eq!(doc.service_path, ""); // default empty string
assert!(doc.resources.is_empty());
assert!(doc.schemas.is_empty());
}
}
+262
View File
@@ -0,0 +1,262 @@
// 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 for Google Workspace API operations.
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum GwsError {
#[error("{message}")]
Api {
code: u16,
message: String,
reason: String,
/// For `accessNotConfigured` errors: the GCP console URL to enable the API.
enable_url: Option<String>,
},
#[error("{0}")]
Validation(String),
#[error("{0}")]
Auth(String),
#[error("{0}")]
Discovery(String),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl GwsError {
/// Exit code for [`GwsError::Api`] variants.
pub const EXIT_CODE_API: i32 = 1;
/// Exit code for [`GwsError::Auth`] variants.
pub const EXIT_CODE_AUTH: i32 = 2;
/// Exit code for [`GwsError::Validation`] variants.
pub const EXIT_CODE_VALIDATION: i32 = 3;
/// Exit code for [`GwsError::Discovery`] variants.
pub const EXIT_CODE_DISCOVERY: i32 = 4;
/// Exit code for [`GwsError::Other`] variants.
pub const EXIT_CODE_OTHER: i32 = 5;
/// Map each error variant to a stable, documented exit code.
pub fn exit_code(&self) -> i32 {
match self {
GwsError::Api { .. } => Self::EXIT_CODE_API,
GwsError::Auth(_) => Self::EXIT_CODE_AUTH,
GwsError::Validation(_) => Self::EXIT_CODE_VALIDATION,
GwsError::Discovery(_) => Self::EXIT_CODE_DISCOVERY,
GwsError::Other(_) => Self::EXIT_CODE_OTHER,
}
}
pub fn to_json(&self) -> serde_json::Value {
match self {
GwsError::Api {
code,
message,
reason,
enable_url,
} => {
let mut error_obj = json!({
"code": code,
"message": message,
"reason": reason,
});
if let Some(url) = enable_url {
error_obj["enable_url"] = json!(url);
}
json!({ "error": error_obj })
}
GwsError::Validation(msg) => json!({
"error": {
"code": 400,
"message": msg,
"reason": "validationError",
}
}),
GwsError::Auth(msg) => json!({
"error": {
"code": 401,
"message": msg,
"reason": "authError",
}
}),
GwsError::Discovery(msg) => json!({
"error": {
"code": 500,
"message": msg,
"reason": "discoveryError",
}
}),
GwsError::Other(e) => json!({
"error": {
"code": 500,
"message": format!("{e:#}"),
"reason": "internalError",
}
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_exit_code_api() {
let err = GwsError::Api {
code: 404,
message: "Not Found".to_string(),
reason: "notFound".to_string(),
enable_url: None,
};
assert_eq!(err.exit_code(), GwsError::EXIT_CODE_API);
}
#[test]
fn test_exit_code_auth() {
assert_eq!(
GwsError::Auth("bad token".to_string()).exit_code(),
GwsError::EXIT_CODE_AUTH
);
}
#[test]
fn test_exit_code_validation() {
assert_eq!(
GwsError::Validation("missing arg".to_string()).exit_code(),
GwsError::EXIT_CODE_VALIDATION
);
}
#[test]
fn test_exit_code_discovery() {
assert_eq!(
GwsError::Discovery("fetch failed".to_string()).exit_code(),
GwsError::EXIT_CODE_DISCOVERY
);
}
#[test]
fn test_exit_code_other() {
assert_eq!(
GwsError::Other(anyhow::anyhow!("oops")).exit_code(),
GwsError::EXIT_CODE_OTHER
);
}
#[test]
fn test_exit_codes_are_distinct() {
let codes = [
GwsError::EXIT_CODE_API,
GwsError::EXIT_CODE_AUTH,
GwsError::EXIT_CODE_VALIDATION,
GwsError::EXIT_CODE_DISCOVERY,
GwsError::EXIT_CODE_OTHER,
];
let unique: std::collections::HashSet<i32> = codes.iter().copied().collect();
assert_eq!(
unique.len(),
codes.len(),
"exit codes must be distinct: {codes:?}"
);
}
#[test]
fn test_error_to_json_api() {
let err = GwsError::Api {
code: 404,
message: "Not Found".to_string(),
reason: "notFound".to_string(),
enable_url: None,
};
let json = err.to_json();
assert_eq!(json["error"]["code"], 404);
assert_eq!(json["error"]["message"], "Not Found");
assert_eq!(json["error"]["reason"], "notFound");
assert!(json["error"]["enable_url"].is_null());
}
#[test]
fn test_error_to_json_validation() {
let err = GwsError::Validation("Invalid input".to_string());
let json = err.to_json();
assert_eq!(json["error"]["code"], 400);
assert_eq!(json["error"]["message"], "Invalid input");
assert_eq!(json["error"]["reason"], "validationError");
}
#[test]
fn test_error_to_json_auth() {
let err = GwsError::Auth("Token expired".to_string());
let json = err.to_json();
assert_eq!(json["error"]["code"], 401);
assert_eq!(json["error"]["message"], "Token expired");
assert_eq!(json["error"]["reason"], "authError");
}
#[test]
fn test_error_to_json_discovery() {
let err = GwsError::Discovery("Failed to fetch doc".to_string());
let json = err.to_json();
assert_eq!(json["error"]["code"], 500);
assert_eq!(json["error"]["message"], "Failed to fetch doc");
assert_eq!(json["error"]["reason"], "discoveryError");
}
#[test]
fn test_error_to_json_other() {
let err = GwsError::Other(anyhow::anyhow!("Something went wrong"));
let json = err.to_json();
assert_eq!(json["error"]["code"], 500);
assert_eq!(json["error"]["message"], "Something went wrong");
assert_eq!(json["error"]["reason"], "internalError");
}
#[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.".to_string(),
reason: "accessNotConfigured".to_string(),
enable_url: Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482".to_string()),
};
let json = err.to_json();
assert_eq!(json["error"]["code"], 403);
assert_eq!(json["error"]["reason"], "accessNotConfigured");
assert_eq!(
json["error"]["enable_url"],
"https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482"
);
}
#[test]
fn test_error_to_json_access_not_configured_without_url() {
let err = GwsError::Api {
code: 403,
message: "API not enabled.".to_string(),
reason: "accessNotConfigured".to_string(),
enable_url: None,
};
let json = err.to_json();
assert_eq!(json["error"]["code"], 403);
assert_eq!(json["error"]["reason"], "accessNotConfigured");
assert!(json["error"]["enable_url"].is_null());
}
}
+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;
+188
View File
@@ -0,0 +1,188 @@
// 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 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.
pub struct ServiceEntry {
pub aliases: &'static [&'static str],
pub api_name: &'static str,
pub version: &'static str,
pub description: &'static str,
}
/// All known services with metadata.
pub const SERVICES: &[ServiceEntry] = &[
ServiceEntry {
aliases: &["drive"],
api_name: "drive",
version: "v3",
description: "Manage files, folders, and shared drives",
},
ServiceEntry {
aliases: &["sheets"],
api_name: "sheets",
version: "v4",
description: "Read and write spreadsheets",
},
ServiceEntry {
aliases: &["gmail"],
api_name: "gmail",
version: "v1",
description: "Send, read, and manage email",
},
ServiceEntry {
aliases: &["calendar"],
api_name: "calendar",
version: "v3",
description: "Manage calendars and events",
},
ServiceEntry {
aliases: &["admin-reports", "reports"],
api_name: "admin",
version: "reports_v1",
description: "Audit logs and usage reports",
},
ServiceEntry {
aliases: &["docs"],
api_name: "docs",
version: "v1",
description: "Read and write Google Docs",
},
ServiceEntry {
aliases: &["slides"],
api_name: "slides",
version: "v1",
description: "Read and write presentations",
},
ServiceEntry {
aliases: &["tasks"],
api_name: "tasks",
version: "v1",
description: "Manage task lists and tasks",
},
ServiceEntry {
aliases: &["people"],
api_name: "people",
version: "v1",
description: "Manage contacts and profiles",
},
ServiceEntry {
aliases: &["chat"],
api_name: "chat",
version: "v1",
description: "Manage Chat spaces and messages",
},
ServiceEntry {
aliases: &["classroom"],
api_name: "classroom",
version: "v1",
description: "Manage classes, rosters, and coursework",
},
ServiceEntry {
aliases: &["forms"],
api_name: "forms",
version: "v1",
description: "Read and write Google Forms",
},
ServiceEntry {
aliases: &["keep"],
api_name: "keep",
version: "v1",
description: "Manage Google Keep notes",
},
ServiceEntry {
aliases: &["meet"],
api_name: "meet",
version: "v2",
description: "Manage Google Meet conferences",
},
ServiceEntry {
aliases: &["events"],
api_name: "workspaceevents",
version: "v1",
description: "Subscribe to Google Workspace events",
},
ServiceEntry {
aliases: &["modelarmor"],
api_name: "modelarmor",
version: "v1",
description: "Filter user-generated content for safety",
},
ServiceEntry {
aliases: &["workflow", "wf"],
api_name: "workflow",
version: "v1",
description: "Cross-service productivity workflows",
},
ServiceEntry {
aliases: &["script"],
api_name: "script",
version: "v1",
description: "Manage Google Apps Script projects",
},
];
/// Resolves a service alias to (api_name, version).
pub fn resolve_service(name: &str) -> Result<(String, String), GwsError> {
for entry in SERVICES {
if entry.aliases.contains(&name) {
return Ok((entry.api_name.to_string(), entry.version.to_string()));
}
}
let all_names: Vec<&str> = SERVICES
.iter()
.flat_map(|e| e.aliases.iter().copied())
.collect();
Err(GwsError::Validation(format!(
"Unknown service '{}'. Known services: {}. Use '<api>:<version>' syntax for unlisted APIs.",
name,
all_names.join(", ")
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_service_known() {
assert_eq!(
resolve_service("drive").unwrap(),
("drive".to_string(), "v3".to_string())
);
assert_eq!(
resolve_service("admin-reports").unwrap(),
("admin".to_string(), "reports_v1".to_string())
);
assert_eq!(
resolve_service("reports").unwrap(),
("admin".to_string(), "reports_v1".to_string())
);
}
#[test]
fn test_resolve_service_unknown() {
let err = resolve_service("unknown_service");
assert!(err.is_err());
match err.unwrap_err() {
GwsError::Validation(msg) => assert!(msg.contains("Unknown service")),
_ => panic!("Expected Validation error"),
}
}
}
+838
View File
@@ -0,0 +1,838 @@
// 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.
//! Shared input validation helpers.
//!
//! 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};
// ── 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.
///
/// The path is resolved relative to CWD. The function rejects paths that
/// would escape above CWD (e.g. `../../.ssh`) or contain null bytes /
/// control characters.
///
/// Returns the canonicalized path on success.
pub fn validate_safe_output_dir(dir: &str) -> Result<PathBuf, GwsError> {
reject_dangerous_chars(dir, "--output-dir")?;
let path = Path::new(dir);
// Reject absolute paths — force everything relative to CWD
if path.is_absolute() {
return Err(GwsError::Validation(format!(
"--output-dir must be a relative path, got absolute path '{}'",
dir
)));
}
// Canonicalize CWD and resolve the target under it
let cwd = std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;
let resolved = cwd.join(path);
// If the directory already exists, canonicalize. Otherwise, canonicalize
// the longest existing prefix and append the remaining segments.
let canonical = if resolved.exists() {
resolved.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to resolve --output-dir '{}': {e}", dir))
})?
} else {
normalize_non_existing(&resolved)?
};
let canonical_cwd = cwd.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to canonicalize current directory: {e}"))
})?;
if !canonical.starts_with(&canonical_cwd) {
return Err(GwsError::Validation(format!(
"--output-dir '{}' resolves to '{}' which is outside the current directory",
dir,
canonical.display()
)));
}
Ok(canonical)
}
/// Validates that `dir` is a safe directory for reading files (e.g. `--dir`
/// in `script +push`).
///
/// 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_dangerous_chars(dir, "--dir")?;
let path = Path::new(dir);
// "." is always safe (CWD itself)
if dir == "." {
return std::env::current_dir().map_err(|e| {
GwsError::Validation(format!("Failed to determine current directory: {e}"))
});
}
if path.is_absolute() {
return Err(GwsError::Validation(format!(
"--dir must be a relative path, got absolute path '{}'",
dir
)));
}
let cwd = std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;
let resolved = cwd.join(path);
let canonical = resolved
.canonicalize()
.map_err(|e| GwsError::Validation(format!("Failed to resolve --dir '{}': {e}", dir)))?;
let canonical_cwd = cwd.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to canonicalize current directory: {e}"))
})?;
if !canonical.starts_with(&canonical_cwd) {
return Err(GwsError::Validation(format!(
"--dir '{}' resolves to '{}' which is outside the current directory",
dir,
canonical.display()
)));
}
Ok(canonical)
}
/// Validates that a file path (e.g. `--upload` or `--output`) is safe.
///
/// Rejects paths that escape above CWD via `..` traversal, contain
/// control characters, or follow symlinks to locations outside CWD.
/// Absolute paths are allowed (reading an existing file from a known
/// location is legitimate) but the resolved target must still live
/// under CWD.
///
/// # TOCTOU caveat
///
/// This is a best-effort defence-in-depth check. A local attacker with
/// write access to a parent directory could replace a path component
/// between this validation and the subsequent I/O. Fully eliminating
/// 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_dangerous_chars(path_str, flag_name)?;
let path = Path::new(path_str);
let cwd = std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?;
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
// For existing files, canonicalize to resolve symlinks.
// For non-existing files, get the prefix canonicalized then normalize
// the remaining components to resolve any `..` or `.` segments.
let canonical = if resolved.exists() {
resolved.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to resolve {flag_name} '{}': {e}", path_str))
})?
} else {
let raw = normalize_non_existing(&resolved)?;
// normalize_non_existing does NOT resolve `..` in the non-existent
// suffix. We must resolve them here to prevent bypass via paths like
// `non_existent/../../etc/passwd`.
normalize_dotdot(&raw)
};
let canonical_cwd = cwd.canonicalize().map_err(|e| {
GwsError::Validation(format!("Failed to canonicalize current directory: {e}"))
})?;
if !canonical.starts_with(&canonical_cwd) {
return Err(GwsError::Validation(format!(
"{flag_name} '{}' resolves to '{}' which is outside the current directory",
path_str,
canonical.display()
)));
}
Ok(canonical)
}
/// Resolve `.` and `..` components in a path without touching the filesystem.
fn normalize_dotdot(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
c => out.push(c),
}
}
out
}
/// 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> {
let mut resolved = PathBuf::new();
let mut remaining = Vec::new();
// Walk backwards until we find a component that exists
let mut current = path.to_path_buf();
loop {
if current.exists() {
resolved = current
.canonicalize()
.map_err(|e| GwsError::Validation(format!("Failed to canonicalize path: {e}")))?;
break;
}
if let Some(name) = current.file_name() {
remaining.push(name.to_os_string());
} else {
// We've exhausted the path without finding an existing prefix
return Err(GwsError::Validation(format!(
"Cannot resolve path '{}'",
path.display()
)));
}
current = match current.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
}
// Append remaining segments (in reverse since we collected them backwards)
for seg in remaining.into_iter().rev() {
resolved.push(seg);
}
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 {
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
}
/// Percent-encode a value for use in URI path templates where `/` should stay
/// as a path separator (e.g., RFC 6570 `{+name}` expansions).
///
/// Each path segment is encoded independently, then joined with `/`, so
/// dangerous characters like `#`/`?` are still escaped while hierarchical
/// resource names such as `projects/p/locations/l` remain readable.
pub fn encode_path_preserving_slashes(s: &str) -> String {
s.split('/')
.map(encode_path_segment)
.collect::<Vec<_>>()
.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.
pub fn validate_resource_name(s: &str) -> Result<&str, GwsError> {
if s.is_empty() {
return Err(GwsError::Validation(
"Resource name must not be empty".to_string(),
));
}
if s.split('/').any(|seg| seg == "..") {
return Err(GwsError::Validation(format!(
"Resource name must not contain path traversal ('..') segments: {s}"
)));
}
if s.chars()
.any(|c| c == '\0' || c.is_control() || is_dangerous_unicode(c))
{
return Err(GwsError::Validation(format!(
"Resource name contains invalid characters: {s}"
)));
}
// Reject URL-special characters that could inject query params or fragments
if s.contains('?') || s.contains('#') {
return Err(GwsError::Validation(format!(
"Resource name must not contain '?' or '#': {s}"
)));
}
// Reject '%' to prevent URL-encoded bypasses (e.g. %2e%2e for ..)
if s.contains('%') {
return Err(GwsError::Validation(format!(
"Resource name must not contain '%' (URL encoding bypass attempt): {s}"
)));
}
Ok(s)
}
/// Validate an API identifier (service name, version string) for use in
/// cache filenames and discovery URLs. Only alphanumeric characters, hyphens,
/// underscores, and dots are allowed to prevent path traversal and injection.
pub fn validate_api_identifier(s: &str) -> Result<&str, GwsError> {
if s.is_empty() {
return Err(GwsError::Validation(
"API identifier must not be empty".to_string(),
));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
return Err(GwsError::Validation(format!(
"API identifier contains invalid characters (only alphanumeric, '-', '_', '.' allowed): {s}"
)));
}
Ok(s)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
use tempfile::tempdir;
// --- validate_safe_output_dir ---
#[test]
#[serial]
fn test_output_dir_relative_subdir() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let sub = canonical_dir.join("output");
fs::create_dir_all(&sub).unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_output_dir("output");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_ok(), "expected Ok, got: {result:?}");
}
#[test]
#[serial]
fn test_output_dir_rejects_symlink_traversal() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let allowed_dir = canonical_dir.join("allowed");
fs::create_dir(&allowed_dir).unwrap();
let symlink_path = canonical_dir.join("sneaky_link");
#[cfg(unix)]
std::os::unix::fs::symlink("/tmp", &symlink_path).unwrap();
#[cfg(windows)]
return;
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_output_dir("sneaky_link");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"), "got: {msg}");
}
#[test]
#[serial]
fn test_output_dir_rejects_traversal() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_output_dir("../../.ssh");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("outside the current directory"), "got: {msg}");
}
#[test]
fn test_output_dir_rejects_absolute() {
assert!(validate_safe_output_dir("/tmp/evil").is_err());
}
#[test]
fn test_output_dir_rejects_null_bytes() {
assert!(validate_safe_output_dir("foo\0bar").is_err());
}
#[test]
fn test_output_dir_rejects_control_chars() {
assert!(validate_safe_output_dir("foo\x01bar").is_err());
}
#[test]
#[serial]
fn test_output_dir_non_existing_subdir() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_output_dir("new/nested/dir");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(
result.is_ok(),
"expected Ok for non-existing subdir, got: {result:?}"
);
}
// --- validate_safe_dir_path ---
#[test]
fn test_dir_path_cwd() {
assert!(validate_safe_dir_path(".").is_ok());
}
#[test]
#[serial]
fn test_dir_path_rejects_traversal() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_dir_path("../../etc");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err());
}
#[test]
fn test_dir_path_rejects_absolute() {
assert!(validate_safe_dir_path("/usr/local").is_err());
}
// --- reject_dangerous_chars ---
#[test]
fn test_reject_dangerous_chars_clean() {
assert!(reject_dangerous_chars("hello/world", "test").is_ok());
}
#[test]
fn test_reject_dangerous_chars_tab() {
assert!(reject_dangerous_chars("hello\tworld", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_newline() {
assert!(reject_dangerous_chars("hello\nworld", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_del() {
assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err());
}
// -- encode_path_segment --------------------------------------------------
#[test]
fn test_encode_path_segment_plain_id() {
assert_eq!(encode_path_segment("abc123"), "abc123");
}
#[test]
fn test_encode_path_segment_email() {
let encoded = encode_path_segment("user@gmail.com");
assert!(!encoded.contains('@'));
assert!(!encoded.contains('.'));
}
#[test]
fn test_encode_path_segment_query_injection() {
let encoded = encode_path_segment("fileid?fields=name");
assert!(!encoded.contains('?'));
assert!(!encoded.contains('='));
}
#[test]
fn test_encode_path_segment_fragment_injection() {
let encoded = encode_path_segment("fileid#section");
assert!(!encoded.contains('#'));
}
#[test]
fn test_encode_path_segment_path_traversal() {
let encoded = encode_path_segment("../../etc/passwd");
assert!(!encoded.contains('/'));
assert!(!encoded.contains(".."));
}
#[test]
fn test_encode_path_segment_unicode() {
let encoded = encode_path_segment("日本語ID");
assert!(!encoded.contains('日'));
}
#[test]
fn test_encode_path_segment_spaces() {
let encoded = encode_path_segment("my file id");
assert!(!encoded.contains(' '));
}
#[test]
fn test_encode_path_segment_already_encoded() {
let encoded = encode_path_segment("user%40gmail.com");
assert!(encoded.contains("%2540"));
}
#[test]
fn test_encode_path_preserving_slashes_hierarchical_name() {
let encoded = encode_path_preserving_slashes("projects/p1/locations/us/topics/t1");
assert_eq!(encoded, "projects/p1/locations/us/topics/t1");
}
#[test]
fn test_encode_path_preserving_slashes_escapes_reserved_chars() {
let encoded = encode_path_preserving_slashes("hash#1/child?x=y");
assert_eq!(encoded, "hash%231/child%3Fx%3Dy");
}
#[test]
fn test_encode_path_preserving_slashes_spaces_and_unicode() {
let encoded = encode_path_preserving_slashes("タイムライン 1/列 A");
assert!(!encoded.contains(' '));
assert!(encoded.contains('/'));
}
// -- validate_resource_name -----------------------------------------------
#[test]
fn test_validate_resource_name_valid() {
assert!(validate_resource_name("spaces/ABC123").is_ok());
assert!(validate_resource_name("subscriptions/my-sub").is_ok());
assert!(validate_resource_name("@default").is_ok());
assert!(validate_resource_name("projects/p1/topics/t1").is_ok());
}
#[test]
fn test_validate_resource_name_traversal() {
assert!(validate_resource_name("../../etc/passwd").is_err());
assert!(validate_resource_name("spaces/../other").is_err());
assert!(validate_resource_name("..").is_err());
}
#[test]
fn test_validate_resource_name_control_chars() {
assert!(validate_resource_name("spaces/\0bad").is_err());
assert!(validate_resource_name("spaces/\nbad").is_err());
assert!(validate_resource_name("spaces/\rbad").is_err());
assert!(validate_resource_name("spaces/\tbad").is_err());
}
#[test]
fn test_validate_resource_name_empty() {
assert!(validate_resource_name("").is_err());
}
#[test]
fn test_validate_resource_name_query_injection() {
assert!(validate_resource_name("spaces/ABC?key=val").is_err());
assert!(validate_resource_name("spaces/ABC#fragment").is_err());
}
#[test]
fn test_validate_resource_name_error_messages_are_clear() {
let err = validate_resource_name("").unwrap_err();
assert!(err.to_string().contains("must not be empty"));
let err = validate_resource_name("../bad").unwrap_err();
assert!(err.to_string().contains("path traversal"));
let err = validate_resource_name("bad\0id").unwrap_err();
assert!(err.to_string().contains("invalid characters"));
}
#[test]
fn test_validate_resource_name_percent_bypass() {
assert!(validate_resource_name("%2e%2e").is_err());
assert!(validate_resource_name("spaces/%2e%2e/etc").is_err());
assert!(validate_resource_name("spaces/100%").is_err());
}
// --- reject_dangerous_chars Unicode ---
#[test]
fn test_reject_dangerous_chars_zero_width_space() {
assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_bom() {
assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_rtl_override() {
assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_unicode_line_separator() {
assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_paragraph_separator() {
assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err());
}
#[test]
fn test_reject_dangerous_chars_zero_width_joiner() {
assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err());
}
#[test]
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 ---
#[test]
fn test_output_dir_rejects_zero_width_chars() {
assert!(validate_safe_output_dir("foo\u{200B}bar").is_err());
}
#[test]
fn test_output_dir_rejects_rtl_override() {
assert!(validate_safe_output_dir("foo\u{202E}bar").is_err());
}
#[test]
fn test_output_dir_rejects_unicode_line_separator() {
assert!(validate_safe_output_dir("foo\u{2028}bar").is_err());
}
// --- validate_resource_name Unicode ---
#[test]
fn test_validate_resource_name_zero_width_chars() {
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());
}
#[test]
fn test_validate_resource_name_unicode_line_seps() {
assert!(validate_resource_name("foo\u{2028}bar").is_err());
assert!(validate_resource_name("foo\u{2029}bar").is_err());
}
#[test]
fn test_validate_resource_name_rtl_override() {
assert!(validate_resource_name("foo\u{202E}bar").is_err());
}
#[test]
fn test_validate_resource_name_bidi_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() {
assert!(validate_resource_name("spaces/ΑΒС").is_ok());
}
#[test]
fn test_validate_resource_name_overlong_accepted() {
let long = "a".repeat(10_000);
assert!(validate_resource_name(&long).is_ok());
}
// --- validate_api_identifier ---
#[test]
fn test_validate_api_identifier_valid() {
assert_eq!(validate_api_identifier("drive").unwrap(), "drive");
assert_eq!(validate_api_identifier("v3").unwrap(), "v3");
assert_eq!(
validate_api_identifier("directory_v1").unwrap(),
"directory_v1"
);
assert_eq!(
validate_api_identifier("admin.reports_v1").unwrap(),
"admin.reports_v1"
);
assert_eq!(validate_api_identifier("v2beta1").unwrap(), "v2beta1");
}
#[test]
fn test_validate_api_identifier_rejects_path_traversal() {
assert!(validate_api_identifier("../etc/passwd").is_err());
assert!(validate_api_identifier("foo/../bar").is_err());
}
#[test]
fn test_validate_api_identifier_rejects_special_chars() {
assert!(validate_api_identifier("drive?key=val").is_err());
assert!(validate_api_identifier("drive#frag").is_err());
assert!(validate_api_identifier("drive%2f..").is_err());
assert!(validate_api_identifier("v3 ").is_err());
assert!(validate_api_identifier("v3\n").is_err());
}
#[test]
fn test_validate_api_identifier_empty() {
assert!(validate_api_identifier("").is_err());
}
// --- validate_safe_file_path ---
#[test]
#[serial]
fn test_file_path_relative_is_ok() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
fs::write(canonical_dir.join("test.txt"), "data").unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_file_path("test.txt", "--upload");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_ok(), "expected Ok, got: {result:?}");
}
#[test]
#[serial]
fn test_file_path_rejects_traversal() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_file_path("../../etc/passwd", "--upload");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err(), "path traversal should be rejected");
assert!(
result.unwrap_err().to_string().contains("outside"),
"error should mention 'outside'"
);
}
#[test]
fn test_file_path_rejects_control_chars() {
let result = validate_safe_file_path("file\x00.txt", "--output");
assert!(result.is_err(), "null bytes should be rejected");
}
#[test]
#[serial]
fn test_file_path_rejects_symlink_escape() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
#[cfg(unix)]
{
let link_path = canonical_dir.join("escape");
std::os::unix::fs::symlink("/tmp", &link_path).unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_file_path("escape/secret.txt", "--output");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(result.is_err(), "symlink escape should be rejected");
}
}
#[test]
#[serial]
fn test_file_path_rejects_traversal_via_nonexistent_prefix() {
let dir = tempdir().unwrap();
let canonical_dir = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical_dir).unwrap();
let result = validate_safe_file_path("doesnt_exist/../../etc/passwd", "--output");
std::env::set_current_dir(&saved_cwd).unwrap();
assert!(
result.is_err(),
"traversal via non-existent prefix should be rejected"
);
}
}