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
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:
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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+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.
|
||||
///
|
||||
/// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user