Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/drive-download-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@googleworkspace/cli": minor
---

Add `drive +download` helper for downloading Drive files to a local path

The new `+download` command is a multi-step helper that:
1. Fetches file metadata (name, MIME type) to determine how to download
2. For Google Workspace native files (Docs, Sheets, Slides) uses `files.export`
with the caller-supplied `--mime-type` (e.g. `application/pdf`, `text/csv`)
3. For all other files uses `files.get?alt=media`
4. Writes the response bytes to a local path validated against path traversal

This complements the existing `+upload` helper and follows all helper
guidelines: it performs multi-step orchestration that the raw Discovery
API cannot express as a single call.

```
gws drive +download --file FILE_ID
gws drive +download --file FILE_ID --output report.pdf
gws drive +download --file FILE_ID --mime-type application/pdf
```
266 changes: 261 additions & 5 deletions crates/google-workspace-cli/src/helpers/drive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,47 @@ TIPS:
Filename is inferred from the local path unless --name is given.",
),
);
cmd = cmd.subcommand(
Command::new("+download")
.about("[Helper] Download a Drive file to a local path")
.arg(
Arg::new("file")
.long("file")
.help("Drive file ID")
.required(true)
.value_name("ID"),
)
.arg(
Arg::new("output")
.long("output")
.help("Output file path (defaults to the file's name in Drive)")
.value_name("PATH"),
)
.arg(
Arg::new("mime-type")
.long("mime-type")
.help(
"Export MIME type for Google Workspace native files \
(e.g. application/pdf, text/csv, \
application/vnd.openxmlformats-officedocument.wordprocessingml.document). \
Required for Docs/Sheets/Slides; ignored for binary files.",
)
.value_name("TYPE"),
)
.after_help(
"\
EXAMPLES:
gws drive +download --file FILE_ID
gws drive +download --file FILE_ID --output report.pdf
gws drive +download --file FILE_ID --mime-type application/pdf
gws drive +download --file FILE_ID --mime-type text/csv --output data.csv

TIPS:
For Google Docs/Sheets/Slides, provide --mime-type to choose the export format.
For binary files (PDFs, images, etc.), --mime-type is not needed.
Output path must be relative to the current directory.",
),
);
cmd
}

Expand Down Expand Up @@ -91,7 +132,7 @@ TIPS:
})?;

// Build metadata
let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str()));
let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str()))?;

let body_str = metadata.to_string();

Expand Down Expand Up @@ -125,11 +166,198 @@ TIPS:

return Ok(true);
}

if let Some(matches) = matches.subcommand_matches("+download") {
handle_download(matches).await?;
return Ok(true);
}

Ok(false)
})
}
}

async fn handle_download(matches: &ArgMatches) -> Result<(), GwsError> {
use futures_util::StreamExt;
use tokio::io::AsyncWriteExt;

let file_id =
crate::validate::validate_resource_name(matches.get_one::<String>("file").unwrap())?;
let output_arg = matches.get_one::<String>("output");
let export_mime = matches.get_one::<String>("mime-type");
let dry_run = matches.get_flag("dry-run");

// Validate export mime-type for dangerous characters if provided
if let Some(mime) = export_mime {
crate::validate::reject_dangerous_chars(mime, "--mime-type")?;
}

let scope = "https://www.googleapis.com/auth/drive.readonly";
let token = auth::get_token(&[scope])
.await
.map_err(|e| GwsError::Auth(format!("Drive auth failed: {e}")))?;

let client = crate::client::build_client()?;

// 1. Fetch file metadata to get name and MIME type
let metadata_url = format!(
"https://www.googleapis.com/drive/v3/files/{}",
crate::validate::encode_path_segment(file_id),
);
let meta_resp = crate::client::send_with_retry(|| {
client
.get(&metadata_url)
.query(&[("fields", "name,mimeType")])
.bearer_auth(&token)
})
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch file metadata: {e}")))?;
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated

if !meta_resp.status().is_success() {
let status = meta_resp.status().as_u16();
let body = meta_resp.text().await.unwrap_or_default();
Comment thread
nuthalapativarun marked this conversation as resolved.
return Err(GwsError::Api {
code: status.into(),
message: crate::output::sanitize_for_terminal(&body),
reason: "files_get_metadata_failed".to_string(),
enable_url: None,
});
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
}

let meta: Value = meta_resp
.json()
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse file metadata: {e}")))?;

let drive_name = meta
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("download");
let mime_type = meta
.get("mimeType")
.and_then(|v| v.as_str())
.unwrap_or("");

// 2. Resolve and validate output path
let out_str = output_arg.map(|s| s.as_str()).unwrap_or(drive_name);
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
let out_path = crate::validate::validate_safe_file_path(out_str, "--output")?;

Comment thread
nuthalapativarun marked this conversation as resolved.
// 3. Dry-run: print what would be done and exit without network or disk I/O
if dry_run {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"dryRun": true,
"fileId": file_id,
"driveName": drive_name,
"mimeType": mime_type,
"output": out_path.display().to_string(),
"exportMimeType": export_mime,
}))
.unwrap_or_default()
);
return Ok(());
}

