From c13b86416f6d731be9d3fc5884aa00b29f1942da Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 11 Sep 2026 16:00:38 -0300 Subject: [PATCH 01/16] Revert "chore: defer unreleased features to develop" This reverts commit 0eaa6d2287d02f331aea79d51c8c276abb4377bb. --- .changeset/allow-unknown-fields.md | 8 + .changeset/docs-review-bundle.md | 11 + .changeset/docs-review-workflow.md | 9 + .changeset/docs-structured-read.md | 9 + .changeset/offline-dry-run.md | 9 + .changeset/preserve-credentials.md | 8 + .changeset/scoped-file-roots.md | 13 + .changeset/yaml-empty-collections.md | 7 + AGENTS.md | 2 + README.md | 108 ++ crates/google-workspace-cli/src/auth.rs | 298 +++-- crates/google-workspace-cli/src/commands.rs | 114 +- crates/google-workspace-cli/src/executor.rs | 484 +++++++- crates/google-workspace-cli/src/formatter.rs | 23 +- .../google-workspace-cli/src/helpers/docs.rs | 28 +- .../src/helpers/docs/read.rs | 541 +++++++++ .../src/helpers/docs/read_tests.rs | 651 ++++++++++ .../src/helpers/gmail/mod.rs | 38 + crates/google-workspace-cli/src/main.rs | 163 ++- crates/google-workspace-cli/tests/dry_run.rs | 651 ++++++++++ .../google-workspace-cli/tests/file_roots.rs | 274 +++++ crates/google-workspace/src/validate.rs | 484 +++++++- docs/docs-read.md | 85 ++ examples/docs-review-bundle/README.md | 212 ++++ .../docs-review-bundle/docs_review_bundle.py | 783 ++++++++++++ .../test_docs_review_bundle.py | 1058 +++++++++++++++++ examples/docs-review/README.md | 174 +++ examples/docs-review/docs_review.py | 710 +++++++++++ examples/docs-review/test_docs_review.py | 771 ++++++++++++ 29 files changed, 7553 insertions(+), 173 deletions(-) create mode 100644 .changeset/allow-unknown-fields.md create mode 100644 .changeset/docs-review-bundle.md create mode 100644 .changeset/docs-review-workflow.md create mode 100644 .changeset/docs-structured-read.md create mode 100644 .changeset/offline-dry-run.md create mode 100644 .changeset/preserve-credentials.md create mode 100644 .changeset/scoped-file-roots.md create mode 100644 .changeset/yaml-empty-collections.md create mode 100644 crates/google-workspace-cli/src/helpers/docs/read.rs create mode 100644 crates/google-workspace-cli/src/helpers/docs/read_tests.rs create mode 100644 crates/google-workspace-cli/tests/dry_run.rs create mode 100644 crates/google-workspace-cli/tests/file_roots.rs create mode 100644 docs/docs-read.md create mode 100644 examples/docs-review-bundle/README.md create mode 100755 examples/docs-review-bundle/docs_review_bundle.py create mode 100644 examples/docs-review-bundle/test_docs_review_bundle.py create mode 100644 examples/docs-review/README.md create mode 100755 examples/docs-review/docs_review.py create mode 100644 examples/docs-review/test_docs_review.py diff --git a/.changeset/allow-unknown-fields.md b/.changeset/allow-unknown-fields.md new file mode 100644 index 000000000..cf9db0878 --- /dev/null +++ b/.changeset/allow-unknown-fields.md @@ -0,0 +1,8 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--allow-unknown-fields` to raw API methods with JSON request bodies. Explicitly +allow fields absent from Discovery recursively, including in dry runs, while +preserving validation of known fields, required fields, JSON, URLs and file paths. +Handwritten helpers retain strict validation. diff --git a/.changeset/docs-review-bundle.md b/.changeset/docs-review-bundle.md new file mode 100644 index 000000000..286e9e7f0 --- /dev/null +++ b/.changeset/docs-review-bundle.md @@ -0,0 +1,11 @@ +--- +"@googleworkspace/cli": minor +--- + +Add a standalone Python companion for visual Google Docs review bundles with +native exports, safe DOCX raster extraction, local HTML, optional PDF page +previews with explicitly unverified page coverage, revision observations, +and an offline fixture workflow. Preserve nested image occurrences and +legitimate asset reuse, and keep oversized optional comments from failing +the required bundle. Verify each export's exact canonical destination against +the real CLI receipt. diff --git a/.changeset/docs-review-workflow.md b/.changeset/docs-review-workflow.md new file mode 100644 index 000000000..eee647198 --- /dev/null +++ b/.changeset/docs-review-workflow.md @@ -0,0 +1,9 @@ +--- +"@googleworkspace/cli": minor +--- + +Add a standalone Python Docs review example that plans one literal text replacement, +binds it to a source revision and tab, applies it through existing gws commands, +and verifies the result without retrying ambiguous writes. Validate structures +and text ranges across all tabs before normalization, and preserve attempted or +confirmed mutation outcomes through final output failures and interruptions. diff --git a/.changeset/docs-structured-read.md b/.changeset/docs-structured-read.md new file mode 100644 index 000000000..41b070876 --- /dev/null +++ b/.changeset/docs-structured-read.md @@ -0,0 +1,9 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +read` to translate documents into compact structured content with +recursive tabs, headings and an outline, styled text, suggestions, nested tables, +figure metadata, and reference markers. Preserve API indices and revisions, +reject partial field masks, and support the existing formatters, sanitization, +and credential-free dry-run. diff --git a/.changeset/offline-dry-run.md b/.changeset/offline-dry-run.md new file mode 100644 index 000000000..ca86d5669 --- /dev/null +++ b/.changeset/offline-dry-run.md @@ -0,0 +1,9 @@ +--- +"@googleworkspace/cli": patch +--- + +Skip authentication for Discovery-generated API and `docs +write` dry-runs. +Validate and preview requests without accessing the keyring or reading, changing, +or deleting stored credentials and token caches. Dry-runs work offline with a +fresh cached Discovery schema; schema fetching on first use or cache expiry is +unchanged. Real requests retain their existing authentication and error handling. diff --git a/.changeset/preserve-credentials.md b/.changeset/preserve-credentials.md new file mode 100644 index 000000000..4b7bafe37 --- /dev/null +++ b/.changeset/preserve-credentials.md @@ -0,0 +1,8 @@ +--- +"@googleworkspace/cli": patch +--- + +Preserve saved encrypted credentials and token caches when credential loading, +decryption, or keyring access fails. Report recovery guidance and stop authentication +instead of silently selecting plaintext credentials or another account through ADC. +Explicit token and credentials-file overrides and intentional logout remain unchanged. diff --git a/.changeset/scoped-file-roots.md b/.changeset/scoped-file-roots.md new file mode 100644 index 000000000..6e17edcc3 --- /dev/null +++ b/.changeset/scoped-file-roots.md @@ -0,0 +1,13 @@ +--- +"@googleworkspace/cli": minor +--- + +Allow operators to set `GOOGLE_WORKSPACE_CLI_FILE_ROOT` to an existing directory +for `--output` and `--upload` paths while keeping CWD confinement by default. +Relative CLI paths remain CWD-relative. Reject invalid roots, parent traversal +with an explicit root, control characters, and symlink escapes, including +dangling symlinks. Directory flags retain their existing boundaries. + +Reject canonical file paths that cannot be represented as UTF-8 at the CLI +string boundary, so explicit output/upload paths cannot silently become omitted +arguments. diff --git a/.changeset/yaml-empty-collections.md b/.changeset/yaml-empty-collections.md new file mode 100644 index 000000000..2ea985ea5 --- /dev/null +++ b/.changeset/yaml-empty-collections.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": patch +--- + +Fix YAML mapping values containing empty arrays or objects by separating their +inline collection syntax from the mapping colon. This also fixes structured +Docs reader output with empty outlines, child tabs, or style maps. diff --git a/AGENTS.md b/AGENTS.md index 82f36405d..5ec7d5665 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,7 @@ When adding new helpers or CLI flags that accept file paths, **always validate** | -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------- | | File path for writing (`--output-dir`) | `validate::validate_safe_output_dir()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars | | File path for reading (`--dir`) | `validate::validate_safe_dir_path()` | Absolute paths, `../` traversal, symlinks outside CWD, control chars | +| File path (`--output`, `--upload`) | `validate::validate_safe_file_path()` | Paths outside CWD or the trusted `GOOGLE_WORKSPACE_CLI_FILE_ROOT`, control chars, symlink escapes; `..` components with an explicit root | | Enum/allowlist values (`--msg-format`) | clap `value_parser` (see `gmail/mod.rs`) | Any value not in the allowlist | ```rust @@ -225,6 +226,7 @@ See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) | Variable | Description | |---|---| | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override the config directory (default: `~/.config/gws`) | +| `GOOGLE_WORKSPACE_CLI_FILE_ROOT` | Trusted boundary for `--output` / `--upload` files (default: CWD). Must be an existing directory; canonicalized. Relative CLI paths remain CWD-relative. Does not expand directory validators. | ### OAuth Client diff --git a/README.md b/README.md index 1b7fe9706..41f1c21eb 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,60 @@ gws schema drive.files.list gws drive files list --params '{"pageSize": 100}' --page-all | jq -r '.files[].name' ``` +Discovery-generated API commands and `gws docs +write` support credential-free +`--dry-run`: they validate inputs and display the request without obtaining a +token, accessing the keyring, reading or changing stored credentials, or sending +the API request. + +```bash +# Preview a Docs append without signing in +gws docs +write --document DOC_ID --text 'Hello, world!' --dry-run +``` + +These previews work offline with a fresh cached Discovery schema (24-hour TTL). +First use or an expired cache can still fetch the schema over the network. +Other helpers may need authenticated reads to prepare their plans; this guarantee +applies to raw API commands and `docs +write`. + +### Fields absent from Discovery + +Raw API methods with a request body accept `--allow-unknown-fields` alongside +`--json`. Use it explicitly when an API supports fields that its public Discovery +document does not yet describe. It allows unknown properties recursively, +including nested objects and array elements, and forwards their values unchanged. +JSON is still parsed and serialized normally; whitespace and key order may change. + +Validation remains strict by default. With the flag, known-field types, enums and +required fields are still checked, as are JSON syntax, required URL parameters and +file paths. It does not allow new enum values on a known field. The flag is local +to raw methods and does not apply to handwritten `+` helpers. + +For example, Docs suggestions and comments require a Cloud project enrolled in the +[Google Workspace Developer Preview Program](https://developers.google.com/workspace/preview). +Google still enforces API availability, OAuth scopes, document permissions and +server-side validation. This flag grants no additional access. + +```bash +# Preview a suggested insertion (Docs Developer Preview). +gws docs documents batchUpdate \ + --params '{"documentId":"DOCUMENT_ID"}' \ + --json '{"requests":[{"insertText":{"location":{"index":1},"text":"Suggested text"}}],"writeControl":{"writeMode":"SUGGEST"}}' \ + --allow-unknown-fields --dry-run + +# Preview a comment anchored to existing text; adjust the range for your document. +gws docs documents batchUpdate \ + --params '{"documentId":"DOCUMENT_ID"}' \ + --json '{"requests":[{"insertComment":{"content":"Please review this text.","range":{"startIndex":1,"endIndex":5}}}]}' \ + --allow-unknown-fields --dry-run +``` + +`--dry-run` uses the same validation policy and shows the request without sending +it. It cannot verify preview enrollment or server acceptance. Remove `--dry-run` +to submit a request. See the Docs +[request reference](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/request#InsertCommentRequest) +for preview field requirements. + + ## Authentication The CLI supports multiple auth workflows so it works on your laptop, in CI, and on a server. @@ -230,6 +284,26 @@ export GOOGLE_WORKSPACE_CLI_TOKEN=$(gcloud auth print-access-token) Environment variables can also live in a `.env` file. +### Troubleshooting saved credentials + +If `gws` cannot read or decrypt `credentials.enc` (including a keyring access +failure), it returns an authentication error and preserves that file, +`token_cache.json`, and `sa_token_cache.json`. It does not silently switch to +plaintext credentials or Application Default Credentials (ADC). This applies to +the default configuration directory and `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`. + +Check that you are using the original configuration directory and can access its +original OS keyring or encryption key. Back up the configuration before changing +key storage or replacing credentials. Preservation does not recover a lost key. +If you intentionally want to discard saved credentials and sign in again, use +`gws auth logout` followed by `gws auth login`; logout still removes saved +credentials and token caches. + +An explicit `GOOGLE_WORKSPACE_CLI_TOKEN` or +`GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` still takes precedence. A missing or invalid +explicit credentials file is an error. When no encrypted credentials file exists, +the usual plaintext and ADC fallback remains available. + ## AI Agent Skills The repo ships 100+ Agent Skills (`SKILL.md` files) — one for every supported API, plus higher-level helpers for common workflows and 50 curated recipes for Gmail, Drive, Docs, Calendar, and Sheets. See the full [Skills Index](docs/skills.md) for the complete list. @@ -281,6 +355,39 @@ Installing this extension gives your Gemini CLI agent direct access to all `gws` gws drive files create --json '{"name": "report.pdf"}' --upload ./report.pdf ``` +### Output and upload file roots + +`--output` and `--upload` accept paths within the current working directory +(CWD) by default, including absolute paths that resolve inside CWD. To allow +files elsewhere, set a trusted operator environment variable to an existing +directory: + +```bash +mkdir -p /tmp/gws-files +export GOOGLE_WORKSPACE_CLI_FILE_ROOT=/tmp/gws-files +gws drive files get --params '{"fileId":"FILE_ID","alt":"media"}' \ + --output /tmp/gws-files/report.pdf +gws drive files create --json '{"name":"report.pdf"}' \ + --upload /tmp/gws-files/report.pdf +``` + +The root replaces the allowed file boundary; relative CLI paths still resolve +from CWD. For example, `--output report.pdf` is rejected if CWD is outside the +configured root. The root is canonicalized and must exist as a directory; an +empty or invalid value fails validation. Relative root settings resolve from +CWD too. With an explicit root, CLI paths containing `..` components are +rejected. Control characters and symlinks escaping the boundary are rejected; +symlinks resolving inside it are allowed, but dangling symlinks are rejected. +These CLI file flags require a UTF-8 canonical path. If a symlink resolves to a +path with unsupported encoding, the command returns a validation error rather +than dropping the upload or selecting the default output file. + +This setting affects only these file flags, not `--dir` or `--output-dir`. +It does not create parent directories or change the default download filename +when `--output` is omitted. Validation cannot prevent another local process +from replacing a path component between validation and I/O; choose a root +whose directories you control. Unset the variable to restore the CWD boundary. + ### Pagination | Flag | Description | Default | @@ -397,6 +504,7 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | +| `GOOGLE_WORKSPACE_CLI_FILE_ROOT` | Existing directory allowed for `--output` / `--upload` paths (default: CWD); relative CLI paths remain CWD-relative | | `GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE` | Default Model Armor template | | `GOOGLE_WORKSPACE_CLI_SANITIZE_MODE` | `warn` (default) or `block` | | `GOOGLE_WORKSPACE_CLI_LOG` | Log level for stderr (e.g., `gws=debug`). Off by default. | diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 9d8847e4b..6b27e3f92 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -336,6 +336,22 @@ async fn load_credentials_inner( env_file: Option<&str>, enc_path: &std::path::Path, default_path: &std::path::Path, +) -> anyhow::Result { + load_credentials_with_loader( + env_file, + enc_path, + default_path, + credential_store::load_encrypted_from_path, + ) + .await +} + +// Keep credential selection testable without accessing the OS keyring. +async fn load_credentials_with_loader( + env_file: Option<&str>, + enc_path: &std::path::Path, + default_path: &std::path::Path, + load_encrypted: impl FnOnce(&std::path::Path) -> anyhow::Result, ) -> anyhow::Result { // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { @@ -353,40 +369,21 @@ async fn load_credentials_inner( // 2. Encrypted credentials if enc_path.exists() { - match credential_store::load_encrypted_from_path(enc_path) { - Ok(json_str) => { - return parse_credential_file(enc_path, &json_str).await; - } - Err(e) => { - // Decryption failed — the encryption key likely changed (e.g. after - // an upgrade that migrated keys between keyring and file storage). - // Remove the stale file so the next `gws auth login` starts fresh, - // and fall through to other credential sources (plaintext, ADC). - eprintln!( - "Warning: removing undecryptable credentials file ({}): {e:#}", - enc_path.display() - ); - if let Err(err) = tokio::fs::remove_file(enc_path).await { - eprintln!( - "Warning: failed to remove stale credentials file '{}': {err}", - enc_path.display() - ); - } - // Also remove stale token caches that used the old key. - for cache_file in ["token_cache.json", "sa_token_cache.json"] { - let path = enc_path.with_file_name(cache_file); - if let Err(err) = tokio::fs::remove_file(&path).await { - if err.kind() != std::io::ErrorKind::NotFound { - eprintln!( - "Warning: failed to remove stale token cache '{}': {err}", - path.display() - ); - } - } - } - // Fall through to remaining credential sources below. - } - } + // A read, decryption, or keyring failure does not mean the files are + // disposable. Stop here so a retry cannot silently select another account. + // Do not render backend error details, which may contain sensitive data. + let json_str = load_encrypted(enc_path).map_err(|_| { + anyhow::anyhow!( + "Failed to read or decrypt saved credentials at {}. \ + Check access to the original OS keyring or encryption key and verify \ + GOOGLE_WORKSPACE_CLI_CONFIG_DIR. Credentials and token caches have been \ + preserved; no fallback credentials were used. Back up the configuration \ + before intentionally replacing credentials with `gws auth logout` and \ + `gws auth login`.", + crate::output::sanitize_for_terminal(&enc_path.display().to_string()) + ) + })?; + return parse_credential_file(enc_path, &json_str).await; } // 3. Plaintext credentials at default path (AuthorizedUser) @@ -825,75 +822,214 @@ mod tests { #[tokio::test] #[serial_test::serial] - async fn test_load_credentials_corrupt_encrypted_file_is_removed() { - // When credentials.enc cannot be decrypted, the file should be removed - // automatically and the function should fall through to other sources. - let tmp = tempfile::tempdir().unwrap(); - let _home_guard = EnvVarGuard::set("HOME", tmp.path()); - let _adc_guard = EnvVarGuard::remove("GOOGLE_APPLICATION_CREDENTIALS"); + async fn test_load_credentials_preserves_failed_encrypted_credentials_and_caches() { + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + let token_path = dir.path().join("token_cache.json"); + let service_token_path = dir.path().join("sa_token_cache.json"); + let absent_path = dir.path().join("missing.json"); + let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", &absent_path); + + // A short invalid payload fails before accessing any OS keyring. + std::fs::write(&enc_path, b"bad").unwrap(); + std::fs::write(&token_path, b"synthetic-user-cache").unwrap(); + std::fs::write(&service_token_path, b"synthetic-service-cache").unwrap(); + + for _ in 0..2 { + let result = load_credentials_inner(None, &enc_path, &absent_path).await; + + assert!(result.is_err()); + assert!( + enc_path.exists(), + "Authentication failure must preserve saved encrypted credentials" + ); + assert_eq!(std::fs::read(&enc_path).unwrap(), b"bad"); + assert_eq!(std::fs::read(&token_path).unwrap(), b"synthetic-user-cache"); + assert_eq!( + std::fs::read(&service_token_path).unwrap(), + b"synthetic-service-cache" + ); + } + } + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_corrupt_encrypted_reports_safe_remediation() { let dir = tempfile::tempdir().unwrap(); let enc_path = dir.path().join("credentials.enc"); + let absent_path = dir.path().join("missing.json"); + let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", &absent_path); + std::fs::write(&enc_path, b"bad").unwrap(); - // Write garbage data that cannot be decrypted. - tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890") + let err = load_credentials_inner(None, &enc_path, &absent_path) .await - .unwrap(); - assert!(enc_path.exists()); - - let result = - load_credentials_inner(None, &enc_path, &PathBuf::from("/does/not/exist")).await; + .unwrap_err(); + let msg = format!("{err:#}"); + + assert!(msg.contains("decrypt"), "{msg}"); + assert!(msg.contains("keyring"), "{msg}"); + assert!(msg.contains("preserved"), "{msg}"); + assert!(msg.contains("Back up"), "{msg}"); + assert!(!msg.contains("No credentials found"), "{msg}"); + assert!(!msg.contains("bad"), "{msg}"); + } - // Should fall through to "No credentials found" (not a decryption error). - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("No credentials found"), - "Should fall through to final error, got: {msg}" - ); - assert!( - !enc_path.exists(), - "Stale credentials.enc must be removed after decryption failure" - ); + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_corrupt_encrypted_blocks_plaintext_and_adc() { + // Exercise both a default-style layout and a configured directory, + // without changing HOME or touching the actual default directory. + let dir = tempfile::tempdir().unwrap(); + let fallback_json = r#"{ + "client_id": "different-account", + "client_secret": "synthetic-secret", + "refresh_token": "synthetic-refresh", + "type": "authorized_user" + }"#; + let adc_path = dir.path().join("adc.json"); + std::fs::write(&adc_path, fallback_json).unwrap(); + let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", &adc_path); + + for layout in [".config/gws", "custom-config"] { + let config = dir.path().join(layout); + std::fs::create_dir_all(&config).unwrap(); + let enc_path = config.join("credentials.enc"); + let plain_path = config.join("credentials.json"); + std::fs::write(&enc_path, b"bad").unwrap(); + std::fs::write(&plain_path, fallback_json).unwrap(); + + for fallback in [&plain_path, &config.join("missing.json")] { + let err = load_credentials_inner(None, &enc_path, fallback) + .await + .expect_err("Broken encrypted credentials must block another account"); + assert!(err.to_string().contains("decrypt")); + assert_eq!(std::fs::read(&enc_path).unwrap(), b"bad"); + assert_eq!(std::fs::read_to_string(&plain_path).unwrap(), fallback_json); + assert_eq!(std::fs::read_to_string(&adc_path).unwrap(), fallback_json); + } + } } #[tokio::test] #[serial_test::serial] - async fn test_load_credentials_corrupt_encrypted_falls_through_to_plaintext() { - // When credentials.enc is corrupt but a valid plaintext file exists, - // the function should fall through and use the plaintext credentials. + async fn test_load_credentials_keyring_failure_preserves_files_and_blocks_adc() { let dir = tempfile::tempdir().unwrap(); let enc_path = dir.path().join("credentials.enc"); let plain_path = dir.path().join("credentials.json"); + let adc_path = dir.path().join("adc.json"); + let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", &adc_path); + let sentinels: &[(&str, &[u8])] = &[ + ("credentials.enc", b"synthetic-encrypted-credentials"), + ("token_cache.json", b"synthetic-user-cache"), + ("sa_token_cache.json", b"synthetic-service-cache"), + (".encryption_key", b"synthetic-key"), + ]; + for (name, bytes) in sentinels { + std::fs::write(dir.path().join(name), bytes).unwrap(); + } + std::fs::write( + &adc_path, + r#"{"type":"authorized_user","client_id":"other","client_secret":"secret","refresh_token":"refresh"}"#, + ) + .unwrap(); - // Write garbage encrypted data. - tokio::fs::write(&enc_path, b"not-valid-encrypted-data-at-all-1234567890") + for _ in 0..2 { + let err = load_credentials_with_loader(None, &enc_path, &plain_path, |_| { + anyhow::bail!("OS keyring unavailable: synthetic-sensitive-detail") + }) .await - .unwrap(); + .expect_err("Key acquisition failure must stop credential selection"); + for (name, bytes) in sentinels { + assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), *bytes); + } + let msg = format!("{err:#}"); + assert!(msg.contains("keyring"), "{msg}"); + assert!(msg.contains("preserved"), "{msg}"); + assert!(!msg.contains("synthetic-sensitive-detail"), "{msg}"); + assert!(!msg.contains("synthetic-key"), "{msg}"); + } + } - // Write valid plaintext credentials. - let plain_json = r#"{ - "client_id": "fallback_id", - "client_secret": "fallback_secret", - "refresh_token": "fallback_refresh", - "type": "authorized_user" - }"#; - tokio::fs::write(&plain_path, plain_json).await.unwrap(); + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_explicit_file_precedes_broken_encrypted_credentials() { + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + let explicit_path = dir.path().join("explicit.json"); + let missing_path = dir.path().join("missing.json"); + let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", &missing_path); + std::fs::write(&enc_path, b"bad").unwrap(); + std::fs::write( + &explicit_path, + r#"{"type":"authorized_user","client_id":"explicit","client_secret":"secret","refresh_token":"refresh"}"#, + ) + .unwrap(); - let res = load_credentials_inner(None, &enc_path, &plain_path) + let creds = load_credentials_inner(explicit_path.to_str(), &enc_path, &missing_path) .await .unwrap(); + match creds { + Credential::AuthorizedUser(secret) => assert_eq!(secret.client_id, "explicit"), + _ => panic!("Expected explicitly selected account"), + } + assert_eq!(std::fs::read(&enc_path).unwrap(), b"bad"); - match res { - Credential::AuthorizedUser(secret) => { - assert_eq!( - secret.client_id, "fallback_id", - "Should fall through to plaintext credentials" - ); + // A missing or malformed explicit file must not select another account. + for contents in [None, Some("invalid-json")] { + if let Some(contents) = contents { + std::fs::write(&missing_path, contents).unwrap(); } - _ => panic!("Expected AuthorizedUser from plaintext fallback"), + let err = load_credentials_inner(missing_path.to_str(), &enc_path, &explicit_path) + .await + .unwrap_err(); + assert!(err.to_string().contains(if contents.is_some() { + "Failed to parse" + } else { + "does not exist" + })); + assert_eq!(std::fs::read(&enc_path).unwrap(), b"bad"); + } + } + + #[tokio::test] + #[serial_test::serial] + async fn test_get_token_preserves_configured_credentials_until_explicit_logout() { + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + let missing_path = dir.path().join("missing.json"); + let _config_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + let _adc_guard = EnvVarGuard::set("GOOGLE_APPLICATION_CREDENTIALS", &missing_path); + let _file_guard = EnvVarGuard::remove("GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE"); + let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", ""); + let names = [ + "credentials.enc", + "credentials.json", + "token_cache.json", + "sa_token_cache.json", + ]; + for name in names { + std::fs::write(dir.path().join(name), b"bad").unwrap(); + } + + let err = get_token(&[]).await.unwrap_err(); + assert!(err.to_string().contains("decrypt"), "{err}"); + for name in names { + assert_eq!(std::fs::read(dir.path().join(name)).unwrap(), b"bad"); + } + + // An explicit token still takes precedence over all credential files. + { + let _token_guard = EnvVarGuard::set("GOOGLE_WORKSPACE_CLI_TOKEN", "synthetic-token"); + assert_eq!(get_token(&[]).await.unwrap(), "synthetic-token"); + assert_eq!(std::fs::read(&enc_path).unwrap(), b"bad"); + } + + crate::auth_commands::handle_auth_command(&["logout".into()]) + .await + .unwrap(); + for name in names { + assert!(!dir.path().join(name).exists(), "Logout must remove {name}"); } - assert!(!enc_path.exists(), "Stale credentials.enc must be removed"); } #[tokio::test] diff --git a/crates/google-workspace-cli/src/commands.rs b/crates/google-workspace-cli/src/commands.rs index 27324e42b..e559ebe10 100644 --- a/crates/google-workspace-cli/src/commands.rs +++ b/crates/google-workspace-cli/src/commands.rs @@ -112,12 +112,20 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option, body_json: Option<&str>, is_media_upload: bool, + validation_policy: BodyValidationPolicy, ) -> Result { let params: Map = if let Some(p) = params_json { serde_json::from_str(p) @@ -112,7 +120,7 @@ fn parse_and_validate_inputs( if let Some(ref req_ref) = method.request { if let Some(ref schema_name) = req_ref.schema_ref { - validate_body_against_schema(&val, schema_name, doc)?; + validate_body_against_schema(&val, schema_name, doc, validation_policy)?; } } @@ -411,7 +419,54 @@ pub async fn execute_method( output_format: &crate::formatter::OutputFormat, capture_output: bool, ) -> Result, GwsError> { - let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some())?; + execute_method_with_policy( + doc, + method, + params_json, + body_json, + token, + auth_method, + output_path, + upload, + dry_run, + pagination, + sanitize_template, + sanitize_mode, + output_format, + capture_output, + BodyValidationPolicy::Strict, + ) + .await +} + +/// Executes a raw API method with an explicit request-body validation policy. +/// Handwritten helpers use [`execute_method`] to retain strict validation. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method_with_policy( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + token: Option<&str>, + auth_method: AuthMethod, + output_path: Option<&str>, + upload: Option>, + dry_run: bool, + pagination: &PaginationConfig, + sanitize_template: Option<&str>, + sanitize_mode: &crate::helpers::modelarmor::SanitizeMode, + output_format: &crate::formatter::OutputFormat, + capture_output: bool, + validation_policy: BodyValidationPolicy, +) -> Result, GwsError> { + let input = parse_and_validate_inputs( + doc, + method, + params_json, + body_json, + upload.is_some(), + validation_policy, + )?; if dry_run { let dry_run_info = json!({ @@ -996,9 +1051,10 @@ fn validate_body_against_schema( body: &Value, schema_name: &str, doc: &RestDescription, + validation_policy: BodyValidationPolicy, ) -> Result<(), GwsError> { let mut errors = Vec::new(); - validate_value(body, schema_name, doc, "$", &mut errors); + validate_value(body, schema_name, doc, "$", &mut errors, validation_policy); if !errors.is_empty() { return Err(GwsError::Validation(format!( @@ -1016,6 +1072,7 @@ fn validate_value( doc: &RestDescription, path: &str, errors: &mut Vec, + validation_policy: BodyValidationPolicy, ) { let schema = match doc.schemas.get(schema_ref_name) { Some(s) => s, @@ -1028,7 +1085,15 @@ fn validate_value( // If the top-level schema is an object if schema.schema_type.as_deref() == Some("object") || !schema.properties.is_empty() { if let Value::Object(obj) = value { - validate_properties(obj, &schema.properties, &schema.required, doc, path, errors); + validate_properties( + obj, + &schema.properties, + &schema.required, + doc, + path, + errors, + validation_policy, + ); } else { errors.push(format!("{path}: Expected object")); } @@ -1042,6 +1107,7 @@ fn validate_properties( doc: &RestDescription, path: &str, errors: &mut Vec, + validation_policy: BodyValidationPolicy, ) { let valid_keys: std::collections::HashSet<&String> = properties.keys().collect(); @@ -1060,15 +1126,24 @@ fn validate_properties( }; if !valid_keys.contains(key) { - errors.push(format!( - "{current_path}: Unknown property. Valid properties: {:?}", - valid_keys.iter().map(|k| k.as_str()).collect::>() - )); + if validation_policy == BodyValidationPolicy::Strict { + errors.push(format!( + "{current_path}: Unknown property. Valid properties: {:?}", + valid_keys.iter().map(|k| k.as_str()).collect::>() + )); + } continue; } let prop_schema = &properties[key]; - validate_property(val, prop_schema, doc, ¤t_path, errors); + validate_property( + val, + prop_schema, + doc, + ¤t_path, + errors, + validation_policy, + ); } } @@ -1078,10 +1153,11 @@ fn validate_property( doc: &RestDescription, path: &str, errors: &mut Vec, + validation_policy: BodyValidationPolicy, ) { // 1. Resolve $ref if present if let Some(ref_name) = &prop_schema.schema_ref { - validate_value(value, ref_name, doc, path, errors); + validate_value(value, ref_name, doc, path, errors, validation_policy); return; } @@ -1113,7 +1189,14 @@ fn validate_property( if let Value::Array(arr) = value { for (i, item) in arr.iter().enumerate() { let item_path = format!("{path}[{i}]"); - validate_property(item, items_schema, doc, &item_path, errors); + validate_property( + item, + items_schema, + doc, + &item_path, + errors, + validation_policy, + ); } } } @@ -1122,7 +1205,15 @@ fn validate_property( // 4. Object properties validation if prop_schema.prop_type.as_deref() == Some("object") && !prop_schema.properties.is_empty() { if let Value::Object(obj) = value { - validate_properties(obj, &prop_schema.properties, &[], doc, path, errors); + validate_properties( + obj, + &prop_schema.properties, + &[], + doc, + path, + errors, + validation_policy, + ); } } @@ -1186,6 +1277,332 @@ pub fn mime_to_extension(mime: &str) -> &str { } } +#[cfg(test)] +mod preview_fields_tests { + use super::*; + + fn fixture() -> (RestDescription, RestMethod) { + let doc = serde_json::from_value(json!({ + "name": "docs", + "version": "v1", + "rootUrl": "https://example.invalid/", + "servicePath": "v1/", + "schemas": { + "Body": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "mode": {"type": "string", "enum": ["ACTIVE"]}, + "count": {"type": "integer"}, + "tags": {"type": "array", "items": {"type": "string"}}, + "writeControl": { + "type": "object", + "properties": {"requiredRevisionId": {"type": "string"}} + }, + "child": {"$ref": "Child"}, + "requests": {"type": "array", "items": {"$ref": "Request"}}, + "children": { + "type": "array", + "items": {"type": "object", "properties": {"id": {"type": "string"}}} + } + } + }, + "Child": { + "type": "object", "required": ["id"], + "properties": {"id": {"type": "string"}} + }, + "Request": { + "type": "object", + "properties": { + "insertText": { + "type": "object", "properties": {"text": {"type": "string"}} + } + } + } + } + })) + .unwrap(); + let method = serde_json::from_value(json!({ + "httpMethod": "POST", + "path": "documents/{+documentId}:batchUpdate", + "parameterOrder": ["documentId"], + "parameters": { + "documentId": {"type": "string", "location": "path", "required": true}, + "view": {"type": "string", "location": "query", "required": true} + }, + "request": {"$ref": "Body"} + })) + .unwrap(); + (doc, method) + } + + const PARAMS: &str = r#"{"documentId":"test-document","view":"preview"}"#; + // Canonical JSON lets the request test assert exact emitted bytes as well as values. + const PREVIEW_BODY: &str = r#"{"name":"demo","preview":[null,true,1.25,9223372036854775807,{"text":"café\n\"quoted\""}],"requests":[{"insertComment":{"content":"Review"}}],"writeControl":{"writeMode":"SUGGEST"}}"#; + + #[test] + fn unknown_properties_require_opt_in_at_every_depth() { + let (doc, _) = fixture(); + for (body, path) in [ + (json!({"name": "demo", "preview": true}), "preview"), + ( + json!({"name": "demo", "writeControl": {"writeMode": "SUGGEST"}}), + "writeControl.writeMode", + ), + ( + json!({"name": "demo", "child": {"id": "one", "preview": null}}), + "child.preview", + ), + ( + json!({"name": "demo", "requests": [{"insertComment": {"content": "Review"}}]}), + "requests[0].insertComment", + ), + ( + json!({"name": "demo", "children": [{"id": "one", "preview": [1, true]}]}), + "children[0].preview", + ), + ] { + let err = + validate_body_against_schema(&body, "Body", &doc, BodyValidationPolicy::Strict) + .unwrap_err(); + assert!(err + .to_string() + .contains(&format!("{path}: Unknown property"))); + let result = validate_body_against_schema( + &body, + "Body", + &doc, + BodyValidationPolicy::AllowUnknownFields, + ); + assert!(result.is_ok(), "{path}: {result:?}"); + } + } + + #[test] + fn opt_in_preserves_known_field_and_required_validation() { + let (doc, _) = fixture(); + for (body, expected) in [ + ( + json!({"name": 42, "preview": true}), + "name: Expected type 'string'", + ), + ( + json!({"name": "demo", "count": 1.5, "preview": true}), + "count: Expected type 'integer'", + ), + ( + json!({"name": "demo", "mode": "PREVIEW", "preview": true}), + "not a valid enum member", + ), + (json!({"preview": true}), "Missing required property 'name'"), + ( + json!({"name": "demo", "child": {"preview": true}}), + "child: Missing required property 'id'", + ), + ( + json!({"name": "demo", "child": []}), + "child: Expected object", + ), + ( + json!({"name": "demo", "writeControl": {"requiredRevisionId": 42, "preview": true}}), + "writeControl.requiredRevisionId: Expected type 'string'", + ), + ( + json!({"name": "demo", "requests": [{"insertText": {"text": 42}, "preview": true}]}), + "requests[0].insertText.text: Expected type 'string'", + ), + ( + json!({"name": "demo", "children": [{"id": 42, "preview": true}]}), + "children[0].id: Expected type 'string'", + ), + ( + json!({"name": "demo", "tags": [true], "preview": true}), + "tags[0]: Expected type 'string'", + ), + ( + json!({"name": "demo", "requests": {}, "preview": true}), + "requests: Expected type 'array'", + ), + (json!([]), "$: Expected object"), + ] { + for policy in [ + BodyValidationPolicy::Strict, + BodyValidationPolicy::AllowUnknownFields, + ] { + let err = validate_body_against_schema(&body, "Body", &doc, policy).unwrap_err(); + assert!(err.to_string().contains(expected), "{policy:?}: {err}"); + } + } + } + + #[test] + fn opt_in_preserves_missing_schema_errors() { + let (doc, _) = fixture(); + let err = validate_body_against_schema( + &json!({}), + "Missing", + &doc, + BodyValidationPolicy::AllowUnknownFields, + ) + .unwrap_err(); + assert!(err.to_string().contains("Schema 'Missing' not found")); + } + + #[tokio::test] + async fn opt_in_dry_run_preserves_preview_body() { + let (doc, method) = fixture(); + let output = execute_method_with_policy( + &doc, + &method, + Some(PARAMS), + Some(PREVIEW_BODY), + None, + AuthMethod::None, + None, + None, + true, + &PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::Json, + true, + BodyValidationPolicy::AllowUnknownFields, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + output, + json!({ + "dry_run": true, + "url": "https://example.invalid/v1/documents/test%2Ddocument:batchUpdate", + "method": "POST", + "query_params": [["view", "preview"]], + "body": { + "name": "demo", + "preview": [null, true, 1.25, 9223372036854775807_i64, {"text": "café\n\"quoted\""}], + "requests": [{"insertComment": {"content": "Review"}}], + "writeControl": {"writeMode": "SUGGEST"} + }, + "is_multipart_upload": false + }) + ); + } + + #[tokio::test] + async fn helper_executor_entry_point_remains_strict() { + let (doc, method) = fixture(); + let err = execute_method( + &doc, + &method, + Some(PARAMS), + Some(PREVIEW_BODY), + None, + AuthMethod::None, + None, + None, + true, + &PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::Json, + true, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("Unknown property")); + } + + #[tokio::test] + #[serial_test::serial] + async fn opt_in_preserves_request_body_bytes() { + let (doc, method) = fixture(); + let input = parse_and_validate_inputs( + &doc, + &method, + Some(PARAMS), + Some(PREVIEW_BODY), + false, + BodyValidationPolicy::AllowUnknownFields, + ) + .unwrap(); + // Avoid native roots and all credential lookup. Build only; never send. + let client = reqwest::Client::builder() + .tls_built_in_root_certs(false) + .build() + .unwrap(); + let previous_project = std::env::var_os("GOOGLE_WORKSPACE_PROJECT_ID"); + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "test-project"); + let request = build_http_request( + &client, + &method, + &input, + None, + &AuthMethod::None, + None, + 0, + &None, + ) + .await; + match previous_project { + Some(value) => std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", value), + None => std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"), + } + let request = request.unwrap().build().unwrap(); + assert_eq!( + request.body().unwrap().as_bytes().unwrap(), + PREVIEW_BODY.as_bytes() + ); + assert_eq!(request.method(), reqwest::Method::POST); + assert_eq!( + request.url().as_str(), + "https://example.invalid/v1/documents/test%2Ddocument:batchUpdate?view=preview" + ); + assert!(!request.headers().contains_key("authorization")); + } + + #[test] + fn opt_in_preserves_json_parameter_and_url_errors() { + let (doc, method) = fixture(); + for (params, body, expected) in [ + (Some(PARAMS), "{", "Invalid --json body"), + (Some("{"), PREVIEW_BODY, "Invalid --params JSON"), + (Some("[]"), PREVIEW_BODY, "Invalid --params JSON"), + (None, PREVIEW_BODY, "Required path parameter documentId"), + ( + Some(r#"{"documentId":"test-document"}"#), + PREVIEW_BODY, + "Required parameter 'view'", + ), + ( + Some(r#"{"documentId":"../secret","view":"preview"}"#), + PREVIEW_BODY, + "path traversal", + ), + ( + Some(r#"{"documentId":"document?injected=true","view":"preview"}"#), + PREVIEW_BODY, + "must not contain '?'", + ), + ] { + let result = parse_and_validate_inputs( + &doc, + &method, + params, + Some(body), + false, + BodyValidationPolicy::AllowUnknownFields, + ); + let err = result.err().expect("unsafe input must fail"); + assert!( + err.to_string().contains(expected), + "expected {expected}: {err}" + ); + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1253,7 +1670,9 @@ mod tests { }; let body = json!({ "name": "My File" }); - assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + assert!( + validate_body_against_schema(&body, "File", &doc, BodyValidationPolicy::Strict).is_ok() + ); } #[test] @@ -1283,7 +1702,8 @@ mod tests { }; let body = json!({ "name": "My File", "invalidField": 123 }); - let result = validate_body_against_schema(&body, "File", &doc); + let result = + validate_body_against_schema(&body, "File", &doc, BodyValidationPolicy::Strict); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("Unknown property")); } @@ -1373,23 +1793,39 @@ mod tests { "tags": ["one", "two"], "parent": { "id": "123" } }); - assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + assert!( + validate_body_against_schema(&body, "File", &doc, BodyValidationPolicy::Strict).is_ok() + ); // Missing Required Field let body_missing = json!({ "name": "My File" }); - let err = validate_body_against_schema(&body_missing, "File", &doc).unwrap_err(); + let err = + validate_body_against_schema(&body_missing, "File", &doc, BodyValidationPolicy::Strict) + .unwrap_err(); assert!(err .to_string() .contains("Missing required property 'status'")); // Invalid Enum Value let body_bad_enum = json!({ "name": "My File", "status": "UNKNOWN" }); - let err = validate_body_against_schema(&body_bad_enum, "File", &doc).unwrap_err(); + let err = validate_body_against_schema( + &body_bad_enum, + "File", + &doc, + BodyValidationPolicy::Strict, + ) + .unwrap_err(); assert!(err.to_string().contains("not a valid enum member")); // Invalid Type let body_bad_type = json!({ "name": "My File", "status": "ACTIVE", "count": "10" }); - let err = validate_body_against_schema(&body_bad_type, "File", &doc).unwrap_err(); + let err = validate_body_against_schema( + &body_bad_type, + "File", + &doc, + BodyValidationPolicy::Strict, + ) + .unwrap_err(); assert!(err .to_string() .contains("Expected type 'integer', found string")); @@ -1400,12 +1836,20 @@ mod tests { "status": "ACTIVE", "parent": { "invalidField": "123" } }); - let err = validate_body_against_schema(&body_bad_ref, "File", &doc).unwrap_err(); + let err = + validate_body_against_schema(&body_bad_ref, "File", &doc, BodyValidationPolicy::Strict) + .unwrap_err(); assert!(err.to_string().contains("Unknown property")); // Expected Object Type Failure let body_not_object = json!([]); - let err = validate_body_against_schema(&body_not_object, "File", &doc).unwrap_err(); + let err = validate_body_against_schema( + &body_not_object, + "File", + &doc, + BodyValidationPolicy::Strict, + ) + .unwrap_err(); assert!(err.to_string().contains("Expected object")); } #[tokio::test] diff --git a/crates/google-workspace-cli/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs index 08d4d287a..57ae406af 100644 --- a/crates/google-workspace-cli/src/formatter.rs +++ b/crates/google-workspace-cli/src/formatter.rs @@ -319,7 +319,10 @@ fn json_to_yaml(value: &Value, indent: usize) -> String { match val { Value::Object(_) | Value::Array(_) => { let val_str = json_to_yaml(val, indent + 1); - let _ = write!(out, "\n{prefix}{key}:{val_str}"); + // Empty collections use inline flow syntax and need a + // space after the colon; block collections start a line. + let separator = if val_str.starts_with('\n') { "" } else { " " }; + let _ = write!(out, "\n{prefix}{key}:{separator}{val_str}"); } _ => { let val_str = json_to_yaml(val, indent); @@ -637,6 +640,24 @@ mod tests { assert!(output.contains("count: 42")); } + #[test] + fn test_format_yaml_empty_collections_as_mapping_values() { + let value = json!({"array": [], "object": {}, "tail": true}); + assert_eq!( + format_value(&value, &OutputFormat::Yaml), + "\narray: []\nobject: {}\ntail: true" + ); + } + + #[test] + fn test_format_yaml_empty_collections_nested_in_sequences() { + let value = json!({"items": [[], {}, {"array": [], "object": {}}, "tail"]}); + assert_eq!( + format_value(&value, &OutputFormat::Yaml), + "\nitems:\n - []\n - {}\n - \n array: []\n object: {}\n - \"tail\"" + ); + } + #[test] fn test_format_table_empty_array() { let val = json!({"files": []}); diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index d3ef7fa21..c42c02feb 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -21,14 +21,21 @@ use serde_json::json; use std::future::Future; use std::pin::Pin; +mod read; + pub struct DocsHelper; +#[cfg(test)] +#[path = "docs/read_tests.rs"] +mod read_tests; + impl Helper for DocsHelper { fn inject_commands( &self, mut cmd: Command, _doc: &crate::discovery::RestDescription, ) -> Command { + cmd = cmd.subcommand(read::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -63,17 +70,26 @@ TIPS: &'a self, doc: &'a crate::discovery::RestDescription, matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, ) -> Pin> + Send + 'a>> { Box::pin(async move { + if let Some(matches) = matches.subcommand_matches("+read") { + read::handle(doc, matches, sanitize_config).await?; + return Ok(true); + } if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scope_strs).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), - Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))), + let dry_run = matches.get_flag("dry-run"); + // Skip auth entirely: even failed auth can mutate stored credentials. + let (token, auth_method) = if dry_run { + (None, executor::AuthMethod::None) + } else { + match auth::get_token(&scope_strs).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))), + } }; // Method: documents.batchUpdate @@ -100,7 +116,7 @@ TIPS: auth_method, None, None, - matches.get_flag("dry-run"), + dry_run, &pagination, None, &crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs new file mode 100644 index 000000000..4e2e8625c --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -0,0 +1,541 @@ +// 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. + +//! Translate Docs structure, rather than render its visual layout. Keep API +//! indices and metadata; never calculate edit offsets from extracted text. + +use crate::discovery::RestDescription; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, PaginationConfig}; +use crate::formatter::{format_value, OutputFormat}; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::{json, Map, Value}; +use std::future::Future; + +pub(super) fn command() -> Command { + Command::new("+read") + .about("[Helper] Read a document as compact structured content") + .arg(Arg::new("document").long("document").help("Document ID").required(true).value_name("ID")) + .arg(Arg::new("params").long("params").help("Additional documents.get API parameters as JSON").value_name("JSON")) + .after_help( + "\ +EXAMPLES: + gws docs +read --document DOC_ID + gws docs +read --document DOC_ID --format yaml + gws docs +read --document DOC_ID --params '{\"fields\":\"*\"}' --dry-run + gws docs +read --document DOC_ID | jq '.outline' + gws docs +read --document DOC_ID | jq '.. | objects | select(.paragraphStyle?.headingId? == \"HEADING_ID\")' + +TIPS: + Requests all tabs with includeTabsContent=true and suggestionsViewMode=SUGGESTIONS_INLINE. + Only those tab/suggestion options are supported; fields must be absent or exactly \"*\". + Other documents.get options pass through --params; alt must be json. $fields is rejected. + JSON/YAML preserve the structured view; table/CSV use the global formatter's array summary. + tabs[].blocks and childTabs keep API order; outline lists headings with tab IDs and JSON Pointer paths. + Paragraph text concatenates text runs only. elements retain styles, links, suggestion IDs and reference markers. + Tables contain rows[].cells[].blocks recursively. Headers, footers and footnotes have separate blocks. + figures contain image/drawing metadata, including alt text and URIs when returned; images are never downloaded. + Unknown blocks, inline elements and tab types retain type=unknown markers and raw data. + startIndex/endIndex are the API's UTF-16 offsets, scoped to each tab/segment; never offsets into extracted text. + revisionId and suggestionsViewMode are retained when returned. Missing revisionId is not synthesized. + source=legacyBody indicates a fallback response without populated tabs; all-tab coverage cannot be confirmed. + This is a content view, not a layout renderer or lossless API round trip. Inherited styles are not resolved. + Suggestions remain inline, including proposed deletions; this helper does not accept or reject suggestions. + Use raw documents get for unsupported views or field masks. Missing body content produces an error. + --dry-run validates and prints a request plan without acquiring credentials or fetching document content. + --sanitize uses the existing Model Armor policy before normalization and retains _sanitization metadata.", + ) +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let output = run(doc, matches, sanitize, async { + crate::auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + println!("{output}"); + Ok(()) +} + +/// Token acquisition is lazy so local validation and dry-run never read +/// credentials. The executor remains responsible for HTTP and sanitization. +pub(super) async fn run( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, + token: impl Future>, +) -> Result { + let params = build_params( + matches.get_one::("document").unwrap(), + matches.get_one::("params").map(String::as_str), + )?; + let method = doc + .resources + .get("documents") + .and_then(|r| r.methods.get("get")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.get' not found".into()))?; + let dry_run = matches.get_flag("dry-run"); + let token = if dry_run { None } else { Some(token.await?) }; + let format = matches + .get_one::("format") + .map(|f| OutputFormat::from_str(f)) + .unwrap_or_default(); + let result = executor::execute_method( + doc, + method, + Some(¶ms.to_string()), + None, + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &format, + true, + ) + .await? + .ok_or_else(|| invalid_content("expected a JSON document response"))?; + let output = if dry_run { result } else { normalize(&result)? }; + Ok(format_value(&output, &format)) +} + +pub(super) fn build_params(document: &str, params: Option<&str>) -> Result { + crate::validate::validate_resource_name(document)?; + let mut params: Map = match params { + Some(raw) => serde_json::from_str(raw) + .map_err(|e| GwsError::Validation(format!("Invalid --params JSON object: {e}")))?, + None => Map::new(), + }; + // A complete response is required for normalization. A narrow allowlist is + // deliberate: parsing arbitrary nested masks cannot prove completeness as + // the Docs API grows. Reject the system-parameter alias as well. + if params.contains_key("$fields") || params.get("fields").is_some_and(|v| v != "*") { + return Err(GwsError::Validation( + "docs +read requires all content: omit fields or use \"*\"; $fields is unsupported" + .into(), + )); + } + for (key, required) in [ + ("documentId", json!(document)), + ("includeTabsContent", json!(true)), + ("suggestionsViewMode", json!("SUGGESTIONS_INLINE")), + ] { + if params.get(key).is_some_and(|v| v != &required) { + return Err(GwsError::Validation(format!( + "docs +read requires {key}={required}" + ))); + } + params.insert(key.into(), required); + } + if params.get("alt").is_some_and(|v| v != "json") { + return Err(GwsError::Validation("docs +read requires alt=json".into())); + } + Ok(Value::Object(params)) +} + +fn invalid_content(detail: &str) -> GwsError { + GwsError::Validation(format!( + "Incomplete or invalid Docs response: {detail}; use raw documents get to inspect it" + )) +} + +fn object(value: &Value) -> Result, GwsError> { + value + .as_object() + .cloned() + .ok_or_else(|| invalid_content("expected an object")) +} + +fn array<'a>(value: &'a Value, field: &str) -> Result<&'a [Value], GwsError> { + value + .get(field) + .and_then(Value::as_array) + .map(Vec::as_slice) + .ok_or_else(|| invalid_content(&format!("missing or invalid {field} array"))) +} + +const TAB_CONTENT: &[&str] = &[ + "body", + "headers", + "footers", + "footnotes", + "inlineObjects", + "positionedObjects", + "lists", + "namedStyles", + "namedRanges", + "suggestedNamedStylesChanges", +]; + +pub(super) fn normalize(document: &Value) -> Result { + let mut result = object(document)?; + let mut outline = Vec::new(); + let tabs = match document.get("tabs") { + Some(_) => array(document, "tabs")?, + None => &[], + }; + let (source, tabs) = if tabs.is_empty() { + let mut tab = Map::from_iter([ + ("tabId".into(), Value::Null), + ("parentTabId".into(), Value::Null), + ("childTabs".into(), json!([])), + ]); + if let Some(title) = document.get("title") { + tab.insert("title".into(), title.clone()); + } + let content: Map = TAB_CONTENT + .iter() + .filter_map(|key| document.get(key).map(|v| ((*key).into(), v.clone()))) + .collect(); + tab.extend(contents( + &Value::Object(content), + &Value::Null, + "/tabs/0", + &mut outline, + )?); + ("legacyBody", vec![Value::Object(tab)]) + } else { + let tabs = tabs + .iter() + .enumerate() + .map(|(i, tab)| normalize_tab(tab, &Value::Null, &format!("/tabs/{i}"), &mut outline)) + .collect::, _>>()?; + ("tabs", tabs) + }; + for field in TAB_CONTENT { + result.remove(*field); + } + result.insert("source".into(), json!(source)); + result.insert("tabs".into(), json!(tabs)); + result.insert("outline".into(), json!(outline)); + Ok(Value::Object(result)) +} + +fn normalize_tab( + tab: &Value, + parent: &Value, + path: &str, + outline: &mut Vec, +) -> Result { + let mut result = tab + .get("tabProperties") + .map(object) + .transpose()? + .unwrap_or_default(); + result + .entry("parentTabId") + .or_insert_with(|| parent.clone()); + let id = result.get("tabId").cloned().unwrap_or(Value::Null); + if let Some(content) = tab.get("documentTab") { + result.extend(contents(content, &id, path, outline)?); + let mut extra = object(tab)?; + for key in ["tabProperties", "documentTab", "childTabs"] { + extra.remove(key); + } + if !extra.is_empty() { + result.insert("metadata".into(), Value::Object(extra)); + } + } else { + result.insert("type".into(), json!("unknown")); + result.insert("data".into(), tab.clone()); + } + let children = if tab.get("childTabs").is_some() { + array(tab, "childTabs")? + } else { + &[] + }; + let children = children + .iter() + .enumerate() + .map(|(i, tab)| normalize_tab(tab, &id, &format!("{path}/childTabs/{i}"), outline)) + .collect::, _>>()?; + result.insert("childTabs".into(), json!(children)); + Ok(Value::Object(result)) +} + +fn contents( + content: &Value, + tab_id: &Value, + path: &str, + outline: &mut Vec, +) -> Result, GwsError> { + let mut result = object(content)?; + let body = result + .remove("body") + .ok_or_else(|| invalid_content("missing body"))?; + result.insert( + "blocks".into(), + blocks( + array(&body, "content")?, + tab_id, + &format!("{path}/blocks"), + outline, + )?, + ); + let mut body_metadata = object(&body)?; + body_metadata.remove("content"); + if !body_metadata.is_empty() { + result.insert("bodyMetadata".into(), Value::Object(body_metadata)); + } + for kind in ["headers", "footers", "footnotes"] { + if let Some(segments) = result.get_mut(kind) { + let mut normalized = Map::new(); + for (id, segment) in object(segments)? { + let mut segment_result = object(&segment)?; + // JSON Pointer escaping, not URL escaping. + let pointer_id = id.replace('~', "~0").replace('/', "~1"); + segment_result.insert( + "blocks".into(), + blocks( + array(&segment, "content")?, + tab_id, + &format!("{path}/{kind}/{pointer_id}/blocks"), + outline, + )?, + ); + segment_result.remove("content"); + normalized.insert(id, Value::Object(segment_result)); + } + *segments = Value::Object(normalized); + } + } + let mut figures = Map::new(); + for (field, properties, placement) in [ + ("inlineObjects", "inlineObjectProperties", "inline"), + ( + "positionedObjects", + "positionedObjectProperties", + "positioned", + ), + ] { + if let Some(objects) = result.remove(field) { + for (id, value) in object(&objects)? { + let mut figure = object(&value)?; + if let Some(properties) = figure.remove(properties) { + figure.extend(object(&properties)?); + } + let kind = if figure + .get("embeddedObject") + .and_then(|v| v.get("imageProperties")) + .is_some() + { + "image" + } else if figure + .get("embeddedObject") + .and_then(|v| v.get("embeddedDrawingProperties")) + .is_some() + { + "drawing" + } else { + "unknown" + }; + figure.insert("type".into(), json!(kind)); + figure.insert("placement".into(), json!(placement)); + figure.entry("objectId").or_insert_with(|| json!(id)); + figures.insert(id, Value::Object(figure)); + } + } + } + if !figures.is_empty() { + result.insert("figures".into(), Value::Object(figures)); + } + Ok(result) +} + +/// Flatten a known union arm, retaining styles, suggestions, source indices and +/// future metadata fields. Unrecognized union arms are retained as raw markers. +fn payload(value: &Value, key: &str, kind: &str) -> Result, GwsError> { + let mut outer = object(value)?; + let mut inner = object( + &outer + .remove(key) + .ok_or_else(|| invalid_content("missing element"))?, + )?; + inner.extend(outer); + inner.insert("type".into(), json!(kind)); + Ok(inner) +} + +fn unknown(value: &Value) -> Value { + let mut marker = json!({"type": "unknown", "data": value}); + for key in ["startIndex", "endIndex"] { + if let Some(index) = value.get(key) { + marker[key] = index.clone(); + } + } + marker +} + +fn element(value: &Value) -> Result { + for (key, kind) in [ + ("textRun", "text"), + ("inlineObjectElement", "figure"), + ("footnoteReference", "footnoteReference"), + ("horizontalRule", "horizontalRule"), + ("pageBreak", "pageBreak"), + ("columnBreak", "columnBreak"), + ("equation", "equation"), + ("autoText", "autoText"), + ] { + if value.get(key).is_some() { + let mut result = payload(value, key, kind)?; + if key == "textRun" { + let text = result + .remove("content") + .filter(Value::is_string) + .ok_or_else(|| invalid_content("textRun missing content"))?; + result.insert("text".into(), text); + } else if key == "inlineObjectElement" { + if let Some(id) = result.remove("inlineObjectId") { + result.insert("objectId".into(), id); + } + } else if key == "autoText" { + // The source's `type` is content, distinct from our union tag. + if let Some(subtype) = value[key].get("type") { + result.insert("autoTextType".into(), subtype.clone()); + } + } + return Ok(Value::Object(result)); + } + } + Ok(unknown(value)) +} + +fn blocks( + content: &[Value], + tab_id: &Value, + path: &str, + outline: &mut Vec, +) -> Result { + content + .iter() + .enumerate() + .map(|(i, block)| { + let path = format!("{path}/{i}"); + if let Some(paragraph) = block.get("paragraph") { + let mut result = payload(block, "paragraph", "paragraph")?; + let elements = array(paragraph, "elements")? + .iter() + .map(element) + .collect::, _>>()?; + let text: String = elements + .iter() + .filter_map(|e| e.get("text").and_then(Value::as_str)) + .collect(); + result.insert("text".into(), json!(text)); + result.insert("elements".into(), json!(elements)); + if let Some(style) = paragraph.get("paragraphStyle") { + if let Some(level) = + style + .get("namedStyleType") + .and_then(Value::as_str) + .filter(|s| { + matches!( + *s, + "TITLE" + | "SUBTITLE" + | "HEADING_1" + | "HEADING_2" + | "HEADING_3" + | "HEADING_4" + | "HEADING_5" + | "HEADING_6" + ) + }) + { + let mut heading = + json!({"tabId": tab_id, "level": level, "text": text, "path": path}); + for key in ["startIndex", "endIndex"] { + if let Some(value) = block.get(key) { + heading[key] = value.clone(); + } + } + if let Some(id) = style.get("headingId") { + heading["headingId"] = id.clone(); + } + outline.push(heading); + } + } + Ok(Value::Object(result)) + } else if let Some(table) = block.get("table") { + let mut result = payload(block, "table", "table")?; + let rows = array(table, "tableRows")? + .iter() + .enumerate() + .map(|(r, row)| { + let mut normalized = object(row)?; + let cells = array(row, "tableCells")? + .iter() + .enumerate() + .map(|(c, cell)| { + let mut normalized = object(cell)?; + normalized.insert( + "blocks".into(), + blocks( + array(cell, "content")?, + tab_id, + &format!("{path}/rows/{r}/cells/{c}/blocks"), + outline, + )?, + ); + normalized.remove("content"); + Ok(Value::Object(normalized)) + }) + .collect::, GwsError>>()?; + normalized.remove("tableCells"); + normalized.insert("cells".into(), json!(cells)); + Ok(Value::Object(normalized)) + }) + .collect::, GwsError>>()?; + if let Some(count) = result.remove("rows") { + result.insert("rowCount".into(), count); + } + result.remove("tableRows"); + result.insert("rows".into(), json!(rows)); + Ok(Value::Object(result)) + } else if let Some(toc) = block.get("tableOfContents") { + let mut result = payload(block, "tableOfContents", "tableOfContents")?; + result.insert( + "blocks".into(), + blocks( + array(toc, "content")?, + tab_id, + &format!("{path}/blocks"), + outline, + )?, + ); + result.remove("content"); + Ok(Value::Object(result)) + } else if block.get("sectionBreak").is_some() { + payload(block, "sectionBreak", "sectionBreak").map(Value::Object) + } else { + Ok(unknown(block)) + } + }) + .collect::, _>>() + .map(Value::Array) +} diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs new file mode 100644 index 000000000..d0152bf9f --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -0,0 +1,651 @@ +// 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. + +use crate::commands::build_cli; +use crate::discovery::RestDescription; +use crate::error::GwsError; +use crate::helpers::modelarmor::{SanitizeConfig, SanitizeMode}; +use serde_json::{json, Value}; + +use super::read; + +fn discovery() -> RestDescription { + serde_json::from_value(serde_json::json!({ + "name": "docs", "version": "v1", "rootUrl": "https://docs.example.invalid/", + "servicePath": "v1/", + "resources": {"documents": {"methods": {"get": { + "id": "docs.documents.get", "httpMethod": "GET", + "path": "documents/{documentId}", "parameterOrder": ["documentId"], + "parameters": {"documentId": {"type": "string", "location": "path", "required": true}}, + "scopes": ["https://www.googleapis.com/auth/documents.readonly"] + }}}} + })) + .unwrap() +} + +#[test] +fn registers_read_alongside_write_with_required_document() { + let cli = build_cli(&discovery()); + assert!(cli.find_subcommand("+write").is_some()); + assert!( + cli.find_subcommand("+read").is_some(), + "missing structured reader" + ); + let error = cli + .clone() + .try_get_matches_from(["gws", "+read"]) + .unwrap_err(); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + assert!(cli + .try_get_matches_from([ + "gws", + "+read", + "--document", + "synthetic", + "--params", + "{}", + "--format", + "yaml", + "--dry-run" + ]) + .is_ok()); +} + +fn matches(args: &[&str]) -> clap::ArgMatches { + build_cli(&discovery()).try_get_matches_from(args).unwrap() +} + +fn paragraph(text: &str) -> Value { + json!({"startIndex": 1, "endIndex": 5, "paragraph": { + "elements": [{"startIndex": 1, "endIndex": 5, "textRun": {"content": text}}] + }}) +} + +fn legacy() -> Value { + json!({ + "documentId": "synthetic", "title": "Example", "revisionId": "rev-1", + "suggestionsViewMode": "SUGGESTIONS_INLINE", + "body": {"content": [paragraph("Hi😀")] } + }) +} + +#[test] +fn auto_text_preserves_page_number_and_count_with_indices_and_styles() { + let mut outputs = Vec::new(); + for subtype in ["PAGE_NUMBER", "PAGE_COUNT"] { + let mut input = legacy(); + input["body"]["content"][0]["paragraph"]["elements"] = json!([{ + "startIndex": 0, "endIndex": 1, + "autoText": {"type": subtype, "textStyle": {"bold": true}, + "suggestedInsertionIds": ["s1"]} + }]); + let output = read::normalize(&input).unwrap(); + let element = &output["tabs"][0]["blocks"][0]["elements"][0]; + assert_eq!( + element, + &json!({ + "type": "autoText", "autoTextType": subtype, + "startIndex": 0, "endIndex": 1, + "textStyle": {"bold": true}, "suggestedInsertionIds": ["s1"] + }) + ); + outputs.push(output); + } + assert_ne!(outputs[0], outputs[1]); +} + +#[test] +fn request_requires_full_inline_tabs_and_preserves_other_params() { + let params = + read::build_params("synthetic", Some(r#"{"fields":"*","prettyPrint":false}"#)).unwrap(); + assert_eq!( + params, + json!({ + "documentId": "synthetic", "fields": "*", "prettyPrint": false, + "includeTabsContent": true, "suggestionsViewMode": "SUGGESTIONS_INLINE" + }) + ); + assert_eq!( + read::build_params("id", None).unwrap()["includeTabsContent"], + true + ); + assert!(read::build_params("id", Some( + r#"{"includeTabsContent":true,"suggestionsViewMode":"SUGGESTIONS_INLINE","documentId":"id"}"# + )).is_ok()); +} + +#[test] +fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { + for params in [ + r#"{"fields":"title"}"#, + r#"{"fields":"tabs(documentTab/body/content)"}"#, + r#"{"fields":""}"#, + r#"{"fields":null}"#, + r#"{"fields": ["*"]}"#, + r#"{"includeTabsContent":false}"#, + r#"{"includeTabsContent":"true"}"#, + r#"{"suggestionsViewMode":"PREVIEW_WITHOUT_SUGGESTIONS"}"#, + r#"{"suggestionsViewMode":"DEFAULT_FOR_CURRENT_ACCESS"}"#, + r#"{"documentId":"other"}"#, + r#"{"alt":"media"}"#, + r#"{"$fields":"title"}"#, + "[]", + "null", + "{", + ] { + assert!(read::build_params("id", Some(params)).is_err(), "{params}"); + } + for id in [ + "", + "../../secret", + "id?fields=title", + "id#fragment", + "id\n", + "%2e%2e", + ] { + assert!(read::build_params(id, None).is_err(), "{id:?}"); + } +} + +#[test] +fn legacy_body_keeps_source_revision_text_and_utf16_indices() { + let output = read::normalize(&legacy()).unwrap(); + assert_eq!(output["documentId"], "synthetic"); + assert_eq!(output["revisionId"], "rev-1"); + assert_eq!(output["suggestionsViewMode"], "SUGGESTIONS_INLINE"); + assert_eq!(output["source"], "legacyBody"); + assert_eq!(output["tabs"][0]["tabId"], Value::Null); + let block = &output["tabs"][0]["blocks"][0]; + assert_eq!(block["type"], "paragraph"); + assert_eq!(block["text"], "Hi😀"); + assert_eq!(block["endIndex"], 5); + assert_eq!(block["elements"][0]["endIndex"], 5); + assert_eq!(block["elements"][0]["text"], "Hi😀"); +} + +#[test] +fn recursively_reads_tabs_and_child_tabs_in_api_order_without_duplicate_legacy_body() { + let mut input = legacy(); + input["tabs"] = json!([ + {"tabProperties": {"tabId": "a", "title": "First", "index": 0}, + "documentTab": {"body": {"content": [paragraph("first")]}}, + "childTabs": [{"tabProperties": {"tabId": "b", "title": "Child", "parentTabId": "a"}, + "documentTab": {"body": {"content": [paragraph("child")]}}, + "childTabs": [{"tabProperties": {"tabId": "c", "title": "Grandchild"}, + "documentTab": {"body": {"content": [paragraph("grandchild")]}}}]}]}, + {"tabProperties": {"tabId": "d", "title": "Last", "index": 1}, + "documentTab": {"body": {"content": [paragraph("last")]}}} + ]); + let output = read::normalize(&input).unwrap(); + assert_eq!(output["source"], "tabs"); + assert_eq!(output["tabs"].as_array().unwrap().len(), 2); + assert_eq!(output["tabs"][0]["blocks"][0]["text"], "first"); + assert_eq!(output["tabs"][0]["childTabs"][0]["parentTabId"], "a"); + assert_eq!( + output["tabs"][0]["childTabs"][0]["childTabs"][0]["parentTabId"], + "b" + ); + assert_eq!(output["tabs"][1]["tabId"], "d"); + input["tabs"] = json!([]); + assert_eq!(read::normalize(&input).unwrap()["source"], "legacyBody"); +} + +#[test] +fn preserves_styled_link_runs_and_inline_suggestion_metadata_in_outline_order() { + let input = json!({"documentId": "id", "title": "Styled", "body": {"content": [{ + "startIndex": 1, "endIndex": 9, "paragraph": { + "paragraphStyle": {"namedStyleType": "HEADING_2", "headingId": "h1"}, + "suggestedParagraphStyleChanges": {"s1": {"paragraphStyle": {"namedStyleType": "HEADING_1"}}}, + "elements": [ + {"startIndex": 1, "endIndex": 5, "textRun": { + "content": "Look", "textStyle": {"bold": true, "link": {"url": "https://example.invalid"}}, + "suggestedInsertionIds": ["s1"], + "suggestedTextStyleChanges": {"s2": {"textStyle": {"italic": true}}}}}, + {"startIndex": 5, "endIndex": 9, "textRun": { + "content": "here", "textStyle": {"italic": true, "link": {"heading": {"id": "h2", "tabId": "t2"}}}, + "suggestedDeletionIds": ["s3"]}} + ] + } + }, {"startIndex": 9, "endIndex": 14, "paragraph": { + "paragraphStyle": {"namedStyleType": "TITLE"}, "elements": [] + }}]}}); + let output = read::normalize(&input).unwrap(); + let block = &output["tabs"][0]["blocks"][0]; + assert_eq!(block["text"], "Lookhere"); + assert_eq!(block["paragraphStyle"]["headingId"], "h1"); + assert!(block["suggestedParagraphStyleChanges"]["s1"].is_object()); + assert_eq!(block["elements"][0]["textStyle"]["bold"], true); + assert_eq!( + block["elements"][0]["textStyle"]["link"]["url"], + "https://example.invalid" + ); + assert_eq!(block["elements"][0]["suggestedInsertionIds"], json!(["s1"])); + assert_eq!(block["elements"][1]["suggestedDeletionIds"], json!(["s3"])); + assert_eq!( + block["elements"][1]["textStyle"]["link"]["heading"]["tabId"], + "t2" + ); + assert!(block["elements"][0]["suggestedTextStyleChanges"]["s2"].is_object()); + assert_eq!( + output["outline"][0], + json!({ + "tabId": null, "level": "HEADING_2", "headingId": "h1", "text": "Lookhere", + "startIndex": 1, "endIndex": 9, "path": "/tabs/0/blocks/0" + }) + ); + assert_eq!(output["outline"][1]["level"], "TITLE"); +} + +#[test] +fn nested_tables_keep_row_cell_order_indices_styles_and_suggestions() { + let input = json!({"documentId": "id", "body": {"content": [{ + "startIndex": 10, "endIndex": 30, "table": {"rows": 1, "columns": 2, + "tableRows": [{"startIndex": 11, "endIndex": 29, "tableCells": [ + {"startIndex": 12, "endIndex": 25, "tableCellStyle": {"rowSpan": 1, "columnSpan": 1}, + "suggestedInsertionIds": ["cell-s"], "content": [ + paragraph("cell"), + {"startIndex": 17, "endIndex": 24, "table": {"rows": 1, "columns": 1, + "tableRows": [{"tableCells": [{"content": [paragraph("nested")]}]}]}} + ]}, + {"content": [paragraph("second")]} + ]}] + } + }, paragraph("after")]}}); + let output = read::normalize(&input).unwrap(); + let table = &output["tabs"][0]["blocks"][0]; + assert_eq!(table["type"], "table"); + assert_eq!(table["startIndex"], 10); + assert_eq!(table["rowCount"], 1); + assert_eq!(table["columns"], 2); + assert_eq!(table["rows"][0]["endIndex"], 29); + let cell = &table["rows"][0]["cells"][0]; + assert_eq!(cell["startIndex"], 12); + assert_eq!(cell["suggestedInsertionIds"], json!(["cell-s"])); + assert_eq!(cell["tableCellStyle"]["columnSpan"], 1); + assert_eq!(cell["blocks"][0]["text"], "cell"); + assert_eq!( + cell["blocks"][1]["rows"][0]["cells"][0]["blocks"][0]["text"], + "nested" + ); + assert_eq!(table["rows"][0]["cells"][1]["blocks"][0]["text"], "second"); + assert_eq!(output["tabs"][0]["blocks"][1]["text"], "after"); +} + +#[test] +fn figures_keep_references_and_metadata_even_without_content_uri() { + let mut input = legacy(); + input["body"]["content"][0]["paragraph"]["elements"] = json!([ + {"startIndex": 1, "endIndex": 2, "inlineObjectElement": { + "inlineObjectId": "image", "suggestedInsertionIds": ["s1"], "textStyle": {"baselineOffset": "SUPERSCRIPT"}}}, + {"startIndex": 2, "endIndex": 3, "inlineObjectElement": {"inlineObjectId": "missing"}} + ]); + input["body"]["content"][0]["paragraph"]["positionedObjectIds"] = json!(["drawing"]); + input["inlineObjects"] = json!({"image": { + "objectId": "image", "inlineObjectProperties": {"embeddedObject": { + "title": "Alt title", "description": "Alt text", "size": {"width": {"magnitude": 42, "unit": "PT"}}, + "imageProperties": {"sourceUri": "https://example.invalid/image.png"} + }}, "suggestedDeletionIds": ["s2"] + }}); + input["positionedObjects"] = json!({"drawing": { + "objectId": "drawing", "positionedObjectProperties": {"embeddedObject": {"embeddedDrawingProperties": {}}} + }}); + let output = read::normalize(&input).unwrap(); + let tab = &output["tabs"][0]; + assert_eq!(tab["blocks"][0]["elements"][0]["type"], "figure"); + assert_eq!(tab["blocks"][0]["elements"][0]["objectId"], "image"); + assert_eq!( + tab["blocks"][0]["elements"][0]["suggestedInsertionIds"], + json!(["s1"]) + ); + assert_eq!(tab["blocks"][0]["elements"][1]["objectId"], "missing"); + assert_eq!(tab["blocks"][0]["positionedObjectIds"], json!(["drawing"])); + assert_eq!(tab["figures"]["image"]["type"], "image"); + assert_eq!( + tab["figures"]["image"]["embeddedObject"]["description"], + "Alt text" + ); + assert!(tab["figures"]["image"]["embeddedObject"]["imageProperties"] + .get("contentUri") + .is_none()); + assert_eq!( + tab["figures"]["image"]["suggestedDeletionIds"], + json!(["s2"]) + ); + assert_eq!(tab["figures"]["drawing"]["placement"], "positioned"); +} + +#[test] +fn preserves_reference_markers_segments_unknown_blocks_and_unknown_inline_elements() { + let mut input = legacy(); + input["body"]["content"] = json!([ + {"endIndex": 1, "sectionBreak": {"sectionStyle": {"columnSeparatorStyle": "NONE"}}}, + {"startIndex": 1, "endIndex": 4, "paragraph": {"elements": [ + {"startIndex": 1, "endIndex": 2, "footnoteReference": {"footnoteId": "f1", "footnoteNumber": "1"}}, + {"startIndex": 2, "endIndex": 3, "person": {"personId": "p1"}}, + {"startIndex": 3, "endIndex": 4, "futureInline": {"label": "unrecognized"}} + ]}}, + {"startIndex": 4, "endIndex": 8, "futureBlock": {"content": "keep me"}}, + {"tableOfContents": {"content": [paragraph("toc")]}} + ]); + input["headers"] = json!({"h1": {"headerId": "h1", "content": [paragraph("header")]}}); + input["footers"] = json!({"f2": {"footerId": "f2", "content": [paragraph("footer")]}}); + input["footnotes"] = json!({"f1": {"footnoteId": "f1", "content": [paragraph("note")]}}); + input["namedStyles"] = json!({"styles": [{"namedStyleType": "NORMAL_TEXT"}]}); + let output = read::normalize(&input).unwrap(); + let tab = &output["tabs"][0]; + assert_eq!(tab["blocks"][0]["type"], "sectionBreak"); + assert!(tab["blocks"][0].get("startIndex").is_none()); + assert_eq!(tab["blocks"][1]["elements"][0]["type"], "footnoteReference"); + assert_eq!(tab["blocks"][1]["elements"][0]["footnoteId"], "f1"); + assert_eq!(tab["blocks"][1]["elements"][1]["type"], "unknown"); + assert_eq!( + tab["blocks"][1]["elements"][1]["data"]["person"]["personId"], + "p1" + ); + assert_eq!( + tab["blocks"][1]["elements"][2]["data"]["futureInline"]["label"], + "unrecognized" + ); + assert_eq!(tab["blocks"][2]["type"], "unknown"); + assert_eq!(tab["blocks"][2]["startIndex"], 4); + assert_eq!( + tab["blocks"][2]["data"]["futureBlock"]["content"], + "keep me" + ); + assert_eq!(tab["blocks"][3]["blocks"][0]["text"], "toc"); + assert_eq!(tab["headers"]["h1"]["blocks"][0]["text"], "header"); + assert_eq!(tab["footers"]["f2"]["blocks"][0]["text"], "footer"); + assert_eq!(tab["footnotes"]["f1"]["blocks"][0]["text"], "note"); + assert_eq!( + tab["namedStyles"]["styles"][0]["namedStyleType"], + "NORMAL_TEXT" + ); +} + +#[test] +fn rejects_missing_content_instead_of_claiming_empty_document() { + for input in [ + json!({"documentId": "id", "title": "metadata only"}), + json!({"body": {}}), + json!({"tabs": [{"tabProperties": {"tabId": "t"}, "documentTab": {}}]}), + json!({"tabs": [{"documentTab": {"body": {"content": "not an array"}}}]}), + json!({"tabs": "not an array", "body": {"content": []}}), + json!({"body": {"content": [{"table": {"rows": 1}}]}}), + ] { + assert!(read::normalize(&input).is_err(), "{input}"); + } + assert!(read::normalize(&json!({"body": {"content": []}})).is_ok()); +} + +#[test] +fn preserves_unknown_tab_and_sanitization_annotation() { + let output = read::normalize(&json!({ + "documentId": "id", "_sanitization": {"filterMatchState": "NO_MATCH_FOUND"}, + "tabs": [{"tabProperties": {"tabId": "future", "title": "Future"}, + "futureTab": {"content": "opaque"}}] + })) + .unwrap(); + assert_eq!( + output["_sanitization"]["filterMatchState"], + "NO_MATCH_FOUND" + ); + assert_eq!(output["tabs"][0]["type"], "unknown"); + assert_eq!(output["tabs"][0]["data"]["futureTab"]["content"], "opaque"); +} + +#[tokio::test] +async fn dry_run_uses_executor_plan_without_polling_auth_or_sanitize() { + let args = matches(&[ + "gws", + "+read", + "--document", + "a/b c", + "--dry-run", + "--params", + r#"{"prettyPrint":false}"#, + ]); + let result = read::run( + &discovery(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig { + template: Some("never-call".into()), + mode: SanitizeMode::Block, + }, + async { panic!("dry-run must not poll authentication") }, + ) + .await + .unwrap(); + let output: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(output["dry_run"], true); + assert_eq!(output["method"], "GET"); + assert_eq!( + output["url"], + "https://docs.example.invalid/v1/documents/a%2Fb%20c" + ); + let query = output["query_params"].as_array().unwrap(); + assert!(query.contains(&json!(["includeTabsContent", "true"]))); + assert!(query.contains(&json!(["suggestionsViewMode", "SUGGESTIONS_INLINE"]))); + assert!(query.contains(&json!(["prettyPrint", "false"]))); + assert!(output.get("tabs").is_none()); +} + +#[tokio::test] +async fn rejects_partial_mask_before_polling_authentication() { + let args = matches(&[ + "gws", + "+read", + "--document", + "id", + "--params", + r#"{"fields":"title"}"#, + ]); + let result = read::run( + &discovery(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { panic!("invalid request must fail before authentication") }, + ) + .await; + assert!(matches!(result, Err(GwsError::Validation(_)))); +} + +#[tokio::test] +async fn propagates_auth_and_discovery_failures() { + let args = matches(&["gws", "+read", "--document", "id"]); + let result = read::run( + &discovery(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Err(GwsError::Auth("synthetic failure".into())) }, + ) + .await; + assert!(matches!(result, Err(GwsError::Auth(_)))); + let result = read::run( + &RestDescription::default(), + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { panic!("missing method must fail before authentication") }, + ) + .await; + assert!(matches!(result, Err(GwsError::Discovery(_)))); +} + +// The transport is the only fake: real executor, request building, capture, +// normalization and formatting run against a loopback server with synthetic auth. +async fn serve(status: &str, body: String) -> (RestDescription, tokio::task::JoinHandle) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut doc = discovery(); + doc.root_url = format!("http://{}/", listener.local_addr().unwrap()); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + loop { + let mut buffer = [0; 1024]; + let len = stream.read(&mut buffer).await.unwrap(); + assert_ne!(len, 0); + request.extend_from_slice(&buffer[..len]); + if request.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + stream.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (doc, task) +} + +// Stop the existing executor's quota lookup before it can read host config/ADC. +struct SyntheticQuota(Option); +impl SyntheticQuota { + fn new() -> Self { + let old = std::env::var_os("GOOGLE_WORKSPACE_PROJECT_ID"); + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", "synthetic-project"); + Self(old) + } +} +impl Drop for SyntheticQuota { + fn drop(&mut self) { + if let Some(old) = &self.0 { + std::env::set_var("GOOGLE_WORKSPACE_PROJECT_ID", old); + } else { + std::env::remove_var("GOOGLE_WORKSPACE_PROJECT_ID"); + } + } +} + +#[tokio::test] +#[serial_test::serial] +async fn executor_fetches_full_content_with_auth_and_honors_all_global_formats() { + let _quota = SyntheticQuota::new(); + for format in ["json", "yaml", "table", "csv"] { + let mut input = legacy(); + input["body"]["content"][0]["paragraph"]["elements"][0]["textRun"]["textStyle"] = json!({}); + let (doc, request) = serve("200 OK", input.to_string()).await; + let args = matches(&[ + "gws", + "+read", + "--document", + "synthetic", + "--format", + format, + ]); + let rendered = read::run( + &doc, + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Ok("synthetic-token".into()) }, + ) + .await + .unwrap(); + let request = request.await.unwrap(); + assert!(request.starts_with("GET /v1/documents/synthetic?")); + assert!(request.contains("includeTabsContent=true")); + assert!(request.contains("suggestionsViewMode=SUGGESTIONS_INLINE")); + assert!(request.contains("authorization: Bearer synthetic-token\r\n")); + match format { + "json" => assert_eq!( + serde_json::from_str::(&rendered).unwrap()["tabs"][0]["blocks"][0]["text"], + "Hi😀" + ), + // Complete consumer-visible YAML, including the empty map and + // arrays that previously lacked a mapping-value separator. + "yaml" => assert_eq!( + rendered, + concat!( + "\ndocumentId: \"synthetic\"", + "\noutline: []", + "\nrevisionId: \"rev-1\"", + "\nsource: \"legacyBody\"", + "\nsuggestionsViewMode: \"SUGGESTIONS_INLINE\"", + "\ntabs:", + "\n - ", + "\n blocks:", + "\n - ", + "\n elements:", + "\n - ", + "\n endIndex: 5", + "\n startIndex: 1", + "\n text: \"Hi😀\"", + "\n textStyle: {}", + "\n type: \"text\"", + "\n endIndex: 5", + "\n startIndex: 1", + "\n text: \"Hi😀\"", + "\n type: \"paragraph\"", + "\n childTabs: []", + "\n parentTabId: null", + "\n tabId: null", + "\n title: \"Example\"", + "\ntitle: \"Example\"" + ) + ), + "table" => assert!(rendered.contains("─") && rendered.contains("blocks")), + "csv" => assert!( + rendered.lines().next().unwrap().contains("blocks,") + && rendered.contains("\"\"text\"\"") + ), + _ => unreachable!(), + } + } +} + +#[tokio::test] +#[serial_test::serial] +async fn executor_propagates_server_errors_and_rejects_non_document_responses() { + let _quota = SyntheticQuota::new(); + for status in ["403 Forbidden", "500 Internal Server Error"] { + let (doc, request) = serve( + status, + json!({"error": {"message": "synthetic denied"}}).to_string(), + ) + .await; + let args = matches(&["gws", "+read", "--document", "synthetic"]); + let result = read::run( + &doc, + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Ok("synthetic-token".into()) }, + ) + .await; + match result.unwrap_err() { + GwsError::Api { code, message, .. } => { + assert_eq!(code, if status.starts_with("403") { 403 } else { 500 }); + assert_eq!(message, "synthetic denied"); + } + other => panic!("unexpected error: {other:?}"), + } + request.await.unwrap(); + } + for body in [r#"{"documentId":"id","title":"partial"}"#, "invalid JSON"] { + let (doc, request) = serve("200 OK", body.into()).await; + let args = matches(&["gws", "+read", "--document", "synthetic"]); + assert!(read::run( + &doc, + args.subcommand_matches("+read").unwrap(), + &SanitizeConfig::default(), + async { Ok("synthetic-token".into()) } + ) + .await + .is_err()); + request.await.unwrap(); + } +} diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index caeb8b6b0..27de3eb9c 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -3008,6 +3008,28 @@ mod tests { // --- Attachment tests --- + // Attachment parsing calls the public file validator. Default-policy tests + // must ignore and restore an inherited operator root, including on panic. + // All users of this guard are serialized with the other environment tests. + struct DefaultFileRoot(Option); + + impl DefaultFileRoot { + fn unset() -> Self { + let saved = Self(std::env::var_os("GOOGLE_WORKSPACE_CLI_FILE_ROOT")); + std::env::remove_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT"); + saved + } + } + + impl Drop for DefaultFileRoot { + fn drop(&mut self) { + match &self.0 { + Some(root) => std::env::set_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT", root), + None => std::env::remove_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), + } + } + } + fn make_attach_matches(args: &[&str]) -> ArgMatches { let cmd = Command::new("test").arg( Arg::new("attach") @@ -3095,14 +3117,18 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_attachments_rejects_control_chars() { + let _root = DefaultFileRoot::unset(); let matches = make_attach_matches(&["test", "-a", "file\0name.pdf"]); let err = parse_attachments(&matches).unwrap_err(); assert!(err.to_string().contains("control characters")); } #[test] + #[serial_test::serial] fn test_parse_attachments_rejects_directory() { + let _root = DefaultFileRoot::unset(); // Use a relative directory that exists in CWD let matches = make_attach_matches(&["test", "-a", "src"]); let err = parse_attachments(&matches).unwrap_err(); @@ -3110,14 +3136,18 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_attachments_empty_returns_empty_vec() { + let _root = DefaultFileRoot::unset(); let matches = make_attach_matches(&["test"]); let attachments = parse_attachments(&matches).unwrap(); assert!(attachments.is_empty()); } #[test] + #[serial_test::serial] fn test_parse_attachments_reads_real_file() { + let _root = DefaultFileRoot::unset(); use std::io::Write; let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); let dir = tempfile::tempdir_in(&cwd).unwrap(); @@ -3137,7 +3167,9 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_attachments_nonexistent_file() { + let _root = DefaultFileRoot::unset(); let matches = make_attach_matches(&["test", "-a", "nonexistent_file.pdf"]); let err = parse_attachments(&matches).unwrap_err(); assert!( @@ -3148,7 +3180,9 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_attachments_unknown_extension_falls_back_to_octet_stream() { + let _root = DefaultFileRoot::unset(); use std::io::Write; let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); let dir = tempfile::tempdir_in(&cwd).unwrap(); @@ -3165,7 +3199,9 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_attachments_size_limit_accumulates() { + let _root = DefaultFileRoot::unset(); let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); let dir = tempfile::tempdir_in(&cwd).unwrap(); @@ -3193,7 +3229,9 @@ mod tests { } #[test] + #[serial_test::serial] fn test_parse_attachments_rejects_empty_file() { + let _root = DefaultFileRoot::unset(); let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); let dir = tempfile::tempdir_in(&cwd).unwrap(); let file_path = dir.path().join("empty.txt"); diff --git a/crates/google-workspace-cli/src/main.rs b/crates/google-workspace-cli/src/main.rs index 16f053aff..7a067b8d7 100644 --- a/crates/google-workspace-cli/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -225,7 +225,7 @@ async fn run() -> Result<(), GwsError> { // Validate file paths against traversal before any I/O. // Use the returned canonical paths so the validated path is the one - // actually used for I/O (closes TOCTOU gap). + // actually used for I/O. Local path-replacement races still apply. let upload_path_buf = if let Some(p) = upload_path { Some(crate::validate::validate_safe_file_path(p, "--upload")?) } else { @@ -236,8 +236,8 @@ async fn run() -> Result<(), GwsError> { } else { None }; - let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str()); - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + let upload_path = optional_file_path_as_str(upload_path_buf.as_deref(), "--upload")?; + let output_path = optional_file_path_as_str(output_path_buf.as_deref(), "--output")?; let upload = { let upload_content_type = matched_args @@ -261,25 +261,30 @@ async fn run() -> Result<(), GwsError> { // to avoid restrictive scopes like gmail.metadata that block query parameters. let scopes: Vec<&str> = select_scope(&method.scopes).into_iter().collect(); - // Authenticate: try OAuth, fail with error if credentials exist but are broken - let (token, auth_method) = match auth::get_token(&scopes).await { - Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(e) => { - // If credentials were found but failed (e.g. decryption error, invalid token), - // propagate the error instead of silently falling back to unauthenticated. - // Only fall back to None if no credentials exist at all. - let err_msg = format!("{e:#}"); - // NB: matches the bail!() message in auth::load_credentials_inner - if err_msg.starts_with("No credentials found") { - (None, executor::AuthMethod::None) - } else { - return Err(GwsError::Auth(format!("Authentication failed: {err_msg}"))); + // Dry-runs only need the schema and inputs. Do not load credentials: + // authentication may access the keyring or remove corrupt credential files. + let (token, auth_method) = if dry_run { + (None, executor::AuthMethod::None) + } else { + match auth::get_token(&scopes).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(e) => { + // If credentials were found but failed (e.g. decryption error, invalid token), + // propagate the error instead of silently falling back to unauthenticated. + // Only fall back to None if no credentials exist at all. + let err_msg = format!("{e:#}"); + // NB: matches the bail!() message in auth::load_credentials_inner + if err_msg.starts_with("No credentials found") { + (None, executor::AuthMethod::None) + } else { + return Err(GwsError::Auth(format!("Authentication failed: {err_msg}"))); + } } } }; // Execute - executor::execute_method( + executor::execute_method_with_policy( &doc, method, params_json, @@ -294,11 +299,42 @@ async fn run() -> Result<(), GwsError> { &sanitize_config.mode, &output_format, false, + parse_body_validation_policy(matched_args), ) .await .map(|_| ()) } +fn parse_body_validation_policy(matches: &clap::ArgMatches) -> executor::BodyValidationPolicy { + if matches + .try_get_one::("allow-unknown-fields") + .ok() + .flatten() + .copied() + .unwrap_or(false) + { + executor::BodyValidationPolicy::AllowUnknownFields + } else { + executor::BodyValidationPolicy::Strict + } +} + +// The executor takes strings. An explicit path must never become an omitted +// argument just because canonicalization found a non-UTF-8 component. +fn optional_file_path_as_str<'a>( + path: Option<&'a std::path::Path>, + flag_name: &str, +) -> Result, GwsError> { + path.map(|path| { + path.to_str().ok_or_else(|| { + GwsError::Validation(format!( + "{flag_name} resolves to a path that is not valid UTF-8; choose a path whose canonical components are valid UTF-8" + )) + }) + }) + .transpose() +} + /// Select the best scope from a method's scope list. /// /// Discovery Documents list method scopes as alternatives — any single scope @@ -526,6 +562,99 @@ fn is_version_flag(arg: &str) -> bool { mod tests { use super::*; + #[test] + fn test_parse_body_validation_policy_from_raw_method_flags() { + let doc: discovery::RestDescription = serde_json::from_value(serde_json::json!({ + "name": "test", + "version": "v1", + "rootUrl": "https://example.invalid/", + "servicePath": "", + "resources": {"files": {"methods": { + "create": {"path": "files", "httpMethod": "POST", "request": {"$ref": "File"}}, + "list": {"path": "files", "httpMethod": "GET"} + }}} + })) + .unwrap(); + for (args, expected) in [ + ( + vec!["gws", "files", "list"], + executor::BodyValidationPolicy::Strict, + ), + ( + vec!["gws", "files", "create", "--json", "{}"], + executor::BodyValidationPolicy::Strict, + ), + ( + vec![ + "gws", + "files", + "create", + "--json", + "{}", + "--allow-unknown-fields", + ], + executor::BodyValidationPolicy::AllowUnknownFields, + ), + ] { + let matches = commands::build_cli(&doc) + .try_get_matches_from(args) + .unwrap(); + let (_, method_args) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(parse_body_validation_policy(method_args), expected); + } + } + + #[test] + fn file_root_path_encoding_preserves_present_and_absent_paths() { + for flag in ["--output", "--upload"] { + assert_eq!(optional_file_path_as_str(None, flag).unwrap(), None); + assert_eq!( + optional_file_path_as_str(Some(std::path::Path::new("résumé.pdf")), flag).unwrap(), + Some("résumé.pdf") + ); + } + } + + #[cfg(any(unix, windows))] + fn non_utf8_canonical_path() -> std::path::PathBuf { + // Construct an OS path in memory: no filesystem support is required. + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + std::ffi::OsString::from_vec(b"/files/bytes-\xff/report.pdf".to_vec()).into() + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + let mut units: Vec = r"C:\files\bytes-".encode_utf16().collect(); + units.push(0xD800); // unpaired surrogate + units.extend(r"\report.pdf".encode_utf16()); + std::ffi::OsString::from_wide(&units).into() + } + } + + #[cfg(any(unix, windows))] + #[test] + fn file_root_path_encoding_rejects_explicit_output_instead_of_fallback() { + let path = non_utf8_canonical_path(); + let error = optional_file_path_as_str(Some(&path), "--output").unwrap_err(); + assert!(matches!(error, GwsError::Validation(_))); + let message = error.to_string(); + assert!(message.contains("--output"), "{message}"); + assert!(message.contains("UTF-8"), "{message}"); + } + + #[cfg(any(unix, windows))] + #[test] + fn file_root_path_encoding_rejects_explicit_upload_instead_of_omitting_it() { + let path = non_utf8_canonical_path(); + let error = optional_file_path_as_str(Some(&path), "--upload").unwrap_err(); + assert!(matches!(error, GwsError::Validation(_))); + let message = error.to_string(); + assert!(message.contains("--upload"), "{message}"); + assert!(message.contains("UTF-8"), "{message}"); + } + #[test] fn test_parse_pagination_config_defaults() { let matches = clap::Command::new("test") diff --git a/crates/google-workspace-cli/tests/dry_run.rs b/crates/google-workspace-cli/tests/dry_run.rs new file mode 100644 index 000000000..c760dccd4 --- /dev/null +++ b/crates/google-workspace-cli/tests/dry_run.rs @@ -0,0 +1,651 @@ +// 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. + +use serde_json::{json, Value}; +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime}; +use tempfile::TempDir; + +const BODY: &str = + r#"{"requests":[{"insertText":{"text":"hello","endOfSegmentLocation":{"segmentId":""}}}]}"#; +const RAW: &[&str] = &[ + "docs", + "documents", + "batchUpdate", + "--params", + r#"{"documentId":"doc /?#","fields":"documentId"}"#, + "--json", + BODY, +]; +const WRITE: &[&str] = &["docs", "+write", "--document", "doc /?#", "--text", "hello"]; + +// Observe every API/proxy connection without contacting Google. Returning an +// error also lets real-request tests verify that API failures stay failures. +struct NetworkTrap { + url: String, + requests: Arc>>, + stop: mpsc::Sender<()>, + worker: Option>, +} + +impl NetworkTrap { + fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let (stop, stopping) = mpsc::channel(); + let worker = thread::spawn(move || loop { + match listener.accept() { + Ok((mut stream, _)) => { + // Accepted sockets may inherit the listener's nonblocking + // mode. Request reads need the timeout below on every OS. + stream.set_nonblocking(false).unwrap(); + // Record the connection even if the client fails before + // sending HTTP (for example, during TLS/proxy setup). + let mut requests = captured.lock().unwrap(); + requests.push(String::new()); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut header = Vec::new(); + let mut buffer = [0; 1024]; + while header.len() < 8192 && !header.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(size) => header.extend_from_slice(&buffer[..size]), + } + } + // TCP may deliver headers and body in separate reads. + // Consume the body before closing the connection, or unread + // request bytes can reset it and hide our synthetic 403. + if let Some(end) = header.windows(4).position(|w| w == b"\r\n\r\n") { + let body_len = String::from_utf8_lossy(&header[..end]) + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + .unwrap_or(0); + let expected_len = end + 4 + body_len; + while header.len() < expected_len { + let remaining = (expected_len - header.len()).min(buffer.len()); + match stream.read(&mut buffer[..remaining]) { + Ok(0) | Err(_) => break, + Ok(size) => header.extend_from_slice(&buffer[..size]), + } + } + } + *requests.last_mut().unwrap() = String::from_utf8_lossy(&header).into_owned(); + let body = r#"{"error":{"code":403,"message":"Synthetic denial","errors":[{"reason":"forbidden"}]}}"#; + let _ = write!( + stream, + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if stopping.recv_timeout(Duration::from_millis(5)) + != Err(mpsc::RecvTimeoutError::Timeout) + { + break; + } + } + Err(error) => panic!("Network trap failed: {error}"), + } + }); + Self { + url, + requests, + stop, + worker: Some(worker), + } + } +} + +impl Drop for NetworkTrap { + fn drop(&mut self) { + let _ = self.stop.send(()); + self.worker.take().unwrap().join().unwrap(); + } +} + +type Snapshot = BTreeMap, SystemTime)>; + +fn snapshot(root: &Path) -> Snapshot { + let mut files = BTreeMap::new(); + for entry in fs::read_dir(root).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files.extend(snapshot(&path)); + } else { + files.insert( + path.clone(), + ( + fs::read(&path).unwrap(), + fs::metadata(path).unwrap().modified().unwrap(), + ), + ); + } + } + files +} + +struct Fixture { + dir: TempDir, + config: PathBuf, + network: NetworkTrap, +} + +impl Fixture { + fn new(corrupt_credentials: bool) -> Self { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config"); + fs::create_dir_all(config.join("cache")).unwrap(); + // Stop dotenvy's ancestor search and isolate all credential sources. + fs::write(dir.path().join(".env"), "").unwrap(); + fs::create_dir(dir.path().join("home")).unwrap(); + let fixture = Self { + dir, + config, + network: NetworkTrap::new(), + }; + fixture.write_discovery(&fixture.discovery()); + if corrupt_credentials { + for name in [ + "credentials.enc", + "credentials.json", + ".encryption_key", + "token_cache.json", + "sa_token_cache.json", + ] { + fs::write( + fixture.config.join(name), + format!("corrupt synthetic sentinel: {name}"), + ) + .unwrap(); + } + } + fixture + } + + fn discovery(&self) -> Value { + json!({ + "name": "docs", + "version": "v1", + "rootUrl": self.network.url, + "servicePath": "v1/", + "resources": { + "documents": { + "methods": { + "batchUpdate": { + "id": "docs.documents.batchUpdate", + "httpMethod": "POST", + "path": "documents/{documentId}:batchUpdate", + "parameterOrder": ["documentId"], + "parameters": { + "documentId": {"type": "string", "location": "path", "required": true}, + "fields": {"type": "string", "location": "query"} + }, + "request": {"$ref": "BatchUpdateDocumentRequest"}, + "scopes": ["https://www.googleapis.com/auth/documents"] + } + } + } + }, + "schemas": { + "BatchUpdateDocumentRequest": { + "type": "object", + "required": ["requests"], + "properties": { + "requests": {"type": "array", "items": {"$ref": "Request"}} + } + }, + "Request": { + "type": "object", + "properties": { + "insertText": { + "type": "object", + "properties": { + "text": {"type": "string"}, + "endOfSegmentLocation": { + "type": "object", + "properties": {"segmentId": {"type": "string"}} + } + } + } + } + } + } + }) + } + + fn write_discovery(&self, discovery: &Value) { + // Exercise the real config override, cache filename and freshness check. + fs::write( + self.config.join("cache/docs_v1.json"), + serde_json::to_vec(discovery).unwrap(), + ) + .unwrap(); + } + + fn command(&self, args: &[&str], token: Option<&str>) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_gws")); + command + .args(args) + .current_dir(self.dir.path()) + .env_clear() + .env("HOME", self.dir.path().join("home")) + .env("USERPROFILE", self.dir.path().join("home")) + .env("APPDATA", self.dir.path().join("home")) + .env("XDG_CONFIG_HOME", self.dir.path().join("home")) + .env("USER", "gws-dry-run-test") + .env("USERNAME", "gws-dry-run-test") + .env("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", &self.config) + // Windows known-folder lookup ignores the home overrides above. + // Pin both token loading and quota-project lookup to the fixture. + .env( + "GOOGLE_APPLICATION_CREDENTIALS", + self.dir.path().join("missing-adc.json"), + ) + // Never query an actual OS account, even when testing broken auth. + .env("GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND", "file") + .env("HTTP_PROXY", &self.network.url) + .env("HTTPS_PROXY", &self.network.url) + .env("ALL_PROXY", &self.network.url) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(system_root) = std::env::var_os("SystemRoot") { + command.env("SystemRoot", system_root); + } + if let Some(token) = token { + command.env("GOOGLE_WORKSPACE_CLI_TOKEN", token); + } + command + } + + fn run(&self, args: &[&str], token: Option<&str>) -> Output { + Self::run_command(self.command(args, token)) + } + + fn run_command(mut command: Command) -> Output { + let mut child = command.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("CLI timed out: {output:?}"); + } + thread::sleep(Duration::from_millis(10)); + } + child.wait_with_output().unwrap() + } + + fn dry_run(&self, args: &[&str], exit_code: i32) -> Value { + let before = snapshot(self.dir.path()); + let mut args = args.to_vec(); + args.push("--dry-run"); + let output = self.run(&args, None); + assert_eq!( + output.status.code(), + Some(exit_code), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let after = snapshot(self.dir.path()); + assert_eq!( + after.keys().collect::>(), + before.keys().collect::>(), + "Dry-run created or deleted fixture files" + ); + for (path, expected) in &before { + assert!( + after.get(path) == Some(expected), + "Dry-run changed contents or mtime: {path:?}" + ); + } + assert!( + self.network.requests.lock().unwrap().is_empty(), + "Dry-run attempted an API, auth or Discovery connection" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("keyring") && !stderr.contains("credentials"), + "Dry-run unexpectedly used auth: {stderr}" + ); + serde_json::from_slice(&output.stdout).unwrap() + } + + fn assert_preview(&self, preview: &Value, query: Value) { + assert_eq!( + preview, + &json!({ + "dry_run": true, + "url": format!("{}v1/documents/doc%20%2F%3F%23:batchUpdate", self.network.url), + "method": "POST", + "query_params": query, + "body": { + "requests": [{ + "insertText": { + "text": "hello", + "endOfSegmentLocation": {"segmentId": ""} + } + }] + }, + "is_multipart_upload": false + }) + ); + } +} + +#[test] +fn raw_dry_run_preserves_corrupt_credentials() { + let fixture = Fixture::new(true); + fixture.assert_preview(&fixture.dry_run(RAW, 0), json!([["fields", "documentId"]])); +} + +#[test] +fn raw_dry_run_needs_no_credentials() { + let fixture = Fixture::new(false); + fixture.assert_preview(&fixture.dry_run(RAW, 0), json!([["fields", "documentId"]])); +} + +#[test] +fn docs_write_dry_run_preserves_corrupt_credentials() { + let fixture = Fixture::new(true); + fixture.assert_preview(&fixture.dry_run(WRITE, 0), json!([])); +} + +#[test] +fn docs_write_dry_run_needs_no_credentials() { + let fixture = Fixture::new(false); + fixture.assert_preview(&fixture.dry_run(WRITE, 0), json!([])); +} + +fn assert_validation(error: &Value, message: &str) { + assert_eq!(error["error"]["reason"], "validationError"); + assert!( + error["error"]["message"] + .as_str() + .unwrap() + .contains(message), + "{error}" + ); +} + +#[test] +fn raw_dry_run_rejects_malformed_body_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + *args.last_mut().unwrap() = "{"; + assert_validation(&fixture.dry_run(&args, 3), "Invalid --json body"); +} + +#[test] +fn raw_dry_run_validates_nested_body_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + *args.last_mut().unwrap() = r#"{"requests":[{"insertText":{"text":42}}]}"#; + assert_validation(&fixture.dry_run(&args, 3), "Expected type 'string'"); +} + +#[test] +fn raw_dry_run_rejects_malformed_params_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + args[4] = "{"; + assert_validation(&fixture.dry_run(&args, 3), "Invalid --params JSON"); +} + +#[test] +fn raw_dry_run_requires_path_parameter_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + args[4] = "{}"; + assert_validation(&fixture.dry_run(&args, 3), "documentId is missing"); +} + +#[test] +fn raw_dry_run_requires_query_parameter_without_auth() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["resources"]["documents"]["methods"]["batchUpdate"]["parameters"]["revision"] = + json!({"type": "string", "location": "query", "required": true}); + fixture.write_discovery(&doc); + assert_validation(&fixture.dry_run(RAW, 3), "'revision' is missing"); +} + +#[test] +fn raw_dry_run_rejects_resource_traversal_without_auth() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["resources"]["documents"]["methods"]["batchUpdate"]["path"] = + json!("documents/{+documentId}:batchUpdate"); + fixture.write_discovery(&doc); + let mut args = RAW.to_vec(); + args[4] = r#"{"documentId":"../outside"}"#; + assert_validation(&fixture.dry_run(&args, 3), "traversal"); +} + +#[test] +fn raw_dry_run_rejects_output_traversal_without_auth() { + let fixture = Fixture::new(true); + let mut args = RAW.to_vec(); + args.extend(["--output", "../outside"]); + assert_validation(&fixture.dry_run(&args, 3), "outside the current directory"); +} + +#[test] +fn docs_write_dry_run_requires_document() { + let fixture = Fixture::new(true); + assert_validation( + &fixture.dry_run(&["docs", "+write", "--text", "hello"], 3), + "--document", + ); +} + +#[test] +fn docs_write_dry_run_requires_text() { + let fixture = Fixture::new(true); + assert_validation( + &fixture.dry_run(&["docs", "+write", "--document", "doc"], 3), + "--text", + ); +} + +#[test] +fn docs_write_dry_run_validates_generated_body_without_auth() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["schemas"]["BatchUpdateDocumentRequest"]["required"] = json!(["requests", "title"]); + fixture.write_discovery(&doc); + assert_validation( + &fixture.dry_run(WRITE, 3), + "Missing required property 'title'", + ); +} + +#[test] +fn docs_write_dry_run_preserves_discovery_errors() { + let fixture = Fixture::new(true); + let mut doc = fixture.discovery(); + doc["resources"]["documents"]["methods"] = json!({}); + fixture.write_discovery(&doc); + let error = fixture.dry_run(WRITE, 4); + assert_eq!(error["error"]["reason"], "discoveryError"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("batchUpdate")); +} + +fn assert_auth_failure(args: &[&str]) { + let fixture = Fixture::new(true); + let output = fixture.run(args, None); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["reason"], "authError"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("credentials")); + assert!(fixture.network.requests.lock().unwrap().is_empty()); +} + +#[test] +fn raw_real_request_still_fails_on_broken_credentials() { + assert_auth_failure(RAW); +} + +#[test] +fn docs_write_real_request_still_fails_on_broken_credentials() { + assert_auth_failure(WRITE); +} + +#[test] +fn raw_real_request_rejects_fixture_adc_without_profile_fallback() { + let fixture = Fixture::new(false); + let output = fixture.run(RAW, None); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["reason"], "authError"); + let message = error["error"]["message"].as_str().unwrap(); + assert!( + message.contains("GOOGLE_APPLICATION_CREDENTIALS points to"), + "{error}" + ); + assert!( + message.contains( + fixture + .dir + .path() + .join("missing-adc.json") + .to_str() + .unwrap() + ), + "{error}" + ); + assert!(message.contains("file does not exist"), "{error}"); + assert!(fixture.network.requests.lock().unwrap().is_empty()); +} + +// This case must leave ADC unset to exercise the real no-credentials fallback. +// Only Unix dirs::home_dir() respects our HOME isolation; Windows uses the +// actual profile's known folder, so this case must not run there. +#[cfg(unix)] +#[test] +fn raw_real_request_without_credentials_preserves_access_denied() { + let fixture = Fixture::new(false); + let mut command = fixture.command(RAW, None); + command.env_remove("GOOGLE_APPLICATION_CREDENTIALS"); + let output = Fixture::run_command(command); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["reason"], "authError"); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("No credentials provided")); + let requests = fixture.network.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(!requests[0].to_lowercase().contains("authorization:")); +} + +fn assert_authenticated_api_failure(args: &[&str]) { + let fixture = Fixture::new(false); + // Make a mistaken profile fallback observable on Unix without using a real + // profile. Windows known-folder lookup ignores these home overrides, so the + // fixture must explicitly redirect ADC there as well. + let adc_dir = fixture.dir.path().join("home/.config/gcloud"); + fs::create_dir_all(&adc_dir).unwrap(); + fs::write( + adc_dir.join("application_default_credentials.json"), + r#"{"quota_project_id":"synthetic-profile-must-not-be-read"}"#, + ) + .unwrap(); + let output = fixture.run(args, Some("synthetic-test-token")); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(error["error"]["code"], 403); + assert_eq!(error["error"]["message"], "Synthetic denial"); + let requests = fixture.network.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0] + .to_lowercase() + .contains("authorization: bearer synthetic-test-token")); + assert!( + !requests[0].to_lowercase().contains("x-goog-user-project:"), + "Token-authenticated requests must not read quota attribution from profile ADC" + ); +} + +#[test] +fn raw_real_request_uses_token_and_preserves_api_failure() { + assert_authenticated_api_failure(RAW); +} + +#[test] +fn docs_write_real_request_uses_token_and_preserves_api_failure() { + assert_authenticated_api_failure(WRITE); +} + +#[test] +fn network_trap_waits_for_split_request_body_before_responding() { + use std::net::TcpStream; + + let trap = NetworkTrap::new(); + let address = trap + .url + .strip_prefix("http://") + .unwrap() + .trim_end_matches('/'); + let mut stream = TcpStream::connect(address).unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + stream + .write_all(b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\n") + .unwrap(); + + let mut byte = [0; 1]; + let error = stream + .read(&mut byte) + .expect_err("must consume the body before responding"); + assert!(matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + )); + + stream.write_all(b"hello").unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + assert!( + response.starts_with("HTTP/1.1 403 Forbidden\r\n"), + "{response}" + ); + assert!(trap.requests.lock().unwrap()[0].ends_with("hello")); +} diff --git a/crates/google-workspace-cli/tests/file_roots.rs b/crates/google-workspace-cli/tests/file_roots.rs new file mode 100644 index 000000000..9dc516821 --- /dev/null +++ b/crates/google-workspace-cli/tests/file_roots.rs @@ -0,0 +1,274 @@ +// 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. + +//! Exercise the real CLI with synthetic cached Discovery and child-only env. + +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tempfile::{tempdir, TempDir}; + +struct Fixture { + _temp: TempDir, + cwd: PathBuf, + root: PathBuf, + config: PathBuf, +} + +impl Fixture { + fn new(root_url: &str) -> Self { + let temp = tempdir().unwrap(); + let base = temp.path().canonicalize().unwrap(); + let cwd = base.join("working"); + let root = base.join("files"); + let config = base.join("config"); + fs::create_dir(&cwd).unwrap(); + fs::create_dir(&root).unwrap(); + fs::create_dir_all(config.join("cache")).unwrap(); + // Stop dotenv from searching parent directories for real configuration. + fs::write(cwd.join(".env"), "").unwrap(); + fs::write(config.join("cache/drive_v3.json"), json!({ + "name": "drive", "version": "v3", "rootUrl": root_url, + "resources": {"files": {"methods": { + "get": {"httpMethod": "GET", "path": "files/synthetic"}, + "create": {"httpMethod": "POST", "path": "files", "supportsMediaUpload": true, + "mediaUpload": {"protocols": {"simple": {"path": "/upload/files", "multipart": true}}}} + }}} + }).to_string()).unwrap(); + Self { + _temp: temp, + cwd, + root, + config, + } + } + + fn command(&self, configured: bool) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_gws")); + command + .env_clear() + .current_dir(&self.cwd) + .env("HOME", &self.cwd) + .env("USERPROFILE", &self.cwd) + .env("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", &self.config) + .env("GOOGLE_WORKSPACE_CLI_TOKEN", "synthetic-test-token") + .env("GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND", "file") + .env("NO_COLOR", "1") + // A cache regression must fail locally, never fetch real Discovery. + .env("HTTPS_PROXY", "http://127.0.0.1:1") + .env("HTTP_PROXY", "http://127.0.0.1:1") + .env("NO_PROXY", "127.0.0.1,localhost"); + if configured { + command.env("GOOGLE_WORKSPACE_CLI_FILE_ROOT", &self.root); + } + command + } + + fn dry_run(&self, flag: &str, path: &Path, configured: bool) -> Output { + let method = if flag == "--upload" { "create" } else { "get" }; + self.command(configured) + .args(["drive", "files", method, "--dry-run", flag]) + .arg(path) + .output() + .unwrap() + } +} + +fn successful_json(output: &Output) -> Value { + assert!( + output.status.success(), + "status: {:?}\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn file_root_cli_dry_run_accepts_external_output_and_upload() { + let fixture = Fixture::new("http://127.0.0.1:1/"); + fs::write(fixture.root.join("upload.txt"), "synthetic upload").unwrap(); + for (flag, name) in [("--output", "new.bin"), ("--upload", "upload.txt")] { + let output = fixture.dry_run(flag, &fixture.root.join(name), true); + let result = successful_json(&output); + assert_eq!(result["dry_run"], true); + assert_eq!(result["is_multipart_upload"], flag == "--upload"); + } + assert!(!fixture.root.join("new.bin").exists()); +} + +#[test] +fn file_root_cli_default_rejects_external_paths_but_keeps_local_output() { + let fixture = Fixture::new("http://127.0.0.1:1/"); + let output = fixture.dry_run("--output", &fixture.root.join("new.bin"), false); + assert_eq!(output.status.code(), Some(3)); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("current directory")); + assert_eq!( + successful_json(&fixture.dry_run("--output", Path::new("new.bin"), false))["dry_run"], + true + ); +} + +#[test] +fn file_root_cli_rejects_cwd_relative_path_outside_configured_root() { + let fixture = Fixture::new("http://127.0.0.1:1/"); + let output = fixture.dry_run("--output", Path::new("new.bin"), true); + assert_eq!( + output.status.code(), + Some(3), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(error["error"]["message"] + .as_str() + .unwrap() + .contains("GOOGLE_WORKSPACE_CLI_FILE_ROOT")); +} + +#[test] +fn file_root_cli_download_propagates_canonical_output() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let fixture = Fixture::new(&format!("http://{}/", listener.local_addr().unwrap())); + let output_path = fixture.root.join("./output.bin"); + let mut child = fixture + .command(true) + .args(["drive", "files", "get", "--output"]) + .arg(&output_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let read = stream.read(&mut buffer).unwrap(); + assert!(read > 0, "request ended before headers"); + request.extend_from_slice(&buffer[..read]); + } + assert!(request.starts_with(b"GET /files/synthetic HTTP/1.1\r\n")); + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: 9\r\nConnection: close\r\n\r\nsynthetic").unwrap(); + break; + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + if child.try_wait().unwrap().is_some() { + break; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!( + "CLI did not contact local fixture: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(err) => panic!("local fixture failed: {err}"), + } + } + let result = successful_json(&child.wait_with_output().unwrap()); + assert_eq!( + result["saved_file"], + fixture.root.join("output.bin").to_str().unwrap() + ); + assert_eq!(result["bytes"], 9); + assert_eq!( + fs::read(fixture.root.join("output.bin")).unwrap(), + b"synthetic" + ); + assert!(!fixture.cwd.join("output.bin").exists()); +} + +// macOS filesystems commonly reject invalid UTF-8 directory names. The CLI +// conversion itself is covered without filesystem access by main.rs unit tests; +// these Linux regressions exercise the complete canonical symlink handoff. +#[cfg(target_os = "linux")] +fn non_utf8_alias(fixture: &Fixture, filename: &str) -> PathBuf { + use std::os::unix::{ffi::OsStringExt, fs::symlink}; + let target = fixture + .root + .join(std::ffi::OsString::from_vec(b"bytes-\xff".to_vec())); + fs::create_dir(&target).unwrap(); + fs::write(target.join("upload.txt"), b"synthetic upload").unwrap(); + let alias = fixture.root.join("alias"); + symlink(&target, &alias).unwrap(); + alias.join(filename) +} + +#[cfg(target_os = "linux")] +#[test] +fn file_root_cli_rejects_non_utf8_output_without_fallback_write() { + let fixture = Fixture::new("http://127.0.0.1:1/"); + let output_path = non_utf8_alias(&fixture, "new.bin"); + let fallback = fixture.cwd.join("download.bin"); + fs::write(&fallback, b"keep existing download").unwrap(); + // A non-dry run must fail at validation, before HTTP or the default output + // can be selected. No live service or credentials are involved. + let output = fixture + .command(true) + .args(["drive", "files", "get", "--output"]) + .arg(&output_path) + .output() + .unwrap(); + assert_eq!(fs::read(&fallback).unwrap(), b"keep existing download"); + assert!(!output_path.exists()); + assert_eq!( + output.status.code(), + Some(3), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("--output"), "{message}"); + assert!(message.contains("UTF-8"), "{message}"); +} + +#[cfg(target_os = "linux")] +#[test] +fn file_root_cli_rejects_non_utf8_upload_instead_of_omitting_it() { + let fixture = Fixture::new("http://127.0.0.1:1/"); + let upload_path = non_utf8_alias(&fixture, "upload.txt"); + let output = fixture.dry_run("--upload", &upload_path, true); + assert_eq!( + output.status.code(), + Some(3), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let error: Value = serde_json::from_slice(&output.stdout).unwrap(); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("--upload"), "{message}"); + assert!(message.contains("UTF-8"), "{message}"); + assert_eq!(fs::read(&upload_path).unwrap(), b"synthetic upload"); +} diff --git a/crates/google-workspace/src/validate.rs b/crates/google-workspace/src/validate.rs index 32ef200f9..a951cae56 100644 --- a/crates/google-workspace/src/validate.rs +++ b/crates/google-workspace/src/validate.rs @@ -161,11 +161,13 @@ pub fn validate_safe_dir_path(dir: &str) -> Result { /// 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. +/// By default, the resolved target must live under CWD. The trusted operator +/// environment variable `GOOGLE_WORKSPACE_CLI_FILE_ROOT` can select a different +/// boundary: an existing directory, canonicalized before validation. With an +/// explicit root, CLI paths must not contain `..` components. Relative CLI paths +/// always resolve from CWD, not from the configured root. Absolute paths within +/// the boundary are allowed. Control characters and symlink escapes are rejected. +/// Directory validators do not use this setting. /// /// # TOCTOU caveat /// @@ -175,48 +177,127 @@ pub fn validate_safe_dir_path(dir: &str) -> Result { /// 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 { - 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 file_root = std::env::var_os("GOOGLE_WORKSPACE_CLI_FILE_ROOT"); + validate_file_path_with_root( + path_str, + flag_name, + &cwd, + file_root.as_deref().map(Path::new), + ) +} - let resolved = if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - }; +/// Explicit policy keeps filesystem validation independent of process-global env. +fn validate_file_path_with_root( + path_str: &str, + flag_name: &str, + cwd: &Path, + file_root: Option<&Path>, +) -> Result { + reject_dangerous_chars(path_str, flag_name)?; - // 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)) + let canonical_root = if let Some(root) = file_root { + if root.as_os_str().is_empty() { + return Err(GwsError::Validation( + "GOOGLE_WORKSPACE_CLI_FILE_ROOT must name an existing directory; got an empty value" + .to_string(), + )); + } + // Environment is trusted: relative roots (including `..`) are valid. + let canonical = cwd.join(root).canonicalize().map_err(|e| { + GwsError::Validation(format!( + "GOOGLE_WORKSPACE_CLI_FILE_ROOT {root:?} must name an existing directory: {e}" + )) + })?; + if !canonical.is_dir() { + return Err(GwsError::Validation(format!( + "GOOGLE_WORKSPACE_CLI_FILE_ROOT {root:?} must name an existing directory" + ))); + } + canonical + } else { + cwd.canonicalize().map_err(|e| { + GwsError::Validation(format!("Failed to canonicalize current directory: {e}")) })? + }; + let boundary = if file_root.is_some() { + format!("GOOGLE_WORKSPACE_CLI_FILE_ROOT directory {canonical_root:?}") } 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) + format!("current directory {canonical_root:?}") }; - let canonical_cwd = cwd.canonicalize().map_err(|e| { - GwsError::Validation(format!("Failed to canonicalize current directory: {e}")) + let path = Path::new(path_str); + if file_root.is_some() + && path + .components() + .any(|component| component == std::path::Component::ParentDir) + { + return Err(GwsError::Validation(format!( + "{flag_name} must not contain parent traversal ('..') components within the {boundary}; use a path without '..'" + ))); + } + + // Path::join preserves absolute arguments; relative arguments stay CWD-relative. + let resolved = cwd.join(path); + let canonical = canonicalize_file_path(&resolved).map_err(|e| { + GwsError::Validation(format!( + "Failed to resolve {flag_name} {path_str:?} within the {boundary}: {e}" + )) })?; + // Preserve default handling of paths that normalize safely within CWD. + let canonical = normalize_dotdot(&canonical); - if !canonical.starts_with(&canonical_cwd) { + if !canonical.starts_with(&canonical_root) { return Err(GwsError::Validation(format!( - "{flag_name} '{}' resolves to '{}' which is outside the current directory", - path_str, - canonical.display() + "{flag_name} {path_str:?} resolves to {canonical:?} which is outside the {boundary}; set GOOGLE_WORKSPACE_CLI_FILE_ROOT to an existing directory containing the intended file" ))); } Ok(canonical) } +/// Canonicalize the existing file or nearest existing parent, then append the +/// missing suffix. Unlike `exists()`, symlink_metadata does not mistake dangling +/// symlinks for missing files. Keep this stricter resolver local to file flags. +fn canonicalize_file_path(path: &Path) -> std::io::Result { + let mut current = path; + let mut remaining = Vec::new(); + loop { + match std::fs::symlink_metadata(current) { + Ok(_) => { + let mut canonical = current.canonicalize()?; + if !remaining.is_empty() && !canonical.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "existing file parent must be a directory", + )); + } + for component in remaining.into_iter().rev() { + canonical.push(component); + } + return Ok(canonical); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let name = current.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot resolve an existing directory prefix", + ) + })?; + remaining.push(name); + current = current.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "cannot resolve a file parent", + ) + })?; + } + Err(error) => return Err(error), + } + } +} + /// Resolve `.` and `..` components in a path without touching the filesystem. fn normalize_dotdot(path: &Path) -> PathBuf { let mut out = PathBuf::new(); @@ -763,11 +844,9 @@ mod tests { 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 _environment = FilePathEnvironment::set(&canonical_dir, None); 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:?}"); } @@ -778,11 +857,9 @@ mod tests { 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 _environment = FilePathEnvironment::set(&canonical_dir, None); 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!( @@ -792,7 +869,10 @@ mod tests { } #[test] + #[serial] fn test_file_path_rejects_control_chars() { + let dir = tempdir().unwrap(); + let _environment = FilePathEnvironment::set(dir.path(), None); let result = validate_safe_file_path("file\x00.txt", "--output"); assert!(result.is_err(), "null bytes should be rejected"); } @@ -808,11 +888,9 @@ mod tests { 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 _environment = FilePathEnvironment::set(&canonical_dir, None); 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"); } @@ -824,15 +902,337 @@ mod tests { 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 _environment = FilePathEnvironment::set(&canonical_dir, None); 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" ); } + + // Default public-validator and directory-scope tests isolate global state; + // scoped file-policy tests pass CWD and the trusted root explicitly. + struct FilePathEnvironment { + cwd: PathBuf, + root: Option, + } + + impl FilePathEnvironment { + fn set(cwd: &Path, root: Option<&Path>) -> Self { + let saved = Self { + cwd: std::env::current_dir().unwrap(), + root: std::env::var_os("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), + }; + std::env::set_current_dir(cwd).unwrap(); + match root { + Some(root) => std::env::set_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT", root), + None => std::env::remove_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), + } + saved + } + } + + impl Drop for FilePathEnvironment { + fn drop(&mut self) { + std::env::set_current_dir(&self.cwd).unwrap(); + match &self.root { + Some(root) => std::env::set_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT", root), + None => std::env::remove_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), + } + } + } + + fn file_path_under_root( + path: &Path, + flag: &str, + cwd: &Path, + root: Option<&Path>, + ) -> Result { + validate_file_path_with_root(path.to_str().unwrap(), flag, cwd, root) + } + + #[test] + fn file_root_default_preserves_cwd_boundary_and_resolution() { + let dir = tempdir().unwrap(); + let cwd = dir.path().canonicalize().unwrap(); + fs::create_dir(cwd.join("nested")).unwrap(); + fs::write(cwd.join("upload.txt"), "synthetic upload").unwrap(); + for path in [cwd.join("upload.txt"), PathBuf::from("upload.txt")] { + assert_eq!( + file_path_under_root(&path, "--upload", &cwd, None).unwrap(), + cwd.join("upload.txt") + ); + } + assert_eq!( + file_path_under_root(Path::new("nested/../new.txt"), "--output", &cwd, None).unwrap(), + cwd.join("new.txt") + ); + for path in [ + cwd.parent().unwrap().join("outside.txt"), + PathBuf::from("../outside.txt"), + ] { + let err = file_path_under_root(&path, "--output", &cwd, None) + .unwrap_err() + .to_string(); + assert!(err.contains("outside the current directory"), "{err}"); + assert!(err.contains("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), "{err}"); + } + assert!(file_path_under_root( + Path::new("missing/../../outside.txt"), + "--output", + &cwd, + None + ) + .is_err()); + } + + #[test] + fn file_root_accepts_absolute_output_and_existing_upload() { + let cwd = tempdir().unwrap(); + let root = tempdir().unwrap(); + let canonical_root = root.path().canonicalize().unwrap(); + fs::write(root.path().join("upload.txt"), "synthetic upload").unwrap(); + for (name, flag) in [("new.txt", "--output"), ("upload.txt", "--upload")] { + assert_eq!( + file_path_under_root(&root.path().join(name), flag, cwd.path(), Some(root.path())) + .unwrap(), + canonical_root.join(name) + ); + } + assert!(!root.path().join("new.txt").exists()); + } + + #[test] + fn file_root_rejects_sibling_even_with_shared_name_prefix() { + let dir = tempdir().unwrap(); + // A literal backslash on Unix, a separator on Windows; both must be + // compared using the escaped canonical representation in diagnostics. + let parent = dir.path().join(r"back\slash"); + fs::create_dir_all(&parent).unwrap(); + let root = parent.join("allowed"); + let sibling = parent.join("allowed-sibling"); + fs::create_dir(&root).unwrap(); + fs::create_dir(&sibling).unwrap(); + let err = file_path_under_root( + &sibling.join("new.txt"), + "--output", + dir.path(), + Some(&root), + ) + .unwrap_err() + .to_string(); + assert!(err.contains("outside"), "{err}"); + assert!(err.contains("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), "{err}"); + assert!( + err.contains(&format!("{:?}", root.canonicalize().unwrap())), + "{err}" + ); + } + + #[test] + fn file_root_rejects_parent_components_even_inside_boundary() { + let root = tempdir().unwrap(); + fs::create_dir(root.path().join("nested")).unwrap(); + for path in ["nested/../new.txt", "missing/../new.txt", "../outside.txt"] { + assert!( + file_path_under_root(Path::new(path), "--output", root.path(), Some(root.path())) + .is_err(), + "accepted {path}" + ); + } + } + + #[test] + fn file_root_rejects_control_and_dangerous_unicode_arguments() { + let root = tempdir().unwrap(); + for path in [ + "bad\0.txt", + "bad\n.txt", + "bad\u{202e}.txt", + "bad\u{200b}.txt", + ] { + assert!( + file_path_under_root(Path::new(path), "--output", root.path(), Some(root.path())) + .is_err(), + "accepted {path:?}" + ); + } + } + + #[test] + fn file_root_rejects_invalid_roots_without_falling_back_to_cwd() { + let cwd = tempdir().unwrap(); + let file = cwd.path().join("file.txt"); + fs::write(&file, "synthetic file").unwrap(); + for root in [PathBuf::new(), cwd.path().join("missing"), file] { + let err = + file_path_under_root(Path::new("new.txt"), "--output", cwd.path(), Some(&root)) + .unwrap_err() + .to_string(); + assert!(err.contains("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), "{err}"); + assert!(err.contains("directory"), "{err}"); + } + } + + #[test] + fn file_root_keeps_relative_arguments_cwd_relative() { + let root = tempdir().unwrap(); + let cwd = root.path().join("working"); + fs::create_dir(&cwd).unwrap(); + assert_eq!( + file_path_under_root(Path::new("new.txt"), "--output", &cwd, Some(root.path())) + .unwrap(), + cwd.canonicalize().unwrap().join("new.txt") + ); + let unrelated = tempdir().unwrap(); + assert!(file_path_under_root( + Path::new("new.txt"), + "--output", + unrelated.path(), + Some(root.path()) + ) + .is_err()); + } + + #[test] + fn file_root_canonicalizes_trusted_relative_root_with_parent_components() { + let root = tempdir().unwrap(); + let cwd = root.path().join("working"); + fs::create_dir(&cwd).unwrap(); + assert_eq!( + file_path_under_root( + Path::new("new.txt"), + "--output", + &cwd, + Some(Path::new("..")) + ) + .unwrap(), + cwd.canonicalize().unwrap().join("new.txt") + ); + } + + #[test] + fn file_root_validates_nested_new_file_parents_without_creating_them() { + let cwd = tempdir().unwrap(); + let root = tempdir().unwrap(); + let output = root.path().join("new/nested/output.bin"); + assert_eq!( + file_path_under_root(&output, "--output", cwd.path(), Some(root.path())).unwrap(), + root.path() + .canonicalize() + .unwrap() + .join("new/nested/output.bin") + ); + assert!(!root.path().join("new").exists()); + let file = root.path().join("file.txt"); + fs::write(&file, "synthetic file").unwrap(); + assert!(file_path_under_root( + &file.join("output.bin"), + "--output", + cwd.path(), + Some(root.path()) + ) + .is_err()); + } + + #[test] + #[serial] + fn file_root_does_not_expand_directory_validators() { + let cwd = tempdir().unwrap(); + let root = tempdir().unwrap(); + let _environment = FilePathEnvironment::set(cwd.path(), Some(root.path())); + assert!(validate_safe_output_dir(root.path().to_str().unwrap()).is_err()); + assert!(validate_safe_dir_path(root.path().to_str().unwrap()).is_err()); + assert_eq!( + validate_safe_output_dir("new").unwrap(), + cwd.path().canonicalize().unwrap().join("new") + ); + assert!(validate_safe_dir_path(".").is_ok()); + } + + #[test] + #[serial] + fn file_root_environment_is_restored_on_unwind() { + let cwd = std::env::current_dir().unwrap(); + let root = std::env::var_os("GOOGLE_WORKSPACE_CLI_FILE_ROOT"); + let dir = tempdir().unwrap(); + let result = std::panic::catch_unwind(|| { + let _environment = FilePathEnvironment::set(dir.path(), Some(dir.path())); + panic!("exercise restoration"); + }); + assert!(result.is_err()); + assert_eq!(std::env::current_dir().unwrap(), cwd); + assert_eq!(std::env::var_os("GOOGLE_WORKSPACE_CLI_FILE_ROOT"), root); + } + + #[cfg(unix)] + #[test] + fn file_root_resolves_inside_symlinks_and_rejects_escapes() { + use std::os::unix::fs::symlink; + let root = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(root.path().join("inside")).unwrap(); + fs::write(root.path().join("inside/upload.txt"), "inside").unwrap(); + fs::write(outside.path().join("upload.txt"), "outside").unwrap(); + symlink(root.path().join("inside"), root.path().join("safe")).unwrap(); + symlink(outside.path(), root.path().join("escape")).unwrap(); + for (suffix, flag) in [("upload.txt", "--upload"), ("new/output.bin", "--output")] { + assert_eq!( + file_path_under_root( + &root.path().join("safe").join(suffix), + flag, + root.path(), + Some(root.path()) + ) + .unwrap(), + root.path() + .canonicalize() + .unwrap() + .join("inside") + .join(suffix) + ); + assert!(file_path_under_root( + &root.path().join("escape").join(suffix), + flag, + root.path(), + Some(root.path()) + ) + .is_err()); + } + // A trusted root may itself be a symlink to an existing directory. + assert_eq!( + file_path_under_root( + &root.path().join("safe/upload.txt"), + "--upload", + outside.path(), + Some(&root.path().join("safe")) + ) + .unwrap(), + root.path() + .canonicalize() + .unwrap() + .join("inside/upload.txt") + ); + } + + #[cfg(unix)] + #[test] + fn file_root_rejects_dangling_symlinks_and_loops() { + use std::os::unix::fs::symlink; + let root = tempdir().unwrap(); + let outside = tempdir().unwrap(); + symlink(outside.path().join("new.txt"), root.path().join("dangling")).unwrap(); + symlink("loop", root.path().join("loop")).unwrap(); + for root_policy in [None, Some(root.path())] { + for name in ["dangling", "dangling/new.txt", "loop", "loop/new.txt"] { + assert!( + file_path_under_root(Path::new(name), "--output", root.path(), root_policy) + .is_err(), + "accepted {name}" + ); + } + } + } } diff --git a/docs/docs-read.md b/docs/docs-read.md new file mode 100644 index 000000000..b95b8a66c --- /dev/null +++ b/docs/docs-read.md @@ -0,0 +1,85 @@ +# Read structured Google Docs content + +```bash +gws docs +read --document DOC_ID +gws docs +read --document DOC_ID --format yaml +gws docs +read --document DOC_ID --dry-run +gws docs +read --document DOC_ID | jq '.outline' +``` + +`+read` translates the Docs API's nested structural elements into ordered +blocks. It uses the existing authentication, request executor, Model Armor +sanitization, and output formatters. `+write` is unchanged. + +The request uses `includeTabsContent=true` and +`suggestionsViewMode=SUGGESTIONS_INLINE`. Pass other API options through +`--params`. To prevent omitted content from looking like an empty document, +`fields` must be absent or exactly `"*"`. `$fields`, other tab/suggestion views, +conflicting document IDs, and non-JSON `alt` responses are rejected before +authentication. Dry-run prints the executor's request plan without acquiring +credentials, fetching document content, or invoking Model Armor. + +## Output + +- The root retains `documentId`, `title`, `revisionId`, `suggestionsViewMode`, + and `_sanitization` when returned. No revision is invented when absent. +- `tabs` and recursive `childTabs` preserve API order, IDs, titles, and parents. + Each tab has ordered `blocks`. `source: "legacyBody"` means the response had + no populated tabs; that fallback cannot confirm coverage of other tabs. +- Paragraph blocks have `text`, ordered `elements`, and `paragraphStyle`. + Text elements retain separate runs, text styles, links (including tab-aware + internal links), and suggested insertion/deletion/style changes. Other + returned paragraph metadata, such as bullets and positioned object IDs, + stays on the block. Automatic-text markers use `type: "autoText"` and retain + their source subtype (`PAGE_NUMBER` or `PAGE_COUNT`) as `autoTextType`. +- Table blocks have `rowCount`, `columns`, and + `rows[].cells[].blocks`, including nested tables. Row/cell styles and + suggestion metadata are retained. +- Each tab's `figures` map contains inline and positioned object metadata. + `embeddedObject` retains alt text, dimensions, and image properties when + available. Figure elements reference `objectId`. Missing metadata or + `contentUri` does not remove the reference. +- Headers, footers, and footnotes remain separate maps with their own `blocks`. + Footnote references and structural markers remain in content order. + Unsupported elements use `type: "unknown"` with their original `data`. +- `outline` contains titles, subtitles, and headings with text, style level, + tab ID, heading ID when present, source indices, and a JSON Pointer `path` + to the normalized paragraph, including headings in tables and segments. + +For example, select a heading by its returned ID: + +```bash +gws docs +read --document DOC_ID | + jq '.. | objects | select(.paragraphStyle?.headingId? == "HEADING_ID")' +``` + +To select the blocks between two top-level headings in a particular tab: + +```bash +gws docs +read --document DOC_ID | + jq --arg tab TAB_ID --argjson start 10 --argjson end 50 \ + '.. | objects | select(.tabId? == $tab and has("blocks")) | + .blocks[] | select(.startIndex >= $start and .startIndex < $end)' +``` + +Use indices actually returned for that tab. `startIndex` and `endIndex` are +UTF-16 offsets in the API's tab/segment, **not** byte or character offsets into +the extracted `text`. The text convenience field concatenates text runs only; +figures and other markers remain in `elements`. + +## Limits + +This is a structured content view, not a visual layout renderer or a lossless +API round trip. It does not resolve inherited styles, render drawings or +equations, download images, or accept/reject suggestions. Proposed deletions +remain inline. Image URIs are included only when returned and may expire. +Use raw `gws docs documents get` for other views or partial field masks. + +JSON is the default and retains the entire normalized tree. YAML uses the +existing serializer. Table and CSV use the existing formatter's first +nonempty-array summary (typically the outline, otherwise tabs); table cells +can be truncated. Use JSON for complete downstream processing. + +Usage and limitations also live in the command's help, the source consumed by +`gws generate-skills`. Generated skill files are maintained by the repository's +Generate Skills workflow. diff --git a/examples/docs-review-bundle/README.md b/examples/docs-review-bundle/README.md new file mode 100644 index 000000000..7975497f3 --- /dev/null +++ b/examples/docs-review-bundle/README.md @@ -0,0 +1,212 @@ +# Visual Docs review bundle + +This standalone companion combines native Docs JSON, Drive PDF/DOCX/Markdown +exports, DOCX raster assets, and a local HTML review index. It uses only the +Python standard library and existing `gws` commands. It does not change `gws`, +authenticate separately, or download document hyperlinks or image `contentUri`s. + +## Run + +Requires Python 3.10+ on Linux or macOS and an authenticated `gws` executable +with read access to the document through both Docs and Drive. + +From the repository root: + +```sh +python3 examples/docs-review-bundle/docs_review_bundle.py \ + --document-id DOCUMENT_ID review-bundle +``` + +`review-bundle` must be a **new relative directory inside the current working +directory**. Existing directories, absolute paths, `..`, symlink components, +control characters, and names outside the portable ASCII subset are refused. +Nested paths work when their parents already exist. The final directory is +created with mode `0700`; keep its parents under your control. + +Optional orchestration flags: + +```sh +python3 examples/docs-review-bundle/docs_review_bundle.py \ + --document-id DOCUMENT_ID --include-comments --render-pages \ + --timeout 120 --gws /trusted/path/to/gws another-review-bundle +``` + +- `--include-comments`: collect every returned Drive comments page. The entire + optional artifact is omitted and marked unavailable if retrieval is incomplete + or fails, or if the final serialized artifact exceeds 20 MiB. Size is checked + before publication, so an oversized optional result does not fail the required + bundle. Comments are a separate observation, not revision-bound or mapped to + PDF coordinates. Deleted comments are not requested. +- `--render-pages`: use a trusted `pdftoppm` on `PATH` to generate 96 DPI PNGs. + Its path is resolved before running inside the bundle, including relative + `PATH` entries. Rendering is opt-in. Missing tools, failures, timeouts, invalid + output and noncontiguous page numbers retain the PDF and report no available + raster previews. Outputs from these detected failures are discarded. After a + successful process exit and validation, previews are labeled `available` + with `coverage: "unverified"`; a missing page suffix cannot be detected. +- `--timeout`: positive finite seconds per subprocess; default 60. This is not + a total workflow deadline. +- `--gws`: trusted executable, resolved before changing subprocess working + directories. Existing `gws` authentication and Model Armor environment + settings are inherited. No credential values or raw process diagnostics + are inserted into HTML or error messages. + +The companion requests `docs documents get` with `includeTabsContent: true` +and uses `drive files export` with `--format json` and fixed relative +`--output` filenames. Every `gws` subprocess runs inside the new bundle. +Export success, MIME type, destination and byte count are checked against the +written artifact. The receipt must name the exact canonical absolute destination, +as returned by `gws`; a matching basename alone is insufficient. This requires +the existing `gws` binary-export receipt format. + +Open `index.html` locally. The index has no JavaScript or remote dependencies. +It contains a sandboxed PDF frame, optional page images, a paragraph/table +outline grouped by native tabs, DOCX figures and nearby text, native object +metadata, and escaped Markdown source. Some browsers block local PDF frames; +use the PDF artifact link in that case. Markdown is readable escaped source, +not rendered Markdown. + +## Artifacts and completion + +| File | Meaning | +| --- | --- | +| `source.json` | Original Docs JSON response, including all returned tabs | +| `revision-after.json` | Final revision observation, when available | +| `document.pdf`, `document.docx`, `document.md` | Required native Drive exports | +| `comments.json` | Optional fully retrieved comments result | +| `assets/` | Recognized DOCX PNG, JPEG, GIF and WebP media | +| `pages/` | Optional successful local page-rendering output | +| `index.html` | Local review index | +| `manifest.json` | State, versions, capabilities, limitations, mappings and hashes | + +The manifest begins as `in-progress` and becomes `complete` only after all +required exports, validation, index generation and artifact hashing succeed. +`complete` means the required bundle files were produced; it does **not** mean +an atomic snapshot, successful optional rendering, or verified export tab +coverage. Check `revisions`, `comments`, and `rendering` independently. + +Page previews are never labeled `complete`. `rendering.status: "available"` +means that local PNG files passed validation, while +`rendering.coverage: "unverified"` means the original PDF page count was not +independently checked. Even a contiguous list beginning with page 1 may omit +later pages. The HTML displays this same coverage limitation. + +Revision status is `mixed` for differing observed Docs revision IDs, `unchanged` +for equal nonempty IDs, and `unknown` if either is missing. Even `unchanged` +does not prove atomicity or that every export represents the same revision. +The manifest always records `atomic_snapshot: false`. + +Required failures return exit code 1 and leave a `failed` manifest with a safe +error code and stage. If writing the failure state also fails (for example, +a full disk), the earlier `in-progress` state can remain. Process termination +can also leave that state. No such bundle should be treated as complete. +Usage errors return 2. Optional failures and mixed/unknown revisions return 0 +when the required bundle completes. Existing output directories are never +overwritten; retries need a new name. + +SHA-256 and byte counts cover each produced artifact, including the index and +assets. The manifest does not hash itself and is not an authenticity signature. + +## Scope and safety limits + +- Native JSON traversal includes nested tabs, body paragraphs, tables and + tables of contents. The outline identifies paragraph styles but does not + recreate full layout. Headers, footers, notes, lists, equations, charts, + suggestions and other document features are not fully represented. +- Google determines the PDF/DOCX/Markdown export layout and tab coverage. + This companion cannot verify that every tab appears in those formats or + associate a PDF page/DOCX figure with an exact native tab. +- DOCX figure order follows individual image occurrences in the main document, + including drawings in tables and nested text boxes. Each occurrence uses its + nearest drawing's alt text and nearest paragraph's text; legitimate repeated + uses of an asset remain separate figures. Alt text and nearby paragraphs are + context, **not exact captions**. Native object IDs are recorded separately; + the companion never fabricates a matching native ID or source tab ID. +- Media basenames are replaced with distinct generated local filenames. + External, missing, traversing and unsupported relationships remain visibly + unavailable. Unreferenced recognized rasters are retained as artifacts. +- ZIP validation rejects traversal, absolute/Windows/control-character paths, + duplicate names (case-insensitive), symlinks and other special files, + encryption and unsupported compression. It never calls `extractall`. + Limits are 2,000 members, 20 MiB per member and 100 MiB total uncompressed. + ZIP paths are restricted to printable ASCII. Only stored/deflate compression + is accepted. +- Every XML/relationship member is parsed after rejecting DTDs, entities, + UTF-16/32 and non-UTF-8 encodings. XML trees are limited to 100 levels and + 100,000 nodes per part. The supported DOCX vocabulary is transitional OOXML + main-document drawings; unsupported content can remain unmapped. +- Each source/export/JSON artifact is capped at 20 MiB. Page output is limited + to 500 files and 100 MiB total. PDF header/EOF and raster signatures are + checked; these are **not full format validation**. Renderer exit success and + contiguous numbering do not independently prove the original PDF page count, + so preview coverage is always labeled unverified. +- Subprocess capture is file-backed, with size checks after exit. Timeouts and + post-render limits do not enforce disk or memory quotas on external tools. + `pdftoppm`, `gws`, local viewers and the operator-controlled parent directory + are trusted. This is not a sandbox against a concurrent local attacker. +- Display text is HTML/attribute escaped; URI references use generated local + allowlisted names. The index has a restrictive content-security policy and + no scripts. Source URLs and recognizable bearer/token strings are redacted + from display text, but this is not a general secret scanner or Model Armor + replacement. Existing `gws` sanitization behavior is preserved; binary + exports are not made safe by JSON sanitization. +- Raw artifacts deliberately retain original content, including any temporary + URLs or sensitive text. Treat the whole directory as sensitive. Review + external links and active content in native viewers separately; the + companion never follows them automatically. + +## Offline fixtures and visual QA + +Offline mode performs no `gws` calls. Supply a relative directory containing +`source.json`, `document.pdf`, `document.docx`, and UTF-8 `document.md`. +`revision-after.json` and `comments.json` are optional; a missing revision +observation remains unknown. Files and path components must not be symlinks. + +Generate entirely synthetic fixtures using the test utility, then create a +review bundle suitable for inspecting the HTML: + +```sh +python3 -B - <<'PY' +import sys +from pathlib import Path +sys.path.insert(0, "examples/docs-review-bundle") +from test_docs_review_bundle import fixtures +fixtures(Path("synthetic-docs-fixture")) +PY + +python3 -B examples/docs-review-bundle/docs_review_bundle.py \ + --from-fixture synthetic-docs-fixture --render-pages synthetic-docs-review +``` + +These generated fixtures intentionally contain HTML injection strings, an +untrusted synthetic URL, duplicate image basenames, missing image URIs, +nested tabs and table content. They are hand-built test data, not an actual +Google export or proof of cross-format fidelity. No real documents, +credentials or network access are needed. Omit `--render-pages` to avoid +running external software. + +## Tests + +```sh +python3 -B -m unittest discover \ + -s examples/docs-review-bundle -p 'test_*.py' -v +``` + +Tests use generated JSON/PDF/DOCX files and explicit `gws`/renderer executables +as stubs. Their subprocess environment omits real authentication settings; +no installed `gws`, real document, or network request is needed for those tests. + +To include the real CLI export-contract regression: + +```sh +cargo build --locked +GWS_TEST_BINARY="$PWD/target/debug/gws" python3 -B -m unittest discover \ + -s examples/docs-review-bundle -p 'test_*.py' -v +``` + +This additional test uses cached synthetic Discovery, a dummy token, isolated +configuration and ADC paths, and a loopback HTTP server. It downloads all three +generated exports through the real CLI and checks that the bundle completes. +It never contacts Google or uses real credentials. The dedicated Linux/macOS CI +job builds `gws` and always enables this test; local runs without +`GWS_TEST_BINARY` explicitly skip it. diff --git a/examples/docs-review-bundle/docs_review_bundle.py b/examples/docs-review-bundle/docs_review_bundle.py new file mode 100755 index 000000000..2ba9ee7c0 --- /dev/null +++ b/examples/docs-review-bundle/docs_review_bundle.py @@ -0,0 +1,783 @@ +#!/usr/bin/env python3 +# 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 at https://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. + +"""Build a local visual review bundle using Python's standard library and gws.""" + +import argparse +import hashlib +import html +import io +import json +import math +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET +import zipfile + + +VERSION = "1.0" +MIB = 1024 * 1024 +FILE_LIMIT = 20 * MIB +TOTAL_LIMIT = 100 * MIB +MEMBER_COUNT = 2000 +MAX_PAGES = 500 +EXPORTS = { + "document.pdf": "application/pdf", + "document.docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "document.md": "text/markdown", +} +W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" +WP = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}" +R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}" +REL = "{http://schemas.openxmlformats.org/package/2006/relationships}" +LIMITATIONS = [ + "Exports are sequential, not an atomic snapshot; unchanged revisions are observations only.", + "Native outline includes body paragraphs, tables and nested tabs, not full layout or styling.", + "Drive export tab coverage is not verified; pages and DOCX figures have no reliable tab mapping.", + "Page preview coverage is unverified; available previews may omit pages.", + "DOCX relationships provide document order and nearby text, " + "not exact captions or native Docs IDs.", + "Only recognized PNG, JPEG, GIF and WebP media are previewed; " + "signatures are not full validation.", + "Headers, footers, notes, charts, vectors and unsupported drawings " + "may be absent from the outline or figures.", + "Content URIs and document hyperlinks are never fetched; URLs are redacted from display text.", + "Raw exports and JSON are sensitive, unsanitized source artifacts " + "and may contain temporary URLs.", + "The companion is not a document sanitizer or a sandbox for PDF/image viewers or pdftoppm.", +] + + +class BundleError(Exception): + """A fixed, non-sensitive failure code safe for manifests and terminals.""" + + +def relative_parts(value): + """Conservative portable path subset; do not normalize away traversal.""" + parts = value.split("/") + if not parts or any( + part in ("", ".", "..") or not re.fullmatch(r"[A-Za-z0-9_. -]+", part) + for part in parts + ): + raise BundleError("unsafe-relative-path") + return parts + + +def directory(value, *, create=False): + """Walk existing parents with no-follow descriptors (Linux/macOS).""" + parts = relative_parts(value) + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + fd = os.open(".", flags) + try: + for index, part in enumerate(parts): + if create and index == len(parts) - 1: + os.mkdir(part, mode=0o700, dir_fd=fd) + next_fd = os.open(part, flags, dir_fd=fd) + os.close(fd) + fd = next_fd + except OSError: + raise BundleError("directory-exists-or-unsafe") from None + finally: + os.close(fd) + return Path.cwd().joinpath(*parts) + + +def read_bytes(path, limit=FILE_LIMIT): + """Bound reads and reject special files, including symlinks.""" + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "rb") as stream: + info = os.fstat(stream.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_size > limit: + raise BundleError("file-type-or-size-limit") + data = stream.read(limit + 1) + except OSError: + raise BundleError("missing-or-unsafe-file") from None + if len(data) > limit: + raise BundleError("file-size-limit") + return data + + +def write_bytes(path, data): + # Files are new, inside the newly created private bundle directory. + with path.open("xb") as stream: + stream.write(data) + + +def json_bytes(value): + return (json.dumps(value, ensure_ascii=True, indent=2) + "\n").encode("utf-8") + + +def parse_json(data): + try: + value = json.loads(data) + except (ValueError, UnicodeError): + raise BundleError("invalid-json") from None + if not isinstance(value, dict) or "error" in value: + raise BundleError("invalid-json-response") + return value + + +def publish_manifest(bundle, manifest): + temporary = bundle / ".manifest.tmp" + write_bytes(temporary, json_bytes(manifest)) + os.replace(temporary, bundle / "manifest.json") + + +def local_uri(value): + """HTML references are generated file names, never document-supplied URIs.""" + if not re.fullmatch(r"[A-Za-z0-9_-]+(?:[./][A-Za-z0-9_-]+)*", value): + raise BundleError("unsafe-local-uri") + return value + + +def display(value): + text = str(value or "") + text = re.sub(r"(?i)\b(?:https?|ftp|file|data|javascript):[^\s<>\"']+", "[URL omitted]", text) + text = re.sub(r"(?i)\bBearer\s+\S+", "[credential omitted]", text) + text = re.sub( + r"(?i)\b(?:access_token|authorization|token)\s*[:=]\s*[^\s<>\"']+", + "[credential omitted]", + text, + ) + text = "".join(char for char in text if char in "\n\t" or ord(char) >= 32) + return html.escape(text, quote=True) + + +def safe_xml(data): + # Reject UTF-16/32 (including declaration smuggling via NUL bytes), DTDs and + # entities before giving XML to the stdlib parser. DOCX normally uses UTF-8. + if b"\x00" in data or re.search(br"]*\bencoding\s*=\s*['\"]([^'\"]+)", text, re.I) + if declaration and declaration[1].lower() not in ("utf-8", "utf8", "us-ascii"): + raise BundleError("unsupported-xml-encoding") + root = ET.fromstring(text) + except (ET.ParseError, UnicodeError): + raise BundleError("invalid-xml") from None + pending = [(root, 0)] + count = 0 + while pending: + node, depth = pending.pop() + count += 1 + if depth > 100 or count > 100_000: + raise BundleError("xml-complexity-limit") + pending.extend((child, depth + 1) for child in node) + return root + + +def raster_extension(data): + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "png" + if data.startswith(b"\xff\xd8\xff"): + return "jpg" + if data.startswith((b"GIF87a", b"GIF89a")): + return "gif" + if data.startswith(b"RIFF") and data[8:12] == b"WEBP": + return "webp" + return None + + +def zip_members(source, member_limit, total_limit, member_count): + """Read a bounded archive without ever extracting its member paths.""" + try: + with zipfile.ZipFile(io.BytesIO(read_bytes(source))) as archive: + infos = archive.infolist() + if len(infos) > member_count: + raise BundleError("zip-member-count-limit") + seen = set() + declared_total = 0 + for info in infos: + name = info.orig_filename + parts = name.rstrip("/").split("/") + kind = stat.S_IFMT(info.external_attr >> 16) + if ( + name != info.filename + or name.startswith("/") + or "\\" in name + or ":" in name + or any(part in ("", ".", "..") for part in parts) + or any(ord(char) < 32 or ord(char) > 126 for char in name) + or kind not in (0, stat.S_IFREG, stat.S_IFDIR) + or info.flag_bits & 1 + or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED) + or name.casefold() in seen + ): + raise BundleError("unsafe-zip-member") + seen.add(name.casefold()) + declared_total += info.file_size + if info.file_size > member_limit or declared_total > total_limit: + raise BundleError("zip-size-limit") + members = {} + actual_total = 0 + for info in infos: + if info.is_dir(): + continue + with archive.open(info) as stream: + data = stream.read(member_limit + 1) + actual_total += len(data) + if ( + len(data) > member_limit + or actual_total > total_limit + or len(data) != info.file_size + ): + raise BundleError("zip-size-limit") + members[info.filename] = data + return members + except (zipfile.BadZipFile, RuntimeError, NotImplementedError, OSError, EOFError): + raise BundleError("invalid-zip") from None + + +def docx_image_occurrences(document): + """Visit each blip once, retaining its nearest paragraph and drawing.""" + paragraph_text = {} + paragraph_index = {} + drawing_alt = {} + occurrences = [] + pending = [(document, None, None)] + while pending: + node, paragraph, drawing = pending.pop() + if node.tag == W + "p": + paragraph = node + paragraph_index[node] = len(paragraph_text) + paragraph_text[node] = [] + elif node.tag == W + "drawing": + drawing = node + elif node.tag == W + "t" and paragraph is not None: + paragraph_text[paragraph].append(node.text or "") + elif node.tag == WP + "docPr" and drawing is not None: + drawing_alt.setdefault( + drawing, " ".join(node.get(field, "") for field in ("title", "descr")).strip() + ) + elif node.tag == A + "blip" and paragraph is not None and drawing is not None: + occurrences.append((node, paragraph, drawing)) + pending.extend((child, paragraph, drawing) for child in reversed(node)) + + texts = ["".join(parts) for parts in paragraph_text.values()] + for blip, paragraph, drawing in occurrences: + index = paragraph_index[paragraph] + nearby = texts[index] or " ".join( + texts[max(0, index - 1):index] + texts[index + 1:index + 2] + ) + yield blip, drawing_alt.get(drawing, ""), nearby + + +def extract_docx( + source, output, *, + member_limit=FILE_LIMIT, total_limit=TOTAL_LIMIT, member_count=MEMBER_COUNT, +): + members = zip_members(source, member_limit, total_limit, member_count) + # Validate even unused XML before any asset writes. + trees = { + name: safe_xml(data) + for name, data in members.items() + if name.lower().endswith((".xml", ".rels")) + } + document = trees.get("word/document.xml") + if document is None or document.tag != W + "document": + raise BundleError("missing-docx-document") + relationships = {} + rels = trees.get("word/_rels/document.xml.rels") + if rels is not None: + for rel in rels.findall(REL + "Relationship"): + identity = rel.get("Id") + if not identity or identity in relationships: + raise BundleError("ambiguous-docx-relationship") + relationships[identity] = rel.attrib + output.mkdir(mode=0o700) + assets = {} + for name, data in members.items(): + extension = raster_extension(data) + if name.startswith("word/media/") and extension: + filename = f"image-{len(assets) + 1}.{extension}" + write_bytes(output / filename, data) + assets[name] = local_uri(f"{output.name}/{filename}") + figures = [] + for blip, alt, nearby in docx_image_occurrences(document): + identity = blip.get(R + "embed") or blip.get(R + "link") + relationship = relationships.get(identity, {}) + asset = None + availability = "missing-or-unsupported" + if relationship.get("TargetMode", "").lower() == "external": + availability = "external-not-fetched" + elif relationship.get("Type") == R[1:-1] + "/image": + target = relationship.get("Target", "") + # Allow only relative media targets in the document's part. + if ( + target.startswith("media/") + and not any(p in ("", ".", "..") for p in target.split("/")) + and not any(char in target for char in "\\:%?#") + ): + asset = assets.get(str(PurePosixPath("word") / target)) + if asset: + availability = "available" + figures.append({ + "order": len(figures) + 1, + "relationship_id": identity, + "asset": asset, + "alt": alt, + "nearby_text": nearby[:1000], + "availability": availability, + "mapping_confidence": "docx-relationship-only", + "native_object_id": None, + "source_tab_id": None, + }) + return {"figures": figures, "assets": list(assets.values())} + + +def native_view(source): + if not isinstance(source.get("body"), dict) and not isinstance(source.get("tabs"), list): + raise BundleError("missing-document-content") + tabs = [] + images = [] + + def add_tab(tab, depth): + if depth > 50 or len(tabs) >= 1000: + raise BundleError("native-tab-limit") + properties = tab.get("tabProperties", {}) + document = tab.get("documentTab", {}) + tab_id = properties.get("tabId") + tabs.append({ + "id": tab_id, + "title": properties.get("title", "Document"), + "depth": depth, + "blocks": document.get("body", {}).get("content", []), + }) + for collection, property_name in ( + ("inlineObjects", "inlineObjectProperties"), + ("positionedObjects", "positionedObjectProperties"), + ): + for object_id, obj in document.get(collection, {}).items(): + embedded = obj.get(property_name, {}).get("embeddedObject", {}) + images.append({ + "object_id": object_id, + "tab_id": tab_id, + "title": embedded.get("title", ""), + "description": embedded.get("description", ""), + "content_uri_available": bool( + embedded.get("imageProperties", {}).get("contentUri") + ), + "asset": None, + "mapping_confidence": "unmapped", + }) + for child in tab.get("childTabs", []): + add_tab(child, depth + 1) + + if source.get("tabs"): + for tab in source["tabs"]: + add_tab(tab, 0) + else: + add_tab({"documentTab": source}, 0) + return tabs, images + + +def outline_html(blocks, depth=0): + if depth > 50: + raise BundleError("native-outline-depth-limit") + parts = [] + for block in blocks: + if "paragraph" in block: + paragraph = block["paragraph"] + text = "".join( + element.get("textRun", {}).get("content", "") + for element in paragraph.get("elements", []) + ) + style = paragraph.get("paragraphStyle", {}).get("namedStyleType", "NORMAL_TEXT") + parts.append(f"

{display(style)} {display(text)}

") + elif "table" in block: + parts.append("") + for row in block["table"].get("tableRows", []): + parts.append("") + for cell in row.get("tableCells", []): + parts.append( + "" + ) + parts.append("") + parts.append("
" + outline_html(cell.get("content", []), depth + 1) + "
") + elif "tableOfContents" in block: + parts.append(outline_html(block["tableOfContents"].get("content", []), depth + 1)) + return "".join(parts) + + +def index_html(source, markdown, tabs, manifest, artifacts): + parts = [ + '', + '', + '", + "Docs review bundle", + "", + f"

{display(source.get('title', 'Docs review bundle'))}

", + f"

Revision observation: {display(manifest['revisions']['status'])}. " + "Sequential exports; not an atomic snapshot.

", + "

Artifacts

    ", + ] + for name in [*artifacts, "manifest.json"]: + parts.append(f'
  • {display(name)}
  • ') + parts.extend( + [ + "

Raw files may contain sensitive content and temporary URLs.

", + "

Native PDF

", + '', + "

If your browser blocks the embedded viewer, open the local PDF artifact.

", + f"

Raster previews: {display(manifest['rendering']['status'])}; " + f"{display(manifest['rendering'].get('reason', ''))}

", + ] + ) + if manifest["rendering"]["pages"]: + parts.append( + f"

Page coverage: {display(manifest['rendering']['coverage'])}. " + "The PDF page count has not been verified; previews may omit pages.

" + ) + for page in manifest["rendering"]["pages"]: + parts.append(f'{display(page)}') + parts.append("

Native outline and tabs

") + for tab in tabs: + parts.append( + f"

{display(tab['title'])}

" + f"

Tab: {display(tab['id'] or 'legacy body')}; " + f"depth: {tab['depth']}

{outline_html(tab['blocks'])}" + ) + parts.append("

DOCX figures

") + for figure in manifest["figures"]: + parts.append(f"

Figure {figure['order']}

") + if figure["asset"]: + parts.append( + f'' + ) + parts.append( + f"
{display(figure['alt'])}
" + f"

Availability: {display(figure['availability'])}

" + f"

Nearby text (not an exact caption): {display(figure['nearby_text'])}

" + "

DOCX relationship only; native object and source tab mapping unknown.

" + ) + parts.append("

Native image metadata

") + for image in manifest["native_images"]: + parts.append( + f"

Object {display(image['object_id'])}, tab {display(image['tab_id'])}: " + f"{display(image['title'])} {display(image['description'])}. " + f"contentUri available: {image['content_uri_available']}; asset mapping: unmapped.

" + ) + parts.append( + "

Readable Markdown source

" + f"
{display(markdown)}
" + ) + parts.append( + f"

Comments

{display(manifest['comments']['status'])}

" + ) + parts.append("

Capabilities and limitations

    ") + parts.extend(f"
  • {display(item)}
  • " for item in LIMITATIONS) + parts.append("
") + return "".join(parts).encode("utf-8") + + +def run_process(argv, bundle, timeout): + # File-backed capture bounds memory. No shell and no untrusted diagnostics + # echoed to the terminal or copied into the manifest/display HTML. + with tempfile.TemporaryFile(dir=bundle) as output: + try: + process = subprocess.run( + argv, + cwd=bundle, + stdout=output, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + raise BundleError("timeout") from None + except OSError: + raise BundleError("process-unavailable") from None + if process.returncode: + raise BundleError("process-failed") + if output.tell() > FILE_LIMIT: + raise BundleError("process-output-limit") + output.seek(0) + return output.read(FILE_LIMIT + 1) + + +def gws_json(executable, bundle, timeout, command, params, output=None): + argv = [executable, *command, "--params", json.dumps(params), "--format", "json"] + if output: + argv.extend(["--output", local_uri(output)]) + data = run_process(argv, bundle, timeout) + return data, parse_json(data) + + +def collect_comments(executable, bundle, timeout, document_id): + comments = [] + seen = set() + token = None + total = 0 + for _ in range(100): + params = {"fileId": document_id, "fields": "nextPageToken,comments", "pageSize": 100} + if token: + params["pageToken"] = token + raw, page = gws_json(executable, bundle, timeout, ["drive", "comments", "list"], params) + total += len(raw) + if total > FILE_LIMIT or not isinstance(page.get("comments", []), list): + raise BundleError("invalid-or-oversized-comments") + comments.extend(page.get("comments", [])) + token = page.get("nextPageToken") + if not token: + return json_bytes({"comments": comments}) + if not isinstance(token, str) or token in seen: + raise BundleError("incomplete-comments") + seen.add(token) + raise BundleError("incomplete-comments") + + +def render_pages(bundle, timeout, requested): + result = {"status": "not-requested", "pages": []} + if not requested: + return result + renderer = shutil.which("pdftoppm") + if not renderer: + return {"status": "unavailable", "reason": "pdftoppm-not-found; PDF retained", "pages": []} + try: + renderer = str(Path(renderer).resolve()) + with tempfile.TemporaryDirectory(prefix=".render-", dir=bundle) as temp: + staging = Path(temp) + prefix = str(staging.relative_to(bundle) / "page") + run_process([renderer, "-png", "-r", "96", "document.pdf", prefix], bundle, timeout) + pages = {} + total = 0 + for path in staging.iterdir(): + match = re.fullmatch(r"page-([0-9]+)\.png", path.name) + if not match: + raise BundleError("invalid-render") + number = int(match[1]) + data = read_bytes(path) + total += len(data) + if number in pages or raster_extension(data) != "png" or total > TOTAL_LIMIT: + raise BundleError("invalid-render") + pages[number] = path.name + if ( + not pages + or len(pages) > MAX_PAGES + or sorted(pages) != list(range(1, len(pages) + 1)) + ): + raise BundleError("invalid-render") + staging.rename(bundle / "pages") + return { + "status": "available", + "coverage": "unverified", + "pages": [local_uri("pages/" + pages[number]) for number in sorted(pages)], + } + except (BundleError, OSError) as error: + return { + "status": "failed", "pages": [], + "reason": "timeout" if str(error) == "timeout" else "invalid-or-failed-render", + } + + +def revision_observation(before, after): + before_id = before.get("revisionId") + after_id = after.get("revisionId") + before_id = before_id if isinstance(before_id, str) and before_id else None + after_id = after_id if isinstance(after_id, str) and after_id else None + status = "unknown" + if before_id and after_id: + status = "unchanged" if before_id == after_id else "mixed" + return {"before": before_id, "after": after_id, "status": status, "atomic_snapshot": False} + + +def build_bundle(args, bundle, manifest): + artifacts = ["source.json", *EXPORTS] + manifest["stage"] = "exports" + if args.from_fixture: + fixture = directory(args.from_fixture) + for name in artifacts: + write_bytes(bundle / name, read_bytes(fixture / name)) + source = parse_json(read_bytes(bundle / "source.json")) + after_path = fixture / "revision-after.json" + after = parse_json(read_bytes(after_path)) if after_path.exists() else {} + if after_path.exists(): + write_bytes(bundle / "revision-after.json", read_bytes(after_path)) + artifacts.append("revision-after.json") + executable = None + else: + executable = shutil.which(args.gws) + if not executable: + raise BundleError("gws-not-found") + executable = str(Path(executable).resolve()) + try: + version = run_process([executable, "--version"], bundle, args.timeout).decode("utf-8") + match = re.fullmatch(r"gws ([0-9][A-Za-z0-9.+-]*)\s*", version) + manifest["versions"]["gws"] = match[1] if match else "unknown" + except BundleError: + manifest["versions"]["gws"] = "unknown" + params = {"documentId": args.document_id, "includeTabsContent": True} + raw, source = gws_json( + executable, bundle, args.timeout, ["docs", "documents", "get"], params + ) + if source.get("documentId") != args.document_id: + raise BundleError("document-id-mismatch") + write_bytes(bundle / "source.json", raw) + for name, mime in EXPORTS.items(): + _, receipt = gws_json( + executable, bundle, args.timeout, ["drive", "files", "export"], + {"fileId": args.document_id, "mimeType": mime}, output=name, + ) + data = read_bytes(bundle / name) + if ( + receipt.get("status") != "success" + or receipt.get("saved_file") != str((bundle / name).resolve()) + or str(receipt.get("mimeType", "")).split(";")[0].strip().lower() != mime + or type(receipt.get("bytes")) is not int + or receipt["bytes"] != len(data) + ): + raise BundleError("invalid-export-receipt") + raw, after = gws_json( + executable, bundle, args.timeout, ["docs", "documents", "get"], + {**params, "fields": "revisionId"}, + ) + write_bytes(bundle / "revision-after.json", raw) + artifacts.append("revision-after.json") + + manifest["revisions"] = revision_observation(source, after) + manifest["comments"] = {"status": "not-requested"} + if args.include_comments: + try: + if executable: + comments = collect_comments(executable, bundle, args.timeout, args.document_id) + else: + comments = read_bytes(fixture / "comments.json") + value = parse_json(comments) + if not isinstance(value.get("comments"), list) or value.get("nextPageToken"): + raise BundleError("incomplete-comments") + if len(comments) > FILE_LIMIT: + raise BundleError("comments-size-limit") + write_bytes(bundle / "comments.json", comments) + artifacts.append("comments.json") + manifest["comments"] = {"status": "available"} + except (BundleError, OSError) as error: + (bundle / "comments.json").unlink(missing_ok=True) + manifest["comments"] = { + "status": "unavailable", + "reason": ( + "comments-size-limit" + if isinstance(error, BundleError) and str(error) == "comments-size-limit" + else "retrieval-incomplete-or-failed" + ), + } + + manifest["stage"] = "validate-and-extract" + pdf = read_bytes(bundle / "document.pdf") + if not pdf.startswith(b"%PDF-") or b"%%EOF" not in pdf[-1024:]: + raise BundleError("invalid-or-truncated-pdf") + markdown = read_bytes(bundle / "document.md").decode("utf-8") + tabs, native_images = native_view(source) + extracted = extract_docx(bundle / "document.docx", bundle / "assets") + manifest["figures"] = extracted["figures"] + manifest["native_images"] = native_images + manifest["tabs"] = [{k: v for k, v in tab.items() if k != "blocks"} for tab in tabs] + artifacts.extend(extracted["assets"]) + manifest["stage"] = "render" + manifest["rendering"] = render_pages(bundle, args.timeout, args.render_pages) + artifacts.extend(manifest["rendering"]["pages"]) + manifest["stage"] = "index" + write_bytes(bundle / "index.html", index_html(source, markdown, tabs, manifest, artifacts)) + artifacts.append("index.html") + manifest["artifacts"] = {} + for name in artifacts: + data = read_bytes(bundle / local_uri(name)) + manifest["artifacts"][name] = { + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + manifest["status"] = "complete" + manifest["stage"] = "complete" + publish_manifest(bundle, manifest) + + +def positive_timeout(value): + number = float(value) + if not math.isfinite(number) or number <= 0: + raise argparse.ArgumentTypeError("timeout must be a positive finite number") + return number + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--document-id", help="Google Docs ID (not a URL)") + source.add_argument( + "--from-fixture", help="Relative directory containing captured export files" + ) + parser.add_argument("output_dir", help="New relative directory within CWD; parents must exist") + parser.add_argument("--include-comments", action="store_true") + parser.add_argument( + "--render-pages", action="store_true", help="Opt in to local pdftoppm rendering" + ) + parser.add_argument( + "--timeout", type=positive_timeout, default=60.0, + help="Seconds per process (default: 60)", + ) + parser.add_argument( + "--gws", default="gws", help="Trusted gws executable (default: PATH lookup)" + ) + args = parser.parse_args(argv) + if args.document_id and not re.fullmatch(r"[A-Za-z0-9_-]+", args.document_id): + parser.error("document-id must be a Google Docs ID, not a URL or path") + bundle = None + manifest = { + "schema_version": 1, "status": "in-progress", "stage": "initialize", + "mode": "offline" if args.from_fixture else "gws", + "versions": {"companion": VERSION, "python": sys.version.split()[0], "gws": None}, + "capabilities": { + "all_native_tabs": True, "native_pdf": True, "docx_raster_assets": True, + "native_markdown": True, "network_image_fetch": False, + "exact_native_asset_mapping": False, "atomic_snapshot": False, + }, + "limitations": LIMITATIONS, + } + try: + bundle = directory(args.output_dir, create=True) + publish_manifest(bundle, manifest) + build_bundle(args, bundle, manifest) + except (Exception, KeyboardInterrupt) as error: + code = str(error) if isinstance(error, BundleError) else "invalid-or-incomplete-bundle" + if bundle is not None: + manifest["status"] = "failed" + manifest["error"] = code + try: + publish_manifest(bundle, manifest) + except OSError: + # An earlier in-progress manifest remains non-complete if storage fails. + pass + print(f"Review bundle failed: {code}.", file=sys.stderr) + return 1 + print("Review bundle complete. Open index.html in the new output directory.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/docs-review-bundle/test_docs_review_bundle.py b/examples/docs-review-bundle/test_docs_review_bundle.py new file mode 100644 index 000000000..299ab0614 --- /dev/null +++ b/examples/docs-review-bundle/test_docs_review_bundle.py @@ -0,0 +1,1058 @@ +#!/usr/bin/env python3 +# 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 at https://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. + +"""Hermetic behavior tests: generated documents and executable process stubs only.""" + +import base64 +import contextlib +import hashlib +import http.server +from html.parser import HTMLParser +import importlib.util +import io +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest import mock +import urllib.parse +import zipfile + + +SCRIPT = Path(__file__).with_name("docs_review_bundle.py") +PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8" + "/x8AAwMCAO+jRZkAAAAASUVORK5CYII=" +) +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +R = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +A = "http://schemas.openxmlformats.org/drawingml/2006/main" +WP = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" +REL = "http://schemas.openxmlformats.org/package/2006/relationships" +REMOTE = "https://untrusted.invalid/image?token=SIGNED_SECRET" + + +def paragraph(text, style="NORMAL_TEXT"): + return { + "paragraph": { + "paragraphStyle": {"namedStyleType": style}, + "elements": [ + {"textRun": {"content": text, "textStyle": {"link": {"url": REMOTE}}}} + ], + } + } + + +def native_document(): + return { + "documentId": "synthetic-doc", + "title": 'Review ', + "revisionId": "revision-one", + "tabs": [ + { + "tabProperties": {"tabId": "tab-main", "title": "Main"}, + "documentTab": { + "body": { + "content": [ + paragraph("Heading ", "HEADING_1"), + { + "table": { + "tableRows": [ + { + "tableCells": [ + {"content": [paragraph("Table cell")]} + ] + } + ] + } + }, + { + "paragraph": { + "elements": [ + { + "inlineObjectElement": { + "inlineObjectId": "native-image" + } + } + ] + } + }, + ] + }, + "inlineObjects": { + "native-image": { + "inlineObjectProperties": { + "embeddedObject": { + "title": "Native figure", + "description": "No exact DOCX mapping", + "imageProperties": {"contentUri": REMOTE}, + } + } + }, + "missing-uri": { + "inlineObjectProperties": { + "embeddedObject": { + "description": "No downloadable URI", + "imageProperties": {}, + } + } + }, + }, + }, + "childTabs": [ + { + "tabProperties": {"tabId": "tab-child", "title": "Child"}, + "documentTab": { + "body": {"content": [paragraph("Nested tab paragraph")]} + }, + } + ], + } + ], + } + + +def pdf_bytes(page_count=1): + """A complete synthetic PDF, also usable by real pdftoppm.""" + content = ( + b"BT /F1 22 Tf 48 720 Td (Synthetic Docs review) Tj ET\n" + b"BT /F1 12 Tf 48 687 Td (Local fixture - no Google document) Tj ET\n" + b"0.85 0.92 1 rg 48 435 516 210 re f\n" + b"0.08 0.25 0.5 rg 72 459 120 162 re f\n" + b"0.12 0.45 0.6 rg 216 459 120 105 re f\n" + b"0.1 0.6 0.45 rg 360 459 120 140 re f\n" + b"0 0 0 rg BT /F1 12 Tf 48 402 Td (Synthetic figure and table context) Tj ET\n" + b"0.5 G 48 270 516 90 re S 48 315 m 564 315 l S\n" + b"306 270 m 306 360 l S\n" + b"BT /F1 12 Tf 60 333 Td (Column A) Tj 258 0 Td (Column B) Tj ET\n" + b"BT /F1 12 Tf 60 288 Td (Cell one) Tj 258 0 Td (Cell two) Tj ET\n" + ) + content_id = page_count + 3 + font_id = page_count + 4 + kids = " ".join(f"{number} 0 R" for number in range(3, page_count + 3)) + objects = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + f"<< /Type /Pages /Kids [{kids}] /Count {page_count} >>".encode(), + ] + objects.extend( + ( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + f"/Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>" + ).encode() + for _ in range(page_count) + ) + objects.extend([ + f"<< /Length {len(content)} >>\nstream\n".encode() + content + b"endstream", + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]) + data = b"%PDF-1.4\n" + offsets = [0] + for index, obj in enumerate(objects, 1): + offsets.append(len(data)) + data += f"{index} 0 obj\n".encode() + obj + b"\nendobj\n" + startxref = len(data) + object_count = len(objects) + 1 + data += f"xref\n0 {object_count}\n0000000000 65535 f \n".encode() + for offset in offsets[1:]: + data += f"{offset:010d} 00000 n \n".encode() + data += ( + f"trailer\n<< /Size {object_count} /Root 1 0 R >>\nstartxref\n{startxref}\n%%EOF\n" + ).encode() + return data + + +def docx_members(): + xml = f""" + + + +Nearby <script>bad()</script> + + +Table image context + + + + + +""" + rels = f""" + + +""" + return { + "word/document.xml": xml.encode(), + "word/_rels/document.xml.rels": rels.encode(), + "word/styles.xml": f''.encode(), + "word/media/image1.png": PNG, + "word/media/nested/image1.png": PNG + b"different", + } + + +def nested_docx_members(*, outer_image=False): + members = docx_members() + outer_blip = '' if outer_image else "" + members["word/document.xml"] = f""" + +Outer paragraph + +Inner paragraph + + + +{outer_blip} +""".encode() + return members + + +def write_docx(path, members=None): + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + for name, data in (members if members is not None else docx_members()).items(): + archive.writestr(name, data) + + +def fixtures(path): + path.mkdir() + (path / "source.json").write_text(json.dumps(native_document()), encoding="utf-8") + (path / "revision-after.json").write_text( + '{"revisionId":"revision-one"}', encoding="utf-8" + ) + (path / "document.pdf").write_bytes(pdf_bytes()) + (path / "document.md").write_text( + "# Markdown\n\n![remote](" + REMOTE + ")\n", + encoding="utf-8", + ) + write_docx(path / "document.docx") + return path + + +GWS_STUB = r''' +import json +import os +from pathlib import Path +import shutil +import sys +import time + +args = sys.argv[1:] +mode = os.environ.get("STUB_MODE", "") +fixture = Path(os.environ["STUB_FIXTURES"]) +with open(os.environ["STUB_LOG"], "a", encoding="utf-8") as log: + log.write(json.dumps({"args": args, "cwd": os.getcwd(), + "sanitize": os.environ.get("GOOGLE_WORKSPACE_CLI_SANITIZE_MODE")}) + "\n") +if args == ["--version"]: + print("gws 0.0.0-synthetic") + sys.exit(0) +params = json.loads(args[args.index("--params") + 1]) +assert args[args.index("--format") + 1] == "json" +if mode == "timeout": + time.sleep(10) +if args[:3] == ["docs", "documents", "get"]: + assert params["documentId"] == "synthetic-doc" + assert params["includeTabsContent"] is True + if params.get("fields") == "revisionId": + source = {"revisionId": "revision-two" if mode == "mixed" else "revision-one"} + if mode == "missing-revision": + source = {} + else: + source = json.loads((fixture / "source.json").read_text()) + print(json.dumps(source)) +elif args[:3] == ["drive", "files", "export"]: + assert params["fileId"] == "synthetic-doc" + expected = { + "application/pdf": "document.pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "document.docx", + "text/markdown": "document.md", + } + name = args[args.index("--output") + 1] + assert expected[params["mimeType"]] == name + assert "/" not in name and "\\" not in name + if mode == "failed-export" and name == "document.docx": + Path(name).write_bytes(b"partial") + print("Bearer PRIVATE_TOKEN " + "\x1b[31m", file=sys.stderr) + sys.exit(7) + if mode != "no-export-file": + shutil.copyfile(fixture / name, name) + print(json.dumps({ + "status": "error" if mode == "bad-export-status" else "success", + "saved_file": ( + str(Path.cwd().parent / "other" / name) if mode == "wrong-export-path" + else name if mode == "relative-export-path" + else str(Path(name).resolve()) + ), + "mimeType": params["mimeType"], + "bytes": 1 if mode == "wrong-export-size" else (fixture / name).stat().st_size, + })) +elif args[:3] == ["drive", "comments", "list"]: + assert params["fileId"] == "synthetic-doc" + assert "nextPageToken" in params["fields"] + if mode == "expanded-comments": + # ~8 MiB on the wire; >24 MiB after ensure_ascii=True serialization. + print(json.dumps( + {"comments": [{"id": "large", "content": "é" * (4 * 1024 * 1024)}]}, + ensure_ascii=False, separators=(",", ":"), + )) + sys.exit(0) + if mode == "failed-comments" and params.get("pageToken"): + sys.exit(9) + if params.get("pageToken"): + print(json.dumps({"comments": [{"id": "two", "content": "Second comment"}]})) + else: + print(json.dumps({"nextPageToken": "page-two", "comments": [{"id": "one"}]})) +else: + raise AssertionError(args) +''' + +RENDER_STUB = r''' +import base64 +import os +from pathlib import Path +import sys +import time +assert sys.argv[1:4] == ["-png", "-r", "96"] +assert sys.argv[-2] == "document.pdf" +prefix = Path(sys.argv[-1]) +png = base64.b64decode(os.environ["STUB_PNG"]) +mode = os.environ.get("RENDER_MODE", "") +Path(str(prefix) + "-1.png").write_bytes(png) +if mode == "failed": + sys.exit(2) +if mode == "timeout": + time.sleep(10) +if mode == "gap": + Path(str(prefix) + "-3.png").write_bytes(png) +''' + + +class HTMLInspection(HTMLParser): + def __init__(self, text): + super().__init__() + self.tags = [] + self.references = [] + self.feed(text) + + def handle_starttag(self, tag, attrs): + self.tags.append((tag, dict(attrs))) + for name, value in attrs: + if name in ("src", "href", "data"): + self.references.append(value) + + +class BundleTestCase(unittest.TestCase): + def setUp(self): + # All generated files stay inside this assigned worktree and are removed. + self.temp = tempfile.TemporaryDirectory(dir=SCRIPT.parent) + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name).resolve() + self.fixture = fixtures(self.root / "fixtures") + self.bin = self.root / "bin" + self.bin.mkdir() + self.env = { + "PATH": str(self.bin), + "PYTHONDONTWRITEBYTECODE": "1", + "STUB_FIXTURES": str(self.fixture), + "STUB_LOG": str(self.root / "gws.log"), + "STUB_PNG": base64.b64encode(PNG).decode(), + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": str(self.root / "unused-config"), + "GOOGLE_WORKSPACE_CLI_SANITIZE_MODE": "block", + } + + def api(self): + self.assertTrue(SCRIPT.is_file(), "Missing executable review bundle companion") + if not hasattr(self, "_api"): + spec = importlib.util.spec_from_file_location("docs_review_bundle", SCRIPT) + self._api = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self._api) + return self._api + + def executable(self, name, source): + path = self.bin / name + path.write_text(f"#!{sys.executable}\n" + source, encoding="utf-8") + path.chmod(0o700) + return path + + def run_bundle(self, *args, live=False, **env): + self.assertTrue(SCRIPT.is_file(), "Missing executable review bundle companion") + if live: + self.executable("gws", GWS_STUB) + source = ["--document-id", "synthetic-doc"] + else: + source = ["--from-fixture", "fixtures"] + return subprocess.run( + [sys.executable, "-B", str(SCRIPT), *source, *args], + cwd=self.root, + env={**self.env, **env}, + text=True, + capture_output=True, + timeout=15, + ) + + def manifest(self, directory="review"): + return json.loads((self.root / directory / "manifest.json").read_text()) + + +class ExportReceiptTests(BundleTestCase): + def test_canonical_receipts_complete_all_exports(self): + result = self.run_bundle("review", live=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.manifest()["status"], "complete") + + def test_wrong_destination_or_relative_receipt_is_refused(self): + for mode in ["wrong-export-path", "relative-export-path"]: + with self.subTest(mode=mode): + result = self.run_bundle(mode, live=True, STUB_MODE=mode) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(self.manifest(mode)["error"], "invalid-export-receipt") + + +@unittest.skipUnless(os.environ.get("GWS_TEST_BINARY"), "Set GWS_TEST_BINARY for real CLI coverage") +class RealCliExportTests(BundleTestCase): + def test_real_cli_exports_complete_bundle_with_canonical_receipts(self): + binary = Path(os.environ["GWS_TEST_BINARY"]).resolve(strict=True) + fixture = self.fixture + requests = [] + exports = { + "application/pdf": "document.pdf", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "document.docx", + "text/markdown": "document.md", + } + + class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + parsed = urllib.parse.urlsplit(self.path) + path = urllib.parse.unquote(parsed.path) + params = urllib.parse.parse_qs(parsed.query) + requests.append((path, params, self.headers.get("Authorization"))) + if path == "/documents/synthetic-doc": + data = (fixture / "source.json").read_bytes() + mime = "application/json" + elif path == "/files/synthetic-doc/export": + mime = params.get("mimeType", [""])[0] + if mime not in exports: + self.send_error(400) + return + data = (fixture / exports[mime]).read_bytes() + else: + self.send_error(404) + return + self.send_response(200) + self.send_header("Content-Type", mime) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + config = self.root / "real-cli-config" + cache = config / "cache" + cache.mkdir(parents=True) + (self.root / ".env").write_text("") + for service, version, resource, method, id_field, path in [ + ("docs", "v1", "documents", "get", "documentId", "documents/{documentId}"), + ("drive", "v3", "files", "export", "fileId", "files/{fileId}/export"), + ]: + discovery = { + "name": service, "version": version, + "rootUrl": f"http://127.0.0.1:{server.server_port}/", + "resources": {resource: {"methods": {method: { + "httpMethod": "GET", "path": path, + "parameters": {id_field: { + "type": "string", "location": "path", "required": True, + }}, + }}}}, + } + (cache / f"{service}_{version}.json").write_text(json.dumps(discovery)) + env = { + **self.env, + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": str(config), + "GOOGLE_WORKSPACE_CLI_TOKEN": "synthetic-loopback-token", + "GOOGLE_APPLICATION_CREDENTIALS": str(self.root / "absent-adc.json"), + "GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND": "file", + "GOOGLE_WORKSPACE_PROJECT_ID": "synthetic-loopback-project", + "HTTP_PROXY": "http://127.0.0.1:1", + "HTTPS_PROXY": "http://127.0.0.1:1", + "ALL_PROXY": "http://127.0.0.1:1", + "NO_PROXY": "127.0.0.1,localhost", + } + result = subprocess.run( + [sys.executable, "-B", str(SCRIPT), "--document-id", "synthetic-doc", + "--gws", str(binary), "--timeout", "10", "review"], + cwd=self.root, env=env, text=True, capture_output=True, timeout=25, + ) + self.assertEqual(result.returncode, 0, result.stderr) + manifest = self.manifest() + self.assertEqual(manifest["status"], "complete") + self.assertEqual(manifest["revisions"]["status"], "unchanged") + for name in exports.values(): + data = (self.root / "review" / name).read_bytes() + self.assertEqual(data, (fixture / name).read_bytes()) + self.assertEqual(manifest["artifacts"][name]["bytes"], len(data)) + self.assertEqual(len(requests), 5) + self.assertTrue(all(auth == "Bearer synthetic-loopback-token" + for _, _, auth in requests)) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +class ExtractionTests(BundleTestCase): + def extract(self, **limits): + return self.api().extract_docx( + self.fixture / "document.docx", self.root / "assets", **limits + ) + + def test_relationships_preserve_order_alt_text_and_table_context(self): + result = self.extract() + figures = result["figures"] + self.assertEqual([f["order"] for f in figures], [1, 2, 3]) + self.assertIn('Figure " onerror="bad()', figures[0]["alt"]) + self.assertIn("Table image context", figures[1]["nearby_text"]) + self.assertIsNone(figures[2]["asset"]) + self.assertEqual(figures[2]["availability"], "external-not-fetched") + self.assertIsNone(figures[0]["native_object_id"]) + self.assertEqual(figures[0]["mapping_confidence"], "docx-relationship-only") + + def test_duplicate_basenames_get_distinct_local_assets(self): + result = self.extract() + paths = [f["asset"] for f in result["figures"][:2]] + self.assertNotEqual(*paths) + self.assertEqual((self.root / paths[0]).read_bytes(), PNG) + self.assertEqual((self.root / paths[1]).read_bytes(), PNG + b"different") + + def test_nested_text_box_image_has_one_occurrence_with_inner_ownership(self): + write_docx(self.fixture / "document.docx", nested_docx_members()) + figures = self.extract()["figures"] + self.assertEqual(len(figures), 1) + self.assertEqual(figures[0]["order"], 1) + self.assertEqual(figures[0]["alt"], "Inner image") + self.assertEqual(figures[0]["nearby_text"], "Inner paragraph") + self.assertEqual((self.root / figures[0]["asset"]).read_bytes(), PNG) + + def test_nested_image_order_and_context_follow_nearest_owners(self): + write_docx(self.fixture / "document.docx", nested_docx_members(outer_image=True)) + figures = self.extract()["figures"] + self.assertEqual([f["relationship_id"] for f in figures], ["rId1", "rId2"]) + self.assertEqual([f["alt"] for f in figures], ["Inner image", "Outer text box"]) + self.assertEqual( + [f["nearby_text"] for f in figures], ["Inner paragraph", "Outer paragraph"] + ) + self.assertEqual([f["order"] for f in figures], [1, 2]) + + def test_repeated_asset_uses_remain_distinct_figure_occurrences(self): + members = docx_members() + members["word/document.xml"] = members["word/document.xml"].replace( + b'r:embed="rId2"', b'r:embed="rId1"' + ) + write_docx(self.fixture / "document.docx", members) + figures = self.extract()["figures"] + self.assertEqual(len(figures), 3) + self.assertEqual([f["order"] for f in figures], [1, 2, 3]) + self.assertEqual(figures[0]["asset"], figures[1]["asset"]) + self.assertEqual([f["relationship_id"] for f in figures[:2]], ["rId1", "rId1"]) + self.assertNotEqual(figures[0]["alt"], figures[1]["alt"]) + self.assertEqual(figures[1]["nearby_text"], "Table image context") + + def test_zip_traversal_absolute_windows_and_control_paths_are_rejected(self): + api = self.api() + for index, name in enumerate( + ["../escape", "/escape", "C:/escape", r"..\escape", "word/../escape", "bad\x01"] + ): + with self.subTest(name=name): + members = {**docx_members(), name: b"bad"} + write_docx(self.fixture / "document.docx", members) + with self.assertRaises(api.BundleError): + api.extract_docx( + self.fixture / "document.docx", self.root / f"assets-{index}" + ) + self.assertFalse((self.root / "escape").exists()) + + def test_zip_symlink_rejected(self): + api = self.api() + with zipfile.ZipFile(self.fixture / "document.docx", "a") as archive: + link = zipfile.ZipInfo("word/media/link.png") + link.create_system = 3 + link.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(link, "../../../escape") + with self.assertRaises(api.BundleError): + self.extract() + + def test_member_total_and_count_limits_reject_before_writing_assets(self): + api = self.api() + for limits in ( + {"member_limit": 32}, + {"total_limit": 32}, + {"member_count": 2}, + ): + with self.subTest(limits=limits), self.assertRaises(api.BundleError): + self.extract(**limits) + self.assertFalse((self.root / "assets").exists()) + + def test_duplicate_archive_member_rejected(self): + api = self.api() + with zipfile.ZipFile(self.fixture / "document.docx", "a") as archive: + archive.writestr("WORD/MEDIA/IMAGE1.PNG", PNG) + with self.assertRaises(api.BundleError): + self.extract() + + def test_doctype_entities_and_utf16_are_rejected_even_in_unused_xml(self): + api = self.api() + for data in ( + b']>&x;', + b'', + ']>&x;'.encode("utf-16"), + ): + with self.subTest(data=data[:30]): + members = {**docx_members(), "word/unused.xml": data} + write_docx(self.fixture / "document.docx", members) + with self.assertRaises(api.BundleError): + self.extract() + self.assertFalse((self.root / "assets").exists()) + + def test_xml_encoding_allowlist_handles_declaration_whitespace(self): + api = self.api() + for encoding in ("UTF-7", "UTF-16", "UTF-32", "ISO-8859-1"): + for assignment in (f' = "{encoding}"', f"= '{encoding}'", f'\t=\n"{encoding}"'): + with self.subTest(encoding=encoding, assignment=assignment): + data = f''.encode("utf-8") + with self.assertRaises(api.BundleError) as raised: + api.safe_xml(data) + self.assertEqual(str(raised.exception), "unsupported-xml-encoding") + + def test_xml_utf8_bom_is_accepted_and_utf16_utf32_are_rejected(self): + api = self.api() + data = 'café'.encode("utf-8-sig") + root = api.safe_xml(data) + self.assertEqual(root.tag, "x") + self.assertEqual(root.text, "café") + for encoding in ("utf-16", "utf-32"): + with self.subTest(encoding=encoding), self.assertRaises(api.BundleError): + api.safe_xml("".encode(encoding)) + + def test_relationship_traversal_and_svg_are_not_local_html_assets(self): + members = docx_members() + members["word/_rels/document.xml.rels"] = members[ + "word/_rels/document.xml.rels" + ].replace(b"media/image1.png", b"../outside.png") + members["word/media/nested/image1.png"] = b"" + write_docx(self.fixture / "document.docx", members) + figures = self.extract()["figures"] + self.assertIsNone(figures[0]["asset"]) + self.assertIsNone(figures[1]["asset"]) + # The safe, now unreferenced raster is still preserved; the SVG is not. + files = list((self.root / "assets").iterdir()) + self.assertEqual(len(files), 1) + self.assertEqual(files[0].read_bytes(), PNG) + + def test_malformed_xml_and_corrupt_zip_fail_closed(self): + api = self.api() + for data in (b"not a zip", None): + if data is None: + write_docx( + self.fixture / "document.docx", + {**docx_members(), "word/document.xml": b"", html) + self.assertIn("<script>", html) + self.assertNotIn("SIGNED_SECRET", html) + self.assertNotIn("untrusted.invalid", html) + self.assertTrue(any(tag == "iframe" for tag, _ in inspection.tags)) + for tag, attrs in inspection.tags: + self.assertNotEqual(tag, "script") + self.assertFalse(any(name.startswith("on") for name in attrs)) + if tag == "iframe": + self.assertIn("sandbox", attrs) + for ref in inspection.references: + self.assertNotIn(":", ref) + self.assertNotIn("..", ref) + self.assertNotIn("%", ref) + self.assertTrue((self.root / "review" / ref).is_file(), ref) + + def test_native_outline_includes_tables_child_tabs_styles_and_uncertain_images(self): + result = self.run_bundle("review") + self.assertEqual(result.returncode, 0, result.stderr) + html = (self.root / "review/index.html").read_text() + for text in ("Table cell", "Nested tab paragraph", "HEADING_1", "tab-child"): + self.assertIn(text, html) + self.assertIn(" find.txt +printf '%s' 'reader' > replacement.txt + +python3 examples/docs-review/docs_review.py plan \ + --document SYNTHETIC_DOCUMENT_ID --tab t.synthetic \ + --find find.txt --replacement replacement.txt --out reviewed-plan.json + +# Inspect the full JSON plan: IDs, revision, literal text, diff, target, digest. +cat reviewed-plan.json + +# Local request/diff preview: validates the plan, calls no gws, writes no files. +python3 examples/docs-review/docs_review.py apply \ + --plan reviewed-plan.json --dry-run + +# After review: reread, compare, submit once, reread and verify. +python3 examples/docs-review/docs_review.py apply --plan reviewed-plan.json +``` + +Omit `--tab` only for a document containing exactly one tab. Child tabs count; +titles are not selectors. Use a new `--out` path when regenerating a plan. +An empty replacement file deletes the matched text. Empty finds and no-op +replacements are rejected. Do not use `echo` to prepare text files: it commonly +adds a newline, which this workflow deliberately rejects. + +The versioned plan contains the document/tab IDs, exact source revision, source +fingerprint, find/replacement text, expected count `1`, UTF-16 target offsets, +diff, and deterministic SHA-256 digest. It does not contain document titles, +the complete source, surrounding text, credentials, or command strings. +The diff is the exact removed/inserted text, not a full-document preview. +Plans still contain sensitive text if your inputs do; treat them accordingly. + +The digest detects accidental edits and binds the review fields together; it +is **not a signature or an authorization mechanism**. Anyone who can replace +the entire plan can recompute it. Protect the reviewed file and compare its +digest with your separately retained review record before applying. + +## Supported text and verification + +- Exactly one case-sensitive literal occurrence in one tab. Regex is disabled. + Overlapping occurrences also count as ambiguous. +- The match must fit inside one top-level body paragraph. It may cross text + style runs. Unicode offsets use UTF-16 code units, including emoji. +- Tables, images, headers, footers, footnotes, other paragraphs and other tabs + remain in the document. Text in non-body regions still counts toward + uniqueness: a duplicate in a header or table causes refusal. +- Apply reconstructs the plan from the fresh source and compares every field. + A changed revision, source, target or digest stops before submission. +- Exactly one `replaceAllText` request is sent with + `writeControl.requiredRevisionId` and `tabsCriteria.tabIds`. No block deletion, + full-document reupload, or arbitrary request from a plan is allowed. +- Success requires `occurrencesChanged == 1`, a new returned revision, and a + reread at that revision. The resulting text, paragraph/structure metadata, + shifted body indices, image metadata, other tabs, and untouched character + formatting must match the expected result. + +Google controls formatting inheritance **inside the replaced span**. This +example does not set or promise the replacement's character styles. It checks +the replacement text and all formatting outside that span, accepting changes +in text-run splitting that leave character formatting unchanged. Temporary +image `contentUri` values and gws `_sanitization` annotations are excluded from +fingerprints; other image/style metadata remains checked. The verification is +an API snapshot, not a visual rendering or a guarantee against later edits. + +## Deliberate limitations + +This is a text patch workflow, not a structural document editor. Paragraph +breaks, tabs/control characters, private-use characters and object markers +are rejected in find/replacement files. Moving blocks, inserting tables/images, +editing across images, and targets inside tables, tables of contents, headers, +footers or footnotes are unsupported. Structural requests cannot be supplied +through the plan. + +The selected tab must have no unresolved suggestions, named ranges or +bookmarks. Unknown tab regions, unknown structural blocks, equations, +automatic text, rich links and smart chips in the selected tab are refused. +These strict limits avoid interpreting inaccessible text, unstable anchors, +or unsupported element boundaries as a safe replacement. Other tabs are +fingerprinted and verified, not edited. Before normalization, every tab's +paragraph elements must have valid UTF-16 lengths and contiguous ranges. +Recognized regions and structural blocks must have valid object/list shapes, +including table rows and cell content. Malformed snapshots in any tab are +refused before submission or reported as ambiguous after submission. +Unknown metadata is retained for comparison; unknown structural blocks and +extra text-element fields that would be discarded are refused. New API +structures may require an explicit compatibility update. + +Each text input is limited to 16 KiB, the plan to 1 MiB, and each gws response +to 16 MiB. Source verification additionally limits total text to 250,000 +characters and its estimated expanded style representation to 16 MiB. +Oversized or malformed inputs fail closed. `--timeout SECONDS` bounds each +gws subprocess (default 60, maximum 600); there are no mutation retries. + +## Outcomes and recovery + +Commands emit JSON. `plan`, offline preview, and a verified apply exit `0` +with status `planned`, `preview`, or `applied`. Preview only validates local +data: it cannot establish that a live revision is still current. + +Exit `2` / `refused` means the companion did not submit a document write. +Correct the input or reread the source and review a **new** plan. + +Exit `3` / `ambiguous` means a write **may have applied**, or its result could +not be verified or reported successfully. This includes subprocess timeouts, +nonzero write exits (including a concurrent API 400), malformed responses, missing revisions, +unexpected reply counts, failed/mismatching rereads, and interruptions or +output failures after submission. The diagnostic JSON on stderr includes +`mutation_state: "attempted"` or `"confirmed"`. A confirmed mutation followed +by a closed stdout consumer or failed final reporting still exits `3`; it +does not claim that the write was refused or never submitted. Stdout is +flushed before success is returned, so buffered output failures are handled +inside the same outcome check. + +The original plan stays unchanged. Inspect the document and revision through +your normal tools; do not blindly rerun apply. Keep the failed plan as your review record and +create a new plan if further work is required. The companion does not persist +a separate attempt journal or prevent an operator from manually rerunning it. + +All subprocesses use argument arrays, checked exit codes and timeouts. +Raw gws output is not echoed into errors. Diagnostics produce a fixed, +redacted notice; inspect your gws/Model Armor configuration when it appears. +Model Armor block outcomes stop the workflow; settings are never disabled. +This example uses raw `gws docs documents` methods, whose executor at the +accompanying source revision does not retry requests. + +## Tests + +```sh +python3 -m unittest discover -s examples/docs-review -p 'test_*.py' -v +``` + +The suite executes the real companion CLI with a temporary stub `gws` and +synthetic documents. It passes an isolated environment, never reads real +credentials, and never contacts Google. CI runs it independently of Rust +changes on Linux and macOS. Tests cover the request contract, no-write +planning/preview, revision/source checks, Unicode/tabs, paths and plan +tampering, structures/styles, reply counts, ambiguity and plan retention. + +## API references + +- [Documents.get](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/get): + `includeTabsContent` and `suggestionsViewMode`. +- [Document structure](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents): + indices are measured in UTF-16 code units; revisions are opaque. +- [ReplaceAllTextRequest](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/request#ReplaceAllTextRequest): + literal matching and tab criteria. +- [BatchUpdate / WriteControl](https://developers.google.com/workspace/docs/api/reference/rest/v1/documents/batchUpdate): + stale `requiredRevisionId` requests are rejected with HTTP 400. A required + revision in the response identifies the revision after application. diff --git a/examples/docs-review/docs_review.py b/examples/docs-review/docs_review.py new file mode 100755 index 000000000..1f122daaf --- /dev/null +++ b/examples/docs-review/docs_review.py @@ -0,0 +1,710 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 +"""Review and apply one revision-bound Google Docs text replacement (stdlib). + +Only the gws executable communicates with Google. Plans contain data, never +commands. See README.md for the intentionally limited structural support. +""" + +import argparse +from contextlib import contextmanager +import difflib +import hashlib +import hmac +import json +import math +import os +import re +import stat +import subprocess +import sys +import tempfile + + +MAX_PLAN = 1024 * 1024 +MAX_TEXT = 16 * 1024 +MAX_DOCUMENT = 16 * 1024 * 1024 +PLAN_KEYS = { + "version", "document_id", "tab_id", "revision_id", "source_sha256", + "find", "replacement", "expected_occurrences", "target", "diff", "digest", +} + + +class Refusal(Exception): + """A fixed, safe message; never construct one from subprocess output.""" + + +class Ambiguous(Refusal): + """A submission may have applied; never retry automatically.""" + + +def require(condition, message): + if not condition: + raise Refusal(message) + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=True, allow_nan=False).encode("utf-8") + + +def sha256(value): + return hashlib.sha256(canonical(value)).hexdigest() + + +def utf16(text): + return len(text.encode("utf-16-le")) // 2 + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON keys are not supported.") + result[key] = value + return result + + +def parse_json(data): + try: + return json.loads(data, object_pairs_hook=unique_object, + parse_constant=lambda _: invalid_json()) + except (ValueError, UnicodeError, RecursionError): + raise Refusal("Invalid UTF-8 JSON; regenerate the plan or check gws.") from None + + +def invalid_json(): + raise Refusal("Non-finite JSON numbers are not supported.") + + +def identifier(value, tab=False): + pattern = r"[A-Za-z0-9_.-]{1,200}" if tab else r"[A-Za-z0-9_-]{1,200}" + require(isinstance(value, str) and re.fullmatch(pattern, value) is not None + and ".." not in value, "Invalid document or tab ID; supply an ID, not a URL.") + return value + + +def revision(value): + require(isinstance(value, str) and 0 < len(value) <= 4096 + and not any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in value), + "Missing or invalid revision; read an editable document again.") + return value + + +def text_input(value, allow_empty=False): + require(isinstance(value, str), "Find and replacement must be UTF-8 text.") + require(allow_empty or bool(value), "Find text must not be empty.") + # Docs can strip these or treat them as structural edits. Reject rather + # than silently submit a replacement different from the reviewed text. + require(not any( + ord(c) < 32 or 127 <= ord(c) <= 159 + or 0xD800 <= ord(c) <= 0xF8FF or 0xFFF9 <= ord(c) <= 0xFFFF + or ord(c) in (0x2028, 0x2029) + or 0xF0000 <= ord(c) <= 0xFFFFD + or 0x100000 <= ord(c) <= 0x10FFFD + for c in value + ), "Unsupported structural/control character; use single-paragraph plain text.") + require(len(value.encode("utf-8")) <= MAX_TEXT, "Text input exceeds 16 KiB.") + return value + + +@contextmanager +def confined_parent(path): + """Hold directory descriptors so symlink swaps cannot redirect file I/O. + + All symlinks (even inward ones) are rejected. POSIX openat/O_NOFOLLOW is + required; fail closed on platforms without it. + """ + require(isinstance(path, str) and path and not path.startswith("/") + and "\\" not in path and ":" not in path + and not any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in path), + "Use a relative path within the current directory.") + parts = path.split("/") + require(all(p not in ("", ".", "..") for p in parts), + "Path traversal and empty path components are not allowed.") + require(hasattr(os, "O_NOFOLLOW") and os.open in os.supports_dir_fd, + "Safe file access requires a POSIX platform with O_NOFOLLOW.") + descriptors = [] + try: + flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + descriptors.append(os.open(".", flags)) + for part in parts[:-1]: + descriptors.append(os.open(part, flags, dir_fd=descriptors[-1])) + yield descriptors[-1], parts[-1] + except OSError: + raise Refusal( + "Cannot access path safely; check parents, symlinks and permissions." + ) from None + finally: + for descriptor in reversed(descriptors): + os.close(descriptor) + + +def read_file(path, limit): + with confined_parent(path) as (parent, name): + descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, + dir_fd=parent) + with os.fdopen(descriptor, "rb") as stream: + require(stat.S_ISREG(os.fstat(stream.fileno()).st_mode), + "Input must be a regular file.") + data = stream.read(limit + 1) + require(len(data) <= limit, "Input file exceeds the supported size limit.") + try: + return data.decode("utf-8") + except UnicodeError: + raise Refusal("Input file must be valid UTF-8.") from None + + +def unused_output(parent, name): + try: + os.stat(name, dir_fd=parent, follow_symlinks=False) + except FileNotFoundError: + return + raise Refusal("Output already exists; choose a new plan path.") + + +def write_plan(parent, name, plan): + data = json.dumps(plan, ensure_ascii=True, sort_keys=True, indent=2) + "\n" + require(len(data.encode()) <= MAX_PLAN, "Plan exceeds 1 MiB; use a smaller patch.") + # Exclusive creation prevents overwrites/hardlink attacks; plans are private. + descriptor = os.open(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, + 0o600, dir_fd=parent) + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + + +class Gws: + def __init__(self, timeout): + self.timeout = timeout + self.diagnostics = False + self.mutation_state = "not_attempted" + + def call(self, method, document_id, request=None): + params = {"documentId": document_id} + if method == "get": + params.update(includeTabsContent=True, suggestionsViewMode="SUGGESTIONS_INLINE") + args = ["gws", "docs", "documents", method, + "--params", canonical(params).decode(), "--format", "json"] + if request is not None: + args += ["--json", canonical(request).decode()] + try: + # Inherit gws auth/Model Armor settings. Raw diagnostics never reach + # the terminal (they may contain document text or credentials). + with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as diagnostics: + result = subprocess.run( + args, stdin=subprocess.DEVNULL, stdout=output, + stderr=diagnostics, timeout=self.timeout, check=False, + ) + self.diagnostics |= diagnostics.tell() > 0 + require(result.returncode == 0, + "gws failed; check access, revision and Model Armor settings.") + output.seek(0) + data = output.read(MAX_DOCUMENT + 1) + require(len(data) <= MAX_DOCUMENT, "gws response exceeds 16 MiB.") + value = parse_json(data) + require(isinstance(value, dict) and "error" not in value, + "gws did not return a successful JSON object.") + return value + except subprocess.TimeoutExpired: + raise Refusal("gws timed out; check connectivity and timeout settings.") from None + except OSError: + raise Refusal("Cannot execute gws; check installation and PATH.") from None + + def get(self, document_id): + return self.call("get", document_id) + + +def walk(value, path=()): + if isinstance(value, dict): + yield path, value + for key, item in value.items(): + yield from walk(item, path + (key,)) + elif isinstance(value, list): + for index, item in enumerate(value): + yield from walk(item, path + (index,)) + + +def at(value, path): + for key in path: + value = value[key] + return value + + +def select_tab(document, document_id, tab_id): + require(isinstance(document, dict) and document.get("documentId") == document_id, + "Document identity mismatch.") + revision(document.get("revisionId")) + require(document.get("suggestionsViewMode") == "SUGGESTIONS_INLINE", + "Expected suggestions-inline source; refusing an incomplete view.") + require(isinstance(document.get("tabs"), list) and document["tabs"], + "Missing all-tabs content; check gws Docs discovery support.") + tabs = [] + + def visit(items, path): + require(isinstance(items, list), "Malformed document tabs.") + for i, item in enumerate(items): + require(isinstance(item, dict) + and isinstance(item.get("tabProperties"), dict) + and isinstance(item.get("documentTab"), dict), "Unsupported tab shape.") + identity = identifier(item["tabProperties"].get("tabId"), tab=True) + tabs.append((identity, path + (i, "documentTab"))) + if "childTabs" in item: + visit(item["childTabs"], path + (i, "childTabs")) + + visit(document["tabs"], ("tabs",)) + require(len({identity for identity, _ in tabs}) == len(tabs), "Duplicate tab IDs.") + if tab_id is None: + require(len(tabs) == 1, "Multiple tabs; select exactly one with --tab ID.") + tab_id = tabs[0][0] + matches = [path for identity, path in tabs if identity == tab_id] + require(len(matches) == 1, "Selected tab does not exist.") + return tab_id, matches[0] + + +def check_supported(tab): + require(isinstance(tab.get("body"), dict) + and isinstance(tab["body"].get("content"), list), "Tab body is missing.") + require(set(tab) <= { + "body", "headers", "footers", "footnotes", "documentStyle", "namedStyles", + "lists", "namedRanges", "inlineObjects", "positionedObjects", "bookmarks", + "suggestedDocumentStyleChanges", "suggestedNamedStylesChanges", + }, "Unsupported tab region; this example requires a known document structure.") + for _, obj in walk(tab): + require(not any(k.startswith("suggested") and v for k, v in obj.items()), + "Suggested content in selected tab is unsupported; resolve suggestions first.") + require(not obj.get("namedRanges") and not obj.get("bookmarks"), + "Named ranges and bookmarks in the selected tab are unsupported.") + if "paragraph" not in obj: + continue + for element in obj["paragraph"]["elements"]: + kinds = set(element) - {"startIndex", "endIndex"} + require(len(kinds) == 1 and kinds <= { + "textRun", "inlineObjectElement", "footnoteReference", + "horizontalRule", "pageBreak", "columnBreak"}, + "Unsupported paragraph element; rich links, equations and chips are excluded.") + + +def index_range(value): + start, end = value.get("startIndex", 0), value.get("endIndex") + require(type(start) is int and type(end) is int and 0 <= start < end, + "Invalid structural or paragraph element indices.") + return start, end + + +def check_region(region): + require(isinstance(region, dict) and isinstance(region.get("content"), list), + "Malformed document region; expected structural content.") + + +def check_content(content): + for block in content: + require(isinstance(block, dict), "Malformed structural block.") + kinds = set(block) - {"startIndex", "endIndex"} + require(len(kinds) == 1 and kinds <= { + "paragraph", "sectionBreak", "table", "tableOfContents"}, + "Unsupported document structure.") + require(isinstance(block[next(iter(kinds))], dict), "Malformed structural block value.") + index_range(block) + + +def check_table(table): + require(isinstance(table, dict), "Malformed table.") + require(type(table.get("rows")) is int and table["rows"] > 0 + and type(table.get("columns")) is int and table["columns"] > 0, + "Malformed table dimensions.") + rows = table.get("tableRows") + require(isinstance(rows, list) and len(rows) == table["rows"], "Malformed table rows.") + for row in rows: + require(isinstance(row, dict) and isinstance(row.get("tableCells"), list) + and 0 < len(row["tableCells"]) <= table["columns"], "Malformed table row.") + for cell in row["tableCells"]: + check_region(cell) + + +def check_paragraph(block): + paragraph = block["paragraph"] + require(isinstance(paragraph, dict) + and isinstance(paragraph.get("elements"), list) + and paragraph["elements"], "Malformed paragraph.") + require(isinstance(paragraph.get("paragraphStyle", {}), dict), + "Malformed paragraph style.") + cursor, paragraph_end = index_range(block) + for element in paragraph["elements"]: + require(isinstance(element, dict), "Malformed paragraph element.") + start, end = index_range(element) + require(start == cursor, "Non-contiguous paragraph indices.") + kinds = set(element) - {"startIndex", "endIndex"} + # Extra siblings of textRun would otherwise disappear in normalization. + # Unrecognized non-text unions are retained whole in unselected tabs. + require(len(kinds) == 1 and isinstance(element[next(iter(kinds))], dict), + "Malformed or unsupported paragraph element fields.") + if "textRun" in element: + run = element["textRun"] + require(isinstance(run.get("content"), str) + and isinstance(run.get("textStyle", {}), dict), "Malformed text run.") + require(utf16(run["content"]) == end - start, + "Text run does not match its UTF-16 indices.") + cursor = end + require(paragraph_end == cursor, "Paragraph end index mismatch.") + + +def check_document_structure(document): + """Validate every region/range the normalizer interprets, in every tab. + + Unknown metadata is retained unchanged. Unknown structural blocks or + text-element siblings that cannot be retained safely are refused. + """ + for _, obj in walk(document): + if "documentTab" in obj: + require(isinstance(obj["documentTab"], dict) and "body" in obj["documentTab"], + "Missing document tab body.") + if "body" in obj: + check_region(obj["body"]) + for group in ("headers", "footers", "footnotes"): + if group in obj: + require(isinstance(obj[group], dict), "Malformed document region map.") + for region in obj[group].values(): + check_region(region) + if isinstance(obj.get("content"), list): + check_content(obj["content"]) + if "paragraph" in obj: + check_paragraph(obj) + if "table" in obj: + check_table(obj["table"]) + if "tableOfContents" in obj: + check_region(obj["tableOfContents"]) + if "sectionBreak" in obj: + require(isinstance(obj["sectionBreak"], dict) + and isinstance(obj["sectionBreak"].get("sectionStyle", {}), dict), + "Malformed section break.") + + +def check_document_budget(document): + """Bound character/style expansion before constructing canonical atoms.""" + expanded_bytes = 0 + characters = 0 + for _, obj in walk(document): + if "textRun" not in obj: + continue + run = obj["textRun"] + require(isinstance(run, dict) and isinstance(run.get("content"), str), + "Malformed text run.") + length = len(run["content"]) + characters += length + metadata = {k: v for k, v in run.items() if k != "content"} + expanded_bytes += length * (len(canonical(metadata)) + 64) + require(characters <= 250000 and expanded_bytes <= MAX_DOCUMENT, + "Document is too large for safe text/style verification.") + + +def normalized(document): + # This gate is part of normalization itself, so no caller can accidentally + # compare discarded text-run indices before validating the complete source. + check_document_structure(document) + return _normalized(document) + + +def _normalized(value, path=()): + """Canonical semantic shape; text-run splitting is not a style change.""" + if isinstance(value, list): + return [_normalized(v, path + (i,)) for i, v in enumerate(value)] + if not isinstance(value, dict): + return value + result = {} + for key, item in value.items(): + if not path and key in ("revisionId", "_sanitization"): + continue + # Google refreshes this temporary URL on reads; keep all other image + # metadata, including sourceUri, object IDs, dimensions and crop data. + if key == "contentUri" and path and path[-1] == "imageProperties": + continue + if key == "elements" and path and path[-1] == "paragraph": + elements = [] + for element in item: + if "textRun" in element: + run = element["textRun"] + metadata = {k: v for k, v in run.items() if k != "content"} + metadata.setdefault("textStyle", {}) + for char in run["content"]: + elements.append({"text": char, "format": metadata}) + else: + elements.append(_normalized(element, path + (key,))) + result[key] = elements + else: + result[key] = _normalized(item, path + (key,)) + return result + + +def locate(document, tab_path, find): + """Count overlapping occurrences in every text segment of the chosen tab.""" + tab = at(document, tab_path) + matches = [] + for path, block in walk(tab): + if "paragraph" not in block: + continue + # Only top-level body paragraphs are editable. Tables, TOCs, headers, + # footers and footnotes still participate in ambiguity detection. + supported = (len(path) == 3 and path[:2] == ("body", "content") + and isinstance(path[2], int)) + cursor = block.get("startIndex", 0) + tokens = [] + indices = [] + for element in block["paragraph"]["elements"]: + if "textRun" in element: + for char in element["textRun"]["content"]: + tokens.append(char) + indices.append(cursor) + cursor += utf16(char) + else: + tokens.append("\ufffc") + indices.append(cursor) + cursor = element["endIndex"] + text = "".join(tokens) + offset = text.find(find) + while offset != -1: + matches.append((supported, tab_path + path + ("paragraph", "elements"), + offset, indices[offset])) + offset = text.find(find, offset + 1) + require(len(matches) == 1, + "Expected exactly one match in the selected tab; found zero or multiple.") + supported, path, offset, start = matches[0] + require(supported, "Target is outside a supported body paragraph.") + return path, offset, start + + +def review_diff(find, replacement): + return "".join(difflib.unified_diff( + [find + "\n"], [replacement + "\n"], fromfile="before", tofile="after", + )) + + +def build_plan(document, document_id, tab_id, find, replacement): + identifier(document_id) + text_input(find) + text_input(replacement, allow_empty=True) + require(find != replacement, "No-op replacement; choose different text.") + tab_id, tab_path = select_tab(document, document_id, tab_id) + check_document_budget(document) + source = normalized(document) + check_supported(at(document, tab_path)) + _, _, start = locate(document, tab_path, find) + result = { + "version": 1, "document_id": document_id, "tab_id": tab_id, + "revision_id": document["revisionId"], "source_sha256": sha256(source), + "find": find, "replacement": replacement, "expected_occurrences": 1, + "target": {"start_index": start, "end_index": start + utf16(find)}, + "diff": review_diff(find, replacement), + } + result["digest"] = sha256(result) + return result + + +def validate_plan(plan): + require(isinstance(plan, dict) and set(plan) == PLAN_KEYS, "Unsupported plan schema.") + require(type(plan["version"]) is int and plan["version"] == 1, + "Unsupported plan version.") + require(type(plan["expected_occurrences"]) is int and plan["expected_occurrences"] == 1, + "Plan must specify exactly one replacement.") + identifier(plan["document_id"]) + identifier(plan["tab_id"], tab=True) + revision(plan["revision_id"]) + text_input(plan["find"]) + text_input(plan["replacement"], allow_empty=True) + require(plan["find"] != plan["replacement"], "No-op replacement.") + target = plan["target"] + require(isinstance(target, dict) and set(target) == {"start_index", "end_index"} + and all(type(v) is int and v >= 1 for v in target.values()) + and target["end_index"] - target["start_index"] == utf16(plan["find"]), + "Invalid UTF-16 target range.") + require(plan["diff"] == review_diff(plan["find"], plan["replacement"]), + "Plan diff does not match its replacement.") + for field in ("digest", "source_sha256"): + require(isinstance(plan[field], str) and re.fullmatch(r"[0-9a-f]{64}", plan[field]), + "Invalid SHA-256 field.") + payload = {k: v for k, v in plan.items() if k != "digest"} + require(hmac.compare_digest(plan["digest"], sha256(payload)), + "Plan digest mismatch; regenerate and review a fresh plan.") + return plan + + +def request_body(plan): + return { + "writeControl": {"requiredRevisionId": plan["revision_id"]}, + "requests": [{"replaceAllText": { + "containsText": {"text": plan["find"], "matchCase": True, "searchByRegex": False}, + "replaceText": plan["replacement"], + "tabsCriteria": {"tabIds": [plan["tab_id"]]}, + }}], + } + + +def verify(before, after, plan, write_revision): + _, tab_path = select_tab(before, plan["document_id"], plan["tab_id"]) + select_tab(after, plan["document_id"], plan["tab_id"]) + require(after["revisionId"] == write_revision, "Post-write revision mismatch.") + check_document_budget(after) + expected, actual = normalized(before), normalized(after) + check_supported(at(after, tab_path)) + path, offset, _ = locate(before, tab_path, plan["find"]) + end = plan["target"]["end_index"] + delta = utf16(plan["replacement"]) - utf16(plan["find"]) + # Indices in the body shift; headers/footers/footnotes have separate indices. + for _, obj in walk(at(expected, tab_path)["body"]): + for key in ("startIndex", "endIndex"): + if key in obj and obj[key] >= end: + obj[key] += delta + replacement = [{"text": c} for c in plan["replacement"]] + expected_elements = at(expected, path) + expected_elements[offset:offset + len(plan["find"])] = replacement + actual_elements = at(actual, path) + # Formatting within newly inserted text is Google-controlled. Verify its + # exact characters but compare formatting of every untouched character. + for i in range(offset, offset + len(replacement)): + require(i < len(actual_elements) and "text" in actual_elements[i], + "Replacement missing from expected location.") + actual_elements[i] = {"text": actual_elements[i]["text"]} + require(actual == expected, "Post-write text, structure or untouched style mismatch.") + + +def apply_plan(plan, gws): + before = gws.get(plan["document_id"]) + rebuilt = build_plan(before, plan["document_id"], plan["tab_id"], + plan["find"], plan["replacement"]) + require(rebuilt == plan, + "Source revision or content changed; regenerate and review a new plan.") + try: + gws.mutation_state = "attempted" + reply = gws.call("batchUpdate", plan["document_id"], request_body(plan)) + require(reply.get("documentId") == plan["document_id"], "Write identity mismatch.") + replies = reply.get("replies") + require(isinstance(replies, list) and len(replies) == 1 + and isinstance(replies[0], dict), "Missing replacement reply.") + change = replies[0].get("replaceAllText") + require(isinstance(change, dict) and type(change.get("occurrencesChanged")) is int + and change["occurrencesChanged"] == 1, "Replacement count was not exactly one.") + control = reply.get("writeControl") + require(isinstance(control, dict), "Missing write revision.") + write_revision = revision(control.get("requiredRevisionId")) + require(write_revision != plan["revision_id"], "Write revision did not advance.") + after = gws.get(plan["document_id"]) + verify(before, after, plan, write_revision) + gws.mutation_state = "confirmed" + except (Refusal, OSError, ValueError, KeyError, TypeError, IndexError, RecursionError, + KeyboardInterrupt): + raise Ambiguous( + "Write may have applied; verification did not establish success. " + "Keep the original plan, inspect the document and revision, and do not " + "blindly retry. A concurrent revision rejection requires a newly reviewed plan." + ) from None + return {"status": "applied", "digest": plan["digest"], "revision_id": write_revision} + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise Refusal("Invalid arguments; use --help for supported options.") + + +def silence_failed_stdout(): + """Prevent a failed buffered write from being retried at interpreter exit.""" + try: + with open(os.devnull, "w") as sink: + os.dup2(sink.fileno(), sys.stdout.fileno()) + except (OSError, ValueError, AttributeError): + pass + + +def report_failure(error, mutation_state): + if mutation_state != "not_attempted": + message = ( + "Write was confirmed, but final reporting failed. " + if mutation_state == "confirmed" else + "Write may have applied; verification did not establish success. " + ) + outcome = { + "status": "ambiguous", "mutation_state": mutation_state, + "message": message + "Keep the original plan, inspect the document and revision, " + "and do not blindly retry. Further changes require a newly reviewed plan.", + } + code = 3 + else: + if isinstance(error, KeyboardInterrupt): + message = "Interrupted before submission." + elif isinstance(error, Refusal): + message = str(error) + else: + message = "Malformed source or inaccessible file; check inputs and regenerate." + outcome = {"status": "refused", "message": message} + code = 2 + try: + print(json.dumps(outcome), file=sys.stderr, flush=True) + except (OSError, KeyboardInterrupt): + # Neither an unavailable diagnostic channel nor another interruption + # changes whether a mutation was attempted. + pass + return code + + +def main(argv=None): + parser = Parser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + plan_parser = commands.add_parser("plan", help="Read a document and create a reviewable plan") + plan_parser.add_argument("--document", required=True) + plan_parser.add_argument( + "--tab", help="Exact tab ID (required when there is more than one tab)") + plan_parser.add_argument("--find", required=True, help="Relative UTF-8 input file") + plan_parser.add_argument("--replacement", required=True, help="Relative UTF-8 input file") + plan_parser.add_argument("--out", required=True, help="New relative plan file") + apply_parser = commands.add_parser("apply", help="Validate, apply once, and verify") + apply_parser.add_argument("--plan", required=True) + apply_parser.add_argument("--dry-run", action="store_true", + help="Offline plan validation and request preview; no gws calls") + for command in (plan_parser, apply_parser): + command.add_argument("--timeout", type=float, default=60, + help="Per-gws-call timeout in seconds (default: 60)") + gws = None + reporting = False + try: + args = parser.parse_args(argv) + require(math.isfinite(args.timeout) and 0 < args.timeout <= 600, + "Timeout must be greater than zero and at most 600 seconds.") + gws = Gws(args.timeout) + if args.command == "plan": + identifier(args.document) + if args.tab is not None: + identifier(args.tab, tab=True) + find = text_input(read_file(args.find, MAX_TEXT)) + replacement = text_input(read_file(args.replacement, MAX_TEXT), allow_empty=True) + with confined_parent(args.out) as (parent, name): + unused_output(parent, name) + plan = build_plan( + gws.get(args.document), args.document, args.tab, find, replacement) + write_plan(parent, name, plan) + result = {"status": "planned", "digest": plan["digest"], + "message": "Review the plan and digest before applying."} + else: + plan = validate_plan(parse_json(read_file(args.plan, MAX_PLAN))) + if args.dry_run: + result = {"status": "preview", "digest": plan["digest"], + "diff": plan["diff"], "request": request_body(plan)} + else: + result = apply_plan(plan, gws) + if gws.diagnostics: + result["gws_diagnostics"] = ( + "gws reported diagnostics; check gws and Model Armor settings. " + "Raw output was suppressed to protect document content." + ) + reporting = True + print(json.dumps(result, ensure_ascii=True, sort_keys=True), flush=True) + return 0 + except (Refusal, OSError, ValueError, KeyError, TypeError, IndexError, + RecursionError, KeyboardInterrupt) as error: + if reporting: + silence_failed_stdout() + mutation_state = gws.mutation_state if gws is not None else "not_attempted" + return report_failure(error, mutation_state) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/docs-review/test_docs_review.py b/examples/docs-review/test_docs_review.py new file mode 100644 index 000000000..03f5af589 --- /dev/null +++ b/examples/docs-review/test_docs_review.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +# Copyright 2026 Google LLC +# SPDX-License-Identifier: Apache-2.0 +"""Behavior tests: all gws calls go to an isolated executable, never Google.""" + +import copy +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("docs_review.py") + + +def paragraph(parts, start=1): + elements = [] + cursor = start + for text, style in parts: + end = cursor + len(text.encode("utf-16-le")) // 2 + elements.append({ + "startIndex": cursor, "endIndex": end, + "textRun": {"content": text, "textStyle": style}, + }) + cursor = end + return { + "startIndex": start, "endIndex": cursor, + "paragraph": { + "elements": elements, + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + }, + } + + +def document(text="Hello world.\n", revision="rev-1"): + return { + "documentId": "synthetic-doc", "revisionId": revision, + "title": "Synthetic review fixture", + "suggestionsViewMode": "SUGGESTIONS_INLINE", + "tabs": [{ + "tabProperties": {"tabId": "t.main", "title": "Main", "index": 0}, + "documentTab": { + "body": {"content": [ + {"endIndex": 1, "sectionBreak": { + "sectionStyle": {"sectionType": "CONTINUOUS"}}}, + paragraph([(text, {})]), + ]}, + }, + }], + } + + +def body(doc): + return doc["tabs"][0]["documentTab"]["body"]["content"] + + +def resign(plan): + payload = {k: v for k, v in plan.items() if k != "digest"} + plan["digest"] = hashlib.sha256(json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True, + allow_nan=False, + ).encode()).hexdigest() + return plan + + +# The executable checks the actual argv contract and simulates the remote +# boundary only. Its output fixtures are independent of production helpers. +STUB = r''' +import json, os, pathlib, sys, time +root = pathlib.Path(os.environ["STUB_ROOT"]) +args = sys.argv[1:] +with (root / "calls.jsonl").open("a") as f: + f.write(json.dumps(args) + "\n") +assert args[:2] == ["docs", "documents"], args +assert args[args.index("--format") + 1] == "json", args +params = json.loads(args[args.index("--params") + 1]) +assert params["documentId"] == "synthetic-doc", params +method = args[2] +mode = (root / "mode").read_text() +if method == "get": + assert params["includeTabsContent"] is True, params + assert params["suggestionsViewMode"] == "SUGGESTIONS_INLINE", params + name = "after.json" if (root / "submitted").exists() else "before.json" + if mode == "read-error" or (mode == "verify-error" and name == "after.json"): + print("secret-token \x1b[31m remote private content", file=sys.stderr) + sys.exit(1) + if mode == "armor-block": + print(json.dumps({"error": "Content blocked by Model Armor"})) + sys.exit(1) + if mode == "armor-warn": + assert os.environ["GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE"] == "synthetic-template" + print("secret-token \x1b[31m Model Armor warning", file=sys.stderr) + print((root / name).read_text()) +elif method == "batchUpdate": + request = json.loads(args[args.index("--json") + 1]) + (root / "request.json").write_text(json.dumps(request)) + (root / "submitted").touch() + if mode == "timeout": + time.sleep(5) + if mode in ("conflict", "write-error"): + print(json.dumps({"error": {"code": 400 if mode == "conflict" else 503, + "message": "secret-token \x1b[31m private response"}})) + sys.exit(1) + if mode == "bad-response": + print("secret-token malformed") + else: + result = {"documentId": "synthetic-doc", + "replies": [{"replaceAllText": {"occurrencesChanged": 1}}], + "writeControl": {"requiredRevisionId": "rev-2"}} + if mode == "zero": + result["replies"][0]["replaceAllText"]["occurrencesChanged"] = 0 + if mode == "two": + result["replies"][0]["replaceAllText"]["occurrencesChanged"] = 2 + if mode == "missing-reply": + result["replies"] = [] + if mode == "missing-write-revision": + result.pop("writeControl") + print(json.dumps(result)) +else: + raise AssertionError(args) +''' + + +class ReviewCliTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.bin = self.root / "bin" + self.bin.mkdir() + stub = self.bin / "gws" + stub.write_text("#!" + sys.executable + "\n" + STUB) + stub.chmod(0o700) + # Do not pass real credentials/configuration into any child process. + self.env = { + "PATH": str(self.bin) + os.pathsep + os.defpath, + "STUB_ROOT": str(self.root), + "PYTHONDONTWRITEBYTECODE": "1", + "GOOGLE_WORKSPACE_CLI_CONFIG_DIR": str(self.root / "config"), + } + self.put("mode", "ok") + self.put("find.txt", "world") + self.put("replacement.txt", "reader") + self.fixture(document(), document("Hello reader.\n", "rev-2")) + + def put(self, name, value): + (self.root / name).write_text(value, encoding="utf-8") + + def fixture(self, before, after=None): + self.put("before.json", json.dumps(before)) + self.put("after.json", json.dumps(after or before)) + + def cli(self, *args): + return subprocess.run( + [sys.executable, str(SCRIPT), *args], cwd=self.root, + env=self.env, text=True, capture_output=True, timeout=10, + ) + + def plan(self, *extra): + return self.cli("plan", "--document", "synthetic-doc", + "--find", "find.txt", "--replacement", "replacement.txt", + "--out", "plan.json", *extra) + + def apply(self, *extra): + return self.cli("apply", "--plan", "plan.json", *extra) + + def load_plan(self): + return json.loads((self.root / "plan.json").read_text()) + + def calls(self): + path = self.root / "calls.jsonl" + return [json.loads(x) for x in path.read_text().splitlines()] if path.exists() else [] + + def success(self, result): + self.assertEqual(result.returncode, 0, result.stderr) + return json.loads(result.stdout) + + def refused(self, result, status="refused"): + self.assertNotEqual(result.returncode, 0) + self.assertNotIn("secret-token", result.stderr + result.stdout) + self.assertNotIn("\x1b", result.stderr + result.stdout) + try: + payload = json.loads(result.stderr) + except ValueError: + self.fail("Expected a structured refusal, got: " + result.stderr[:200]) + self.assertEqual(payload["status"], status) + + def test_plan_is_deterministic_reviewable_and_reads_only_once(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + plan = self.load_plan() + self.assertEqual(plan["version"], 1) + self.assertEqual(plan["revision_id"], "rev-1") + self.assertEqual(plan["tab_id"], "t.main") + self.assertEqual(plan["expected_occurrences"], 1) + self.assertEqual(plan["target"], {"start_index": 7, "end_index": 12}) + self.assertIn("-world", plan["diff"]) + self.assertIn("+reader", plan["diff"]) + self.assertNotIn("Synthetic review fixture", original.decode()) + self.assertNotIn("Hello", original.decode()) + self.assertEqual(resign(copy.deepcopy(plan)), plan) + self.assertEqual([c[2] for c in self.calls()], ["get"]) + (self.root / "plan.json").unlink() + self.success(self.plan()) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_apply_submits_only_reviewed_revision_and_tab_then_verifies(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + result = self.success(self.apply()) + self.assertEqual(result["status"], "applied") + self.assertEqual(json.loads((self.root / "request.json").read_text()), { + "writeControl": {"requiredRevisionId": "rev-1"}, + "requests": [{"replaceAllText": { + "containsText": {"text": "world", "matchCase": True, + "searchByRegex": False}, + "replaceText": "reader", "tabsCriteria": {"tabIds": ["t.main"]}, + }}], + }) + self.assertEqual([c[2] for c in self.calls()], + ["get", "get", "batchUpdate", "get"]) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_closed_stdout_after_apply_keeps_confirmed_mutation_state(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + for python_flags in [[], ["-u"]]: + with self.subTest(python_flags=python_flags): + (self.root / "submitted").unlink(missing_ok=True) + previous_calls = len(self.calls()) + read_fd, write_fd = os.pipe() + os.close(read_fd) + try: + result = subprocess.run( + [sys.executable, *python_flags, str(SCRIPT), + "apply", "--plan", "plan.json"], + cwd=self.root, env=self.env, stdout=write_fd, + stderr=subprocess.PIPE, text=True, timeout=10, + ) + finally: + os.close(write_fd) + self.assertEqual(result.returncode, 3, result.stderr) + outcome = json.loads(result.stderr) + self.assertEqual(outcome["status"], "ambiguous") + self.assertEqual(outcome["mutation_state"], "confirmed") + self.assertIn("inspect", outcome["message"]) + self.assertIn("do not blindly retry", outcome["message"]) + self.assertEqual([c[2] for c in self.calls()[previous_calls:]], + ["get", "batchUpdate", "get"]) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_interruption_after_apply_returns_keeps_confirmed_mutation_state(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + # Inject only the interruption at the return boundary. The actual + # apply implementation still performs every read/write/verification. + wrapper = """ +import runpy, sys +namespace = runpy.run_path(sys.argv[1]) +real_apply = namespace["apply_plan"] +def interrupted_return(*args, **kwargs): + real_apply(*args, **kwargs) + raise KeyboardInterrupt +namespace["main"].__globals__["apply_plan"] = interrupted_return +sys.exit(namespace["main"](["apply", "--plan", "plan.json"])) +""" + result = subprocess.run( + [sys.executable, "-c", wrapper, str(SCRIPT)], + cwd=self.root, env=self.env, capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 3, result.stderr) + self.refused(result, "ambiguous") + outcome = json.loads(result.stderr) + self.assertEqual(outcome["mutation_state"], "confirmed") + self.assertIn("do not blindly retry", outcome["message"]) + self.assertNotIn("before submission", outcome["message"]) + self.assertEqual([c[2] for c in self.calls()], + ["get", "get", "batchUpdate", "get"]) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_final_serialization_failure_keeps_confirmed_mutation_state(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + wrapper = """ +import json, runpy, sys +namespace = runpy.run_path(sys.argv[1]) +real_dumps = json.dumps +def failed_result(value, *args, **kwargs): + if isinstance(value, dict) and value.get("status") == "applied": + raise ValueError("secret-token final output failure") + return real_dumps(value, *args, **kwargs) +json.dumps = failed_result +sys.exit(namespace["main"](["apply", "--plan", "plan.json"])) +""" + result = subprocess.run( + [sys.executable, "-c", wrapper, str(SCRIPT)], + cwd=self.root, env=self.env, capture_output=True, text=True, timeout=10, + ) + self.assertEqual(result.returncode, 3, result.stderr) + self.refused(result, "ambiguous") + self.assertEqual(json.loads(result.stderr)["mutation_state"], "confirmed") + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_zero_multiple_and_overlapping_matches_refuse_without_write(self): + for text, find in [("Nothing.\n", "world"), + ("world world\n", "world"), ("aaa\n", "aa")]: + with self.subTest(text=text): + self.fixture(document(text)) + self.put("find.txt", find) + self.refused(self.plan()) + self.assertFalse((self.root / "plan.json").exists()) + self.assertTrue(all(c[2] == "get" for c in self.calls())) + + def test_unicode_utf16_and_nested_tab_scope(self): + before = document("😀 café world.\n") + before["tabs"][0]["childTabs"] = [{ + "tabProperties": {"tabId": "t.child", "title": "Main", "index": 0}, + "documentTab": {"body": {"content": [paragraph([("world\n", {})])]}} + }] + after = copy.deepcopy(before) + after["revisionId"] = "rev-2" + body(after)[1] = paragraph([("😀 café reader.\n", {})]) + self.fixture(before, after) + self.refused(self.plan()) # No silent first-tab selection. + self.success(self.plan("--tab", "t.main")) + self.assertEqual(self.load_plan()["target"], + {"start_index": 9, "end_index": 14}) + self.success(self.apply()) + request = json.loads((self.root / "request.json").read_text()) + self.assertEqual(request["requests"][0]["replaceAllText"]["tabsCriteria"], + {"tabIds": ["t.main"]}) + + def test_replaces_unicode_in_selected_child_without_touching_parent(self): + before = document("😀targetZ\n") + child = copy.deepcopy(before["tabs"][0]) + child["tabProperties"]["tabId"] = "t.child" + before["tabs"][0]["childTabs"] = [child] + after = copy.deepcopy(before) + after["revisionId"] = "rev-2" + # Preserve Z/newline styles while allowing Google to style inserted text. + after["tabs"][0]["childTabs"][0]["documentTab"]["body"]["content"][1] = ( + paragraph([("🛰️", {"italic": True}), ("Z\n", {})])) + self.fixture(before, after) + self.put("find.txt", "😀target") + self.put("replacement.txt", "🛰️") + self.success(self.plan("--tab", "t.child")) + self.assertEqual(self.load_plan()["target"], + {"start_index": 1, "end_index": 9}) + self.success(self.apply()) + + def test_literal_matching_does_not_enable_regex_or_shell(self): + literal = "$(touch injected);.*" + self.fixture(document(literal + "\n"), document("done\n", "rev-2")) + self.put("find.txt", literal) + self.put("replacement.txt", "done") + self.success(self.plan()) + self.success(self.apply()) + self.assertFalse((self.root / "injected").exists()) + + def test_preview_is_offline_and_does_not_submit(self): + self.success(self.plan()) + calls = self.calls() + before = (self.root / "plan.json").read_bytes() + self.success(self.apply("--dry-run")) + self.assertEqual(self.calls(), calls) + self.assertEqual((self.root / "plan.json").read_bytes(), before) + + def test_source_revision_missing_or_changed_refuses(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + for doc in [document(revision="rev-new"), document("Different world.\n"), + {k: v for k, v in document().items() if k != "revisionId"}]: + with self.subTest(doc=doc): + self.fixture(doc) + self.refused(self.apply()) + self.assertFalse((self.root / "submitted").exists()) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_plan_without_revision_refuses(self): + doc = document() + doc.pop("revisionId") + self.fixture(doc) + self.refused(self.plan()) + + def test_malformed_tampered_and_oversized_plan_refuse_offline(self): + self.success(self.plan()) + original = self.load_plan() + tampered = copy.deepcopy(original) + tampered["replacement"] = "unreviewed" + unknown = resign(dict(original, command="touch injected")) + wrong_target = copy.deepcopy(original) + wrong_target["target"]["start_index"] = -1 + wrong_version = resign(dict(original, version=True)) + for value in ["{", "[]", '{"version":1,"version":2}', + json.dumps(tampered), json.dumps(unknown), + json.dumps(resign(wrong_target)), json.dumps(wrong_version), + " " * (1024 * 1024 + 1)]: + with self.subTest(value=value[:90]): + self.put("plan.json", value) + before = (self.root / "plan.json").read_bytes() + calls = self.calls() + self.refused(self.apply()) + self.assertEqual(self.calls(), calls) + self.assertEqual((self.root / "plan.json").read_bytes(), before) + + def test_resigned_semantic_tampering_is_reconstructed_before_write(self): + self.success(self.plan()) + plan = self.load_plan() + altered_target = copy.deepcopy(plan) + altered_target["target"] = {"start_index": 8, "end_index": 13} + altered_fingerprint = dict(plan, source_sha256="0" * 64) + for altered in [altered_target, altered_fingerprint]: + with self.subTest(altered=altered): + self.put("plan.json", json.dumps(resign(altered))) + original = (self.root / "plan.json").read_bytes() + previous_calls = len(self.calls()) + self.refused(self.apply()) + self.assertEqual([c[2] for c in self.calls()[previous_calls:]], ["get"]) + self.assertFalse((self.root / "submitted").exists()) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_unsupported_structural_edits_and_noops_refuse(self): + for find, replacement in [("", "new"), ("world", "world"), + ("world", "one\ntwo"), ("world", "\ufffc"), + ("world.\n", "new"), ("world", "\x00")]: + with self.subTest(find=find, replacement=replacement): + self.put("find.txt", find) + self.put("replacement.txt", replacement) + self.refused(self.plan()) + self.assertFalse((self.root / "plan.json").exists()) + + def test_table_header_suggestion_and_image_crossing_targets_refuse(self): + table = document() + body(table)[1] = {"startIndex": 1, "endIndex": 14, "table": { + "rows": 1, "columns": 1, "tableRows": [{"tableCells": [{ + "content": [paragraph([("world\n", {})], 3)]}]}]}} + header = document("Other.\n") + header["tabs"][0]["documentTab"]["headers"] = { + "h.1": {"content": [paragraph([("world\n", {})])]}} + suggested = document() + body(suggested)[1]["paragraph"]["elements"][0]["textRun"][ + "suggestedInsertionIds"] = ["suggestion-1"] + image = document() + body(image)[1] = paragraph([("wor", {}), ("ld\n", {})]) + elements = body(image)[1]["paragraph"]["elements"] + elements.insert(1, {"startIndex": 4, "endIndex": 5, + "inlineObjectElement": {"inlineObjectId": "img-1"}}) + elements[2]["startIndex"] += 1 + elements[2]["endIndex"] += 1 + body(image)[1]["endIndex"] += 1 + for doc in [table, header, suggested, image]: + with self.subTest(doc=doc): + self.fixture(doc) + self.refused(self.plan()) + self.assertFalse((self.root / "submitted").exists()) + + def test_duplicate_in_table_or_header_also_refuses(self): + doc = document() + doc["tabs"][0]["documentTab"]["footers"] = { + "f.1": {"content": [paragraph([("world\n", {})])]}} + self.fixture(doc) + self.refused(self.plan()) + + def test_style_run_splitting_and_deletion_are_supported(self): + before = document() + body(before)[1] = paragraph([ + ("Hello wo", {"bold": True}), ("rld", {"italic": True}), (".\n", {})]) + after = document(revision="rev-2") + body(after)[1] = paragraph([("Hello ", {"bold": True}), (".\n", {})]) + self.fixture(before, after) + self.put("replacement.txt", "") + self.success(self.plan()) + self.success(self.apply()) + + def test_preserves_tables_images_styles_and_checks_untouched_content(self): + before = document() + body(before).append({"startIndex": 14, "endIndex": 15, "paragraph": { + "elements": [{"startIndex": 14, "endIndex": 15, + "inlineObjectElement": {"inlineObjectId": "img-1"}}]}}) + before["tabs"][0]["documentTab"]["inlineObjects"] = { + "img-1": {"inlineObjectProperties": {"embeddedObject": { + "imageProperties": {"contentUri": "https://example.invalid/temporary"}, + "size": {"width": {"magnitude": 50, "unit": "PT"}}}}}} + body(before).append({"startIndex": 15, "endIndex": 25, "table": { + "rows": 1, "columns": 1, "tableRows": [{"tableCells": [{ + "content": [paragraph([("cell\n", {"bold": True})], 18)]}]}]}}) + after = copy.deepcopy(before) + after["revisionId"] = "rev-2" + body(after)[1] = paragraph([("Hello reader.\n", {})]) + # One extra UTF-16 unit shifts following structures, not their content. + def shift(value): + if isinstance(value, dict): + for k, v in value.items(): + if k in ("startIndex", "endIndex"): + value[k] = v + 1 + else: + shift(v) + elif isinstance(value, list): + for v in value: + shift(v) + shift(body(after)[2:]) + after["tabs"][0]["documentTab"]["inlineObjects"]["img-1"][ + "inlineObjectProperties"]["embeddedObject"]["imageProperties"][ + "contentUri"] = "https://example.invalid/refreshed" + self.fixture(before, after) + self.success(self.plan()) + self.success(self.apply()) + for kind in ["table", "image-id", "image-size"]: + with self.subTest(kind=kind): + changed = copy.deepcopy(after) + if kind == "table": + table_paragraph = body(changed)[3]["table"]["tableRows"][0][ + "tableCells"][0]["content"][0] + table_paragraph["paragraph"]["elements"][0]["textRun"]["content"] = "sell\n" + elif kind == "image-id": + body(changed)[2]["paragraph"]["elements"][0][ + "inlineObjectElement"]["inlineObjectId"] = "different-image" + else: + changed["tabs"][0]["documentTab"]["inlineObjects"]["img-1"][ + "inlineObjectProperties"]["embeddedObject"]["size"]["width"][ + "magnitude"] = 51 + (self.root / "submitted").unlink() + self.fixture(before, changed) + self.refused(self.apply(), "ambiguous") + + def test_post_write_mismatch_and_missing_revision_are_not_success(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + missing = document("Hello reader.\n") + missing.pop("revisionId") + changed_style = document("Hello reader.\n", "rev-2") + body(changed_style)[1]["paragraph"]["elements"][0]["textRun"][ + "textStyle"] = {"bold": True} + for doc in [document("Hello incorrect.\n", "rev-2"), missing, + changed_style, document("Hello reader.\n", "rev-unexpected")]: + with self.subTest(doc=doc): + (self.root / "submitted").unlink(missing_ok=True) + self.fixture(document(), doc) + self.refused(self.apply(), "ambiguous") + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_failed_write_or_verification_never_retries_and_keeps_plan(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + for mode in ["conflict", "write-error", "zero", "two", "missing-reply", + "bad-response", "missing-write-revision", "verify-error"]: + with self.subTest(mode=mode): + (self.root / "submitted").unlink(missing_ok=True) + self.put("mode", mode) + calls = len(self.calls()) + self.refused(self.apply(), "ambiguous") + writes = [c for c in self.calls()[calls:] if c[2] == "batchUpdate"] + self.assertEqual(len(writes), 1) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_timeout_after_submission_reports_possible_application(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + self.put("mode", "timeout") + result = self.apply("--timeout", "0.3") + self.refused(result, "ambiguous") + self.assertIn("may have", json.loads(result.stderr)["message"]) + self.assertEqual(len([c for c in self.calls() if c[2] == "batchUpdate"]), 1) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_paths_are_relative_confined_and_never_overwrite(self): + self.success(self.plan()) + original = (self.root / "plan.json").read_bytes() + self.refused(self.plan()) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + for path in ["../escape", "/tmp/escape", "sub/../../escape", "bad\nname"]: + with self.subTest(path=path): + self.refused(self.cli("apply", "--plan", path)) + self.refused(self.cli("plan", "--document", "synthetic-doc", + "--find", path, "--replacement", "replacement.txt", + "--out", "new-plan.json")) + self.refused(self.cli("plan", "--document", "synthetic-doc", + "--find", "find.txt", "--replacement", "replacement.txt", + "--out", path)) + with tempfile.TemporaryDirectory() as outside: + (self.root / "escape").symlink_to(outside, target_is_directory=True) + Path(outside, "plan.json").write_bytes(original) + self.refused(self.cli("apply", "--plan", "escape/plan.json")) + self.refused(self.cli("plan", "--document", "synthetic-doc", + "--find", "find.txt", "--replacement", "replacement.txt", + "--out", "escape/new.json")) + self.assertFalse(Path(outside, "new.json").exists()) + + def test_invalid_document_id_and_gws_failure_are_redacted(self): + result = self.cli("plan", "--document", "../secret?token", + "--find", "find.txt", "--replacement", "replacement.txt", + "--out", "plan.json") + self.refused(result) + self.assertEqual(self.calls(), []) + self.put("mode", "read-error") + self.refused(self.plan()) + + def test_model_armor_diagnostics_remain_visible_without_leaking_content(self): + self.env["GOOGLE_WORKSPACE_CLI_SANITIZE_TEMPLATE"] = "synthetic-template" + self.put("mode", "armor-block") + self.refused(self.plan()) + self.assertFalse((self.root / "plan.json").exists()) + self.put("mode", "armor-warn") + result = self.success(self.plan()) + self.assertTrue(result.get("gws_diagnostics")) + self.assertNotIn("secret-token", json.dumps(result)) + self.assertNotIn("\x1b", json.dumps(result)) + + def test_unknown_regions_and_malformed_structures_fail_before_plan(self): + unknown = document() + unknown["tabs"][0]["documentTab"]["futureRegion"] = {"text": "world"} + malformed = document() + body(malformed)[0]["futureBlock"] = {} + for doc in [unknown, malformed]: + with self.subTest(doc=doc): + self.fixture(doc) + self.refused(self.plan()) + self.assertFalse((self.root / "plan.json").exists()) + + def test_null_scalar_and_incomplete_structures_refuse_before_plan(self): + cases = [] + for value in [None, 3, {}, {"rows": 1, "columns": 1, "tableRows": None}, + {"rows": 1, "columns": 1, "tableRows": [None]}, + {"rows": 1, "columns": 1, "tableRows": [{"tableCells": [None]}]}, + {"rows": 1, "columns": 1, "tableRows": [ + {"tableCells": [{"content": "not structural content"}]}]}]: + doc = document() + body(doc).append({"startIndex": 14, "endIndex": 15, "table": value}) + cases.append(doc) + for region in ["headers", "footers", "footnotes"]: + for value in [None, [], {"segment-1": None}, + {"segment-1": {"content": "not structural content"}}]: + doc = document() + doc["tabs"][0]["documentTab"][region] = value + cases.append(doc) + for kind in ["sectionBreak", "tableOfContents"]: + doc = document() + body(doc).append({"startIndex": 14, "endIndex": 15, kind: None}) + cases.append(doc) + for doc in cases: + with self.subTest(document=doc): + (self.root / "plan.json").unlink(missing_ok=True) + self.fixture(doc) + self.refused(self.plan()) + self.assertFalse((self.root / "plan.json").exists()) + self.assertFalse((self.root / "submitted").exists()) + + def test_unselected_tab_malformed_ranges_refuse_preflight_and_postwrite(self): + before = document() + child = copy.deepcopy(document("😀 untouched\n")["tabs"][0]) + child["tabProperties"]["tabId"] = "t.other" + before["tabs"].append(child) + after = copy.deepcopy(before) + after["revisionId"] = "rev-2" + body(after)[1] = paragraph([("Hello reader.\n", {})]) + self.fixture(before, after) + self.success(self.plan("--tab", "t.main")) + original = (self.root / "plan.json").read_bytes() + for phase in ["preflight", "postwrite"]: + for field, value in [("endIndex", 999), ("startIndex", 2), + ("endIndex", True)]: + with self.subTest(phase=phase, field=field, value=value): + (self.root / "submitted").unlink(missing_ok=True) + bad = copy.deepcopy(before if phase == "preflight" else after) + bad["tabs"][1]["documentTab"]["body"]["content"][1][ + "paragraph"]["elements"][0][field] = value + self.fixture(bad if phase == "preflight" else before, + bad if phase == "postwrite" else after) + previous_calls = len(self.calls()) + self.refused(self.apply(), + "refused" if phase == "preflight" else "ambiguous") + expected_calls = (["get"] if phase == "preflight" else + ["get", "batchUpdate", "get"]) + self.assertEqual([c[2] for c in self.calls()[previous_calls:]], + expected_calls) + self.assertEqual((self.root / "plan.json").read_bytes(), original) + + def test_unselected_text_run_shape_is_checked_before_discarding_fields(self): + for extra in [{"textRun": None}, {"textRun": {"content": "extra\n", + "textStyle": "invalid"}}, + {"futureElementMetadata": {"value": "must not disappear"}}]: + with self.subTest(extra=extra): + (self.root / "plan.json").unlink(missing_ok=True) + doc = document() + other = copy.deepcopy(document("extra\n")["tabs"][0]) + other["tabProperties"]["tabId"] = "t.other" + other["documentTab"]["body"]["content"][1]["paragraph"]["elements"][0].update( + extra) + doc["tabs"].append(other) + self.fixture(doc) + self.refused(self.plan("--tab", "t.main")) + self.assertFalse((self.root / "plan.json").exists()) + self.assertFalse((self.root / "submitted").exists()) + + def test_omitted_empty_text_style_does_not_fail_verification(self): + before = document() + after = document("Hello reader.\n", "rev-2") + body(after)[1]["paragraph"]["elements"][0]["textRun"].pop("textStyle") + self.fixture(before, after) + self.success(self.plan()) + self.success(self.apply()) + + def test_c1_control_characters_in_paths_are_rejected_before_read(self): + self.put("unsafe\u0085.txt", "world") + self.refused(self.cli( + "plan", "--document", "synthetic-doc", "--find", "unsafe\u0085.txt", + "--replacement", "replacement.txt", "--out", "plan.json", + )) + self.assertEqual(self.calls(), []) + + def test_nonregular_files_and_leaf_symlinks_refuse(self): + os.mkfifo(self.root / "pipe") + (self.root / "link.txt").symlink_to(self.root / "find.txt") + for path in ["pipe", "link.txt"]: + with self.subTest(path=path): + self.refused(self.cli( + "plan", "--document", "synthetic-doc", "--find", path, + "--replacement", "replacement.txt", "--out", "plan.json", + )) + self.assertEqual(self.calls(), []) + + def test_invalid_utf8_oversized_text_and_timeout_values_refuse(self): + for data in [b"\xff", b"x" * (16 * 1024 + 1)]: + with self.subTest(size=len(data)): + (self.root / "find.txt").write_bytes(data) + self.refused(self.plan()) + for timeout in ["nan", "inf", "0", "-1", "601"]: + with self.subTest(timeout=timeout): + self.refused(self.plan("--timeout", timeout)) + self.assertEqual(self.calls(), []) + + def test_malformed_source_and_unsupported_anchors_refuse(self): + for value in ["[]", "{", '{"documentId":NaN}']: + with self.subTest(value=value): + self.put("before.json", value) + self.refused(self.plan()) + for key in ["namedRanges", "bookmarks"]: + doc = document() + doc["tabs"][0]["documentTab"][key] = {"synthetic-anchor": {}} + self.fixture(doc) + self.refused(self.plan()) + self.assertFalse((self.root / "submitted").exists()) + + def test_large_expanded_style_payload_refuses_before_plan(self): + doc = document("world" + "a" * 20000 + "\n") + body(doc)[1]["paragraph"]["elements"][0]["textRun"]["textStyle"] = { + "link": {"url": "https://example.invalid/" + "x" * 2000}} + self.fixture(doc) + self.refused(self.plan()) + self.assertFalse((self.root / "plan.json").exists()) + + def test_default_zero_indices_in_untouched_header_are_supported(self): + before = document() + header = paragraph([("Header\n", {})], start=0) + del header["startIndex"] + del header["paragraph"]["elements"][0]["startIndex"] + before["tabs"][0]["documentTab"]["headers"] = {"h.1": {"content": [header]}} + after = copy.deepcopy(before) + after["revisionId"] = "rev-2" + body(after)[1] = paragraph([("Hello reader.\n", {})]) + self.fixture(before, after) + self.success(self.plan()) + self.success(self.apply()) + + +if __name__ == "__main__": + unittest.main() From e6ef7a9f20363523c143836d37315132f8c050f3 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:50:22 -0300 Subject: [PATCH 02/16] feat(docs): add suggestion workflow helper --- .changeset/docs-suggest-workflow.md | 7 + README.md | 20 + .../google-workspace-cli/src/helpers/docs.rs | 7 + .../src/helpers/docs/read.rs | 6 +- .../src/helpers/docs/suggest.rs | 419 ++++++++++++++++++ 5 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 .changeset/docs-suggest-workflow.md create mode 100644 crates/google-workspace-cli/src/helpers/docs/suggest.rs diff --git a/.changeset/docs-suggest-workflow.md b/.changeset/docs-suggest-workflow.md new file mode 100644 index 000000000..a9948d1bf --- /dev/null +++ b/.changeset/docs-suggest-workflow.md @@ -0,0 +1,7 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +suggest` for creating and managing Google Docs suggestions, +including suggested insertions, exact replacements, range deletions, and +accept, reject, or delete actions. \ No newline at end of file diff --git a/README.md b/README.md index 41f1c21eb..1b63f2c34 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,25 @@ For example, Docs suggestions and comments require a Cloud project enrolled in t Google still enforces API availability, OAuth scopes, document permissions and server-side validation. This flag grants no additional access. +### Docs suggestions + +On `develop`, `gws docs +suggest` provides a guided workflow for Google Docs +suggestions. It can insert text, replace one exact text run, propose a range +deletion, list the structured document with suggestion context, and accept, +reject, or delete an existing suggestion: + +```bash +gws docs +suggest insert --document DOC_ID --text 'Suggested text' +gws docs +suggest replace --document DOC_ID --find 'old text' --text 'new text' +gws docs +suggest delete-text --document DOC_ID --start-index 10 --end-index 20 +gws docs +suggest list --document DOC_ID +gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID +``` + +The helper applies the preview-only `writeMode` request fields internally, so +these commands do not need `--allow-unknown-fields`. Suggestion writes remain +subject to Google Workspace Developer Preview access and document permissions. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ @@ -438,6 +457,7 @@ gws drive --help # shows +upload … | `sheets` | `+append` | Append a row to a spreadsheet | | `sheets` | `+read` | Read values from a spreadsheet | | `docs` | `+write` | Append text to a document | +| `docs` | `+suggest` | Create and manage document suggestions | | `chat` | `+send` | Send a message to a space | | `drive` | `+upload` | Upload a file with automatic metadata | | `calendar` | `+insert` | Create a new event | diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index c42c02feb..ea538921d 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -22,6 +22,7 @@ use std::future::Future; use std::pin::Pin; mod read; +mod suggest; pub struct DocsHelper; @@ -36,6 +37,7 @@ impl Helper for DocsHelper { _doc: &crate::discovery::RestDescription, ) -> Command { cmd = cmd.subcommand(read::command()); + cmd = cmd.subcommand(suggest::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -77,6 +79,10 @@ TIPS: read::handle(doc, matches, sanitize_config).await?; return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+suggest") { + suggest::handle(doc, matches, sanitize_config).await?; + return Ok(true); + } if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; @@ -219,4 +225,5 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } + } diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 4e2e8625c..b49eec62f 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -76,7 +76,7 @@ pub(super) async fn handle( /// Token acquisition is lazy so local validation and dry-run never read /// credentials. The executor remains responsible for HTTP and sanitization. -pub(super) async fn run( +pub(crate) async fn run( doc: &RestDescription, matches: &ArgMatches, sanitize: &SanitizeConfig, @@ -94,7 +94,9 @@ pub(super) async fn run( let dry_run = matches.get_flag("dry-run"); let token = if dry_run { None } else { Some(token.await?) }; let format = matches - .get_one::("format") + .try_get_one::("format") + .ok() + .flatten() .map(|f| OutputFormat::from_str(f)) .unwrap_or_default(); let result = executor::execute_method( diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs new file mode 100644 index 000000000..31a83c836 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -0,0 +1,419 @@ +use super::read; +use crate::auth; +use crate::discovery::{RestDescription, RestMethod}; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, BodyValidationPolicy, PaginationConfig}; +use crate::formatter::OutputFormat; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::{json, Value}; + +pub(super) fn command() -> Command { + let document = || { + Arg::new("document") + .long("document") + .help("Document ID") + .required(true) + .value_name("ID") + }; + let mut cmd = Command::new("+suggest").about("[Helper] Create and manage Docs suggestions"); + cmd = cmd.subcommand( + Command::new("insert") + .about("Insert text as a suggestion") + .arg(document()) + .arg(text_arg()) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("replace") + .about("Replace one exact text run as a suggestion") + .arg(document()) + .arg( + Arg::new("find") + .long("find") + .help("Exact text to replace") + .required(true) + .value_name("TEXT"), + ) + .arg(text_arg()) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("delete-text") + .about("Propose deleting a document range") + .arg(document()) + .arg(index_arg("start-index")) + .arg(index_arg("end-index")) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ); + cmd = cmd.subcommand( + Command::new("list") + .about("List suggestions with document context") + .arg(document()) + .arg(Arg::new("params").long("params").value_name("JSON")), + ); + for action in ["accept", "reject", "delete"] { + cmd = cmd.subcommand( + Command::new(action) + .about(format!("{} an existing suggestion", capitalize(action))) + .arg(document()) + .arg( + Arg::new("suggestion-id") + .long("suggestion-id") + .required(true) + .value_name("ID"), + ), + ); + } + cmd.after_help( + "EXAMPLES:\n gws docs +suggest insert --document DOC_ID --text 'Suggested text'\n gws docs +suggest replace --document DOC_ID --find 'old' --text 'new'\n gws docs +suggest list --document DOC_ID\n gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID\n\nTIPS:\n Suggestion writes are a Google Workspace Developer Preview feature.\n The helper opts into unknown preview fields internally; raw commands remain strict.\n Use --dry-run to preview insert, delete-text, accept, reject, and delete requests.\n replace reads the document to locate exactly one matching text run before writing.", + ) +} + +fn text_arg() -> Arg { + Arg::new("text") + .long("text") + .help("Text to insert") + .required(true) + .value_name("TEXT") +} + +fn index_arg(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .help("UTF-16 document index") + .required(true) + .value_parser(clap::value_parser!(i32)) +} + +fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let (action, action_matches) = matches + .subcommand() + .ok_or_else(|| GwsError::Validation("docs +suggest requires an action".into()))?; + if action == "list" { + return handle_list(doc, action_matches, sanitize).await; + } + + let (params, body, method) = match action { + "insert" => { + let method = batch_update_method(doc)?; + let body = build_insert_body(action_matches)?; + (document_params(action_matches)?, body, method) + } + "replace" => { + let method = batch_update_method(doc)?; + let body = build_replace_body(doc, action_matches, sanitize).await?; + (document_params(action_matches)?, body, method) + } + "delete-text" => { + let method = batch_update_method(doc)?; + let body = build_delete_text_body(action_matches)?; + (document_params(action_matches)?, body, method) + } + "accept" | "reject" | "delete" => { + let method = batch_update_method(doc)?; + let body = build_suggestion_action_body(action, action_matches)?; + (document_params(action_matches)?, body, method) + } + _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + }; + + execute_suggestion_write(doc, method, ¶ms, &body, sanitize, action_matches).await +} + +async fn handle_list( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let output = read::run(doc, matches, sanitize, async { + auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + println!("{output}"); + Ok(()) +} + +async fn execute_suggestion_write( + doc: &RestDescription, + method: &RestMethod, + params: &str, + body: &str, + sanitize: &SanitizeConfig, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let dry_run = matches.get_flag("dry-run"); + let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let token = if dry_run { + None + } else { + Some( + auth::get_token(&scopes) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?, + ) + }; + executor::execute_method_with_policy( + doc, + method, + Some(params), + Some(body), + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &OutputFormat::default(), + false, + BodyValidationPolicy::AllowUnknownFields, + ) + .await + .map(|_| ()) +} + +fn batch_update_method(doc: &RestDescription) -> Result<&RestMethod, GwsError> { + doc.resources + .get("documents") + .and_then(|resource| resource.methods.get("batchUpdate")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.batchUpdate' not found".into())) +} + +fn document_params(matches: &ArgMatches) -> Result { + let document = matches + .get_one::("document") + .ok_or_else(|| GwsError::Validation("Document ID is required".into()))?; + crate::validate::validate_resource_name(document)?; + Ok(json!({"documentId": document}).to_string()) +} + +fn tab_id(matches: &ArgMatches) -> Option<&str> { + matches.get_one::("tab-id").map(String::as_str) +} + +fn insert_location(matches: &ArgMatches) -> Value { + let mut location = json!({"segmentId": ""}); + if let Some(tab_id) = tab_id(matches) { + location["tabId"] = json!(tab_id); + } + location +} + +fn build_insert_body(matches: &ArgMatches) -> Result { + let text = matches + .get_one::("text") + .ok_or_else(|| GwsError::Validation("Text is required".into()))?; + Ok(json!({ + "requests": [{"insertText": {"text": text, "endOfSegmentLocation": insert_location(matches)}}], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +async fn build_replace_body( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result { + if matches.get_flag("dry-run") { + return Err(GwsError::Validation( + "replace cannot use --dry-run because it must read the document to locate the unique match" + .into(), + )); + } + let document = matches.get_one::("document").unwrap(); + let find = matches.get_one::("find").unwrap(); + let read_matches = read_command_matches(document)?; + let normalized = read::run(doc, &read_matches, sanitize, async { + auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) + }) + .await?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let text = matches.get_one::("text").unwrap(); + let mut delete_range = json!({"startIndex": start, "endIndex": end}); + let mut insert_location = json!({"index": start}); + if let Some(tab_id) = tab_id { + delete_range["tabId"] = json!(tab_id); + insert_location["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [ + {"deleteContentRange": {"range": delete_range}}, + {"insertText": {"text": text, "location": insert_location}} + ], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +fn read_command_matches(document: &str) -> Result { + read::command() + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(clap::ArgAction::SetTrue), + ) + .try_get_matches_from(["+read", "--document", document]) + .map_err(|e| GwsError::Validation(format!("Unable to prepare document read: {e}"))) +} + +fn find_unique_text_run(document: &str, needle: &str) -> Result<(Option, i32, i32), GwsError> { + let mut matches = Vec::new(); + find_text_runs(document, needle, &mut matches); + match matches.as_slice() { + [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), + [] => Err(GwsError::Validation("Text to replace was not found in one text run".into())), + _ => Err(GwsError::Validation("Text to replace matched more than once".into())), + } +} + +fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { + let Ok(value) = serde_json::from_str::(value) else { + return; + }; + walk_text_runs(&value, needle, None, matches); +} + +fn walk_text_runs( + value: &Value, + needle: &str, + current_tab: Option, + matches: &mut Vec<(Option, i32, i32)>, +) { + match value { + Value::Object(object) => { + let tab = object + .get("tabId") + .and_then(Value::as_str) + .map(String::from) + .or(current_tab); + if object.get("type").and_then(Value::as_str) == Some("text") { + if let (Some(text), Some(start), Some(run_end)) = ( + object.get("text").and_then(Value::as_str), + object.get("startIndex").and_then(Value::as_i64), + object.get("endIndex").and_then(Value::as_i64), + ) { + if let Some(offset) = text.find(needle) { + let start = start + text[..offset].encode_utf16().count() as i64; + let end = start + needle.encode_utf16().count() as i64; + if end <= run_end { + matches.push((tab.clone(), start as i32, end as i32)); + } + } + } + } + for child in object.values() { + walk_text_runs(child, needle, tab.clone(), matches); + } + } + Value::Array(array) => { + for child in array { + walk_text_runs(child, needle, current_tab.clone(), matches); + } + } + _ => {} + } +} + +fn build_delete_text_body(matches: &ArgMatches) -> Result { + let start = *matches.get_one::("start-index").unwrap(); + let end = *matches.get_one::("end-index").unwrap(); + if start < 0 || end <= start { + return Err(GwsError::Validation( + "end-index must be greater than start-index and both must be non-negative".into(), + )); + } + let mut range = json!({"startIndex": start, "endIndex": end}); + if let Some(tab_id) = tab_id(matches) { + range["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [{"deleteContentRange": {"range": range}}], + "writeControl": {"writeMode": "SUGGEST"} + }) + .to_string()) +} + +fn build_suggestion_action_body(action: &str, matches: &ArgMatches) -> Result { + let suggestion_id = matches.get_one::("suggestion-id").unwrap(); + let request = match action { + "accept" => json!({"acceptSuggestion": {"suggestionId": suggestion_id}}), + "reject" => json!({"rejectSuggestion": {"suggestionId": suggestion_id}}), + "delete" => json!({"deleteSuggestion": {"suggestionId": suggestion_id}}), + _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + }; + Ok(json!({"requests": [request]}).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn insert_body_uses_suggest_mode() { + let matches = command() + .try_get_matches_from(["+suggest", "insert", "--document", "doc", "--text", "hello"]) + .unwrap(); + let body = build_insert_body(matches.subcommand_matches("insert").unwrap()).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["writeControl"]["writeMode"], "SUGGEST"); + assert_eq!(body["requests"][0]["insertText"]["text"], "hello"); + } + + #[test] + fn delete_text_rejects_invalid_range() { + let matches = command() + .try_get_matches_from([ + "+suggest", "delete-text", "--document", "doc", "--start-index", "4", + "--end-index", "4", + ]) + .unwrap(); + let error = build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()) + .unwrap_err(); + assert!(error.to_string().contains("end-index")); + } + + #[test] + fn suggestion_action_uses_requested_id() { + let matches = command() + .try_get_matches_from([ + "+suggest", "accept", "--document", "doc", "--suggestion-id", "s1", + ]) + .unwrap(); + let body = build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(body["requests"][0]["acceptSuggestion"]["suggestionId"], "s1"); + assert!(body.get("writeControl").is_none()); + } + + #[test] + fn finds_unique_match_using_utf16_indices() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "😀B").unwrap(), + (Some("tab-1".into()), 6, 9) + ); + } +} From 4716d5df008cb2b416bd872841a98c79affedff4 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:53:57 -0300 Subject: [PATCH 03/16] style: format docs suggestion helper --- .../src/helpers/docs/suggest.rs | 55 +++++++++++++++---- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 31a83c836..452c09faf 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -127,7 +127,11 @@ pub(super) async fn handle( let body = build_suggestion_action_body(action, action_matches)?; (document_params(action_matches)?, body, method) } - _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + _ => { + return Err(GwsError::Validation(format!( + "Unknown suggestion action: {action}" + ))) + } }; execute_suggestion_write(doc, method, ¶ms, &body, sanitize, action_matches).await @@ -279,13 +283,20 @@ fn read_command_matches(document: &str) -> Result { .map_err(|e| GwsError::Validation(format!("Unable to prepare document read: {e}"))) } -fn find_unique_text_run(document: &str, needle: &str) -> Result<(Option, i32, i32), GwsError> { +fn find_unique_text_run( + document: &str, + needle: &str, +) -> Result<(Option, i32, i32), GwsError> { let mut matches = Vec::new(); find_text_runs(document, needle, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), - [] => Err(GwsError::Validation("Text to replace was not found in one text run".into())), - _ => Err(GwsError::Validation("Text to replace matched more than once".into())), + [] => Err(GwsError::Validation( + "Text to replace was not found in one text run".into(), + )), + _ => Err(GwsError::Validation( + "Text to replace matched more than once".into(), + )), } } @@ -362,7 +373,11 @@ fn build_suggestion_action_body(action: &str, matches: &ArgMatches) -> Result json!({"acceptSuggestion": {"suggestionId": suggestion_id}}), "reject" => json!({"rejectSuggestion": {"suggestionId": suggestion_id}}), "delete" => json!({"deleteSuggestion": {"suggestionId": suggestion_id}}), - _ => return Err(GwsError::Validation(format!("Unknown suggestion action: {action}"))), + _ => { + return Err(GwsError::Validation(format!( + "Unknown suggestion action: {action}" + ))) + } }; Ok(json!({"requests": [request]}).to_string()) } @@ -386,12 +401,18 @@ mod tests { fn delete_text_rejects_invalid_range() { let matches = command() .try_get_matches_from([ - "+suggest", "delete-text", "--document", "doc", "--start-index", "4", - "--end-index", "4", + "+suggest", + "delete-text", + "--document", + "doc", + "--start-index", + "4", + "--end-index", + "4", ]) .unwrap(); - let error = build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()) - .unwrap_err(); + let error = + build_delete_text_body(matches.subcommand_matches("delete-text").unwrap()).unwrap_err(); assert!(error.to_string().contains("end-index")); } @@ -399,12 +420,22 @@ mod tests { fn suggestion_action_uses_requested_id() { let matches = command() .try_get_matches_from([ - "+suggest", "accept", "--document", "doc", "--suggestion-id", "s1", + "+suggest", + "accept", + "--document", + "doc", + "--suggestion-id", + "s1", ]) .unwrap(); - let body = build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()).unwrap(); + let body = + build_suggestion_action_body("accept", matches.subcommand_matches("accept").unwrap()) + .unwrap(); let body: Value = serde_json::from_str(&body).unwrap(); - assert_eq!(body["requests"][0]["acceptSuggestion"]["suggestionId"], "s1"); + assert_eq!( + body["requests"][0]["acceptSuggestion"]["suggestionId"], + "s1" + ); assert!(body.get("writeControl").is_none()); } From 57542582650c0633297d2ac10832d393466fac2f Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 15:54:19 -0300 Subject: [PATCH 04/16] style: apply rustfmt to docs helper tests --- crates/google-workspace-cli/src/helpers/docs.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index ea538921d..ac9d712e6 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -225,5 +225,4 @@ mod tests { assert!(body.contains("endOfSegmentLocation")); assert_eq!(scopes[0], "https://scope"); } - } From b0c6482b23e0e5f77ed5e7a8d6f7d2c830099a8d Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:25:36 -0300 Subject: [PATCH 05/16] feat(docs): read comments and referenced text --- .changeset/docs-read-comments.md | 6 + README.md | 13 ++ .../src/helpers/docs/read.rs | 186 +++++++++++++++++- .../src/helpers/docs/read_tests.rs | 66 ++++++- 4 files changed, 260 insertions(+), 11 deletions(-) create mode 100644 .changeset/docs-read-comments.md diff --git a/.changeset/docs-read-comments.md b/.changeset/docs-read-comments.md new file mode 100644 index 000000000..dca2acc63 --- /dev/null +++ b/.changeset/docs-read-comments.md @@ -0,0 +1,6 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--include-comments` to `gws docs +read` to return comment threads and the +text referenced by each comment anchor range. \ No newline at end of file diff --git a/README.md b/README.md index 1b63f2c34..56f8063ea 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,19 @@ The helper applies the preview-only `writeMode` request fields internally, so these commands do not need `--allow-unknown-fields`. Suggestion writes remain subject to Google Workspace Developer Preview access and document permissions. +### Reading comments and their text anchors + +Use `--include-comments` with `gws docs +read` to retrieve comment threads and +resolve each anchored range to the text it refers to: + +```bash +gws docs +read --document DOC_ID --include-comments +``` + +Each comment includes its thread data, anchor ranges, and `referencedText`, an +array with one value per anchored range. Unresolvable ranges are returned as +`null`; comments remain opt-in because they may contain sensitive content. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index b49eec62f..f0e9af68d 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -29,6 +29,7 @@ pub(super) fn command() -> Command { .about("[Helper] Read a document as compact structured content") .arg(Arg::new("document").long("document").help("Document ID").required(true).value_name("ID")) .arg(Arg::new("params").long("params").help("Additional documents.get API parameters as JSON").value_name("JSON")) + .arg(Arg::new("include-comments").long("include-comments").help("Include comments and their referenced text").action(clap::ArgAction::SetTrue)) .after_help( "\ EXAMPLES: @@ -52,7 +53,8 @@ TIPS: revisionId and suggestionsViewMode are retained when returned. Missing revisionId is not synthesized. source=legacyBody indicates a fallback response without populated tabs; all-tab coverage cannot be confirmed. This is a content view, not a layout renderer or lossless API round trip. Inherited styles are not resolved. - Suggestions remain inline, including proposed deletions; this helper does not accept or reject suggestions. + Suggestions remain inline, including proposed deletions; this helper does not accept or reject suggestions. + --include-comments requests comment threads and resolves each comment anchor to referenced text. Use raw documents get for unsupported views or field masks. Missing body content produces an error. --dry-run validates and prints a request plan without acquiring credentials or fetching document content. --sanitize uses the existing Model Armor policy before normalization and retains _sanitization metadata.", @@ -82,9 +84,16 @@ pub(crate) async fn run( sanitize: &SanitizeConfig, token: impl Future>, ) -> Result { - let params = build_params( + let include_comments = matches + .try_get_one::("include-comments") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let params = build_params_with_comments( matches.get_one::("document").unwrap(), matches.get_one::("params").map(String::as_str), + include_comments, )?; let method = doc .resources @@ -121,11 +130,21 @@ pub(crate) async fn run( ) .await? .ok_or_else(|| invalid_content("expected a JSON document response"))?; - let output = if dry_run { result } else { normalize(&result)? }; + let output = if dry_run { + result + } else if include_comments { + normalize_with_comments(&result)? + } else { + normalize(&result)? + }; Ok(format_value(&output, &format)) } -pub(super) fn build_params(document: &str, params: Option<&str>) -> Result { +pub(super) fn build_params_with_comments( + document: &str, + params: Option<&str>, + include_comments: bool, +) -> Result { crate::validate::validate_resource_name(document)?; let mut params: Map = match params { Some(raw) => serde_json::from_str(raw) @@ -153,6 +172,22 @@ pub(super) fn build_params(document: &str, params: Option<&str>) -> Result Result { Ok(Value::Object(result)) } +pub(super) fn normalize_with_comments(document: &Value) -> Result { + let mut output = normalize(document)?; + let comments = document + .get("comments") + .and_then(Value::as_array) + .ok_or_else(|| { + invalid_content("comments were requested but response has no comments array") + })?; + let anchors = collect_comment_anchors(document); + let text_runs = collect_text_runs(&output); + let enriched = comments + .iter() + .map(|comment| { + let mut comment = object(comment)?; + let anchor_id = comment.get("anchorId").and_then(Value::as_str); + let referenced_text = anchor_id + .and_then(|id| anchors.get(id)) + .map(|ranges| { + ranges + .iter() + .map(|range| resolve_range(range, &text_runs)) + .collect::>() + }) + .unwrap_or_default(); + comment.insert("referencedText".into(), Value::Array(referenced_text)); + Ok(Value::Object(comment)) + }) + .collect::, GwsError>>()?; + output["comments"] = Value::Array(enriched); + Ok(output) +} + +fn collect_comment_anchors(document: &Value) -> std::collections::HashMap> { + let mut anchors = std::collections::HashMap::new(); + collect_comment_anchors_recursive(document, &mut anchors); + anchors +} + +fn collect_comment_anchors_recursive( + value: &Value, + anchors: &mut std::collections::HashMap>, +) { + match value { + Value::Object(object) => { + if let Some(comment_anchors) = object.get("commentAnchors").and_then(Value::as_object) { + for (id, anchor) in comment_anchors { + let ranges = anchor + .get("ranges") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + anchors.insert(id.clone(), ranges); + } + } + for child in object.values() { + collect_comment_anchors_recursive(child, anchors); + } + } + Value::Array(array) => { + for child in array { + collect_comment_anchors_recursive(child, anchors); + } + } + _ => {} + } +} + +type TextRun = (Option, i64, i64, String); + +fn collect_text_runs(document: &Value) -> Vec { + let mut runs = Vec::new(); + collect_text_runs_recursive(document, None, &mut runs); + runs +} + +fn collect_text_runs_recursive(value: &Value, tab_id: Option, runs: &mut Vec) { + match value { + Value::Object(object) => { + let tab_id = object + .get("tabId") + .and_then(Value::as_str) + .map(String::from) + .or(tab_id); + if object.get("type").and_then(Value::as_str) == Some("text") { + if let (Some(start), Some(end), Some(text)) = ( + object.get("startIndex").and_then(Value::as_i64), + object.get("endIndex").and_then(Value::as_i64), + object.get("text").and_then(Value::as_str), + ) { + runs.push((tab_id.clone(), start, end, text.to_string())); + } + } + for child in object.values() { + collect_text_runs_recursive(child, tab_id.clone(), runs); + } + } + Value::Array(array) => { + for child in array { + collect_text_runs_recursive(child, tab_id.clone(), runs); + } + } + _ => {} + } +} + +fn resolve_range(range: &Value, runs: &[TextRun]) -> Value { + let Some(start) = range.get("startIndex").and_then(Value::as_i64) else { + return Value::Null; + }; + let Some(end) = range.get("endIndex").and_then(Value::as_i64) else { + return Value::Null; + }; + let tab_id = range.get("tabId").and_then(Value::as_str); + let tab_count = runs + .iter() + .filter_map(|(run_tab, _, _, _)| run_tab.as_deref()) + .collect::>() + .len(); + let mut fragments = Vec::new(); + for (run_tab, run_start, run_end, text) in runs { + let tab_matches = match tab_id { + Some(tab_id) => run_tab.as_deref() == Some(tab_id), + None => run_tab.is_none() || tab_count <= 1, + }; + if !tab_matches || *run_end <= start || *run_start >= end { + continue; + } + let from = (start.max(*run_start) - *run_start) as usize; + let to = (end.min(*run_end) - *run_start) as usize; + fragments.push(slice_utf16(text, from, to)); + } + if fragments.is_empty() { + Value::Null + } else { + Value::String(fragments.concat()) + } +} + +fn slice_utf16(text: &str, start: usize, end: usize) -> String { + let units: Vec = text.encode_utf16().collect(); + String::from_utf16_lossy(&units[start.min(units.len())..end.min(units.len())]) +} + fn normalize_tab( tab: &Value, parent: &Value, diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index d0152bf9f..050968b36 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -110,8 +110,12 @@ fn auto_text_preserves_page_number_and_count_with_indices_and_styles() { #[test] fn request_requires_full_inline_tabs_and_preserves_other_params() { - let params = - read::build_params("synthetic", Some(r#"{"fields":"*","prettyPrint":false}"#)).unwrap(); + let params = read::build_params_with_comments( + "synthetic", + Some(r#"{"fields":"*","prettyPrint":false}"#), + false, + ) + .unwrap(); assert_eq!( params, json!({ @@ -120,12 +124,54 @@ fn request_requires_full_inline_tabs_and_preserves_other_params() { }) ); assert_eq!( - read::build_params("id", None).unwrap()["includeTabsContent"], + read::build_params_with_comments("id", None, false).unwrap()["includeTabsContent"], true ); - assert!(read::build_params("id", Some( + assert!(read::build_params_with_comments("id", Some( r#"{"includeTabsContent":true,"suggestionsViewMode":"SUGGESTIONS_INLINE","documentId":"id"}"# - )).is_ok()); + ), false).is_ok()); +} + +#[test] +fn comments_are_opt_in_and_request_includes_comment_view_mode() { + let params = read::build_params_with_comments("synthetic", None, true).unwrap(); + assert_eq!(params["commentsViewMode"], "COMMENTS_VIEW_MODE_INCLUDED"); +} + +#[test] +fn comments_include_text_for_each_anchor_range() { + let mut input = legacy(); + input["comments"] = json!([{ + "commentId": "c1", + "anchorId": "a1", + "headPost": {"content": "Please review"}, + "status": "OPEN" + }]); + input["tabs"] = json!([{ + "tabProperties": {"tabId": "tab-1"}, + "documentTab": { + "body": {"content": [{ + "startIndex": 1, "endIndex": 12, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 12, + "textRun": {"content": "Hello world"} + }]} + }]}, + "commentAnchors": { + "a1": {"anchorId": "a1", "ranges": [ + {"startIndex": 7, "endIndex": 12}, + {"startIndex": 1, "endIndex": 6} + ]} + } + } + }]); + + let output = read::normalize_with_comments(&input).unwrap(); + assert_eq!(output["comments"][0]["commentId"], "c1"); + assert_eq!( + output["comments"][0]["referencedText"], + json!(["world", "Hello"]) + ); } #[test] @@ -147,7 +193,10 @@ fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { "null", "{", ] { - assert!(read::build_params("id", Some(params)).is_err(), "{params}"); + assert!( + read::build_params_with_comments("id", Some(params), false).is_err(), + "{params}" + ); } for id in [ "", @@ -157,7 +206,10 @@ fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { "id\n", "%2e%2e", ] { - assert!(read::build_params(id, None).is_err(), "{id:?}"); + assert!( + read::build_params_with_comments(id, None, false).is_err(), + "{id:?}" + ); } } From f81bf9f61979689a83e30dd007bb7619c2dd07a0 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:44:58 -0300 Subject: [PATCH 06/16] feat(docs): add comment creation helper --- .changeset/docs-comment-create.md | 6 + README.md | 14 ++ .../google-workspace-cli/src/helpers/docs.rs | 6 + .../src/helpers/docs/comment.rs | 215 ++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 .changeset/docs-comment-create.md create mode 100644 crates/google-workspace-cli/src/helpers/docs/comment.rs diff --git a/.changeset/docs-comment-create.md b/.changeset/docs-comment-create.md new file mode 100644 index 000000000..b0899d14b --- /dev/null +++ b/.changeset/docs-comment-create.md @@ -0,0 +1,6 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `gws docs +comment create` for creating anchored Google Docs comments +without manually using preview-only request fields. \ No newline at end of file diff --git a/README.md b/README.md index 56f8063ea..a007bb3eb 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,20 @@ Each comment includes its thread data, anchor ranges, and `referencedText`, an array with one value per anchored range. Unresolvable ranges are returned as `null`; comments remain opt-in because they may contain sensitive content. +Create a comment without manually constructing the preview API payload: + +```bash +gws docs +comment create \ + --document DOC_ID \ + --text 'Please review this.' \ + --start-index 1 \ + --end-index 20 +``` + +The helper validates UTF-16 ranges and applies the preview-field opt-in +internally, so `--allow-unknown-fields` is not required. The request still +requires edit access and Google Workspace Developer Preview availability. + ```bash # Preview a suggested insertion (Docs Developer Preview). gws docs documents batchUpdate \ diff --git a/crates/google-workspace-cli/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs index ac9d712e6..3f2c29c7e 100644 --- a/crates/google-workspace-cli/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -21,6 +21,7 @@ use serde_json::json; use std::future::Future; use std::pin::Pin; +mod comment; mod read; mod suggest; @@ -38,6 +39,7 @@ impl Helper for DocsHelper { ) -> Command { cmd = cmd.subcommand(read::command()); cmd = cmd.subcommand(suggest::command()); + cmd = cmd.subcommand(comment::command()); cmd = cmd.subcommand( Command::new("+write") .about("[Helper] Append text to a document") @@ -83,6 +85,10 @@ TIPS: suggest::handle(doc, matches, sanitize_config).await?; return Ok(true); } + if let Some(matches) = matches.subcommand_matches("+comment") { + comment::handle(doc, matches, sanitize_config).await?; + return Ok(true); + } if let Some(matches) = matches.subcommand_matches("+write") { let (params_str, body_str, scopes) = build_write_request(matches, doc)?; diff --git a/crates/google-workspace-cli/src/helpers/docs/comment.rs b/crates/google-workspace-cli/src/helpers/docs/comment.rs new file mode 100644 index 000000000..5f605803e --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/docs/comment.rs @@ -0,0 +1,215 @@ +use crate::auth; +use crate::discovery::{RestDescription, RestMethod}; +use crate::error::GwsError; +use crate::executor::{self, AuthMethod, BodyValidationPolicy, PaginationConfig}; +use crate::formatter::OutputFormat; +use crate::helpers::modelarmor::SanitizeConfig; +use clap::{Arg, ArgMatches, Command}; +use serde_json::json; + +pub(super) fn command() -> Command { + Command::new("+comment") + .about("[Helper] Create a comment anchored to document text") + .subcommand( + Command::new("create") + .about("Create a comment on a document range") + .arg( + Arg::new("document") + .long("document") + .help("Document ID") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("text") + .long("text") + .help("Comment text") + .required(true) + .value_name("TEXT"), + ) + .arg(index_arg("start-index")) + .arg(index_arg("end-index")) + .arg(Arg::new("tab-id").long("tab-id").value_name("ID")), + ) + .after_help( + "EXAMPLES:\n gws docs +comment create --document DOC_ID --text 'Please review this.' --start-index 1 --end-index 20\n\nTIPS:\n Indexes are UTF-16 document indexes.\n Comment creation is a Google Workspace Developer Preview feature.\n Use --dry-run to validate without authentication or sending the request.", + ) +} + +fn index_arg(name: &'static str) -> Arg { + Arg::new(name) + .long(name) + .help("UTF-16 document index") + .required(true) + .value_parser(clap::value_parser!(i32)) +} + +pub(super) async fn handle( + doc: &RestDescription, + matches: &ArgMatches, + sanitize: &SanitizeConfig, +) -> Result<(), GwsError> { + let (action, action_matches) = matches + .subcommand() + .ok_or_else(|| GwsError::Validation("docs +comment requires an action".into()))?; + if action != "create" { + return Err(GwsError::Validation(format!( + "Unknown comment action: {action}" + ))); + } + let method = batch_update_method(doc)?; + let params = document_params(action_matches)?; + let body = build_comment_create_body(action_matches)?; + let dry_run = action_matches.get_flag("dry-run"); + let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let token = if dry_run { + None + } else { + Some( + auth::get_token(&scopes) + .await + .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}")))?, + ) + }; + executor::execute_method_with_policy( + doc, + method, + Some(¶ms), + Some(&body), + token.as_deref(), + if token.is_some() { + AuthMethod::OAuth + } else { + AuthMethod::None + }, + None, + None, + dry_run, + &PaginationConfig::default(), + sanitize.template.as_deref(), + &sanitize.mode, + &OutputFormat::default(), + false, + BodyValidationPolicy::AllowUnknownFields, + ) + .await + .map(|_| ()) +} + +fn batch_update_method(doc: &RestDescription) -> Result<&RestMethod, GwsError> { + doc.resources + .get("documents") + .and_then(|resource| resource.methods.get("batchUpdate")) + .ok_or_else(|| GwsError::Discovery("Method 'documents.batchUpdate' not found".into())) +} + +fn document_params(matches: &ArgMatches) -> Result { + let document = matches + .get_one::("document") + .ok_or_else(|| GwsError::Validation("Document ID is required".into()))?; + crate::validate::validate_resource_name(document)?; + Ok(json!({"documentId": document}).to_string()) +} + +fn build_comment_create_body(matches: &ArgMatches) -> Result { + let text = matches + .get_one::("text") + .ok_or_else(|| GwsError::Validation("Comment text is required".into()))?; + let start = *matches + .get_one::("start-index") + .ok_or_else(|| GwsError::Validation("start-index is required".into()))?; + let end = *matches + .get_one::("end-index") + .ok_or_else(|| GwsError::Validation("end-index is required".into()))?; + if start < 0 || end <= start { + return Err(GwsError::Validation( + "end-index must be greater than start-index and both must be non-negative".into(), + )); + } + let mut range = json!({"startIndex": start, "endIndex": end}); + if let Some(tab_id) = matches.get_one::("tab-id") { + range["tabId"] = json!(tab_id); + } + Ok(json!({ + "requests": [{"insertComment": {"content": text, "range": range}}] + }) + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + #[test] + fn comment_create_body_uses_requested_range_and_content() { + let matches = Command::new("+comment") + .arg(Arg::new("document").long("document")) + .arg(Arg::new("text").long("text")) + .arg( + Arg::new("start-index") + .long("start-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg( + Arg::new("end-index") + .long("end-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg(Arg::new("tab-id").long("tab-id")) + .try_get_matches_from([ + "+comment", + "--document", + "doc", + "--text", + "Review this", + "--start-index", + "1", + "--end-index", + "10", + ]) + .unwrap(); + let body = build_comment_create_body(&matches).unwrap(); + let body: Value = serde_json::from_str(&body).unwrap(); + assert_eq!( + body["requests"][0]["insertComment"]["content"], + "Review this" + ); + assert_eq!( + body["requests"][0]["insertComment"]["range"]["startIndex"], + 1 + ); + assert_eq!( + body["requests"][0]["insertComment"]["range"]["endIndex"], + 10 + ); + } + + #[test] + fn comment_create_rejects_non_positive_range() { + let matches = Command::new("+comment") + .arg(Arg::new("text").long("text")) + .arg( + Arg::new("start-index") + .long("start-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg( + Arg::new("end-index") + .long("end-index") + .value_parser(clap::value_parser!(i32)), + ) + .arg(Arg::new("tab-id").long("tab-id")) + .try_get_matches_from([ + "+comment", + "--text", + "Review this", + "--start-index", + "10", + "--end-index", + "10", + ]) + .unwrap(); + assert!(build_comment_create_body(&matches).is_err()); + } +} From 9ea63dbdb8d70da69fb2c46b20c22f6745bc70b9 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:53:14 -0300 Subject: [PATCH 07/16] fix(docs): harden suggested replacements --- .../src/helpers/docs/suggest.rs | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 452c09faf..da8fe046b 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -247,6 +247,11 @@ async fn build_replace_body( } let document = matches.get_one::("document").unwrap(); let find = matches.get_one::("find").unwrap(); + if find.is_empty() { + return Err(GwsError::Validation( + "find must not be empty when replacing text".into(), + )); + } let read_matches = read_command_matches(document)?; let normalized = read::run(doc, &read_matches, sanitize, async { auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) @@ -254,7 +259,19 @@ async fn build_replace_body( .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) }) .await?; - let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let normalized_value: Value = serde_json::from_str(&normalized) + .map_err(|e| GwsError::Validation(format!("Invalid normalized Docs response: {e}")))?; + let revision_id = normalized_value + .get("revisionId") + .and_then(Value::as_str) + .filter(|revision| !revision.is_empty()) + .ok_or_else(|| { + GwsError::Validation( + "Docs response did not include a revisionId; refusing an unprotected replacement" + .into(), + ) + })?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find, tab_id(matches))?; let text = matches.get_one::("text").unwrap(); let mut delete_range = json!({"startIndex": start, "endIndex": end}); let mut insert_location = json!({"index": start}); @@ -267,7 +284,7 @@ async fn build_replace_body( {"deleteContentRange": {"range": delete_range}}, {"insertText": {"text": text, "location": insert_location}} ], - "writeControl": {"writeMode": "SUGGEST"} + "writeControl": {"writeMode": "SUGGEST", "requiredRevisionId": revision_id} }) .to_string()) } @@ -286,9 +303,15 @@ fn read_command_matches(document: &str) -> Result { fn find_unique_text_run( document: &str, needle: &str, + requested_tab: Option<&str>, ) -> Result<(Option, i32, i32), GwsError> { + if needle.is_empty() { + return Err(GwsError::Validation( + "Text to replace must not be empty".into(), + )); + } let mut matches = Vec::new(); - find_text_runs(document, needle, &mut matches); + find_text_runs(document, needle, requested_tab, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), [] => Err(GwsError::Validation( @@ -300,11 +323,27 @@ fn find_unique_text_run( } } -fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { +fn find_text_runs( + value: &str, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { let Ok(value) = serde_json::from_str::(value) else { return; }; - walk_text_runs(&value, needle, None, matches); + let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { + return; + }; + for tab in tabs { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { + continue; + } + if let Some(blocks) = tab.get("blocks") { + walk_text_runs(blocks, needle, tab_id, matches); + } + } } fn walk_text_runs( @@ -326,7 +365,7 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - if let Some(offset) = text.find(needle) { + for offset in text.match_indices(needle).map(|(offset, _)| offset) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -443,8 +482,29 @@ mod tests { fn finds_unique_match_using_utf16_indices() { let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; assert_eq!( - find_unique_text_run(document, "😀B").unwrap(), + find_unique_text_run(document, "😀B", None).unwrap(), (Some("tab-1".into()), 6, 9) ); } + + #[test] + fn rejects_empty_replacement_text() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"hello","startIndex":1,"endIndex":6}]}]}]}"#; + assert!(find_unique_text_run(document, "", None).is_err()); + } + + #[test] + fn rejects_duplicate_matches_within_one_text_run() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo foo","startIndex":1,"endIndex":8}]}]}]}"#; + assert!(find_unique_text_run(document, "foo", None).is_err()); + } + + #[test] + fn filters_replacement_matches_by_tab_id() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]},{"tabId":"tab-2","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "foo", Some("tab-2")).unwrap(), + (Some("tab-2".into()), 1, 4) + ); + } } From 16d8357a13636b71a56cee0da157e92fad65a78a Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:53:14 -0300 Subject: [PATCH 08/16] fix(docs): harden suggested replacements --- .../src/helpers/docs/suggest.rs | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index 452c09faf..da8fe046b 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -247,6 +247,11 @@ async fn build_replace_body( } let document = matches.get_one::("document").unwrap(); let find = matches.get_one::("find").unwrap(); + if find.is_empty() { + return Err(GwsError::Validation( + "find must not be empty when replacing text".into(), + )); + } let read_matches = read_command_matches(document)?; let normalized = read::run(doc, &read_matches, sanitize, async { auth::get_token(&["https://www.googleapis.com/auth/documents.readonly"]) @@ -254,7 +259,19 @@ async fn build_replace_body( .map_err(|e| GwsError::Auth(format!("Docs auth failed: {e}"))) }) .await?; - let (tab_id, start, end) = find_unique_text_run(&normalized, find)?; + let normalized_value: Value = serde_json::from_str(&normalized) + .map_err(|e| GwsError::Validation(format!("Invalid normalized Docs response: {e}")))?; + let revision_id = normalized_value + .get("revisionId") + .and_then(Value::as_str) + .filter(|revision| !revision.is_empty()) + .ok_or_else(|| { + GwsError::Validation( + "Docs response did not include a revisionId; refusing an unprotected replacement" + .into(), + ) + })?; + let (tab_id, start, end) = find_unique_text_run(&normalized, find, tab_id(matches))?; let text = matches.get_one::("text").unwrap(); let mut delete_range = json!({"startIndex": start, "endIndex": end}); let mut insert_location = json!({"index": start}); @@ -267,7 +284,7 @@ async fn build_replace_body( {"deleteContentRange": {"range": delete_range}}, {"insertText": {"text": text, "location": insert_location}} ], - "writeControl": {"writeMode": "SUGGEST"} + "writeControl": {"writeMode": "SUGGEST", "requiredRevisionId": revision_id} }) .to_string()) } @@ -286,9 +303,15 @@ fn read_command_matches(document: &str) -> Result { fn find_unique_text_run( document: &str, needle: &str, + requested_tab: Option<&str>, ) -> Result<(Option, i32, i32), GwsError> { + if needle.is_empty() { + return Err(GwsError::Validation( + "Text to replace must not be empty".into(), + )); + } let mut matches = Vec::new(); - find_text_runs(document, needle, &mut matches); + find_text_runs(document, needle, requested_tab, &mut matches); match matches.as_slice() { [(tab_id, start, end)] => Ok((tab_id.clone(), *start, *end)), [] => Err(GwsError::Validation( @@ -300,11 +323,27 @@ fn find_unique_text_run( } } -fn find_text_runs(value: &str, needle: &str, matches: &mut Vec<(Option, i32, i32)>) { +fn find_text_runs( + value: &str, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { let Ok(value) = serde_json::from_str::(value) else { return; }; - walk_text_runs(&value, needle, None, matches); + let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { + return; + }; + for tab in tabs { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { + continue; + } + if let Some(blocks) = tab.get("blocks") { + walk_text_runs(blocks, needle, tab_id, matches); + } + } } fn walk_text_runs( @@ -326,7 +365,7 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - if let Some(offset) = text.find(needle) { + for offset in text.match_indices(needle).map(|(offset, _)| offset) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -443,8 +482,29 @@ mod tests { fn finds_unique_match_using_utf16_indices() { let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"A😀BC","startIndex":5,"endIndex":10}]}]}]}"#; assert_eq!( - find_unique_text_run(document, "😀B").unwrap(), + find_unique_text_run(document, "😀B", None).unwrap(), (Some("tab-1".into()), 6, 9) ); } + + #[test] + fn rejects_empty_replacement_text() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"hello","startIndex":1,"endIndex":6}]}]}]}"#; + assert!(find_unique_text_run(document, "", None).is_err()); + } + + #[test] + fn rejects_duplicate_matches_within_one_text_run() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo foo","startIndex":1,"endIndex":8}]}]}]}"#; + assert!(find_unique_text_run(document, "foo", None).is_err()); + } + + #[test] + fn filters_replacement_matches_by_tab_id() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]},{"tabId":"tab-2","blocks":[{"elements":[{"type":"text","text":"foo","startIndex":1,"endIndex":4}]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "foo", Some("tab-2")).unwrap(), + (Some("tab-2".into()), 1, 4) + ); + } } From 92ff044f22377c34898ca5f7db3bafed717f221f Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 16:57:50 -0300 Subject: [PATCH 09/16] fix(docs): address comment review findings --- README.md | 1 + crates/google-workspace-cli/src/helpers/docs/read.rs | 12 ++++++------ .../src/helpers/docs/read_tests.rs | 6 ++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a007bb3eb..c0058383d 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ gws drive --help # shows +upload … | `sheets` | `+read` | Read values from a spreadsheet | | `docs` | `+write` | Append text to a document | | `docs` | `+suggest` | Create and manage document suggestions | +| `docs` | `+comment` | Create anchored document comments | | `chat` | `+send` | Send a message to a space | | `drive` | `+upload` | Upload a file with automatic metadata | | `calendar` | `+insert` | Create a new event | diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index f0e9af68d..117e8ff91 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -274,12 +274,12 @@ pub(super) fn normalize(document: &Value) -> Result { pub(super) fn normalize_with_comments(document: &Value) -> Result { let mut output = normalize(document)?; - let comments = document - .get("comments") - .and_then(Value::as_array) - .ok_or_else(|| { - invalid_content("comments were requested but response has no comments array") - })?; + let comments: &[Value] = match document.get("comments") { + Some(comments) => comments + .as_array() + .ok_or_else(|| invalid_content("response comments field is not an array"))?, + None => &[], + }; let anchors = collect_comment_anchors(document); let text_runs = collect_text_runs(&output); let enriched = comments diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index 050968b36..95f33d673 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -174,6 +174,12 @@ fn comments_include_text_for_each_anchor_range() { ); } +#[test] +fn comments_without_threads_are_returned_as_empty() { + let output = read::normalize_with_comments(&legacy()).unwrap(); + assert_eq!(output["comments"], json!([])); +} + #[test] fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { for params in [ From 19a9155f9d3490dced205b374bf8350a93404b71 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 17:03:05 -0300 Subject: [PATCH 10/16] fix(docs): handle nested suggestion replacements --- .../src/helpers/docs/suggest.rs | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index da8fe046b..a638c096e 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -161,7 +161,7 @@ async fn execute_suggestion_write( matches: &ArgMatches, ) -> Result<(), GwsError> { let dry_run = matches.get_flag("dry-run"); - let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let token = if dry_run { None } else { @@ -332,16 +332,28 @@ fn find_text_runs( let Ok(value) = serde_json::from_str::(value) else { return; }; - let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { - return; - }; - for tab in tabs { - let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); - if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { - continue; + if let Some(tabs) = value.get("tabs").and_then(Value::as_array) { + for tab in tabs { + walk_tab_text_runs(tab, needle, requested_tab, matches); } + } +} + +fn walk_tab_text_runs( + tab: &Value, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_none_or(|requested| tab_id.as_deref() == Some(requested)) { if let Some(blocks) = tab.get("blocks") { - walk_text_runs(blocks, needle, tab_id, matches); + walk_text_runs(blocks, needle, tab_id.clone(), matches); + } + } + if let Some(children) = tab.get("childTabs").and_then(Value::as_array) { + for child in children { + walk_tab_text_runs(child, needle, requested_tab, matches); } } } @@ -365,7 +377,9 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - for offset in text.match_indices(needle).map(|(offset, _)| offset) { + for offset in text.char_indices().filter_map(|(offset, _)| { + text[offset..].starts_with(needle).then_some(offset) + }) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -507,4 +521,19 @@ mod tests { (Some("tab-2".into()), 1, 4) ); } + + #[test] + fn rejects_overlapping_matches() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"aaa","startIndex":1,"endIndex":4}]}]}]}"#; + assert!(find_unique_text_run(document, "aa", None).is_err()); + } + + #[test] + fn finds_text_in_child_tabs() { + let document = r#"{"tabs":[{"tabId":"root","blocks":[],"childTabs":[{"tabId":"child","blocks":[{"elements":[{"type":"text","text":"child text","startIndex":1,"endIndex":11}]}],"childTabs":[]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "child", Some("child")).unwrap(), + (Some("child".into()), 1, 6) + ); + } } From b2d17879f1d73e9c823ad475f0b9ff8fc0f7ed3e Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 17:03:05 -0300 Subject: [PATCH 11/16] fix(docs): handle nested suggestion replacements --- .../src/helpers/docs/suggest.rs | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/suggest.rs b/crates/google-workspace-cli/src/helpers/docs/suggest.rs index da8fe046b..a638c096e 100644 --- a/crates/google-workspace-cli/src/helpers/docs/suggest.rs +++ b/crates/google-workspace-cli/src/helpers/docs/suggest.rs @@ -161,7 +161,7 @@ async fn execute_suggestion_write( matches: &ArgMatches, ) -> Result<(), GwsError> { let dry_run = matches.get_flag("dry-run"); - let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let token = if dry_run { None } else { @@ -332,16 +332,28 @@ fn find_text_runs( let Ok(value) = serde_json::from_str::(value) else { return; }; - let Some(tabs) = value.get("tabs").and_then(Value::as_array) else { - return; - }; - for tab in tabs { - let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); - if requested_tab.is_some_and(|requested| tab_id.as_deref() != Some(requested)) { - continue; + if let Some(tabs) = value.get("tabs").and_then(Value::as_array) { + for tab in tabs { + walk_tab_text_runs(tab, needle, requested_tab, matches); } + } +} + +fn walk_tab_text_runs( + tab: &Value, + needle: &str, + requested_tab: Option<&str>, + matches: &mut Vec<(Option, i32, i32)>, +) { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if requested_tab.is_none_or(|requested| tab_id.as_deref() == Some(requested)) { if let Some(blocks) = tab.get("blocks") { - walk_text_runs(blocks, needle, tab_id, matches); + walk_text_runs(blocks, needle, tab_id.clone(), matches); + } + } + if let Some(children) = tab.get("childTabs").and_then(Value::as_array) { + for child in children { + walk_tab_text_runs(child, needle, requested_tab, matches); } } } @@ -365,7 +377,9 @@ fn walk_text_runs( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), ) { - for offset in text.match_indices(needle).map(|(offset, _)| offset) { + for offset in text.char_indices().filter_map(|(offset, _)| { + text[offset..].starts_with(needle).then_some(offset) + }) { let start = start + text[..offset].encode_utf16().count() as i64; let end = start + needle.encode_utf16().count() as i64; if end <= run_end { @@ -507,4 +521,19 @@ mod tests { (Some("tab-2".into()), 1, 4) ); } + + #[test] + fn rejects_overlapping_matches() { + let document = r#"{"tabs":[{"tabId":"tab-1","blocks":[{"elements":[{"type":"text","text":"aaa","startIndex":1,"endIndex":4}]}]}]}"#; + assert!(find_unique_text_run(document, "aa", None).is_err()); + } + + #[test] + fn finds_text_in_child_tabs() { + let document = r#"{"tabs":[{"tabId":"root","blocks":[],"childTabs":[{"tabId":"child","blocks":[{"elements":[{"type":"text","text":"child text","startIndex":1,"endIndex":11}]}],"childTabs":[]}]}]}"#; + assert_eq!( + find_unique_text_run(document, "child", Some("child")).unwrap(), + (Some("child".into()), 1, 6) + ); + } } From f941a79f170580fdfd8dc4bf77834c32c1a44e54 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 17:11:04 -0300 Subject: [PATCH 12/16] fix(docs): preserve comment anchor segments --- .../src/helpers/docs/read.rs | 72 +++++++++++++++---- .../src/helpers/docs/read_tests.rs | 35 +++++++++ 2 files changed, 93 insertions(+), 14 deletions(-) diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 117e8ff91..33a06e2d9 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -339,38 +339,74 @@ fn collect_comment_anchors_recursive( } } -type TextRun = (Option, i64, i64, String); +type TextRun = (Option, Option, i64, i64, String); fn collect_text_runs(document: &Value) -> Vec { let mut runs = Vec::new(); - collect_text_runs_recursive(document, None, &mut runs); + if let Some(tabs) = document.get("tabs").and_then(Value::as_array) { + for tab in tabs { + collect_tab_text_runs(tab, &mut runs); + } + } runs } -fn collect_text_runs_recursive(value: &Value, tab_id: Option, runs: &mut Vec) { +fn collect_tab_text_runs(tab: &Value, runs: &mut Vec) { + let tab_id = tab.get("tabId").and_then(Value::as_str).map(String::from); + if let Some(blocks) = tab.get("blocks") { + collect_text_runs_recursive(blocks, tab_id.clone(), None, runs); + } + for segment_name in ["headers", "footers", "footnotes"] { + if let Some(segments) = tab.get(segment_name).and_then(Value::as_object) { + for (segment_id, segment) in segments { + if let Some(blocks) = segment.get("blocks") { + collect_text_runs_recursive( + blocks, + tab_id.clone(), + Some(segment_id.clone()), + runs, + ); + } + } + } + } + if let Some(children) = tab.get("childTabs").and_then(Value::as_array) { + for child in children { + collect_tab_text_runs(child, runs); + } + } +} + +fn collect_text_runs_recursive( + value: &Value, + tab_id: Option, + segment_id: Option, + runs: &mut Vec, +) { match value { Value::Object(object) => { - let tab_id = object - .get("tabId") - .and_then(Value::as_str) - .map(String::from) - .or(tab_id); if object.get("type").and_then(Value::as_str) == Some("text") { if let (Some(start), Some(end), Some(text)) = ( object.get("startIndex").and_then(Value::as_i64), object.get("endIndex").and_then(Value::as_i64), object.get("text").and_then(Value::as_str), ) { - runs.push((tab_id.clone(), start, end, text.to_string())); + runs.push(( + tab_id.clone(), + segment_id.clone(), + start, + end, + text.to_string(), + )); } } for child in object.values() { - collect_text_runs_recursive(child, tab_id.clone(), runs); + collect_text_runs_recursive(child, tab_id.clone(), segment_id.clone(), runs); } } Value::Array(array) => { for child in array { - collect_text_runs_recursive(child, tab_id.clone(), runs); + collect_text_runs_recursive(child, tab_id.clone(), segment_id.clone(), runs); } } _ => {} @@ -385,18 +421,26 @@ fn resolve_range(range: &Value, runs: &[TextRun]) -> Value { return Value::Null; }; let tab_id = range.get("tabId").and_then(Value::as_str); + let segment_id = range + .get("segmentId") + .and_then(Value::as_str) + .filter(|segment| !segment.is_empty()); let tab_count = runs .iter() - .filter_map(|(run_tab, _, _, _)| run_tab.as_deref()) + .filter_map(|(run_tab, _, _, _, _)| run_tab.as_deref()) .collect::>() .len(); let mut fragments = Vec::new(); - for (run_tab, run_start, run_end, text) in runs { + for (run_tab, run_segment, run_start, run_end, text) in runs { let tab_matches = match tab_id { Some(tab_id) => run_tab.as_deref() == Some(tab_id), None => run_tab.is_none() || tab_count <= 1, }; - if !tab_matches || *run_end <= start || *run_start >= end { + if !tab_matches + || run_segment.as_deref() != segment_id + || *run_end <= start + || *run_start >= end + { continue; } let from = (start.max(*run_start) - *run_start) as usize; diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index 95f33d673..ca8b4c07b 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -180,6 +180,41 @@ fn comments_without_threads_are_returned_as_empty() { assert_eq!(output["comments"], json!([])); } +#[test] +fn comment_anchor_resolution_respects_document_segments() { + let mut input = legacy(); + input["comments"] = json!([ + {"commentId": "body-comment", "anchorId": "body-anchor"}, + {"commentId": "header-comment", "anchorId": "header-anchor"} + ]); + input["tabs"] = json!([{ + "tabProperties": {"tabId": "tab-1"}, + "documentTab": { + "body": {"content": [{ + "startIndex": 1, "endIndex": 7, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 7, + "textRun": {"content": "body text"} + }]} + }]}, + "headers": {"header-1": {"content": [{ + "startIndex": 1, "endIndex": 7, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 7, + "textRun": {"content": "header text"} + }]} + }]}}, + "commentAnchors": { + "body-anchor": {"ranges": [{"startIndex": 1, "endIndex": 5}]}, + "header-anchor": {"ranges": [{"segmentId": "header-1", "startIndex": 1, "endIndex": 7}]} + } + } + }]); + let output = read::normalize_with_comments(&input).unwrap(); + assert_eq!(output["comments"][0]["referencedText"], json!(["body"])); + assert_eq!(output["comments"][1]["referencedText"], json!(["header"])); +} + #[test] fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { for params in [ From 6e3c58e88b71ca4a385c4ad3f965625132be4264 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 18:19:28 -0300 Subject: [PATCH 13/16] chore(release): prepare fork v0.23.0 --- .changeset/allow-unknown-fields.md | 8 - .changeset/current-clippy-baseline.md | 5 - .changeset/develop-release-flow.md | 7 - .changeset/docs-comment-create.md | 6 - .changeset/docs-read-comments.md | 6 - .changeset/docs-review-bundle.md | 11 - .changeset/docs-review-workflow.md | 9 - .changeset/docs-structured-read.md | 9 - .changeset/docs-suggest-workflow.md | 7 - .changeset/maintained-public-fork.md | 7 - .changeset/offline-dry-run.md | 9 - .changeset/preserve-credentials.md | 8 - .changeset/rename-personal-fork.md | 6 - .changeset/scoped-file-roots.md | 13 - .changeset/sheets-append-range.md | 5 - .changeset/single-upstream-pilot.md | 6 - .changeset/yaml-empty-collections.md | 7 - CHANGELOG.md | 70 ++ Cargo.lock | 1116 ++++++++--------- README.md | 10 +- crates/google-workspace-cli/Cargo.toml | 4 +- .../src/helpers/docs/comment.rs | 2 +- .../src/helpers/gmail/mod.rs | 2 +- crates/google-workspace/Cargo.toml | 2 +- crates/google-workspace/src/validate.rs | 9 + docs/skills.md | 3 + npm/package.json | 2 +- package.json | 10 +- pnpm-lock.yaml | 415 ++++-- pnpm-workspace.yaml | 2 + skills/gws-admin-reports/SKILL.md | 2 +- skills/gws-calendar-agenda/SKILL.md | 2 +- skills/gws-calendar-insert/SKILL.md | 2 +- skills/gws-calendar/SKILL.md | 5 +- skills/gws-chat-send/SKILL.md | 2 +- skills/gws-chat/SKILL.md | 7 +- skills/gws-classroom/SKILL.md | 4 +- skills/gws-docs-comment/SKILL.md | 41 + skills/gws-docs-read/SKILL.md | 68 + skills/gws-docs-suggest/SKILL.md | 45 + skills/gws-docs-write/SKILL.md | 2 +- skills/gws-docs/SKILL.md | 5 +- skills/gws-drive-upload/SKILL.md | 2 +- skills/gws-drive/SKILL.md | 13 +- skills/gws-events-renew/SKILL.md | 2 +- skills/gws-events-subscribe/SKILL.md | 2 +- skills/gws-events/SKILL.md | 2 +- skills/gws-forms/SKILL.md | 2 +- skills/gws-gmail-forward/SKILL.md | 2 +- skills/gws-gmail-read/SKILL.md | 2 +- skills/gws-gmail-reply-all/SKILL.md | 2 +- skills/gws-gmail-reply/SKILL.md | 2 +- skills/gws-gmail-send/SKILL.md | 2 +- skills/gws-gmail-triage/SKILL.md | 2 +- skills/gws-gmail-watch/SKILL.md | 2 +- skills/gws-gmail/SKILL.md | 6 +- skills/gws-keep/SKILL.md | 2 +- skills/gws-meet/SKILL.md | 15 +- .../gws-modelarmor-create-template/SKILL.md | 2 +- .../gws-modelarmor-sanitize-prompt/SKILL.md | 2 +- .../gws-modelarmor-sanitize-response/SKILL.md | 2 +- skills/gws-modelarmor/SKILL.md | 2 +- skills/gws-people/SKILL.md | 2 +- skills/gws-script-push/SKILL.md | 2 +- skills/gws-script/SKILL.md | 2 +- skills/gws-shared/SKILL.md | 2 +- skills/gws-sheets-append/SKILL.md | 6 +- skills/gws-sheets-read/SKILL.md | 2 +- skills/gws-sheets/SKILL.md | 2 +- skills/gws-slides/SKILL.md | 2 +- skills/gws-tasks/SKILL.md | 2 +- skills/gws-workflow-email-to-task/SKILL.md | 2 +- skills/gws-workflow-file-announce/SKILL.md | 2 +- skills/gws-workflow-meeting-prep/SKILL.md | 2 +- skills/gws-workflow-standup-report/SKILL.md | 2 +- skills/gws-workflow-weekly-digest/SKILL.md | 2 +- skills/gws-workflow/SKILL.md | 2 +- skills/persona-content-creator/SKILL.md | 2 +- skills/persona-customer-support/SKILL.md | 2 +- skills/persona-event-coordinator/SKILL.md | 2 +- skills/persona-exec-assistant/SKILL.md | 2 +- skills/persona-hr-coordinator/SKILL.md | 2 +- skills/persona-it-admin/SKILL.md | 2 +- skills/persona-project-manager/SKILL.md | 2 +- skills/persona-researcher/SKILL.md | 2 +- skills/persona-sales-ops/SKILL.md | 2 +- skills/persona-team-lead/SKILL.md | 2 +- skills/recipe-backup-sheet-as-csv/SKILL.md | 2 +- skills/recipe-batch-invite-to-event/SKILL.md | 2 +- skills/recipe-block-focus-time/SKILL.md | 2 +- skills/recipe-bulk-download-folder/SKILL.md | 2 +- skills/recipe-collect-form-responses/SKILL.md | 2 +- skills/recipe-compare-sheet-tabs/SKILL.md | 2 +- .../recipe-copy-sheet-for-new-month/SKILL.md | 2 +- .../recipe-create-classroom-course/SKILL.md | 2 +- .../recipe-create-doc-from-template/SKILL.md | 2 +- .../recipe-create-events-from-sheet/SKILL.md | 2 +- skills/recipe-create-expense-tracker/SKILL.md | 2 +- skills/recipe-create-feedback-form/SKILL.md | 2 +- skills/recipe-create-gmail-filter/SKILL.md | 2 +- skills/recipe-create-meet-space/SKILL.md | 2 +- skills/recipe-create-presentation/SKILL.md | 2 +- skills/recipe-create-shared-drive/SKILL.md | 2 +- skills/recipe-create-task-list/SKILL.md | 2 +- .../recipe-create-vacation-responder/SKILL.md | 2 +- skills/recipe-draft-email-from-doc/SKILL.md | 2 +- skills/recipe-email-drive-link/SKILL.md | 2 +- skills/recipe-find-free-time/SKILL.md | 2 +- skills/recipe-find-large-files/SKILL.md | 2 +- skills/recipe-forward-labeled-emails/SKILL.md | 2 +- .../SKILL.md | 2 +- .../recipe-label-and-archive-emails/SKILL.md | 2 +- skills/recipe-log-deal-update/SKILL.md | 2 +- skills/recipe-organize-drive-folder/SKILL.md | 2 +- skills/recipe-plan-weekly-schedule/SKILL.md | 2 +- skills/recipe-post-mortem-setup/SKILL.md | 2 +- skills/recipe-reschedule-meeting/SKILL.md | 2 +- .../recipe-review-meet-participants/SKILL.md | 2 +- skills/recipe-review-overdue-tasks/SKILL.md | 2 +- skills/recipe-save-email-attachments/SKILL.md | 2 +- skills/recipe-save-email-to-doc/SKILL.md | 2 +- .../recipe-schedule-recurring-event/SKILL.md | 2 +- skills/recipe-send-team-announcement/SKILL.md | 2 +- skills/recipe-share-doc-and-notify/SKILL.md | 2 +- skills/recipe-share-event-materials/SKILL.md | 2 +- skills/recipe-share-folder-with-team/SKILL.md | 2 +- skills/recipe-sync-contacts-to-sheet/SKILL.md | 2 +- skills/recipe-watch-drive-changes/SKILL.md | 2 +- 128 files changed, 1186 insertions(+), 979 deletions(-) delete mode 100644 .changeset/allow-unknown-fields.md delete mode 100644 .changeset/current-clippy-baseline.md delete mode 100644 .changeset/develop-release-flow.md delete mode 100644 .changeset/docs-comment-create.md delete mode 100644 .changeset/docs-read-comments.md delete mode 100644 .changeset/docs-review-bundle.md delete mode 100644 .changeset/docs-review-workflow.md delete mode 100644 .changeset/docs-structured-read.md delete mode 100644 .changeset/docs-suggest-workflow.md delete mode 100644 .changeset/maintained-public-fork.md delete mode 100644 .changeset/offline-dry-run.md delete mode 100644 .changeset/preserve-credentials.md delete mode 100644 .changeset/rename-personal-fork.md delete mode 100644 .changeset/scoped-file-roots.md delete mode 100644 .changeset/sheets-append-range.md delete mode 100644 .changeset/single-upstream-pilot.md delete mode 100644 .changeset/yaml-empty-collections.md create mode 100644 pnpm-workspace.yaml create mode 100644 skills/gws-docs-comment/SKILL.md create mode 100644 skills/gws-docs-read/SKILL.md create mode 100644 skills/gws-docs-suggest/SKILL.md diff --git a/.changeset/allow-unknown-fields.md b/.changeset/allow-unknown-fields.md deleted file mode 100644 index cf9db0878..000000000 --- a/.changeset/allow-unknown-fields.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add `--allow-unknown-fields` to raw API methods with JSON request bodies. Explicitly -allow fields absent from Discovery recursively, including in dry runs, while -preserving validation of known fields, required fields, JSON, URLs and file paths. -Handwritten helpers retain strict validation. diff --git a/.changeset/current-clippy-baseline.md b/.changeset/current-clippy-baseline.md deleted file mode 100644 index a14e86242..000000000 --- a/.changeset/current-clippy-baseline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Keep Apps Script file selection compatible with the current Clippy checks. diff --git a/.changeset/develop-release-flow.md b/.changeset/develop-release-flow.md deleted file mode 100644 index 5cc4945a0..000000000 --- a/.changeset/develop-release-flow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Keep unreleased work on develop. Require a versioned develop-to-main release PR, -run Rust and companion checks before publication, and create a fork-prefixed -GitHub release at the tested merge commit. diff --git a/.changeset/docs-comment-create.md b/.changeset/docs-comment-create.md deleted file mode 100644 index b0899d14b..000000000 --- a/.changeset/docs-comment-create.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add `gws docs +comment create` for creating anchored Google Docs comments -without manually using preview-only request fields. \ No newline at end of file diff --git a/.changeset/docs-read-comments.md b/.changeset/docs-read-comments.md deleted file mode 100644 index dca2acc63..000000000 --- a/.changeset/docs-read-comments.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add `--include-comments` to `gws docs +read` to return comment threads and the -text referenced by each comment anchor range. \ No newline at end of file diff --git a/.changeset/docs-review-bundle.md b/.changeset/docs-review-bundle.md deleted file mode 100644 index 286e9e7f0..000000000 --- a/.changeset/docs-review-bundle.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add a standalone Python companion for visual Google Docs review bundles with -native exports, safe DOCX raster extraction, local HTML, optional PDF page -previews with explicitly unverified page coverage, revision observations, -and an offline fixture workflow. Preserve nested image occurrences and -legitimate asset reuse, and keep oversized optional comments from failing -the required bundle. Verify each export's exact canonical destination against -the real CLI receipt. diff --git a/.changeset/docs-review-workflow.md b/.changeset/docs-review-workflow.md deleted file mode 100644 index eee647198..000000000 --- a/.changeset/docs-review-workflow.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add a standalone Python Docs review example that plans one literal text replacement, -binds it to a source revision and tab, applies it through existing gws commands, -and verifies the result without retrying ambiguous writes. Validate structures -and text ranges across all tabs before normalization, and preserve attempted or -confirmed mutation outcomes through final output failures and interruptions. diff --git a/.changeset/docs-structured-read.md b/.changeset/docs-structured-read.md deleted file mode 100644 index 41b070876..000000000 --- a/.changeset/docs-structured-read.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add `gws docs +read` to translate documents into compact structured content with -recursive tabs, headings and an outline, styled text, suggestions, nested tables, -figure metadata, and reference markers. Preserve API indices and revisions, -reject partial field masks, and support the existing formatters, sanitization, -and credential-free dry-run. diff --git a/.changeset/docs-suggest-workflow.md b/.changeset/docs-suggest-workflow.md deleted file mode 100644 index a9948d1bf..000000000 --- a/.changeset/docs-suggest-workflow.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add `gws docs +suggest` for creating and managing Google Docs suggestions, -including suggested insertions, exact replacements, range deletions, and -accept, reject, or delete actions. \ No newline at end of file diff --git a/.changeset/maintained-public-fork.md b/.changeset/maintained-public-fork.md deleted file mode 100644 index 874117e7b..000000000 --- a/.changeset/maintained-public-fork.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Publish the integrated Docs workflow improvements in the independent ratovarius -fork with upstream attribution, source-install instructions, contribution -tracking, and credential-independent CI. diff --git a/.changeset/offline-dry-run.md b/.changeset/offline-dry-run.md deleted file mode 100644 index ca86d5669..000000000 --- a/.changeset/offline-dry-run.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Skip authentication for Discovery-generated API and `docs +write` dry-runs. -Validate and preview requests without accessing the keyring or reading, changing, -or deleting stored credentials and token caches. Dry-runs work offline with a -fresh cached Discovery schema; schema fetching on first use or cache expiry is -unchanged. Real requests retain their existing authentication and error handling. diff --git a/.changeset/preserve-credentials.md b/.changeset/preserve-credentials.md deleted file mode 100644 index 4b7bafe37..000000000 --- a/.changeset/preserve-credentials.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Preserve saved encrypted credentials and token caches when credential loading, -decryption, or keyring access fails. Report recovery guidance and stop authentication -instead of silently selecting plaintext credentials or another account through ADC. -Explicit token and credentials-file overrides and intentional logout remain unchanged. diff --git a/.changeset/rename-personal-fork.md b/.changeset/rename-personal-fork.md deleted file mode 100644 index b9954b67f..000000000 --- a/.changeset/rename-personal-fork.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Rename the maintained fork to ratovarius/googleworkspace-cli and update repository -links, source installation examples, package metadata, CLI help, and agent guidance. diff --git a/.changeset/scoped-file-roots.md b/.changeset/scoped-file-roots.md deleted file mode 100644 index 6e17edcc3..000000000 --- a/.changeset/scoped-file-roots.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Allow operators to set `GOOGLE_WORKSPACE_CLI_FILE_ROOT` to an existing directory -for `--output` and `--upload` paths while keeping CWD confinement by default. -Relative CLI paths remain CWD-relative. Reject invalid roots, parent traversal -with an explicit root, control characters, and symlink escapes, including -dangling symlinks. Directory flags retain their existing boundaries. - -Reject canonical file paths that cannot be represented as UTF-8 at the CLI -string boundary, so explicit output/upload paths cannot silently become omitted -arguments. diff --git a/.changeset/sheets-append-range.md b/.changeset/sheets-append-range.md deleted file mode 100644 index 1f6ec7b29..000000000 --- a/.changeset/sheets-append-range.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@googleworkspace/cli": minor ---- - -Add `--range` flag to `sheets +append` for targeting specific sheet tabs diff --git a/.changeset/single-upstream-pilot.md b/.changeset/single-upstream-pilot.md deleted file mode 100644 index 93c1f1938..000000000 --- a/.changeset/single-upstream-pilot.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Document credential preservation as the sole upstream pilot and keep further -development of the other improvements in this public fork. diff --git a/.changeset/yaml-empty-collections.md b/.changeset/yaml-empty-collections.md deleted file mode 100644 index 2ea985ea5..000000000 --- a/.changeset/yaml-empty-collections.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@googleworkspace/cli": patch ---- - -Fix YAML mapping values containing empty arrays or objects by separating their -inline collection syntax from the mapping colon. This also fixes structured -Docs reader output with empty outlines, child tabs, or style maps. diff --git a/CHANGELOG.md b/CHANGELOG.md index f332f4ecc..14464dc3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # @googleworkspace/cli +## 0.23.0 + +### Minor Changes + +- c13b864: Add `--allow-unknown-fields` to raw API methods with JSON request bodies. Explicitly + allow fields absent from Discovery recursively, including in dry runs, while + preserving validation of known fields, required fields, JSON, URLs and file paths. + Handwritten helpers retain strict validation. +- f81bf9f: Add `gws docs +comment create` for creating anchored Google Docs comments + without manually using preview-only request fields. +- b0c6482: Add `--include-comments` to `gws docs +read` to return comment threads and the + text referenced by each comment anchor range. +- c13b864: Add a standalone Python companion for visual Google Docs review bundles with + native exports, safe DOCX raster extraction, local HTML, optional PDF page + previews with explicitly unverified page coverage, revision observations, + and an offline fixture workflow. Preserve nested image occurrences and + legitimate asset reuse, and keep oversized optional comments from failing + the required bundle. Verify each export's exact canonical destination against + the real CLI receipt. +- c13b864: Add a standalone Python Docs review example that plans one literal text replacement, + binds it to a source revision and tab, applies it through existing gws commands, + and verifies the result without retrying ambiguous writes. Validate structures + and text ranges across all tabs before normalization, and preserve attempted or + confirmed mutation outcomes through final output failures and interruptions. +- c13b864: Add `gws docs +read` to translate documents into compact structured content with + recursive tabs, headings and an outline, styled text, suggestions, nested tables, + figure metadata, and reference markers. Preserve API indices and revisions, + reject partial field masks, and support the existing formatters, sanitization, + and credential-free dry-run. +- e6ef7a9: Add `gws docs +suggest` for creating and managing Google Docs suggestions, + including suggested insertions, exact replacements, range deletions, and + accept, reject, or delete actions. +- c13b864: Allow operators to set `GOOGLE_WORKSPACE_CLI_FILE_ROOT` to an existing directory + for `--output` and `--upload` paths while keeping CWD confinement by default. + Relative CLI paths remain CWD-relative. Reject invalid roots, parent traversal + with an explicit root, control characters, and symlink escapes, including + dangling symlinks. Directory flags retain their existing boundaries. + + Reject canonical file paths that cannot be represented as UTF-8 at the CLI + string boundary, so explicit output/upload paths cannot silently become omitted + arguments. + +- a3768d0: Add `--range` flag to `sheets +append` for targeting specific sheet tabs + +### Patch Changes + +- 5db4424: Keep Apps Script file selection compatible with the current Clippy checks. +- 05836b4: Keep unreleased work on develop. Require a versioned develop-to-main release PR, + run Rust and companion checks before publication, and create a fork-prefixed + GitHub release at the tested merge commit. +- 18410e9: Publish the integrated Docs workflow improvements in the independent ratovarius + fork with upstream attribution, source-install instructions, contribution + tracking, and credential-independent CI. +- c13b864: Skip authentication for Discovery-generated API and `docs +write` dry-runs. + Validate and preview requests without accessing the keyring or reading, changing, + or deleting stored credentials and token caches. Dry-runs work offline with a + fresh cached Discovery schema; schema fetching on first use or cache expiry is + unchanged. Real requests retain their existing authentication and error handling. +- c13b864: Preserve saved encrypted credentials and token caches when credential loading, + decryption, or keyring access fails. Report recovery guidance and stop authentication + instead of silently selecting plaintext credentials or another account through ADC. + Explicit token and credentials-file overrides and intentional logout remain unchanged. +- 05fb6c2: Rename the maintained fork to ratovarius/googleworkspace-cli and update repository + links, source installation examples, package metadata, CLI help, and agent guidance. +- 465b98f: Document credential preservation as the sole upstream pilot and keep further + development of the other improvements in this public fork. +- c13b864: Fix YAML mapping values containing empty arrays or objects by separating their + inline collection syntax from the mapping colon. This also fixes structured + Docs reader output with empty outlines, child tabs, or style maps. + ## 0.22.5 ### Patch Changes diff --git a/Cargo.lock b/Cargo.lock index b7e7baecd..2b3d3f030 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -39,9 +39,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -54,9 +54,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -113,19 +113,28 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -145,9 +154,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -178,9 +187,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -193,15 +202,21 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -211,9 +226,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -226,9 +241,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "shlex", @@ -236,21 +251,32 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -281,9 +307,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -291,9 +317,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -303,21 +329,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "colorchoice" @@ -327,9 +353,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -383,20 +409,35 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crossterm" @@ -404,7 +445,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "crossterm_winapi", "derive_more", "document-features", @@ -467,12 +508,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -486,20 +527,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -510,18 +551,18 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -536,7 +577,6 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -558,7 +598,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -568,7 +608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -590,7 +630,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -626,13 +666,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -652,9 +692,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "equivalent" @@ -693,9 +733,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "filedescriptor" @@ -710,15 +750,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "finl_unicode" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" [[package]] name = "fixedbitset" @@ -732,12 +772,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -755,24 +789,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -781,38 +815,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -864,24 +898,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -896,7 +929,7 @@ dependencies = [ [[package]] name = "google-workspace" -version = "0.22.5" +version = "0.23.0" dependencies = [ "anyhow", "percent-encoding", @@ -905,14 +938,14 @@ dependencies = [ "serde_json", "serial_test", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] [[package]] name = "google-workspace-cli" -version = "0.22.5" +version = "0.23.0" dependencies = [ "aes-gcm", "anyhow", @@ -934,7 +967,7 @@ dependencies = [ "mail-builder", "mime_guess2", "percent-encoding", - "rand 0.8.5", + "rand 0.8.8", "ratatui", "reqwest", "serde", @@ -942,7 +975,7 @@ dependencies = [ "serial_test", "sha2", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "toml", @@ -956,9 +989,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -975,22 +1008,24 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.1.5", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -1018,9 +1053,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1028,9 +1063,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1038,9 +1073,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1063,9 +1098,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1085,16 +1120,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1149,12 +1183,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1162,9 +1197,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1175,9 +1210,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1189,16 +1224,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1209,15 +1245,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1228,12 +1264,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -1253,9 +1283,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1263,14 +1293,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", + "hashbrown 0.17.1", ] [[package]] @@ -1293,32 +1321,22 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -1343,13 +1361,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.93" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -1361,7 +1378,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -1391,33 +1408,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "libc" -version = "0.2.183" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" dependencies = [ "libc", ] [[package]] name = "line-clipping" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", ] [[package]] @@ -1428,9 +1445,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -1449,24 +1466,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.16.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "mac_address" @@ -1498,9 +1515,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmem" @@ -1543,9 +1560,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.0" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -1559,7 +1576,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -1587,9 +1604,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -1599,7 +1616,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1659,6 +1676,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1690,9 +1740,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" dependencies = [ "memchr", "ucd-trie", @@ -1700,9 +1750,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" dependencies = [ "pest", "pest_generator", @@ -1710,25 +1760,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" dependencies = [ "pest", - "sha2", ] [[package]] @@ -1767,7 +1816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -1780,7 +1829,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "unicase", ] @@ -1816,22 +1865,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1851,30 +1900,20 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1884,7 +1923,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -1892,20 +1931,21 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -1913,23 +1953,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -1948,23 +1988,24 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1977,16 +2018,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1998,42 +2029,52 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core 0.10.1", ] [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", + "serde", ] [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "compact_str", - "hashbrown 0.16.1", - "indoc", + "critical-section", + "hashbrown 0.17.1", "itertools", "kasuari", "lru", + "palette", + "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -2041,9 +2082,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -2053,19 +2094,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" +name = "ratatui-termina" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -2073,17 +2125,18 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.16.1", + "bitflags 2.13.2", + "hashbrown 0.17.1", "indoc", "instability", "itertools", "line-clipping", "ratatui-core", + "serde", "strum", "time", "unicode-segmentation", @@ -2096,7 +2149,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", ] [[package]] @@ -2112,9 +2165,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2124,9 +2177,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2135,9 +2188,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -2196,9 +2249,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -2211,11 +2264,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -2224,9 +2277,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -2238,9 +2291,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2250,9 +2303,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2260,9 +2313,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -2271,9 +2324,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -2281,15 +2334,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -2305,12 +2349,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" @@ -2323,7 +2361,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -2336,7 +2374,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2355,15 +2393,15 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2371,29 +2409,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2425,28 +2463,27 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2462,7 +2499,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2477,9 +2514,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -2514,9 +2551,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -2526,15 +2563,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2560,23 +2597,23 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2585,6 +2622,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -2598,9 +2641,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -2618,13 +2672,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -2634,12 +2688,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -2669,7 +2736,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.11.0", + "bitflags 2.13.2", "fancy-regex", "filedescriptor", "finl_unicode", @@ -2714,11 +2781,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -2729,37 +2796,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", @@ -2771,15 +2837,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2787,9 +2853,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2797,24 +2863,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.50.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -2829,20 +2886,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -2850,13 +2907,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -2919,20 +2977,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2960,12 +3018,13 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "symlink", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -2978,7 +3037,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3041,9 +3100,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -3059,15 +3118,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" @@ -3086,12 +3145,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -3134,12 +3187,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "atomic", - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "sha1_smol", "wasm-bindgen", @@ -3183,27 +3236,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.116" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -3214,9 +3258,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.66" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -3224,9 +3268,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.116" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3234,48 +3278,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.116" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.116" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -3289,23 +3311,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.93" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -3436,7 +3446,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3447,7 +3457,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3707,103 +3717,21 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3812,13 +3740,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] @@ -3841,7 +3769,7 @@ dependencies = [ "seahash", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "tokio", "url", @@ -3849,70 +3777,70 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3921,9 +3849,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3932,17 +3860,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/README.md b/README.md index c0058383d..ed9e62c13 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ server-side validation. This flag grants no additional access. ### Docs suggestions -On `develop`, `gws docs +suggest` provides a guided workflow for Google Docs +`gws docs +suggest` provides a guided workflow for Google Docs suggestions. It can insert text, replace one exact text run, propose a range deletion, list the structured document with suggestion context, and accept, reject, or delete an existing suggestion: @@ -194,9 +194,11 @@ gws docs +comment create \ --end-index 20 ``` -The helper validates UTF-16 ranges and applies the preview-field opt-in -internally, so `--allow-unknown-fields` is not required. The request still -requires edit access and Google Workspace Developer Preview availability. +The helper validates that the indexes are non-negative and ordered, then leaves +document-boundary and UTF-16 boundary validation to Google. It applies the +preview-field opt-in internally, so `--allow-unknown-fields` is not required. +The request still requires edit access and Google Workspace Developer Preview +availability. ```bash # Preview a suggested insertion (Docs Developer Preview). diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml index 3e5603ce4..653834e2a 100644 --- a/crates/google-workspace-cli/Cargo.toml +++ b/crates/google-workspace-cli/Cargo.toml @@ -14,7 +14,7 @@ [package] name = "google-workspace-cli" -version = "0.22.5" +version = "0.23.0" edition = "2021" description = "Google Workspace CLI — dynamic command surface from Discovery Service" license = "Apache-2.0" @@ -30,7 +30,7 @@ name = "gws" path = "src/main.rs" [dependencies] -google-workspace = { version = "0.22.5", path = "../google-workspace" } +google-workspace = { version = "0.23.0", path = "../google-workspace" } tempfile = "3" aes-gcm = "0.10" anyhow = "1" diff --git a/crates/google-workspace-cli/src/helpers/docs/comment.rs b/crates/google-workspace-cli/src/helpers/docs/comment.rs index 5f605803e..9040ef006 100644 --- a/crates/google-workspace-cli/src/helpers/docs/comment.rs +++ b/crates/google-workspace-cli/src/helpers/docs/comment.rs @@ -61,7 +61,7 @@ pub(super) async fn handle( let params = document_params(action_matches)?; let body = build_comment_create_body(action_matches)?; let dry_run = action_matches.get_flag("dry-run"); - let scopes: Vec<&str> = method.scopes.iter().map(String::as_str).collect(); + let scopes: Vec<&str> = crate::select_scope(&method.scopes).into_iter().collect(); let token = if dry_run { None } else { diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index 27de3eb9c..66719a139 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -1319,7 +1319,7 @@ pub(super) fn parse_attachments(matches: &ArgMatches) -> Result, let mut total_bytes: u64 = 0; for path in paths { - let canonical = crate::validate::validate_safe_file_path(path, "--attach")?; + let canonical = crate::validate::validate_safe_local_file_path(path, "--attach")?; let metadata = std::fs::metadata(&canonical) .map_err(|e| GwsError::Validation(format!("Cannot read --attach '{path}': {e}")))?; diff --git a/crates/google-workspace/Cargo.toml b/crates/google-workspace/Cargo.toml index d1b5b6fd9..acd0856e8 100644 --- a/crates/google-workspace/Cargo.toml +++ b/crates/google-workspace/Cargo.toml @@ -14,7 +14,7 @@ [package] name = "google-workspace" -version = "0.22.5" +version = "0.23.0" edition = "2021" description = "Google Workspace API client — Discovery Document types, service registry, and HTTP utilities" license = "Apache-2.0" diff --git a/crates/google-workspace/src/validate.rs b/crates/google-workspace/src/validate.rs index a951cae56..c20dc0169 100644 --- a/crates/google-workspace/src/validate.rs +++ b/crates/google-workspace/src/validate.rs @@ -188,6 +188,15 @@ pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result Result { + let cwd = std::env::current_dir() + .map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?; + validate_file_path_with_root(path_str, flag_name, &cwd, None) +} + /// Explicit policy keeps filesystem validation independent of process-global env. fn validate_file_path_with_root( path_str: &str, diff --git a/docs/skills.md b/docs/skills.md index 96fcae2eb..2214e1c04 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -46,6 +46,9 @@ Shortcut commands for common operations. | [gws-gmail-watch](../skills/gws-gmail-watch/SKILL.md) | Gmail: Watch for new emails and stream them as NDJSON. | | [gws-calendar-insert](../skills/gws-calendar-insert/SKILL.md) | Google Calendar: Create a new event. | | [gws-calendar-agenda](../skills/gws-calendar-agenda/SKILL.md) | Google Calendar: Show upcoming events across all calendars. | +| [gws-docs-read](../skills/gws-docs-read/SKILL.md) | Google Docs: Read a document as compact structured content. | +| [gws-docs-suggest](../skills/gws-docs-suggest/SKILL.md) | Google Docs: Create and manage Docs suggestions. | +| [gws-docs-comment](../skills/gws-docs-comment/SKILL.md) | Google Docs: Create a comment anchored to document text. | | [gws-docs-write](../skills/gws-docs-write/SKILL.md) | Google Docs: Append text to a document. | | [gws-chat-send](../skills/gws-chat-send/SKILL.md) | Google Chat: Send a message to a space. | | [gws-events-subscribe](../skills/gws-events-subscribe/SKILL.md) | Google Workspace Events: Subscribe to Workspace events and stream them as NDJSON. | diff --git a/npm/package.json b/npm/package.json index 46293ffae..34ef52eda 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,7 +1,7 @@ { "name": "@googleworkspace/cli", "description": "Google Workspace CLI — dynamic command surface from Discovery Service", - "version": "0.22.5", + "version": "0.23.0", "license": "Apache-2.0", "author": "Justin Poehnelt", "repository": { diff --git a/package.json b/package.json index 70d27556c..01745f794 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "@googleworkspace/cli", - "version": "0.22.5", + "version": "0.23.0", "private": true, - "description": "Google Workspace CLI \u2014 dynamic command surface from Discovery Service", + "description": "Google Workspace CLI — dynamic command surface from Discovery Service", "license": "Apache-2.0", "repository": { "type": "git", @@ -29,7 +29,7 @@ "engines": { "node": ">=18" }, - "packageManager": "pnpm@10.0.0", + "packageManager": "pnpm@12.4.2", "keywords": [ "cli", "google-workspace", @@ -51,7 +51,7 @@ "rust" ], "devDependencies": { - "@changesets/cli": "^2.29.8", - "lefthook": "^2.1.2" + "@changesets/cli": "^2.31.1", + "lefthook": "^2.1.14" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa0f0290f..efdfe37b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.4.2 + version: 12.4.2 + +packages: + + '@pnpm/exe.android-arm64@12.4.2': + resolution: {integrity: sha512-E255MbcQ0V1577M2BV0ajWuP7KmTPYA0jqZnk3rK6Lq3kTvZWulMMb7iqRmnLsQbyWTkMEB2CfyM70loVDA8xg==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.4.2': + resolution: {integrity: sha512-J1pSeCUwuKxMG70ZzpWn8JzElxiEa8NN/3N0SlZRtU2xbztChaJCKF3HaBNIj9yPfeofWzJo/lwNvDDjraQuZw==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.4.2': + resolution: {integrity: sha512-A0WDo8iErfZBXgrLseQxw8i8Y9ctUpOEl/Uu+cubnTzpD8tT9ykIB548L8YTM2WD4OS+ZOHSxy8aGZcvKq8PaQ==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.4.2': + resolution: {integrity: sha512-MSgJdovBWHcb5DEOvfPH9yNi/T5O1Xa4ess+E1ESGo/5yuty7S4JoiIjami+fsNXfnoQMlwsMUqYU4zAvBcNCw==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.4.2': + resolution: {integrity: sha512-h2YumlQSNvgbRPv+RXwABohX65f9bOBZn+jMIt7bFDISZPCzQ+Nvpt6Awbp4ip5PwQgYxbu5iREJ1fHE39Fm8A==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.4.2': + resolution: {integrity: sha512-LwSEtSEDTv6S51YLs3YvSkPyun/QmfMic1UGICUkPWFu6ByP43RdMlkKvmVkfGhAYCpnxO057vrmyJqtfZrPCA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.4.2': + resolution: {integrity: sha512-2dSiDXyhx+RTHsewxex8f/jVjqQXWJ2oow4kCVHEWdZKeBpgMxvZ6fHkTAUBJXgqhbKIDHvuNlZBP7gJfUWL5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.4.2': + resolution: {integrity: sha512-8Itc+jQk+MTz04LS9D1RcH+VctAWmzM4l1cJQ+Sx7pAJNo7EKHdRMbC0Tok1jyQ7eEHNJZD5EleYneZqWfZM+g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.4.2': + resolution: {integrity: sha512-hleOeqhTVpH+z9RVMGnxvU4ZnrkBUClWjCbHD9u6kwyqhGSpevoU1wTGish+CBRhmIgMAy9pAfFpqhbAKOKNfw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.4.2': + resolution: {integrity: sha512-LAsQRRdP9aToENR6dtcIJ9l+e1zMYOX0tQcLGpRyLPVBQcYRLlvAPcmDshsiIHQjp05SDa9FI0czX1ZQ1a7a/A==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.4.2': + resolution: {integrity: sha512-kzfzH2/0BWdTABK14Yj5a1xsdkTEQUp2eXEPNakaD9jKL025lq3hyaKHIz/gIZaPDMe/1bFFK/En4ztFlbBJxw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.4.2': + resolution: {integrity: sha512-/pbt0UVTa8NMDhzOWLQRfZ6G9ROKXlJPZtx845BqyWfc7hCrWXhTLBd70yO2y8+E+IWN3oHM1s/JsIQNGk1yvg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.4.2': + resolution: {integrity: sha512-PsW19e4dAUNpZ0cS9flaxFuAmpt2dKlH/Vvi8TZ4qyJjjQzue/CEsWm+6wKVP4CJ7IT8RdL4qh+soOPWIgF2Zw==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.4.2': + resolution: {integrity: sha512-+xGoeE0g55ztWvl8i5QqdmNfW3nIrTVcoQrNshEOwxthm9Ag48oXton3uxOA3/SANyz5AXexEjj2KO20NnOrEw==} + cpu: [x64] + os: [win32] + + pnpm@12.4.2: + resolution: {integrity: sha512-CK3GYTGAJ1x8ntraOdzwjJxhrU5+rzMKTzRh8QKw+QdCNFTRF/mOctR/7wYWBwZE17/8lzpqV/UJCm18NosHyQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.4.2': + optional: true + + '@pnpm/exe.android-x64@12.4.2': + optional: true + + '@pnpm/exe.darwin-arm64@12.4.2': + optional: true + + '@pnpm/exe.darwin-x64@12.4.2': + optional: true + + '@pnpm/exe.freebsd-x64@12.4.2': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.4.2': + optional: true + + '@pnpm/exe.linux-arm64@12.4.2': + optional: true + + '@pnpm/exe.linux-ppc64@12.4.2': + optional: true + + '@pnpm/exe.linux-riscv64@12.4.2': + optional: true + + '@pnpm/exe.linux-s390x@12.4.2': + optional: true + + '@pnpm/exe.linux-x64-musl@12.4.2': + optional: true + + '@pnpm/exe.linux-x64@12.4.2': + optional: true + + '@pnpm/exe.win32-arm64@12.4.2': + optional: true + + '@pnpm/exe.win32-x64@12.4.2': + optional: true + + pnpm@12.4.2: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.4.2 + '@pnpm/exe.android-x64': 12.4.2 + '@pnpm/exe.darwin-arm64': 12.4.2 + '@pnpm/exe.darwin-x64': 12.4.2 + '@pnpm/exe.freebsd-x64': 12.4.2 + '@pnpm/exe.linux-arm64': 12.4.2 + '@pnpm/exe.linux-arm64-musl': 12.4.2 + '@pnpm/exe.linux-ppc64': 12.4.2 + '@pnpm/exe.linux-riscv64': 12.4.2 + '@pnpm/exe.linux-s390x': 12.4.2 + '@pnpm/exe.linux-x64': 12.4.2 + '@pnpm/exe.linux-x64-musl': 12.4.2 + '@pnpm/exe.win32-arm64': 12.4.2 + '@pnpm/exe.win32-x64': 12.4.2 + +--- lockfileVersion: '9.0' settings: @@ -9,42 +167,42 @@ importers: .: devDependencies: '@changesets/cli': - specifier: ^2.29.8 - version: 2.29.8 + specifier: ^2.31.1 + version: 2.31.1 lefthook: - specifier: ^2.1.2 - version: 2.1.2 + specifier: ^2.1.14 + version: 2.1.14 packages: - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@changesets/apply-release-plan@7.0.14': - resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - '@changesets/assemble-release-plan@6.0.9': - resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@changesets/cli@2.29.8': - resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} hasBin: true - '@changesets/config@3.1.2': - resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==} + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} '@changesets/errors@0.2.0': resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - '@changesets/get-dependents-graph@2.1.3': - resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} - '@changesets/get-release-plan@4.0.14': - resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==} + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} '@changesets/get-version-range-type@0.4.0': resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} @@ -55,14 +213,14 @@ packages: '@changesets/logger@0.1.1': resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - '@changesets/parse@0.4.2': - resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==} + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} '@changesets/pre@2.0.2': resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - '@changesets/read@0.6.6': - resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==} + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} '@changesets/should-skip-package@0.1.2': resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} @@ -132,12 +290,8 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - chardet@2.1.1: - resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -167,8 +321,8 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -197,12 +351,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - human-id@4.1.3: - resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + human-id@4.2.1: + resolution: {integrity: sha512-zPGsiS+dWoTZtZ4AtpA9Y+BdSFSNWvnouNlWNoUFyAM6xHOHmdCvqO3k8AIbdamCOv4gUFUVNPf6rJFfc4UiJw==} hasBin: true - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} ignore@5.3.2: @@ -232,69 +386,69 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - lefthook-darwin-arm64@2.1.2: - resolution: {integrity: sha512-AgHu93YuJtj1l9bcKlCbo4Tg8N8xFl9iD6BjXCGaGMu46LSjFiXbJFlkUdpgrL8fIbwoCjJi5FNp3POpqs4Wdw==} + lefthook-darwin-arm64@2.1.14: + resolution: {integrity: sha512-VMUAqY81+8ph7Mc0iROd3JWqj2sOuK7SYDyd8ZgzYVKChCt+Nnz8ehd4gUwprJy7QI28IFSUh1M3wnIRiYh+fA==} cpu: [arm64] os: [darwin] - lefthook-darwin-x64@2.1.2: - resolution: {integrity: sha512-exooc9Ectz13OLJJOXM9AzaFQbqzf9QCF8JuVvGfbr4RYABYK+BwwtydjlPQrA76/n/h4tsS11MH5bBULnLkYA==} + lefthook-darwin-x64@2.1.14: + resolution: {integrity: sha512-uSmjcPRU+yB+D6vk+u6WJDnyHlB+XIv1QZw97N2+qNGG2bNSLH/7MuJ6puCdJv+lVPBEO5wBjE3JIbaEzPOQCw==} cpu: [x64] os: [darwin] - lefthook-freebsd-arm64@2.1.2: - resolution: {integrity: sha512-E1QMlJPEU21n9eewv6ePfh+JmoTSg5R1jaYcKCky10kfbMdohNucI3xV91F2LcerE+p3UejKDqr/1wWO2RMGeQ==} + lefthook-freebsd-arm64@2.1.14: + resolution: {integrity: sha512-po/pmjbp/7BjE9RFfeDnh+CeNsA0d+utppOYKB425I7mgI1GcmT8GAZ9DDut4DIutNjVqKL/lzrj+F87Gm40Rg==} cpu: [arm64] os: [freebsd] - lefthook-freebsd-x64@2.1.2: - resolution: {integrity: sha512-/5zp+x8055Thj46x9S7hgnneZxvWhHQvPWkkgISCab1Lh6eLrbxvhE1qTb1lU3DqTnNmH9NeXdq1xPHc9uGluA==} + lefthook-freebsd-x64@2.1.14: + resolution: {integrity: sha512-+KiXiFjJf7YdHUYjzhDsklcA5bAFZN+pNcz/NbBftrhVTwzNALNoK+SYEKt+9QJsrzL6tpDWmHHFpeLGp+8t8w==} cpu: [x64] os: [freebsd] - lefthook-linux-arm64@2.1.2: - resolution: {integrity: sha512-UK5FvDTkwKO7tOznY8iEZzuTsM1jXMZAG5BMRs7olN1k1K6m2unR6oKABP0hCd0wDErK6DZKDJDJfB564Rzqtw==} + lefthook-linux-arm64@2.1.14: + resolution: {integrity: sha512-bjeJB16ftJaPWGTedssh8ZrqRy1ACfQuTV7a0O3NU1FoIsJtfHOm0o0mV4cPf4q+ZCVCwq1Xf2I11qUpuJFV4Q==} cpu: [arm64] os: [linux] - lefthook-linux-x64@2.1.2: - resolution: {integrity: sha512-4eOtz4PNh8GbJ+nA8YVDfW/eMirQWdZqMP/V/MVtoVBGobf6oXvvuDOySvAPOgNYEFN0Boegytmuji/851Vstg==} + lefthook-linux-x64@2.1.14: + resolution: {integrity: sha512-gYExzjU2w3uKrP1MklbHENS1cXUdCQRIiEJkykI7q4RjhIHi+mQEUAZLxZw93a6jE2pNWaO0me5tB8EbEt4/GQ==} cpu: [x64] os: [linux] - lefthook-openbsd-arm64@2.1.2: - resolution: {integrity: sha512-lJXRJ6iJIBKwomuNBA3CUNSclj2/rKuxGAQoUra214B92VB6jL9zaY5YEs6h/ie9jQrzSnllEeg7xyDIsuVCrQ==} + lefthook-openbsd-arm64@2.1.14: + resolution: {integrity: sha512-MCW76gTlEMBF3Ds8O63UTxjaSguha/5zT0U3Vr719I1sXueSpttfRmm/5YfIB4MygB116yA/Vi+DsizT/X0kcg==} cpu: [arm64] os: [openbsd] - lefthook-openbsd-x64@2.1.2: - resolution: {integrity: sha512-GyOje4W0DIqkmR7/Of5D+mZ0vWqMvtGAVedtJR6d1239xNeMzCS8Q+/a3O1xigceZa5xhlqq0BWlssB/QYPQnA==} + lefthook-openbsd-x64@2.1.14: + resolution: {integrity: sha512-salAc1PAhLI6xrB9pth5UxALQlTOGvZAfaAvgFwixhkdu8D//uW8zS3faFq31ktd1bDAy5wDI8p9yg+fF5+2uA==} cpu: [x64] os: [openbsd] - lefthook-windows-arm64@2.1.2: - resolution: {integrity: sha512-MZKMqTULEpX/8N3fKXAR0A9RjsGKkEEY0japLqrHOIpxsJXry1DRz0FvQo2kkY4WW3rtFegV9m6eesOymuDrUg==} + lefthook-windows-arm64@2.1.14: + resolution: {integrity: sha512-q3JC6lBrYLzkhXEhfnmFxJF47gSd5JeDweH3aNrHoLGX/KkRiOrk9RSyoRwSuGq03EYxSkJhl1FLj2GUxF7VqQ==} cpu: [arm64] os: [win32] - lefthook-windows-x64@2.1.2: - resolution: {integrity: sha512-NZUgObuaSxc0EXAwC/CzkMf7TuQc++GGIk6TLPdaUpoSsNSJSZEwBVz5DtFB1cG+eMkfO/wOKplls+yjimTTtQ==} + lefthook-windows-x64@2.1.14: + resolution: {integrity: sha512-WwIxdwW1lDAaJUU3ETcKlCtdqluGr3Cy+LjKvP4QIQnFf0FEXL78THstIkZ6k6qhfUn7/WvyqSIf0cbTAKw2nQ==} cpu: [x64] os: [win32] - lefthook@2.1.2: - resolution: {integrity: sha512-HdAMl4g47kbWSkrUkCx3Kucq54omFS6piMJtXwXNtmCAfB40UaybTJuYtFW4hNzZ5SvaEimtxTp7P/MNIkEfsA==} + lefthook@2.1.14: + resolution: {integrity: sha512-Y9TL1dmpRlIdSp73SJ6bb+xAZ4s2SinGTK3pgC9PRWMSjMr2iOfowPGnlSOfAo5fu6S5rdiItsQDxeLEWCGk4g==} hasBin: true locate-path@5.0.0: @@ -357,8 +511,8 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} pify@4.0.1: @@ -394,8 +548,8 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -448,11 +602,11 @@ packages: snapshots: - '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': {} - '@changesets/apply-release-plan@7.0.14': + '@changesets/apply-release-plan@7.1.1': dependencies: - '@changesets/config': 3.1.2 + '@changesets/config': 3.1.4 '@changesets/get-version-range-type': 0.4.0 '@changesets/git': 3.0.4 '@changesets/should-skip-package': 0.1.2 @@ -464,59 +618,58 @@ snapshots: outdent: 0.5.0 prettier: 2.8.8 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.5 - '@changesets/assemble-release-plan@6.0.9': + '@changesets/assemble-release-plan@6.0.10': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 - semver: 7.7.4 + semver: 7.8.5 '@changesets/changelog-git@0.2.1': dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8': + '@changesets/cli@2.31.1': dependencies: - '@changesets/apply-release-plan': 7.0.14 - '@changesets/assemble-release-plan': 6.0.9 + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.2 + '@changesets/config': 3.1.4 '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/get-release-plan': 4.0.14 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.6 + '@changesets/read': 0.6.7 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 '@inquirer/external-editor': 1.0.3 '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 - ci-info: 3.9.0 enquirer: 2.4.1 fs-extra: 7.0.1 mri: 1.2.0 - p-limit: 2.3.0 package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.5 spawndamnit: 3.0.1 term-size: 2.2.1 transitivePeerDependencies: - '@types/node' - '@changesets/config@3.1.2': + '@changesets/config@3.1.4': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 @@ -526,19 +679,19 @@ snapshots: dependencies: extendable-error: 0.1.7 - '@changesets/get-dependents-graph@2.1.3': + '@changesets/get-dependents-graph@2.1.4': dependencies: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 - semver: 7.7.4 + semver: 7.8.5 - '@changesets/get-release-plan@4.0.14': + '@changesets/get-release-plan@4.0.16': dependencies: - '@changesets/assemble-release-plan': 6.0.9 - '@changesets/config': 3.1.2 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.6 + '@changesets/read': 0.6.7 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -556,10 +709,10 @@ snapshots: dependencies: picocolors: 1.1.1 - '@changesets/parse@0.4.2': + '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.1.1 + js-yaml: 4.3.2 '@changesets/pre@2.0.2': dependencies: @@ -568,11 +721,11 @@ snapshots: '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 - '@changesets/read@0.6.6': + '@changesets/read@0.6.7': dependencies: '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 - '@changesets/parse': 0.4.2 + '@changesets/parse': 0.4.3 '@changesets/types': 6.1.0 fs-extra: 7.0.1 p-filter: 2.1.0 @@ -591,24 +744,24 @@ snapshots: dependencies: '@changesets/types': 6.1.0 fs-extra: 7.0.1 - human-id: 4.1.3 + human-id: 4.2.1 prettier: 2.8.8 '@inquirer/external-editor@1.0.3': dependencies: - chardet: 2.1.1 - iconv-lite: 0.7.2 + chardet: 2.2.0 + iconv-lite: 0.7.3 '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -625,7 +778,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + fastq: 1.20.3 '@types/node@12.20.55': {} @@ -649,9 +802,7 @@ snapshots: dependencies: fill-range: 7.1.1 - chardet@2.1.1: {} - - ci-info@3.9.0: {} + chardet@2.2.0: {} cross-spawn@7.0.6: dependencies: @@ -682,7 +833,7 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fastq@1.20.1: + fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -722,9 +873,9 @@ snapshots: graceful-fs@4.2.11: {} - human-id@4.1.3: {} + human-id@4.2.1: {} - iconv-lite@0.7.2: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -746,12 +897,12 @@ snapshots: isexe@2.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.2: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -759,48 +910,48 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - lefthook-darwin-arm64@2.1.2: + lefthook-darwin-arm64@2.1.14: optional: true - lefthook-darwin-x64@2.1.2: + lefthook-darwin-x64@2.1.14: optional: true - lefthook-freebsd-arm64@2.1.2: + lefthook-freebsd-arm64@2.1.14: optional: true - lefthook-freebsd-x64@2.1.2: + lefthook-freebsd-x64@2.1.14: optional: true - lefthook-linux-arm64@2.1.2: + lefthook-linux-arm64@2.1.14: optional: true - lefthook-linux-x64@2.1.2: + lefthook-linux-x64@2.1.14: optional: true - lefthook-openbsd-arm64@2.1.2: + lefthook-openbsd-arm64@2.1.14: optional: true - lefthook-openbsd-x64@2.1.2: + lefthook-openbsd-x64@2.1.14: optional: true - lefthook-windows-arm64@2.1.2: + lefthook-windows-arm64@2.1.14: optional: true - lefthook-windows-x64@2.1.2: + lefthook-windows-x64@2.1.14: optional: true - lefthook@2.1.2: + lefthook@2.1.14: optionalDependencies: - lefthook-darwin-arm64: 2.1.2 - lefthook-darwin-x64: 2.1.2 - lefthook-freebsd-arm64: 2.1.2 - lefthook-freebsd-x64: 2.1.2 - lefthook-linux-arm64: 2.1.2 - lefthook-linux-x64: 2.1.2 - lefthook-openbsd-arm64: 2.1.2 - lefthook-openbsd-x64: 2.1.2 - lefthook-windows-arm64: 2.1.2 - lefthook-windows-x64: 2.1.2 + lefthook-darwin-arm64: 2.1.14 + lefthook-darwin-x64: 2.1.14 + lefthook-freebsd-arm64: 2.1.14 + lefthook-freebsd-x64: 2.1.14 + lefthook-linux-arm64: 2.1.14 + lefthook-linux-x64: 2.1.14 + lefthook-openbsd-arm64: 2.1.14 + lefthook-openbsd-x64: 2.1.14 + lefthook-windows-arm64: 2.1.14 + lefthook-windows-x64: 2.1.14 locate-path@5.0.0: dependencies: @@ -813,7 +964,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mri@1.2.0: {} @@ -847,7 +998,7 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} pify@4.0.1: {} @@ -860,7 +1011,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.2 + js-yaml: 3.15.2 pify: 4.0.1 strip-bom: 3.0.0 @@ -874,7 +1025,7 @@ snapshots: safer-buffer@2.1.2: {} - semver@7.7.4: {} + semver@7.8.5: {} shebang-command@2.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..41934f5c0 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + lefthook: set this to true or false diff --git a/skills/gws-admin-reports/SKILL.md b/skills/gws-admin-reports/SKILL.md index 4c9eae171..f1af99afc 100644 --- a/skills/gws-admin-reports/SKILL.md +++ b/skills/gws-admin-reports/SKILL.md @@ -2,7 +2,7 @@ name: gws-admin-reports description: "Google Workspace Admin SDK: Audit logs and usage reports." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-calendar-agenda/SKILL.md b/skills/gws-calendar-agenda/SKILL.md index 89259ff34..3dee03da4 100644 --- a/skills/gws-calendar-agenda/SKILL.md +++ b/skills/gws-calendar-agenda/SKILL.md @@ -2,7 +2,7 @@ name: gws-calendar-agenda description: "Google Calendar: Show upcoming events across all calendars." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-calendar-insert/SKILL.md b/skills/gws-calendar-insert/SKILL.md index dd2989d8c..83b70c813 100644 --- a/skills/gws-calendar-insert/SKILL.md +++ b/skills/gws-calendar-insert/SKILL.md @@ -2,7 +2,7 @@ name: gws-calendar-insert description: "Google Calendar: Create a new event." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-calendar/SKILL.md b/skills/gws-calendar/SKILL.md index 08e134ce7..2b0f6fdcd 100644 --- a/skills/gws-calendar/SKILL.md +++ b/skills/gws-calendar/SKILL.md @@ -2,7 +2,7 @@ name: gws-calendar description: "Google Calendar: Manage calendars and events." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -58,6 +58,9 @@ The authenticated user for the request is made the data owner of the new calenda Note: We recommend to authenticate as the intended data owner of the calendar. You can use domain-wide delegation of authority to allow applications to act on behalf of a specific user. Don't use a service account for authentication. If you use a service account for authentication, the service account is the data owner, which can lead to unexpected behavior. - `patch` — Updates metadata for a calendar. This method supports patch semantics. + - `transferOwnership` — Transfers a secondary calendar between users within a Google Workspace organization. Requires user authentication with Manage Calendars administrator privilege, and one of the following authorization scopes: +- https://www.googleapis.com/auth/calendar +- https://www.googleapis.com/auth/calendar.calendars In the request, set useAdminAccess to true. The secondary calendar must be active to be transferred. Transferring disabled or deleted calendars isn't supported. - `update` — Updates metadata for a calendar. ### channels diff --git a/skills/gws-chat-send/SKILL.md b/skills/gws-chat-send/SKILL.md index 3ac2e2375..70f5d0260 100644 --- a/skills/gws-chat-send/SKILL.md +++ b/skills/gws-chat-send/SKILL.md @@ -2,7 +2,7 @@ name: gws-chat-send description: "Google Chat: Send a message to a space." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-chat/SKILL.md b/skills/gws-chat/SKILL.md index 5eaca50c3..9a20418b5 100644 --- a/skills/gws-chat/SKILL.md +++ b/skills/gws-chat/SKILL.md @@ -2,7 +2,7 @@ name: gws-chat description: "Google Chat: Manage Chat spaces and messages." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -45,17 +45,20 @@ gws chat [flags] - `create` — Creates a space. Can be used to create a named space, or a group chat in `Import mode`. For an example, see [Create a space](https://developers.google.com/workspace/chat/create-spaces). - `delete` — Deletes a named space. Always performs a cascading delete, which means that the space's child resources—like messages posted in the space and memberships in the space—are also deleted. For an example, see [Delete a space](https://developers.google.com/workspace/chat/delete-spaces). - `findDirectMessage` — Returns the existing direct message with the specified user. If no direct message space is found, returns a `404 NOT_FOUND` error. For an example, see [Find a direct message](/chat/api/guides/v1/spaces/find-direct-message). With [app authentication](https://developers.google.com/workspace/chat/authenticate-authorize-chat-app), returns the direct message space between the specified user and the calling Chat app. + - `findGroupChats` — Returns all spaces with `spaceType == GROUP_CHAT`, whose human memberships contain exactly the calling user, and the users specified in `FindGroupChatsRequest.users`. Only members that have joined the conversation are supported. For an example, see [Find group chats](https://developers.google.com/workspace/chat/find-group-chats). - `get` — Returns details about a space. For an example, see [Get details about a space](https://developers.google.com/workspace/chat/get-spaces). - `list` — Lists spaces the caller is a member of. Group chats and DMs aren't listed until the first message is sent. For an example, see [List spaces](https://developers.google.com/workspace/chat/list-spaces). - `patch` — Updates a space. For an example, see [Update a space](https://developers.google.com/workspace/chat/update-spaces). If you're updating the `displayName` field and receive the error message `ALREADY_EXISTS`, try a different display name.. An existing space within the Google Workspace organization might already use this display name. - - `search` — Returns a list of spaces in a Google Workspace organization based on an administrator's search. In the request, set `use_admin_access` to `true`. For an example, see [Search for and manage spaces](https://developers.google.com/workspace/chat/search-manage-admin). + - `search` — Returns a list of spaces in a Google Workspace organization. For an example, see [Search for and manage spaces](https://developers.google.com/workspace/chat/search-manage-admin). When `use_admin_access` is set to `false`, the results are limited to spaces where the calling user is a joined member. To search with administrator privileges, set `use_admin_access` to `true`. - `setup` — Creates a space and adds specified users to it. The calling user is automatically added to the space, and shouldn't be specified as a membership in the request. For an example, see [Set up a space with initial members](https://developers.google.com/workspace/chat/set-up-spaces). To specify the human members to add, add memberships with the appropriate `membership.member.name`. To add a human user, use `users/{user}`, where `{user}` can be the email address for the user. - `members` — Operations on the 'members' resource + - `messagePins` — Operations on the 'messagePins' resource - `messages` — Operations on the 'messages' resource - `spaceEvents` — Operations on the 'spaceEvents' resource ### users + - `availability` — Operations on the 'availability' resource - `sections` — Operations on the 'sections' resource - `spaces` — Operations on the 'spaces' resource diff --git a/skills/gws-classroom/SKILL.md b/skills/gws-classroom/SKILL.md index 466a71058..5fa842630 100644 --- a/skills/gws-classroom/SKILL.md +++ b/skills/gws-classroom/SKILL.md @@ -2,7 +2,7 @@ name: gws-classroom description: "Google Classroom: Manage classes, rosters, and coursework." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -29,7 +29,7 @@ gws classroom [flags] - `getGradingPeriodSettings` — Returns the grading period settings in a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user isn't permitted to access the grading period settings in the requested course or for access errors. * `NOT_FOUND` if the requested course does not exist. - `list` — Returns a list of courses that the requesting user is permitted to view, restricted to those that match the request. Returned courses are ordered by creation time, with the most recently created coming first. This method returns the following error codes: * `PERMISSION_DENIED` for access errors. * `INVALID_ARGUMENT` if the query argument is malformed. * `NOT_FOUND` if any users specified in the query arguments do not exist. - `patch` — Updates one or more fields in a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to modify the requested course or for access errors. * `NOT_FOUND` if no course exists with the requested ID. * `INVALID_ARGUMENT` if invalid fields are specified in the update mask or if no update mask is supplied. - - `update` — Updates a course. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to modify the requested course or for access errors. * `NOT_FOUND` if no course exists with the requested ID. * `FAILED_PRECONDITION` for the following request errors: * CourseNotModifiable * CourseTitleCannotContainUrl + - `update` — Updates a course. Note: Unlike other fields, `levels` is not cleared if omitted from the request. The `UpdateCourse` method only modifies `levels` if it is explicitly provided; otherwise, the existing value is preserved. Use the `PatchCourse` method to clear the `levels` field. This method returns the following error codes: * `PERMISSION_DENIED` if the requesting user is not permitted to modify the requested course or for access errors. * `NOT_FOUND` if no course exists with the requested ID. - `updateGradingPeriodSettings` — Updates grading period settings of a course. Individual grading periods can be added, removed, or modified using this method. The requesting user and course owner must be eligible to modify Grading Periods. For details, see [licensing requirements](https://developers.google.com/workspace/classroom/grading-periods/manage-grading-periods#licensing_requirements). - `aliases` — Operations on the 'aliases' resource - `announcements` — Operations on the 'announcements' resource diff --git a/skills/gws-docs-comment/SKILL.md b/skills/gws-docs-comment/SKILL.md new file mode 100644 index 000000000..9bbae17d3 --- /dev/null +++ b/skills/gws-docs-comment/SKILL.md @@ -0,0 +1,41 @@ +--- +name: gws-docs-comment +description: "Google Docs: Create a comment anchored to document text." +metadata: + version: 0.23.0 + openclaw: + category: "productivity" + requires: + bins: + - gws + cliHelp: "gws docs +comment --help" +--- + +# docs +comment + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Create a comment anchored to document text + +## Usage + +```bash +gws docs +comment +``` + +## Examples + +```bash +gws docs +comment create --document DOC_ID --text 'Please review this.' --start-index 1 --end-index 20 +``` + +## Tips + +- Indexes are UTF-16 document indexes. +- Comment creation is a Google Workspace Developer Preview feature. +- Use --dry-run to validate without authentication or sending the request. + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-docs](../gws-docs/SKILL.md) — All read and write google docs commands diff --git a/skills/gws-docs-read/SKILL.md b/skills/gws-docs-read/SKILL.md new file mode 100644 index 000000000..f461b9f9f --- /dev/null +++ b/skills/gws-docs-read/SKILL.md @@ -0,0 +1,68 @@ +--- +name: gws-docs-read +description: "Google Docs: Read a document as compact structured content." +metadata: + version: 0.23.0 + openclaw: + category: "productivity" + requires: + bins: + - gws + cliHelp: "gws docs +read --help" +--- + +# docs +read + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Read a document as compact structured content + +## Usage + +```bash +gws docs +read --document +``` + +## Flags + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--document` | ✓ | — | Document ID | +| `--params` | — | — | Additional documents.get API parameters as JSON | +| `--include-comments` | — | — | Include comments and their referenced text | + +## Examples + +```bash +gws docs +read --document DOC_ID +gws docs +read --document DOC_ID --format yaml +gws docs +read --document DOC_ID --params '{"fields":"*"}' --dry-run +gws docs +read --document DOC_ID | jq '.outline' +gws docs +read --document DOC_ID | jq '.. | objects | select(.paragraphStyle?.headingId? == "HEADING_ID")' +``` + +## Tips + +- Requests all tabs with includeTabsContent=true and suggestionsViewMode=SUGGESTIONS_INLINE. +- Only those tab/suggestion options are supported; fields must be absent or exactly "*". +- Other documents.get options pass through --params; alt must be json. $fields is rejected. +- JSON/YAML preserve the structured view; table/CSV use the global formatter's array summary. +- tabs[].blocks and childTabs keep API order; outline lists headings with tab IDs and JSON Pointer paths. +- Paragraph text concatenates text runs only. elements retain styles, links, suggestion IDs and reference markers. +- Tables contain rows[].cells[].blocks recursively. Headers, footers and footnotes have separate blocks. +- figures contain image/drawing metadata, including alt text and URIs when returned; images are never downloaded. +- Unknown blocks, inline elements and tab types retain type=unknown markers and raw data. +- startIndex/endIndex are the API's UTF-16 offsets, scoped to each tab/segment; never offsets into extracted text. +- revisionId and suggestionsViewMode are retained when returned. Missing revisionId is not synthesized. +- source=legacyBody indicates a fallback response without populated tabs; all-tab coverage cannot be confirmed. +- This is a content view, not a layout renderer or lossless API round trip. Inherited styles are not resolved. +- Suggestions remain inline, including proposed deletions; this helper does not accept or reject suggestions. +- --include-comments requests comment threads and resolves each comment anchor to referenced text. +- Use raw documents get for unsupported views or field masks. Missing body content produces an error. +- --dry-run validates and prints a request plan without acquiring credentials or fetching document content. +- --sanitize uses the existing Model Armor policy before normalization and retains _sanitization metadata. + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-docs](../gws-docs/SKILL.md) — All read and write google docs commands diff --git a/skills/gws-docs-suggest/SKILL.md b/skills/gws-docs-suggest/SKILL.md new file mode 100644 index 000000000..5b78a3d08 --- /dev/null +++ b/skills/gws-docs-suggest/SKILL.md @@ -0,0 +1,45 @@ +--- +name: gws-docs-suggest +description: "Google Docs: Create and manage Docs suggestions." +metadata: + version: 0.23.0 + openclaw: + category: "productivity" + requires: + bins: + - gws + cliHelp: "gws docs +suggest --help" +--- + +# docs +suggest + +> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it. + +Create and manage Docs suggestions + +## Usage + +```bash +gws docs +suggest +``` + +## Examples + +```bash +gws docs +suggest insert --document DOC_ID --text 'Suggested text' +gws docs +suggest replace --document DOC_ID --find 'old' --text 'new' +gws docs +suggest list --document DOC_ID +gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID +``` + +## Tips + +- Suggestion writes are a Google Workspace Developer Preview feature. +- The helper opts into unknown preview fields internally; raw commands remain strict. +- Use --dry-run to preview insert, delete-text, accept, reject, and delete requests. +- replace reads the document to locate exactly one matching text run before writing. + +## See Also + +- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth +- [gws-docs](../gws-docs/SKILL.md) — All read and write google docs commands diff --git a/skills/gws-docs-write/SKILL.md b/skills/gws-docs-write/SKILL.md index 4c903126f..f93530f81 100644 --- a/skills/gws-docs-write/SKILL.md +++ b/skills/gws-docs-write/SKILL.md @@ -2,7 +2,7 @@ name: gws-docs-write description: "Google Docs: Append text to a document." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-docs/SKILL.md b/skills/gws-docs/SKILL.md index f51b19503..8913676b2 100644 --- a/skills/gws-docs/SKILL.md +++ b/skills/gws-docs/SKILL.md @@ -2,7 +2,7 @@ name: gws-docs description: "Read and write Google Docs." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -23,6 +23,9 @@ gws docs [flags] | Command | Description | |---------|-------------| +| [`+read`](../gws-docs-read/SKILL.md) | Read a document as compact structured content | +| [`+suggest`](../gws-docs-suggest/SKILL.md) | Create and manage Docs suggestions | +| [`+comment`](../gws-docs-comment/SKILL.md) | Create a comment anchored to document text | | [`+write`](../gws-docs-write/SKILL.md) | Append text to a document | ## API Resources diff --git a/skills/gws-drive-upload/SKILL.md b/skills/gws-drive-upload/SKILL.md index fe2b4017e..7b9bd6c2c 100644 --- a/skills/gws-drive-upload/SKILL.md +++ b/skills/gws-drive-upload/SKILL.md @@ -2,7 +2,7 @@ name: gws-drive-upload description: "Google Drive: Upload a file with automatic metadata." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-drive/SKILL.md b/skills/gws-drive/SKILL.md index d4debbdb0..0df671687 100644 --- a/skills/gws-drive/SKILL.md +++ b/skills/gws-drive/SKILL.md @@ -2,7 +2,7 @@ name: gws-drive description: "Google Drive: Manage files, folders, and shared drives." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -39,8 +39,14 @@ gws drive [flags] ### approvals - - `get` — Gets an Approval by ID. - - `list` — Lists the Approvals on a file. + - `approve` — Approves an approval. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). This is used to update the ReviewerResponse of the requesting user with a Response of `APPROVED`. If this is the last required reviewer response, this also completes the approval and sets the approval Status to `APPROVED`. + - `cancel` — Cancels an approval. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). Updates the approval Status to `CANCELLED`. This can be called by any user with the `writer` permission on the file while the approval Status is `IN_PROGRESS`. + - `comment` — Comments on an approval. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). This sends a notification to both the initiator and the reviewers. Additionally, a message is also added to the approval activity log. + - `decline` — Declines an approval. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). This is used to update the ReviewerResponse of the requesting user with a Response of `DECLINED`. This also completes the approval and sets the approval Status to `DECLINED`. + - `get` — Gets an approval by ID. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). + - `list` — Lists the approvals on a file. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). By default, this method returns a minimal response that may not include the items array. To retrieve approval details, you must explicitly specify the fields you want using the `fields` query parameter. To return the exact fields you need, see [Return specific fields](https://developers.google.com/workspace/drive/api/guides/fields-parameter). + - `reassign` — Reassigns the reviewers on an approval. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). Adds or replaces reviewers in the ReviewerResponse of the approval. This can be called by any user with the `writer` permission on the file while the approval Status is `IN_PROGRESS` and the Response for the reviewer being reassigned is `NO_RESPONSE`. + - `start` — Starts an approval on a file. For more information, see [Manage approvals](https://developers.google.com/workspace/drive/api/guides/approvals). ### apps @@ -80,6 +86,7 @@ gws drive [flags] - `create` — Creates a file. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-file). This method supports an */upload* URI and accepts uploaded media with the following characteristics: - *Maximum file size:* 5,120 GB - *Accepted Media MIME types:* `*/*` (Specify a valid MIME type, rather than the literal `*/*` value. The literal `*/*` is only used to indicate that any valid MIME type can be uploaded. - `download` — Downloads the content of a file. For more information, see [Download and export files](https://developers.google.com/workspace/drive/api/guides/manage-downloads). Operations are valid for 24 hours from the time of creation. - `export` — Exports a Google Workspace document to the requested MIME type and returns exported byte content. For more information, see [Download and export files](https://developers.google.com/workspace/drive/api/guides/manage-downloads). Note that the exported content is limited to 10 MB. + - `generateCseToken` — Generates a CSE token which can be used to create or update CSE files. - `generateIds` — Generates a set of file IDs which can be provided in create or copy requests. For more information, see [Create and manage files](https://developers.google.com/workspace/drive/api/guides/create-file). - `get` — Gets a file's metadata or content by ID. For more information, see [Search for files and folders](https://developers.google.com/workspace/drive/api/guides/search-files). If you provide the URL parameter `alt=media`, then the response includes the file contents in the response body. Downloading content with `alt=media` only works if the file is stored in Drive. - `list` — Lists the user's files. For more information, see [Search for files and folders](https://developers.google.com/workspace/drive/api/guides/search-files). This method accepts the `q` parameter, which is a search query combining one or more search terms. This method returns *all* files by default, including trashed files. If you don't want trashed files to appear in the list, use the `trashed=false` query parameter to remove trashed files from the results. diff --git a/skills/gws-events-renew/SKILL.md b/skills/gws-events-renew/SKILL.md index bb825ed7a..c4592cb05 100644 --- a/skills/gws-events-renew/SKILL.md +++ b/skills/gws-events-renew/SKILL.md @@ -2,7 +2,7 @@ name: gws-events-renew description: "Google Workspace Events: Renew/reactivate Workspace Events subscriptions." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-events-subscribe/SKILL.md b/skills/gws-events-subscribe/SKILL.md index f2e45e90f..2d34a5b59 100644 --- a/skills/gws-events-subscribe/SKILL.md +++ b/skills/gws-events-subscribe/SKILL.md @@ -2,7 +2,7 @@ name: gws-events-subscribe description: "Google Workspace Events: Subscribe to Workspace events and stream them as NDJSON." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-events/SKILL.md b/skills/gws-events/SKILL.md index e72f5124a..38d36a1f6 100644 --- a/skills/gws-events/SKILL.md +++ b/skills/gws-events/SKILL.md @@ -2,7 +2,7 @@ name: gws-events description: "Subscribe to Google Workspace events." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-forms/SKILL.md b/skills/gws-forms/SKILL.md index 3a11a94e9..61ab2682e 100644 --- a/skills/gws-forms/SKILL.md +++ b/skills/gws-forms/SKILL.md @@ -2,7 +2,7 @@ name: gws-forms description: "Read and write Google Forms." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-forward/SKILL.md b/skills/gws-gmail-forward/SKILL.md index d433cef54..fac0a6d17 100644 --- a/skills/gws-gmail-forward/SKILL.md +++ b/skills/gws-gmail-forward/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-forward description: "Gmail: Forward a message to new recipients." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-read/SKILL.md b/skills/gws-gmail-read/SKILL.md index dc153af44..ab9fdabe0 100644 --- a/skills/gws-gmail-read/SKILL.md +++ b/skills/gws-gmail-read/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-read description: "Gmail: Read a message and extract its body or headers." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-reply-all/SKILL.md b/skills/gws-gmail-reply-all/SKILL.md index 1a4e4c8b9..ef233afa5 100644 --- a/skills/gws-gmail-reply-all/SKILL.md +++ b/skills/gws-gmail-reply-all/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-reply-all description: "Gmail: Reply-all to a message (handles threading automatically)." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-reply/SKILL.md b/skills/gws-gmail-reply/SKILL.md index e3538827e..a8fb229e2 100644 --- a/skills/gws-gmail-reply/SKILL.md +++ b/skills/gws-gmail-reply/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-reply description: "Gmail: Reply to a message (handles threading automatically)." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-send/SKILL.md b/skills/gws-gmail-send/SKILL.md index 55da9c28c..a1e0a0206 100644 --- a/skills/gws-gmail-send/SKILL.md +++ b/skills/gws-gmail-send/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-send description: "Gmail: Send an email." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-triage/SKILL.md b/skills/gws-gmail-triage/SKILL.md index 641a2d114..94eed95a8 100644 --- a/skills/gws-gmail-triage/SKILL.md +++ b/skills/gws-gmail-triage/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-triage description: "Gmail: Show unread inbox summary (sender, subject, date)." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail-watch/SKILL.md b/skills/gws-gmail-watch/SKILL.md index fca780f7e..fd48ca40b 100644 --- a/skills/gws-gmail-watch/SKILL.md +++ b/skills/gws-gmail-watch/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail-watch description: "Gmail: Watch for new emails and stream them as NDJSON." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-gmail/SKILL.md b/skills/gws-gmail/SKILL.md index 3423770e6..c80446508 100644 --- a/skills/gws-gmail/SKILL.md +++ b/skills/gws-gmail/SKILL.md @@ -2,7 +2,7 @@ name: gws-gmail description: "Gmail: Send, read, and manage email." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -36,8 +36,8 @@ gws gmail [flags] ### users - `getProfile` — Gets the current user's Gmail profile. - - `stop` — Stop receiving push notifications for the given user mailbox. - - `watch` — Set up or update a push notification watch on the given user mailbox. + - `stop` — Turn off push notification delivery for the given user mailbox. For more information, see [Configure push notifications in Gmail API](https://developers.google.com/workspace/gmail/api/guides/push). + - `watch` — Set up or update a push notification watch on the given user mailbox. For more information, see [Configure push notifications in Gmail API](https://developers.google.com/workspace/gmail/api/guides/push). - `drafts` — Operations on the 'drafts' resource - `history` — Operations on the 'history' resource - `labels` — Operations on the 'labels' resource diff --git a/skills/gws-keep/SKILL.md b/skills/gws-keep/SKILL.md index 8c2d0bf4e..c5ab1789d 100644 --- a/skills/gws-keep/SKILL.md +++ b/skills/gws-keep/SKILL.md @@ -2,7 +2,7 @@ name: gws-keep description: "Manage Google Keep notes." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-meet/SKILL.md b/skills/gws-meet/SKILL.md index 6959c2bde..c748672e0 100644 --- a/skills/gws-meet/SKILL.md +++ b/skills/gws-meet/SKILL.md @@ -2,7 +2,7 @@ name: gws-meet description: "Manage Google Meet conferences." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -23,8 +23,8 @@ gws meet [flags] ### conferenceRecords - - `get` — Gets a conference record by conference ID. - - `list` — Lists the conference records. By default, ordered by start time and in descending order. + - `get` — Gets a conference record by conference ID. For more information, see [Work with conferences](https://developers.google.com/workspace/meet/api/guides/conferences). + - `list` — Lists the conference records. By default, ordered by start time and in descending order. For more information, see [Work with conferences](https://developers.google.com/workspace/meet/api/guides/conferences). - `participants` — Operations on the 'participants' resource - `recordings` — Operations on the 'recordings' resource - `smartNotes` — Operations on the 'smartNotes' resource @@ -32,10 +32,11 @@ gws meet [flags] ### spaces - - `create` — Creates a space. - - `endActiveConference` — Ends an active conference (if there's one). For an example, see [End active conference](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#end-active-conference). - - `get` — Gets details about a meeting space. For an example, see [Get a meeting space](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#get-meeting-space). - - `patch` — Updates details about a meeting space. For an example, see [Update a meeting space](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#update-meeting-space). + - `create` — Creates a space. For more information, see [Manage meeting spaces](https://developers.google.com/workspace/meet/api/guides/manage-meeting-spaces). + - `endActiveConference` — Ends an active conference (if there's one). For more information, see [Manage meeting spaces](https://developers.google.com/workspace/meet/api/guides/manage-meeting-spaces). + - `get` — Gets details about a meeting space. For more information, see [Manage meeting spaces](https://developers.google.com/workspace/meet/api/guides/manage-meeting-spaces). For an example, see [Get a meeting space](https://developers.google.com/workspace/meet/api/guides/meeting-spaces#get-meeting-space). + - `patch` — Updates details about a meeting space. For more information, see [Manage meeting spaces](https://developers.google.com/workspace/meet/api/guides/manage-meeting-spaces). + - `members` — Operations on the 'members' resource ## Discovering Commands diff --git a/skills/gws-modelarmor-create-template/SKILL.md b/skills/gws-modelarmor-create-template/SKILL.md index 3926280e7..0896cb062 100644 --- a/skills/gws-modelarmor-create-template/SKILL.md +++ b/skills/gws-modelarmor-create-template/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor-create-template description: "Google Model Armor: Create a new Model Armor template." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "security" requires: diff --git a/skills/gws-modelarmor-sanitize-prompt/SKILL.md b/skills/gws-modelarmor-sanitize-prompt/SKILL.md index cff9dde94..8fa304319 100644 --- a/skills/gws-modelarmor-sanitize-prompt/SKILL.md +++ b/skills/gws-modelarmor-sanitize-prompt/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor-sanitize-prompt description: "Google Model Armor: Sanitize a user prompt through a Model Armor template." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "security" requires: diff --git a/skills/gws-modelarmor-sanitize-response/SKILL.md b/skills/gws-modelarmor-sanitize-response/SKILL.md index b4cd60faa..e578d0de0 100644 --- a/skills/gws-modelarmor-sanitize-response/SKILL.md +++ b/skills/gws-modelarmor-sanitize-response/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor-sanitize-response description: "Google Model Armor: Sanitize a model response through a Model Armor template." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "security" requires: diff --git a/skills/gws-modelarmor/SKILL.md b/skills/gws-modelarmor/SKILL.md index 15efca471..c6a4f56c5 100644 --- a/skills/gws-modelarmor/SKILL.md +++ b/skills/gws-modelarmor/SKILL.md @@ -2,7 +2,7 @@ name: gws-modelarmor description: "Google Model Armor: Filter user-generated content for safety." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-people/SKILL.md b/skills/gws-people/SKILL.md index 06527a73a..8cd5a5d29 100644 --- a/skills/gws-people/SKILL.md +++ b/skills/gws-people/SKILL.md @@ -2,7 +2,7 @@ name: gws-people description: "Google People: Manage contacts and profiles." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-script-push/SKILL.md b/skills/gws-script-push/SKILL.md index c3920af2b..a6b610d99 100644 --- a/skills/gws-script-push/SKILL.md +++ b/skills/gws-script-push/SKILL.md @@ -2,7 +2,7 @@ name: gws-script-push description: "Google Apps Script: Upload local files to an Apps Script project." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-script/SKILL.md b/skills/gws-script/SKILL.md index e7e288aaf..15801831c 100644 --- a/skills/gws-script/SKILL.md +++ b/skills/gws-script/SKILL.md @@ -2,7 +2,7 @@ name: gws-script description: "Manage Google Apps Script projects." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-shared/SKILL.md b/skills/gws-shared/SKILL.md index ea9497646..15f18839a 100644 --- a/skills/gws-shared/SKILL.md +++ b/skills/gws-shared/SKILL.md @@ -2,7 +2,7 @@ name: gws-shared description: "gws CLI: Shared patterns for authentication, global flags, and output formatting." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-sheets-append/SKILL.md b/skills/gws-sheets-append/SKILL.md index cac70a28c..ea2fccf09 100644 --- a/skills/gws-sheets-append/SKILL.md +++ b/skills/gws-sheets-append/SKILL.md @@ -2,7 +2,7 @@ name: gws-sheets-append description: "Google Sheets: Append a row to a spreadsheet." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: @@ -30,7 +30,7 @@ gws sheets +append --spreadsheet | `--spreadsheet` | ✓ | — | Spreadsheet ID | | `--values` | — | — | Comma-separated values (simple strings) | | `--json-values` | — | — | JSON array of rows, e.g. '[["a","b"],["c","d"]]' | -| `--range` | — | `A1` | Target range in A1 notation (e.g. 'Sheet2!A1') to select a specific tab | +| `--range` | — | — | Target range in A1 notation (e.g. 'Sheet2!A1'). Defaults to 'A1' (first sheet) | ## Examples @@ -44,7 +44,7 @@ gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100' - Use --values for simple single-row appends. - Use --json-values for bulk multi-row inserts. -- Use --range to append to a specific sheet tab (default: A1, i.e. first sheet). +- Use --range to target a specific sheet tab (default: A1, i.e. first sheet). > [!CAUTION] > This is a **write** command — confirm with the user before executing. diff --git a/skills/gws-sheets-read/SKILL.md b/skills/gws-sheets-read/SKILL.md index a1711dc32..e7d4d9157 100644 --- a/skills/gws-sheets-read/SKILL.md +++ b/skills/gws-sheets-read/SKILL.md @@ -2,7 +2,7 @@ name: gws-sheets-read description: "Google Sheets: Read values from a spreadsheet." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-sheets/SKILL.md b/skills/gws-sheets/SKILL.md index d713253db..ab6eeb279 100644 --- a/skills/gws-sheets/SKILL.md +++ b/skills/gws-sheets/SKILL.md @@ -2,7 +2,7 @@ name: gws-sheets description: "Google Sheets: Read and write spreadsheets." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-slides/SKILL.md b/skills/gws-slides/SKILL.md index afa06f97a..5db86f152 100644 --- a/skills/gws-slides/SKILL.md +++ b/skills/gws-slides/SKILL.md @@ -2,7 +2,7 @@ name: gws-slides description: "Google Slides: Read and write presentations." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-tasks/SKILL.md b/skills/gws-tasks/SKILL.md index da4b44d11..4fbc6812a 100644 --- a/skills/gws-tasks/SKILL.md +++ b/skills/gws-tasks/SKILL.md @@ -2,7 +2,7 @@ name: gws-tasks description: "Google Tasks: Manage task lists and tasks." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-email-to-task/SKILL.md b/skills/gws-workflow-email-to-task/SKILL.md index bf9456d77..1142009e2 100644 --- a/skills/gws-workflow-email-to-task/SKILL.md +++ b/skills/gws-workflow-email-to-task/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-email-to-task description: "Google Workflow: Convert a Gmail message into a Google Tasks entry." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-file-announce/SKILL.md b/skills/gws-workflow-file-announce/SKILL.md index 0937cd684..d0afb412a 100644 --- a/skills/gws-workflow-file-announce/SKILL.md +++ b/skills/gws-workflow-file-announce/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-file-announce description: "Google Workflow: Announce a Drive file in a Chat space." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-meeting-prep/SKILL.md b/skills/gws-workflow-meeting-prep/SKILL.md index f0edea82e..4b8cfd548 100644 --- a/skills/gws-workflow-meeting-prep/SKILL.md +++ b/skills/gws-workflow-meeting-prep/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-meeting-prep description: "Google Workflow: Prepare for your next meeting: agenda, attendees, and linked docs." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-standup-report/SKILL.md b/skills/gws-workflow-standup-report/SKILL.md index 9b1a8c914..62061375c 100644 --- a/skills/gws-workflow-standup-report/SKILL.md +++ b/skills/gws-workflow-standup-report/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-standup-report description: "Google Workflow: Today's meetings + open tasks as a standup summary." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow-weekly-digest/SKILL.md b/skills/gws-workflow-weekly-digest/SKILL.md index f07df867c..cb0a9dab6 100644 --- a/skills/gws-workflow-weekly-digest/SKILL.md +++ b/skills/gws-workflow-weekly-digest/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow-weekly-digest description: "Google Workflow: Weekly summary: this week's meetings + unread email count." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/gws-workflow/SKILL.md b/skills/gws-workflow/SKILL.md index 4078e1597..eb8c18b4d 100644 --- a/skills/gws-workflow/SKILL.md +++ b/skills/gws-workflow/SKILL.md @@ -2,7 +2,7 @@ name: gws-workflow description: "Google Workflow: Cross-service productivity workflows." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "productivity" requires: diff --git a/skills/persona-content-creator/SKILL.md b/skills/persona-content-creator/SKILL.md index f9846fc6c..29c34f7f8 100644 --- a/skills/persona-content-creator/SKILL.md +++ b/skills/persona-content-creator/SKILL.md @@ -2,7 +2,7 @@ name: persona-content-creator description: "Create, organize, and distribute content across Workspace." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-customer-support/SKILL.md b/skills/persona-customer-support/SKILL.md index 7d777cdb3..40e16ce63 100644 --- a/skills/persona-customer-support/SKILL.md +++ b/skills/persona-customer-support/SKILL.md @@ -2,7 +2,7 @@ name: persona-customer-support description: "Manage customer support — track tickets, respond, escalate issues." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-event-coordinator/SKILL.md b/skills/persona-event-coordinator/SKILL.md index a6a63e2fa..3b3c24be2 100644 --- a/skills/persona-event-coordinator/SKILL.md +++ b/skills/persona-event-coordinator/SKILL.md @@ -2,7 +2,7 @@ name: persona-event-coordinator description: "Plan and manage events — scheduling, invitations, and logistics." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-exec-assistant/SKILL.md b/skills/persona-exec-assistant/SKILL.md index 8e0dc8607..413471898 100644 --- a/skills/persona-exec-assistant/SKILL.md +++ b/skills/persona-exec-assistant/SKILL.md @@ -2,7 +2,7 @@ name: persona-exec-assistant description: "Manage an executive's schedule, inbox, and communications." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-hr-coordinator/SKILL.md b/skills/persona-hr-coordinator/SKILL.md index b0fd11875..35709bc67 100644 --- a/skills/persona-hr-coordinator/SKILL.md +++ b/skills/persona-hr-coordinator/SKILL.md @@ -2,7 +2,7 @@ name: persona-hr-coordinator description: "Handle HR workflows — onboarding, announcements, and employee comms." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-it-admin/SKILL.md b/skills/persona-it-admin/SKILL.md index 270fdeff9..06008fa17 100644 --- a/skills/persona-it-admin/SKILL.md +++ b/skills/persona-it-admin/SKILL.md @@ -2,7 +2,7 @@ name: persona-it-admin description: "Administer IT — monitor security and configure Workspace." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-project-manager/SKILL.md b/skills/persona-project-manager/SKILL.md index 995872c18..84b45cecc 100644 --- a/skills/persona-project-manager/SKILL.md +++ b/skills/persona-project-manager/SKILL.md @@ -2,7 +2,7 @@ name: persona-project-manager description: "Coordinate projects — track tasks, schedule meetings, and share docs." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-researcher/SKILL.md b/skills/persona-researcher/SKILL.md index ef0068cbf..058ae117a 100644 --- a/skills/persona-researcher/SKILL.md +++ b/skills/persona-researcher/SKILL.md @@ -2,7 +2,7 @@ name: persona-researcher description: "Organize research — manage references, notes, and collaboration." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-sales-ops/SKILL.md b/skills/persona-sales-ops/SKILL.md index b22f75524..af639985e 100644 --- a/skills/persona-sales-ops/SKILL.md +++ b/skills/persona-sales-ops/SKILL.md @@ -2,7 +2,7 @@ name: persona-sales-ops description: "Manage sales workflows — track deals, schedule calls, client comms." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/persona-team-lead/SKILL.md b/skills/persona-team-lead/SKILL.md index 46d3e33ab..f559b73d9 100644 --- a/skills/persona-team-lead/SKILL.md +++ b/skills/persona-team-lead/SKILL.md @@ -2,7 +2,7 @@ name: persona-team-lead description: "Lead a team — run standups, coordinate tasks, and communicate." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "persona" requires: diff --git a/skills/recipe-backup-sheet-as-csv/SKILL.md b/skills/recipe-backup-sheet-as-csv/SKILL.md index 8adc1d00a..205ae6abe 100644 --- a/skills/recipe-backup-sheet-as-csv/SKILL.md +++ b/skills/recipe-backup-sheet-as-csv/SKILL.md @@ -2,7 +2,7 @@ name: recipe-backup-sheet-as-csv description: "Export a Google Sheets spreadsheet as a CSV file for local backup or processing." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-batch-invite-to-event/SKILL.md b/skills/recipe-batch-invite-to-event/SKILL.md index c811d150a..6543caa00 100644 --- a/skills/recipe-batch-invite-to-event/SKILL.md +++ b/skills/recipe-batch-invite-to-event/SKILL.md @@ -2,7 +2,7 @@ name: recipe-batch-invite-to-event description: "Add a list of attendees to an existing Google Calendar event and send notifications." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-block-focus-time/SKILL.md b/skills/recipe-block-focus-time/SKILL.md index 6ac952e8c..24d662190 100644 --- a/skills/recipe-block-focus-time/SKILL.md +++ b/skills/recipe-block-focus-time/SKILL.md @@ -2,7 +2,7 @@ name: recipe-block-focus-time description: "Create recurring focus time blocks on Google Calendar to protect deep work hours." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-bulk-download-folder/SKILL.md b/skills/recipe-bulk-download-folder/SKILL.md index 261be02af..aca42cd72 100644 --- a/skills/recipe-bulk-download-folder/SKILL.md +++ b/skills/recipe-bulk-download-folder/SKILL.md @@ -2,7 +2,7 @@ name: recipe-bulk-download-folder description: "List and download all files from a Google Drive folder." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-collect-form-responses/SKILL.md b/skills/recipe-collect-form-responses/SKILL.md index 50e44d387..2ab5cc3bb 100644 --- a/skills/recipe-collect-form-responses/SKILL.md +++ b/skills/recipe-collect-form-responses/SKILL.md @@ -2,7 +2,7 @@ name: recipe-collect-form-responses description: "Retrieve and review responses from a Google Form." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-compare-sheet-tabs/SKILL.md b/skills/recipe-compare-sheet-tabs/SKILL.md index d5b68317d..a149eec64 100644 --- a/skills/recipe-compare-sheet-tabs/SKILL.md +++ b/skills/recipe-compare-sheet-tabs/SKILL.md @@ -2,7 +2,7 @@ name: recipe-compare-sheet-tabs description: "Read data from two tabs in a Google Sheet to compare and identify differences." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-copy-sheet-for-new-month/SKILL.md b/skills/recipe-copy-sheet-for-new-month/SKILL.md index 31ac231c4..4770b97d9 100644 --- a/skills/recipe-copy-sheet-for-new-month/SKILL.md +++ b/skills/recipe-copy-sheet-for-new-month/SKILL.md @@ -2,7 +2,7 @@ name: recipe-copy-sheet-for-new-month description: "Duplicate a Google Sheets template tab for a new month of tracking." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-classroom-course/SKILL.md b/skills/recipe-create-classroom-course/SKILL.md index 0e7b08fea..cc09512d6 100644 --- a/skills/recipe-create-classroom-course/SKILL.md +++ b/skills/recipe-create-classroom-course/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-classroom-course description: "Create a Google Classroom course and invite students." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "education" diff --git a/skills/recipe-create-doc-from-template/SKILL.md b/skills/recipe-create-doc-from-template/SKILL.md index 4e9312fb4..77ce816d1 100644 --- a/skills/recipe-create-doc-from-template/SKILL.md +++ b/skills/recipe-create-doc-from-template/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-doc-from-template description: "Copy a Google Docs template, fill in content, and share with collaborators." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-events-from-sheet/SKILL.md b/skills/recipe-create-events-from-sheet/SKILL.md index ba248e2f9..151911786 100644 --- a/skills/recipe-create-events-from-sheet/SKILL.md +++ b/skills/recipe-create-events-from-sheet/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-events-from-sheet description: "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-expense-tracker/SKILL.md b/skills/recipe-create-expense-tracker/SKILL.md index 86edb3c7b..8acbe9097 100644 --- a/skills/recipe-create-expense-tracker/SKILL.md +++ b/skills/recipe-create-expense-tracker/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-expense-tracker description: "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-feedback-form/SKILL.md b/skills/recipe-create-feedback-form/SKILL.md index 2be679184..bfb5effee 100644 --- a/skills/recipe-create-feedback-form/SKILL.md +++ b/skills/recipe-create-feedback-form/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-feedback-form description: "Create a Google Form for feedback and share it via Gmail." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-gmail-filter/SKILL.md b/skills/recipe-create-gmail-filter/SKILL.md index 32e856060..81913c451 100644 --- a/skills/recipe-create-gmail-filter/SKILL.md +++ b/skills/recipe-create-gmail-filter/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-gmail-filter description: "Create a Gmail filter to automatically label, star, or categorize incoming messages." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-meet-space/SKILL.md b/skills/recipe-create-meet-space/SKILL.md index bd998538b..a80aa2654 100644 --- a/skills/recipe-create-meet-space/SKILL.md +++ b/skills/recipe-create-meet-space/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-meet-space description: "Create a Google Meet meeting space and share the join link." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-create-presentation/SKILL.md b/skills/recipe-create-presentation/SKILL.md index 5cba51261..39863f403 100644 --- a/skills/recipe-create-presentation/SKILL.md +++ b/skills/recipe-create-presentation/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-presentation description: "Create a new Google Slides presentation and add initial slides." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-shared-drive/SKILL.md b/skills/recipe-create-shared-drive/SKILL.md index 2ebebb5fb..a377dfe6d 100644 --- a/skills/recipe-create-shared-drive/SKILL.md +++ b/skills/recipe-create-shared-drive/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-shared-drive description: "Create a Google Shared Drive and add members with appropriate roles." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-task-list/SKILL.md b/skills/recipe-create-task-list/SKILL.md index d79a8a0b3..63627e47c 100644 --- a/skills/recipe-create-task-list/SKILL.md +++ b/skills/recipe-create-task-list/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-task-list description: "Set up a new Google Tasks list with initial tasks." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-create-vacation-responder/SKILL.md b/skills/recipe-create-vacation-responder/SKILL.md index 3ad7c2e64..9ab338149 100644 --- a/skills/recipe-create-vacation-responder/SKILL.md +++ b/skills/recipe-create-vacation-responder/SKILL.md @@ -2,7 +2,7 @@ name: recipe-create-vacation-responder description: "Enable a Gmail out-of-office auto-reply with a custom message and date range." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-draft-email-from-doc/SKILL.md b/skills/recipe-draft-email-from-doc/SKILL.md index 993a2cfe3..2ab4c35f3 100644 --- a/skills/recipe-draft-email-from-doc/SKILL.md +++ b/skills/recipe-draft-email-from-doc/SKILL.md @@ -2,7 +2,7 @@ name: recipe-draft-email-from-doc description: "Read content from a Google Doc and use it as the body of a Gmail message." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-email-drive-link/SKILL.md b/skills/recipe-email-drive-link/SKILL.md index 17e190bcd..9a0bd235b 100644 --- a/skills/recipe-email-drive-link/SKILL.md +++ b/skills/recipe-email-drive-link/SKILL.md @@ -2,7 +2,7 @@ name: recipe-email-drive-link description: "Share a Google Drive file and email the link with a message to recipients." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-find-free-time/SKILL.md b/skills/recipe-find-free-time/SKILL.md index 4d3ab2f88..9bb40fff6 100644 --- a/skills/recipe-find-free-time/SKILL.md +++ b/skills/recipe-find-free-time/SKILL.md @@ -2,7 +2,7 @@ name: recipe-find-free-time description: "Query Google Calendar free/busy status for multiple users to find a meeting slot." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-find-large-files/SKILL.md b/skills/recipe-find-large-files/SKILL.md index 27610996b..5d0759179 100644 --- a/skills/recipe-find-large-files/SKILL.md +++ b/skills/recipe-find-large-files/SKILL.md @@ -2,7 +2,7 @@ name: recipe-find-large-files description: "Identify large Google Drive files consuming storage quota." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-forward-labeled-emails/SKILL.md b/skills/recipe-forward-labeled-emails/SKILL.md index 5162a63de..9b1894458 100644 --- a/skills/recipe-forward-labeled-emails/SKILL.md +++ b/skills/recipe-forward-labeled-emails/SKILL.md @@ -2,7 +2,7 @@ name: recipe-forward-labeled-emails description: "Find Gmail messages with a specific label and forward them to another address." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-generate-report-from-sheet/SKILL.md b/skills/recipe-generate-report-from-sheet/SKILL.md index 62bd1ed5a..8dcaf9e72 100644 --- a/skills/recipe-generate-report-from-sheet/SKILL.md +++ b/skills/recipe-generate-report-from-sheet/SKILL.md @@ -2,7 +2,7 @@ name: recipe-generate-report-from-sheet description: "Read data from a Google Sheet and create a formatted Google Docs report." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-label-and-archive-emails/SKILL.md b/skills/recipe-label-and-archive-emails/SKILL.md index c23febd90..7c1d71985 100644 --- a/skills/recipe-label-and-archive-emails/SKILL.md +++ b/skills/recipe-label-and-archive-emails/SKILL.md @@ -2,7 +2,7 @@ name: recipe-label-and-archive-emails description: "Apply Gmail labels to matching messages and archive them to keep your inbox clean." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-log-deal-update/SKILL.md b/skills/recipe-log-deal-update/SKILL.md index 67f98aa01..b6d6013ca 100644 --- a/skills/recipe-log-deal-update/SKILL.md +++ b/skills/recipe-log-deal-update/SKILL.md @@ -2,7 +2,7 @@ name: recipe-log-deal-update description: "Append a deal status update to a Google Sheets sales tracking spreadsheet." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "sales" diff --git a/skills/recipe-organize-drive-folder/SKILL.md b/skills/recipe-organize-drive-folder/SKILL.md index ac74d1db5..ee50e0023 100644 --- a/skills/recipe-organize-drive-folder/SKILL.md +++ b/skills/recipe-organize-drive-folder/SKILL.md @@ -2,7 +2,7 @@ name: recipe-organize-drive-folder description: "Create a Google Drive folder structure and move files into the right locations." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-plan-weekly-schedule/SKILL.md b/skills/recipe-plan-weekly-schedule/SKILL.md index f8445ac10..41a0e5658 100644 --- a/skills/recipe-plan-weekly-schedule/SKILL.md +++ b/skills/recipe-plan-weekly-schedule/SKILL.md @@ -2,7 +2,7 @@ name: recipe-plan-weekly-schedule description: "Review your Google Calendar week, identify gaps, and add events to fill them." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-post-mortem-setup/SKILL.md b/skills/recipe-post-mortem-setup/SKILL.md index 88dcfc835..81182cfb5 100644 --- a/skills/recipe-post-mortem-setup/SKILL.md +++ b/skills/recipe-post-mortem-setup/SKILL.md @@ -2,7 +2,7 @@ name: recipe-post-mortem-setup description: "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "engineering" diff --git a/skills/recipe-reschedule-meeting/SKILL.md b/skills/recipe-reschedule-meeting/SKILL.md index 83be0954f..23c4b5ed7 100644 --- a/skills/recipe-reschedule-meeting/SKILL.md +++ b/skills/recipe-reschedule-meeting/SKILL.md @@ -2,7 +2,7 @@ name: recipe-reschedule-meeting description: "Move a Google Calendar event to a new time and automatically notify all attendees." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-review-meet-participants/SKILL.md b/skills/recipe-review-meet-participants/SKILL.md index ae54f6301..3c264b3f8 100644 --- a/skills/recipe-review-meet-participants/SKILL.md +++ b/skills/recipe-review-meet-participants/SKILL.md @@ -2,7 +2,7 @@ name: recipe-review-meet-participants description: "Review who attended a Google Meet conference and for how long." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-review-overdue-tasks/SKILL.md b/skills/recipe-review-overdue-tasks/SKILL.md index 396ca928f..1623b4a54 100644 --- a/skills/recipe-review-overdue-tasks/SKILL.md +++ b/skills/recipe-review-overdue-tasks/SKILL.md @@ -2,7 +2,7 @@ name: recipe-review-overdue-tasks description: "Find Google Tasks that are past due and need attention." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-save-email-attachments/SKILL.md b/skills/recipe-save-email-attachments/SKILL.md index 2ea287eef..ba8b5ba0b 100644 --- a/skills/recipe-save-email-attachments/SKILL.md +++ b/skills/recipe-save-email-attachments/SKILL.md @@ -2,7 +2,7 @@ name: recipe-save-email-attachments description: "Find Gmail messages with attachments and save them to a Google Drive folder." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-save-email-to-doc/SKILL.md b/skills/recipe-save-email-to-doc/SKILL.md index 5554e31d5..a89b9d240 100644 --- a/skills/recipe-save-email-to-doc/SKILL.md +++ b/skills/recipe-save-email-to-doc/SKILL.md @@ -2,7 +2,7 @@ name: recipe-save-email-to-doc description: "Save a Gmail message body into a Google Doc for archival or reference." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-schedule-recurring-event/SKILL.md b/skills/recipe-schedule-recurring-event/SKILL.md index 152d3e576..6f5aa95f1 100644 --- a/skills/recipe-schedule-recurring-event/SKILL.md +++ b/skills/recipe-schedule-recurring-event/SKILL.md @@ -2,7 +2,7 @@ name: recipe-schedule-recurring-event description: "Create a recurring Google Calendar event with attendees." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "scheduling" diff --git a/skills/recipe-send-team-announcement/SKILL.md b/skills/recipe-send-team-announcement/SKILL.md index 70b4ee0b2..1a8319f04 100644 --- a/skills/recipe-send-team-announcement/SKILL.md +++ b/skills/recipe-send-team-announcement/SKILL.md @@ -2,7 +2,7 @@ name: recipe-send-team-announcement description: "Send a team announcement via both Gmail and a Google Chat space." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "communication" diff --git a/skills/recipe-share-doc-and-notify/SKILL.md b/skills/recipe-share-doc-and-notify/SKILL.md index 9a5f86954..4406ec9a7 100644 --- a/skills/recipe-share-doc-and-notify/SKILL.md +++ b/skills/recipe-share-doc-and-notify/SKILL.md @@ -2,7 +2,7 @@ name: recipe-share-doc-and-notify description: "Share a Google Docs document with edit access and email collaborators the link." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-share-event-materials/SKILL.md b/skills/recipe-share-event-materials/SKILL.md index ff730a54a..bef7819dd 100644 --- a/skills/recipe-share-event-materials/SKILL.md +++ b/skills/recipe-share-event-materials/SKILL.md @@ -2,7 +2,7 @@ name: recipe-share-event-materials description: "Share Google Drive files with all attendees of a Google Calendar event." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-share-folder-with-team/SKILL.md b/skills/recipe-share-folder-with-team/SKILL.md index 5c5505688..aed9901d5 100644 --- a/skills/recipe-share-folder-with-team/SKILL.md +++ b/skills/recipe-share-folder-with-team/SKILL.md @@ -2,7 +2,7 @@ name: recipe-share-folder-with-team description: "Share a Google Drive folder and all its contents with a list of collaborators." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-sync-contacts-to-sheet/SKILL.md b/skills/recipe-sync-contacts-to-sheet/SKILL.md index d924426b7..6227dfb1c 100644 --- a/skills/recipe-sync-contacts-to-sheet/SKILL.md +++ b/skills/recipe-sync-contacts-to-sheet/SKILL.md @@ -2,7 +2,7 @@ name: recipe-sync-contacts-to-sheet description: "Export Google Contacts directory to a Google Sheets spreadsheet." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "productivity" diff --git a/skills/recipe-watch-drive-changes/SKILL.md b/skills/recipe-watch-drive-changes/SKILL.md index 14f3d79a8..b239de57b 100644 --- a/skills/recipe-watch-drive-changes/SKILL.md +++ b/skills/recipe-watch-drive-changes/SKILL.md @@ -2,7 +2,7 @@ name: recipe-watch-drive-changes description: "Subscribe to change notifications on a Google Drive file or folder." metadata: - version: 0.22.5 + version: 0.23.0 openclaw: category: "recipe" domain: "engineering" From 2d65de164e6cfb810d1b24e74aacf9d973fb589f Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 18:37:54 -0300 Subject: [PATCH 14/16] fix(release): repair pnpm metadata and file-root coverage --- FORK.md | 2 +- .../src/helpers/gmail/mod.rs | 26 +++ package.json | 2 +- pnpm-lock.yaml | 158 ------------------ pnpm-workspace.yaml | 4 +- skills/gws-docs-suggest/SKILL.md | 2 +- 6 files changed, 32 insertions(+), 162 deletions(-) diff --git a/FORK.md b/FORK.md index cf0660b87..848a484ed 100644 --- a/FORK.md +++ b/FORK.md @@ -73,7 +73,7 @@ from `main` without rewriting history. Install this fork from source using the [README](README.md#installation). The upstream npm, crates.io, Homebrew, and binary releases do not include -fork-only changes. Package names and version `0.22.5` currently retain their +fork-only changes. Package names and version `0.23.0` currently retain their upstream values for source compatibility; identify a fork build by its Git commit. The inherited `npm/` downloader remains an upstream distribution tool, not an installer for this fork. diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index 66719a139..713e0ab45 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -3166,6 +3166,32 @@ mod tests { assert_eq!(attachments[0].data, b"hello world"); } + #[test] + #[serial_test::serial] + fn test_parse_attachments_ignores_configured_file_root() { + let _root = DefaultFileRoot::unset(); + use std::io::Write; + let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); + let local_dir = tempfile::tempdir_in(&cwd).unwrap(); + let local_file = local_dir.path().join("local.txt"); + std::fs::write(&local_file, b"local").unwrap(); + + let external_dir = tempfile::tempdir().unwrap(); + let external_file = external_dir.path().join("external.txt"); + let mut file = std::fs::File::create(&external_file).unwrap(); + file.write_all(b"external").unwrap(); + drop(file); + std::env::set_var("GOOGLE_WORKSPACE_CLI_FILE_ROOT", external_dir.path()); + + let local_matches = make_attach_matches(&["test", "-a", local_file.to_str().unwrap()]); + assert!(parse_attachments(&local_matches).is_ok()); + + let external_matches = + make_attach_matches(&["test", "-a", external_file.to_str().unwrap()]); + let err = parse_attachments(&external_matches).unwrap_err(); + assert!(err.to_string().contains("outside the current directory")); + } + #[test] #[serial_test::serial] fn test_parse_attachments_nonexistent_file() { diff --git a/package.json b/package.json index 01745f794..391844134 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "engines": { "node": ">=18" }, - "packageManager": "pnpm@12.4.2", + "packageManager": "pnpm@10.0.0", "keywords": [ "cli", "google-workspace", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index efdfe37b9..3f54666b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,161 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - pnpm: - specifier: 12.4.2 - version: 12.4.2 - -packages: - - '@pnpm/exe.android-arm64@12.4.2': - resolution: {integrity: sha512-E255MbcQ0V1577M2BV0ajWuP7KmTPYA0jqZnk3rK6Lq3kTvZWulMMb7iqRmnLsQbyWTkMEB2CfyM70loVDA8xg==} - cpu: [arm64] - os: [android] - - '@pnpm/exe.android-x64@12.4.2': - resolution: {integrity: sha512-J1pSeCUwuKxMG70ZzpWn8JzElxiEa8NN/3N0SlZRtU2xbztChaJCKF3HaBNIj9yPfeofWzJo/lwNvDDjraQuZw==} - cpu: [x64] - os: [android] - - '@pnpm/exe.darwin-arm64@12.4.2': - resolution: {integrity: sha512-A0WDo8iErfZBXgrLseQxw8i8Y9ctUpOEl/Uu+cubnTzpD8tT9ykIB548L8YTM2WD4OS+ZOHSxy8aGZcvKq8PaQ==} - cpu: [arm64] - os: [darwin] - - '@pnpm/exe.darwin-x64@12.4.2': - resolution: {integrity: sha512-MSgJdovBWHcb5DEOvfPH9yNi/T5O1Xa4ess+E1ESGo/5yuty7S4JoiIjami+fsNXfnoQMlwsMUqYU4zAvBcNCw==} - cpu: [x64] - os: [darwin] - - '@pnpm/exe.freebsd-x64@12.4.2': - resolution: {integrity: sha512-h2YumlQSNvgbRPv+RXwABohX65f9bOBZn+jMIt7bFDISZPCzQ+Nvpt6Awbp4ip5PwQgYxbu5iREJ1fHE39Fm8A==} - cpu: [x64] - os: [freebsd] - - '@pnpm/exe.linux-arm64-musl@12.4.2': - resolution: {integrity: sha512-LwSEtSEDTv6S51YLs3YvSkPyun/QmfMic1UGICUkPWFu6ByP43RdMlkKvmVkfGhAYCpnxO057vrmyJqtfZrPCA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-arm64@12.4.2': - resolution: {integrity: sha512-2dSiDXyhx+RTHsewxex8f/jVjqQXWJ2oow4kCVHEWdZKeBpgMxvZ6fHkTAUBJXgqhbKIDHvuNlZBP7gJfUWL5A==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-ppc64@12.4.2': - resolution: {integrity: sha512-8Itc+jQk+MTz04LS9D1RcH+VctAWmzM4l1cJQ+Sx7pAJNo7EKHdRMbC0Tok1jyQ7eEHNJZD5EleYneZqWfZM+g==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-riscv64@12.4.2': - resolution: {integrity: sha512-hleOeqhTVpH+z9RVMGnxvU4ZnrkBUClWjCbHD9u6kwyqhGSpevoU1wTGish+CBRhmIgMAy9pAfFpqhbAKOKNfw==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-s390x@12.4.2': - resolution: {integrity: sha512-LAsQRRdP9aToENR6dtcIJ9l+e1zMYOX0tQcLGpRyLPVBQcYRLlvAPcmDshsiIHQjp05SDa9FI0czX1ZQ1a7a/A==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@pnpm/exe.linux-x64-musl@12.4.2': - resolution: {integrity: sha512-kzfzH2/0BWdTABK14Yj5a1xsdkTEQUp2eXEPNakaD9jKL025lq3hyaKHIz/gIZaPDMe/1bFFK/En4ztFlbBJxw==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/exe.linux-x64@12.4.2': - resolution: {integrity: sha512-/pbt0UVTa8NMDhzOWLQRfZ6G9ROKXlJPZtx845BqyWfc7hCrWXhTLBd70yO2y8+E+IWN3oHM1s/JsIQNGk1yvg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@pnpm/exe.win32-arm64@12.4.2': - resolution: {integrity: sha512-PsW19e4dAUNpZ0cS9flaxFuAmpt2dKlH/Vvi8TZ4qyJjjQzue/CEsWm+6wKVP4CJ7IT8RdL4qh+soOPWIgF2Zw==} - cpu: [arm64] - os: [win32] - - '@pnpm/exe.win32-x64@12.4.2': - resolution: {integrity: sha512-+xGoeE0g55ztWvl8i5QqdmNfW3nIrTVcoQrNshEOwxthm9Ag48oXton3uxOA3/SANyz5AXexEjj2KO20NnOrEw==} - cpu: [x64] - os: [win32] - - pnpm@12.4.2: - resolution: {integrity: sha512-CK3GYTGAJ1x8ntraOdzwjJxhrU5+rzMKTzRh8QKw+QdCNFTRF/mOctR/7wYWBwZE17/8lzpqV/UJCm18NosHyQ==} - engines: {node: '>=18.*'} - hasBin: true - -snapshots: - - '@pnpm/exe.android-arm64@12.4.2': - optional: true - - '@pnpm/exe.android-x64@12.4.2': - optional: true - - '@pnpm/exe.darwin-arm64@12.4.2': - optional: true - - '@pnpm/exe.darwin-x64@12.4.2': - optional: true - - '@pnpm/exe.freebsd-x64@12.4.2': - optional: true - - '@pnpm/exe.linux-arm64-musl@12.4.2': - optional: true - - '@pnpm/exe.linux-arm64@12.4.2': - optional: true - - '@pnpm/exe.linux-ppc64@12.4.2': - optional: true - - '@pnpm/exe.linux-riscv64@12.4.2': - optional: true - - '@pnpm/exe.linux-s390x@12.4.2': - optional: true - - '@pnpm/exe.linux-x64-musl@12.4.2': - optional: true - - '@pnpm/exe.linux-x64@12.4.2': - optional: true - - '@pnpm/exe.win32-arm64@12.4.2': - optional: true - - '@pnpm/exe.win32-x64@12.4.2': - optional: true - - pnpm@12.4.2: - optionalDependencies: - '@pnpm/exe.android-arm64': 12.4.2 - '@pnpm/exe.android-x64': 12.4.2 - '@pnpm/exe.darwin-arm64': 12.4.2 - '@pnpm/exe.darwin-x64': 12.4.2 - '@pnpm/exe.freebsd-x64': 12.4.2 - '@pnpm/exe.linux-arm64': 12.4.2 - '@pnpm/exe.linux-arm64-musl': 12.4.2 - '@pnpm/exe.linux-ppc64': 12.4.2 - '@pnpm/exe.linux-riscv64': 12.4.2 - '@pnpm/exe.linux-s390x': 12.4.2 - '@pnpm/exe.linux-x64': 12.4.2 - '@pnpm/exe.linux-x64-musl': 12.4.2 - '@pnpm/exe.win32-arm64': 12.4.2 - '@pnpm/exe.win32-x64': 12.4.2 - ---- lockfileVersion: '9.0' settings: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 41934f5c0..9ef5fe0af 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,4 @@ +packages: [] + allowBuilds: - lefthook: set this to true or false + lefthook: true diff --git a/skills/gws-docs-suggest/SKILL.md b/skills/gws-docs-suggest/SKILL.md index 5b78a3d08..430138559 100644 --- a/skills/gws-docs-suggest/SKILL.md +++ b/skills/gws-docs-suggest/SKILL.md @@ -42,4 +42,4 @@ gws docs +suggest accept --document DOC_ID --suggestion-id SUGGESTION_ID ## See Also - [gws-shared](../gws-shared/SKILL.md) — Global flags and auth -- [gws-docs](../gws-docs/SKILL.md) — All read and write google docs commands +- [gws-docs](../gws-docs/SKILL.md) — All read and write Google Docs commands From a4544ee7ac8c6489bbc88e03f7a5f1d342c3e3bd Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 19:09:32 -0300 Subject: [PATCH 15/16] fix(release): preserve credential and comment context --- crates/google-workspace-cli/src/auth.rs | 34 +++++++++++++++++-- .../src/helpers/docs/read.rs | 21 +++++++++--- .../src/helpers/docs/read_tests.rs | 22 ++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 6b27e3f92..6182dc46f 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -367,8 +367,19 @@ async fn load_credentials_with_loader( ); } - // 2. Encrypted credentials - if enc_path.exists() { + // 2. Encrypted credentials. Inspect symlink metadata so dangling symlinks + // and inaccessible paths are treated as credential failures, not absence. + let encrypted_present = match std::fs::symlink_metadata(enc_path) { + Ok(_) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(_) => { + anyhow::bail!( + "Failed to inspect saved credentials at {}. Credentials and token caches have been preserved; no fallback credentials were used.", + crate::output::sanitize_for_terminal(&enc_path.display().to_string()) + ) + } + }; + if encrypted_present { // A read, decryption, or keyring failure does not mean the files are // disposable. Stop here so a retry cannot silently select another account. // Do not render backend error details, which may contain sensitive data. @@ -910,6 +921,25 @@ mod tests { } } + #[cfg(unix)] + #[tokio::test] + #[serial_test::serial] + async fn test_load_credentials_dangling_encrypted_symlink_blocks_fallback() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + let fallback_path = dir.path().join("credentials.json"); + let fallback_json = r#"{"client_id":"fallback","client_secret":"secret","refresh_token":"refresh","type":"authorized_user"}"#; + std::fs::write(&fallback_path, fallback_json).unwrap(); + symlink(dir.path().join("missing-encrypted"), &enc_path).unwrap(); + + let error = load_credentials_inner(None, &enc_path, &fallback_path) + .await + .expect_err("dangling encrypted credential symlink must block fallback"); + assert!(error.to_string().contains("saved credentials")); + } + #[tokio::test] #[serial_test::serial] async fn test_load_credentials_keyring_failure_preserves_files_and_blocks_adc() { diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index 33a06e2d9..b70824614 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -306,33 +306,46 @@ pub(super) fn normalize_with_comments(document: &Value) -> Result std::collections::HashMap> { let mut anchors = std::collections::HashMap::new(); - collect_comment_anchors_recursive(document, &mut anchors); + collect_comment_anchors_recursive(document, None, &mut anchors); anchors } fn collect_comment_anchors_recursive( value: &Value, + current_tab_id: Option, anchors: &mut std::collections::HashMap>, ) { match value { Value::Object(object) => { + let tab_id = object + .get("tabId") + .and_then(Value::as_str) + .map(String::from) + .or(current_tab_id); if let Some(comment_anchors) = object.get("commentAnchors").and_then(Value::as_object) { for (id, anchor) in comment_anchors { - let ranges = anchor + let mut ranges = anchor .get("ranges") .and_then(Value::as_array) .cloned() .unwrap_or_default(); + if let Some(tab_id) = tab_id.as_deref() { + for range in &mut ranges { + if range.get("tabId").is_none() { + range["tabId"] = Value::String(tab_id.to_string()); + } + } + } anchors.insert(id.clone(), ranges); } } for child in object.values() { - collect_comment_anchors_recursive(child, anchors); + collect_comment_anchors_recursive(child, tab_id.clone(), anchors); } } Value::Array(array) => { for child in array { - collect_comment_anchors_recursive(child, anchors); + collect_comment_anchors_recursive(child, current_tab_id.clone(), anchors); } } _ => {} diff --git a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs index ca8b4c07b..b2b751116 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read_tests.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read_tests.rs @@ -215,6 +215,28 @@ fn comment_anchor_resolution_respects_document_segments() { assert_eq!(output["comments"][1]["referencedText"], json!(["header"])); } +#[test] +fn comment_anchor_without_tab_id_uses_enclosing_tab() { + let mut input = legacy(); + input["comments"] = json!([{"commentId": "c2", "anchorId": "a2"}]); + input["tabs"] = json!([ + {"tabProperties": {"tabId": "tab-1"}, "documentTab": {"body": {"content": []}}}, + {"tabProperties": {"tabId": "tab-2"}, "documentTab": { + "body": {"content": [{ + "startIndex": 1, "endIndex": 7, + "paragraph": {"elements": [{ + "startIndex": 1, "endIndex": 7, + "textRun": {"content": "second"} + }]} + }]}, + "commentAnchors": {"a2": {"ranges": [{"startIndex": 1, "endIndex": 7}]}} + }} + ]); + + let output = read::normalize_with_comments(&input).unwrap(); + assert_eq!(output["comments"][0]["referencedText"], json!(["second"])); +} + #[test] fn request_rejects_partial_masks_lossy_views_and_parameter_bypasses() { for params in [ From 33ebe36c0471cea4055b9ac925a32584ca2e6c49 Mon Sep 17 00:00:00 2001 From: ratovarius Date: Fri, 18 Sep 2026 19:17:59 -0300 Subject: [PATCH 16/16] fix(release): close PR 22 review findings --- .changeset/release-review-fixes.md | 5 +++ crates/google-workspace-cli/src/auth.rs | 39 ++++++++++++++++++- .../src/helpers/docs/read.rs | 4 +- 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 .changeset/release-review-fixes.md diff --git a/.changeset/release-review-fixes.md b/.changeset/release-review-fixes.md new file mode 100644 index 000000000..8433d1de8 --- /dev/null +++ b/.changeset/release-review-fixes.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Harden credential fallback and preserve document tab context when resolving comment anchors. \ No newline at end of file diff --git a/crates/google-workspace-cli/src/auth.rs b/crates/google-workspace-cli/src/auth.rs index 6182dc46f..20f87c091 100644 --- a/crates/google-workspace-cli/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -352,6 +352,23 @@ async fn load_credentials_with_loader( enc_path: &std::path::Path, default_path: &std::path::Path, load_encrypted: impl FnOnce(&std::path::Path) -> anyhow::Result, +) -> anyhow::Result { + load_credentials_with_loaders( + env_file, + enc_path, + default_path, + |path| std::fs::symlink_metadata(path), + load_encrypted, + ) + .await +} + +async fn load_credentials_with_loaders( + env_file: Option<&str>, + enc_path: &std::path::Path, + default_path: &std::path::Path, + metadata: impl FnOnce(&std::path::Path) -> std::io::Result, + load_encrypted: impl FnOnce(&std::path::Path) -> anyhow::Result, ) -> anyhow::Result { // 1. Explicit env var — plaintext file (User or Service Account) if let Some(path) = env_file { @@ -369,7 +386,7 @@ async fn load_credentials_with_loader( // 2. Encrypted credentials. Inspect symlink metadata so dangling symlinks // and inaccessible paths are treated as credential failures, not absence. - let encrypted_present = match std::fs::symlink_metadata(enc_path) { + let encrypted_present = match metadata(enc_path) { Ok(_) => true, Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, Err(_) => { @@ -940,6 +957,26 @@ mod tests { assert!(error.to_string().contains("saved credentials")); } + #[tokio::test] + async fn test_load_credentials_metadata_failure_blocks_fallback() { + let dir = tempfile::tempdir().unwrap(); + let enc_path = dir.path().join("credentials.enc"); + let fallback_path = dir.path().join("credentials.json"); + let error = load_credentials_with_loaders( + None, + &enc_path, + &fallback_path, + |_| Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied)), + |_| Ok(String::new()), + ) + .await + .expect_err("credential metadata failures must block fallback"); + assert!(error + .to_string() + .contains("Failed to inspect saved credentials")); + assert!(!error.to_string().contains("No credentials found")); + } + #[tokio::test] #[serial_test::serial] async fn test_load_credentials_keyring_failure_preserves_files_and_blocks_adc() { diff --git a/crates/google-workspace-cli/src/helpers/docs/read.rs b/crates/google-workspace-cli/src/helpers/docs/read.rs index b70824614..8b7e97523 100644 --- a/crates/google-workspace-cli/src/helpers/docs/read.rs +++ b/crates/google-workspace-cli/src/helpers/docs/read.rs @@ -318,7 +318,9 @@ fn collect_comment_anchors_recursive( match value { Value::Object(object) => { let tab_id = object - .get("tabId") + .get("tabProperties") + .and_then(|properties| properties.get("tabId")) + .or_else(|| object.get("tabId")) .and_then(Value::as_str) .map(String::from) .or(current_tab_id);