Skip to content

Commit e8f62f5

Browse files
committed
feat(drive): add +download, +export, and +move helpers
Add three new Drive helper commands that provide multi-step orchestration beyond what Discovery commands offer: - +download: fetches file metadata for the real filename, guards against Google Workspace docs (suggests +export), then streams binary content to disk - +export: maps friendly format names (pdf, docx, xlsx, md, etc.) to MIME types, fetches metadata, guards against non-Workspace files (suggests +download), then streams exported content to disk - +move: fetches current parent folder(s), filters destination from removal list to avoid API conflicts, then patches with addParents/removeParents in a single update Security: - Final resolved paths are validated with validate_safe_file_path() after incorporating untrusted remote filenames from the Drive API - encode_path_segment() used for all file IDs in URL paths - MIME type guards prevent misuse in both directions Also refactors the existing +upload helper from a single drive.rs file into a drive/ module directory, matching the gmail/ and events/ module patterns.
1 parent a3768d0 commit e8f62f5

7 files changed

Lines changed: 1081 additions & 196 deletions

File tree

.changeset/drive-helpers.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@googleworkspace/cli": minor
3+
---
4+
5+
Add Drive helper commands: `+download` (download files by ID), `+export` (export Google Workspace docs to local formats), and `+move` (move files between folders)

crates/google-workspace-cli/src/helpers/drive.rs

Lines changed: 0 additions & 196 deletions
This file was deleted.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
use super::*;
16+
17+
/// Handle the `+download` subcommand.
18+
pub(super) async fn handle_download(matches: &ArgMatches) -> Result<(), GwsError> {
19+
let file_id = matches.get_one::<String>("file-id").unwrap();
20+
let output_path = matches.get_one::<String>("output");
21+
22+
let dry_run = matches.get_flag("dry-run");
23+
24+
if dry_run {
25+
let info = json!({
26+
"dry_run": true,
27+
"action": "download",
28+
"file_id": file_id,
29+
"output": output_path,
30+
});
31+
println!(
32+
"{}",
33+
serde_json::to_string_pretty(&info).unwrap_or_default()
34+
);
35+
return Ok(());
36+
}
37+
38+
let token = auth::get_token(&[DRIVE_READONLY_SCOPE])
39+
.await
40+
.map_err(|e| GwsError::Auth(format!("Drive auth failed: {e}")))?;
41+
42+
let client = crate::client::build_client()?;
43+
let encoded_id = encode_path_segment(file_id);
44+
45+
// Step 1: Fetch metadata for the real filename and MIME type
46+
let metadata = fetch_file_metadata(&client, &token, &encoded_id, "name,mimeType,size").await?;
47+
48+
let remote_name = metadata
49+
.get("name")
50+
.and_then(|v| v.as_str())
51+
.unwrap_or("download");
52+
53+
// Guard: Google Workspace files cannot be downloaded directly
54+
if let Some(mime) = metadata.get("mimeType").and_then(|v| v.as_str()) {
55+
if mime.starts_with(GOOGLE_APPS_MIME_PREFIX) {
56+
return Err(GwsError::Validation(format!(
57+
"File '{}' is a Google Workspace document ({}). \
58+
Use `gws drive +export` to export it to a local format (e.g., --format pdf).",
59+
crate::output::sanitize_for_terminal(remote_name),
60+
crate::output::sanitize_for_terminal(mime)
61+
)));
62+
}
63+
}
64+
65+
// Determine output file path and validate the final resolved path.
66+
// The remote filename is untrusted (from Drive API), so validation must
67+
// happen after path resolution, not just on the raw --output flag.
68+
let dest = resolve_output_path(output_path.map(|s| s.as_str()), remote_name)?;
69+
crate::validate::validate_safe_file_path(&dest.to_string_lossy(), "output path")?;
70+
71+
// Step 2: Download binary content
72+
let download_url = format!(
73+
"https://www.googleapis.com/drive/v3/files/{}",
74+
encoded_id,
75+
);
76+
let download_resp = crate::client::send_with_retry(|| {
77+
client
78+
.get(&download_url)
79+
.query(&[("alt", "media")])
80+
.bearer_auth(&token)
81+
})
82+
.await
83+
.map_err(|e| GwsError::Other(anyhow::anyhow!("Download request failed: {e}")))?;
84+
85+
if !download_resp.status().is_success() {
86+
let status = download_resp.status();
87+
let body = download_resp.text().await.unwrap_or_default();
88+
return Err(GwsError::Api {
89+
code: status.as_u16(),
90+
message: body,
91+
reason: "download_failed".to_string(),
92+
enable_url: None,
93+
});
94+
}
95+
96+
let total_bytes = stream_to_file(download_resp, &dest).await?;
97+
98+
let result = json!({
99+
"status": "success",
100+
"file": dest.display().to_string(),
101+
"bytes": total_bytes,
102+
"sourceFileId": file_id,
103+
});
104+
println!(
105+
"{}",
106+
serde_json::to_string_pretty(&result).unwrap_or_default()
107+
);
108+
109+
Ok(())
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use clap::{Arg, Command};
115+
116+
fn download_cmd() -> Command {
117+
Command::new("download")
118+
.arg(Arg::new("file-id").required(true).index(1))
119+
.arg(Arg::new("output").long("output").short('o').value_name("PATH"))
120+
}
121+
122+
#[test]
123+
fn test_download_command_requires_file_id() {
124+
assert!(download_cmd().try_get_matches_from(["download"]).is_err());
125+
}
126+
127+
#[test]
128+
fn test_download_command_parses_args() {
129+
let m = download_cmd()
130+
.try_get_matches_from(["download", "abc123", "--output", "out.pdf"])
131+
.unwrap();
132+
assert_eq!(m.get_one::<String>("file-id").unwrap(), "abc123");
133+
assert_eq!(m.get_one::<String>("output").unwrap(), "out.pdf");
134+
}
135+
}

0 commit comments

Comments
 (0)