// 4. Fetch file content and stream it to disk — native Google Workspace
// files require export; everything else uses alt=media.
let is_google_native = mime_type.starts_with("application/vnd.google-apps.");
let resp = if is_google_native {
let mime = export_mime.ok_or_else(|| {
GwsError::Validation(format!(
"The file is a Google Workspace native file ({mime_type}). \
Provide --mime-type to choose an export format, e.g. \
--mime-type application/pdf or --mime-type text/csv"
))
})?;
let export_url = format!(
"https://www.googleapis.com/drive/v3/files/{}/export",
crate::validate::encode_path_segment(file_id),
);
let r = crate::client::send_with_retry(|| {
client
.get(&export_url)
.query(&[("mimeType", mime.as_str())])
.bearer_auth(&token)
})
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Drive export request failed: {e}")))?;

if !r.status().is_success() {
let status = r.status().as_u16();
let body = r.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.into(),
message: crate::output::sanitize_for_terminal(&body),
reason: "files_export_failed".to_string(),
enable_url: None,
});
}
r
} else {
let r = crate::client::send_with_retry(|| {
client
.get(&metadata_url)
.query(&[("alt", "media")])
.bearer_auth(&token)
})
.await
.map_err(|e| GwsError::Other(anyhow::anyhow!("Drive download request failed: {e}")))?;

if !r.status().is_success() {
let status = r.status().as_u16();
let body = r.text().await.unwrap_or_default();
return Err(GwsError::Api {
code: status.into(),
message: crate::output::sanitize_for_terminal(&body),
reason: "files_get_media_failed".to_string(),
enable_url: None,
});
}
r
};

// 5. Stream response body to file to avoid OOM on large downloads
let mut file = tokio::fs::File::create(&out_path).await.map_err(|e| {
GwsError::Other(anyhow::anyhow!(
"Failed to create '{}': {e}",
out_path.display()
))
})?;
let mut byte_count = 0usize;
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|e| GwsError::Other(anyhow::anyhow!("Download stream error: {e}")))?;
byte_count += chunk.len();
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
file.write_all(&chunk).await.map_err(|e| {
GwsError::Other(anyhow::anyhow!(
"Failed to write to '{}': {e}",
out_path.display()
))
})?;
}
file.flush().await.map_err(|e| {
GwsError::Other(anyhow::anyhow!(
"Failed to flush '{}': {e}",
out_path.display()
))
})?;
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated

// 6. Print result as JSON (consistent with other helper output)
println!(
"{}",
serde_json::to_string_pretty(&json!({
"file": out_path.display().to_string(),
"bytes": byte_count,
"mimeType": mime_type,
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
Comment thread
nuthalapativarun marked this conversation as resolved.
Outdated
}))
Comment thread
nuthalapativarun marked this conversation as resolved.
.unwrap_or_default()
);

Ok(())
}

fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result<String, GwsError> {
if let Some(n) = name_arg {
Ok(n.to_string())
Expand All @@ -142,16 +370,17 @@ fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result<String,
}
}

fn build_metadata(filename: &str, parent_id: Option<&str>) -> Value {
fn build_metadata(filename: &str, parent_id: Option<&str>) -> Result<Value, GwsError> {
let mut metadata = json!({
"name": filename
});

if let Some(parent) = parent_id {
crate::validate::validate_resource_name(parent)?;
metadata["parents"] = json!([parent]);
}

metadata
Ok(metadata)
}

#[cfg(test)]
Expand Down Expand Up @@ -182,15 +411,42 @@ mod tests {

#[test]
fn test_build_metadata_no_parent() {
let meta = build_metadata("file.txt", None);
let meta = build_metadata("file.txt", None).unwrap();
assert_eq!(meta["name"], "file.txt");
assert!(meta.get("parents").is_none());
}

#[test]
fn test_build_metadata_with_parent() {
let meta = build_metadata("file.txt", Some("folder123"));
let meta = build_metadata("file.txt", Some("folder123")).unwrap();
assert_eq!(meta["name"], "file.txt");
assert_eq!(meta["parents"][0], "folder123");
}

#[test]
fn test_build_metadata_rejects_traversal_parent_id() {
assert!(
build_metadata("file.txt", Some("../../.ssh/id_rsa")).is_err(),
"path traversal in --parent must be rejected"
);
}

#[test]
fn test_build_metadata_rejects_query_injection_parent_id() {
assert!(
build_metadata("file.txt", Some("folder?evil=1")).is_err(),
"'?' in --parent must be rejected"
);
}

#[test]
fn test_download_command_injected() {
let helper = DriveHelper;
let cmd = Command::new("test");
let doc = crate::discovery::RestDescription::default();
let cmd = helper.inject_commands(cmd, &doc);
let names: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect();
assert!(names.contains(&"+upload"));
assert!(names.contains(&"+download"));
}
}
Loading