diff --git a/.changeset/drive-helpers.md b/.changeset/drive-helpers.md new file mode 100644 index 00000000..6224c073 --- /dev/null +++ b/.changeset/drive-helpers.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add Drive helper commands: `+download` (download files by ID), `+export` (export Google Workspace docs to local formats), and `+move` (move files between folders) diff --git a/crates/google-workspace-cli/src/helpers/drive.rs b/crates/google-workspace-cli/src/helpers/drive.rs deleted file mode 100644 index 68662ec6..00000000 --- a/crates/google-workspace-cli/src/helpers/drive.rs +++ /dev/null @@ -1,196 +0,0 @@ -// 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 super::Helper; -use crate::auth; -use crate::error::GwsError; -use crate::executor; -use clap::{Arg, ArgMatches, Command}; -use serde_json::{json, Value}; -use std::future::Future; -use std::path::Path; -use std::pin::Pin; - -pub struct DriveHelper; - -impl Helper for DriveHelper { - fn inject_commands( - &self, - mut cmd: Command, - _doc: &crate::discovery::RestDescription, - ) -> Command { - cmd = cmd.subcommand( - Command::new("+upload") - .about("[Helper] Upload a file with automatic metadata") - .arg( - Arg::new("file") - .help("Path to file to upload") - .required(true) - .index(1), - ) - .arg( - Arg::new("parent") - .long("parent") - .help("Parent folder ID") - .value_name("ID"), - ) - .arg( - Arg::new("name") - .long("name") - .help("Target filename (defaults to source filename)") - .value_name("NAME"), - ) - .after_help( - "\ -EXAMPLES: - gws drive +upload ./report.pdf - gws drive +upload ./report.pdf --parent FOLDER_ID - gws drive +upload ./data.csv --name 'Sales Data.csv' - -TIPS: - MIME type is detected automatically. - Filename is inferred from the local path unless --name is given.", - ), - ); - cmd - } - - fn handle<'a>( - &'a self, - doc: &'a crate::discovery::RestDescription, - matches: &'a ArgMatches, - _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, - ) -> Pin> + Send + 'a>> { - Box::pin(async move { - if let Some(matches) = matches.subcommand_matches("+upload") { - let file_path = matches.get_one::("file").unwrap(); - let parent_id = matches.get_one::("parent"); - let name_arg = matches.get_one::("name"); - - // Determine filename - let filename = determine_filename(file_path, name_arg.map(|s| s.as_str()))?; - - // Find method: files.create - let files_res = doc - .resources - .get("files") - .ok_or_else(|| GwsError::Discovery("Resource 'files' not found".to_string()))?; - let create_method = files_res.methods.get("create").ok_or_else(|| { - GwsError::Discovery("Method 'files.create' not found".to_string()) - })?; - - // Build metadata - let metadata = build_metadata(&filename, parent_id.map(|s| s.as_str())); - - let body_str = metadata.to_string(); - - let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); - let (token, auth_method) = match auth::get_token(&scopes).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!("Drive auth failed: {e}"))), - }; - - executor::execute_method( - doc, - create_method, - None, - Some(&body_str), - token.as_deref(), - auth_method, - None, - Some(executor::UploadSource::File { - path: file_path, - content_type: None, - }), - matches.get_flag("dry-run"), - &executor::PaginationConfig::default(), - None, - &crate::helpers::modelarmor::SanitizeMode::Warn, - &crate::formatter::OutputFormat::default(), - false, - ) - .await?; - - return Ok(true); - } - Ok(false) - }) - } -} - -fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result { - if let Some(n) = name_arg { - Ok(n.to_string()) - } else { - Path::new(file_path) - .file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()) - .ok_or_else(|| GwsError::Validation("Invalid file path".to_string())) - } -} - -fn build_metadata(filename: &str, parent_id: Option<&str>) -> Value { - let mut metadata = json!({ - "name": filename - }); - - if let Some(parent) = parent_id { - metadata["parents"] = json!([parent]); - } - - metadata -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_determine_filename_explicit() { - assert_eq!( - determine_filename("path/to/file.txt", Some("custom.txt")).unwrap(), - "custom.txt" - ); - } - - #[test] - fn test_determine_filename_from_path() { - assert_eq!( - determine_filename("path/to/file.txt", None).unwrap(), - "file.txt" - ); - } - - #[test] - fn test_determine_filename_invalid_path() { - assert!(determine_filename("", None).is_err()); - assert!(determine_filename("/", None).is_err()); // Root has no filename component usually - } - - #[test] - fn test_build_metadata_no_parent() { - let meta = build_metadata("file.txt", None); - 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")); - assert_eq!(meta["name"], "file.txt"); - assert_eq!(meta["parents"][0], "folder123"); - } -} diff --git a/crates/google-workspace-cli/src/helpers/drive/download.rs b/crates/google-workspace-cli/src/helpers/drive/download.rs new file mode 100644 index 00000000..a6772e91 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/drive/download.rs @@ -0,0 +1,135 @@ +// 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 super::*; + +/// Handle the `+download` subcommand. +pub(super) async fn handle_download(matches: &ArgMatches) -> Result<(), GwsError> { + let file_id = matches.get_one::("file-id").unwrap(); + let output_path = matches.get_one::("output"); + + let dry_run = matches.get_flag("dry-run"); + + if dry_run { + let info = json!({ + "dry_run": true, + "action": "download", + "file_id": file_id, + "output": output_path, + }); + println!( + "{}", + serde_json::to_string_pretty(&info).unwrap_or_default() + ); + return Ok(()); + } + + let token = auth::get_token(&[DRIVE_READONLY_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Drive auth failed: {e}")))?; + + let client = crate::client::build_client()?; + let encoded_id = encode_path_segment(file_id); + + // Step 1: Fetch metadata for the real filename and MIME type + let metadata = fetch_file_metadata(&client, &token, &encoded_id, "name,mimeType,size").await?; + + let remote_name = metadata + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("download"); + + // Guard: Google Workspace files cannot be downloaded directly + if let Some(mime) = metadata.get("mimeType").and_then(|v| v.as_str()) { + if mime.starts_with(GOOGLE_APPS_MIME_PREFIX) { + return Err(GwsError::Validation(format!( + "File '{}' is a Google Workspace document ({}). \ + Use `gws drive +export` to export it to a local format (e.g., --format pdf).", + crate::output::sanitize_for_terminal(remote_name), + crate::output::sanitize_for_terminal(mime) + ))); + } + } + + // Determine output file path and validate the final resolved path. + // The remote filename is untrusted (from Drive API), so validation must + // happen after path resolution, not just on the raw --output flag. + let dest = resolve_output_path(output_path.map(|s| s.as_str()), remote_name)?; + crate::validate::validate_safe_file_path(&dest.to_string_lossy(), "output path")?; + + // Step 2: Download binary content + let download_url = format!( + "https://www.googleapis.com/drive/v3/files/{}", + encoded_id, + ); + let download_resp = crate::client::send_with_retry(|| { + client + .get(&download_url) + .query(&[("alt", "media")]) + .bearer_auth(&token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Download request failed: {e}")))?; + + if !download_resp.status().is_success() { + let status = download_resp.status(); + let body = download_resp.text().await.unwrap_or_default(); + return Err(GwsError::Api { + code: status.as_u16(), + message: body, + reason: "download_failed".to_string(), + enable_url: None, + }); + } + + let total_bytes = stream_to_file(download_resp, &dest).await?; + + let result = json!({ + "status": "success", + "file": dest.display().to_string(), + "bytes": total_bytes, + "sourceFileId": file_id, + }); + println!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_default() + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use clap::{Arg, Command}; + + fn download_cmd() -> Command { + Command::new("download") + .arg(Arg::new("file-id").required(true).index(1)) + .arg(Arg::new("output").long("output").short('o').value_name("PATH")) + } + + #[test] + fn test_download_command_requires_file_id() { + assert!(download_cmd().try_get_matches_from(["download"]).is_err()); + } + + #[test] + fn test_download_command_parses_args() { + let m = download_cmd() + .try_get_matches_from(["download", "abc123", "--output", "out.pdf"]) + .unwrap(); + assert_eq!(m.get_one::("file-id").unwrap(), "abc123"); + assert_eq!(m.get_one::("output").unwrap(), "out.pdf"); + } +} diff --git a/crates/google-workspace-cli/src/helpers/drive/export.rs b/crates/google-workspace-cli/src/helpers/drive/export.rs new file mode 100644 index 00000000..2f956030 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/drive/export.rs @@ -0,0 +1,278 @@ +// 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 super::*; +use std::path::Path; + +/// Handle the `+export` subcommand. +pub(super) async fn handle_export(matches: &ArgMatches) -> Result<(), GwsError> { + let file_id = matches.get_one::("file-id").unwrap(); + let format = matches.get_one::("format").unwrap(); + let output_path = matches.get_one::("output"); + + let dry_run = matches.get_flag("dry-run"); + + let mime_type = format_to_mime(format)?; + + if dry_run { + let info = json!({ + "dry_run": true, + "action": "export", + "file_id": file_id, + "format": format, + "mimeType": mime_type, + "output": output_path, + }); + println!( + "{}", + serde_json::to_string_pretty(&info).unwrap_or_default() + ); + return Ok(()); + } + + let token = auth::get_token(&[DRIVE_READONLY_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Drive auth failed: {e}")))?; + + let client = crate::client::build_client()?; + let encoded_id = encode_path_segment(file_id); + + // Step 1: Fetch metadata for the original filename and MIME type + let metadata = fetch_file_metadata(&client, &token, &encoded_id, "name,mimeType").await?; + + let remote_name = metadata + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("export"); + + // Guard: export only works with Google Workspace native documents + if let Some(mime) = metadata.get("mimeType").and_then(|v| v.as_str()) { + if !mime.starts_with(GOOGLE_APPS_MIME_PREFIX) { + return Err(GwsError::Validation(format!( + "File '{}' is not a Google Workspace document ({}). \ + Use `gws drive +download` to download it directly.", + crate::output::sanitize_for_terminal(remote_name), + crate::output::sanitize_for_terminal(mime) + ))); + } + } + + // Build output filename with the export format extension. + // Use file_stem from the remote name (untrusted, from Drive API) to + // construct a safe filename like "MyDoc.pdf". + let export_filename = { + let safe_name = Path::new(remote_name) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("export"); + let stem = Path::new(safe_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(safe_name); + format!("{}.{}", stem, format) + }; + + let dest = match output_path { + Some(p) if p.ends_with('/') || p.ends_with('\\') => { + // Directory output: append the export filename with extension + std::path::PathBuf::from(p).join(&export_filename) + } + Some(p) => { + // Explicit file path: use as-is + std::path::PathBuf::from(p) + } + None => { + // No output specified: use export filename in current directory + std::path::PathBuf::from(&export_filename) + } + }; + + // Validate the final resolved path -- the remote filename is untrusted + // (from Drive API) and could contain path traversal segments. + crate::validate::validate_safe_file_path(&dest.to_string_lossy(), "output path")?; + + // Step 2: Export the document + let export_url = format!( + "https://www.googleapis.com/drive/v3/files/{}/export", + encoded_id, + ); + let export_resp = crate::client::send_with_retry(|| { + client + .get(&export_url) + .query(&[("mimeType", mime_type)]) + .bearer_auth(&token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Export request failed: {e}")))?; + + if !export_resp.status().is_success() { + let status = export_resp.status(); + let body = export_resp.text().await.unwrap_or_default(); + return Err(GwsError::Api { + code: status.as_u16(), + message: body, + reason: "export_failed".to_string(), + enable_url: None, + }); + } + + let total_bytes = stream_to_file(export_resp, &dest).await?; + + let result = json!({ + "status": "success", + "file": dest.display().to_string(), + "mimeType": mime_type, + "format": format, + "bytes": total_bytes, + "sourceFileId": file_id, + }); + println!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_default() + ); + + Ok(()) +} + +/// Map a user-friendly format name to the corresponding MIME type for Drive export. +fn format_to_mime(format: &str) -> Result<&'static str, GwsError> { + match format.to_lowercase().as_str() { + // Documents + "pdf" => Ok("application/pdf"), + "docx" => Ok("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + "odt" => Ok("application/vnd.oasis.opendocument.text"), + "rtf" => Ok("application/rtf"), + "txt" | "text" => Ok("text/plain"), + "html" => Ok("text/html"), + "epub" => Ok("application/epub+zip"), + "md" | "markdown" => Ok("text/markdown"), + "zip" => Ok("application/zip"), + // Spreadsheets + "xlsx" => Ok("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + "ods" => Ok("application/vnd.oasis.opendocument.spreadsheet"), + "csv" => Ok("text/csv"), + "tsv" => Ok("text/tab-separated-values"), + // Presentations + "pptx" => Ok("application/vnd.openxmlformats-officedocument.presentationml.presentation"), + "odp" => Ok("application/vnd.oasis.opendocument.presentation"), + // Images (for Drawings) + "png" => Ok("image/png"), + "jpg" | "jpeg" => Ok("image/jpeg"), + "svg" => Ok("image/svg+xml"), + _ => Err(GwsError::Validation(format!( + "Unsupported export format: '{}'. Supported: pdf, docx, odt, rtf, txt, html, epub, md, zip, xlsx, ods, csv, tsv, pptx, odp, png, jpg, svg", + crate::output::sanitize_for_terminal(format) + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_to_mime_documents() { + assert_eq!(format_to_mime("pdf").unwrap(), "application/pdf"); + assert_eq!( + format_to_mime("docx").unwrap(), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + assert_eq!( + format_to_mime("odt").unwrap(), + "application/vnd.oasis.opendocument.text" + ); + assert_eq!(format_to_mime("rtf").unwrap(), "application/rtf"); + assert_eq!(format_to_mime("txt").unwrap(), "text/plain"); + assert_eq!(format_to_mime("text").unwrap(), "text/plain"); + assert_eq!(format_to_mime("html").unwrap(), "text/html"); + assert_eq!(format_to_mime("epub").unwrap(), "application/epub+zip"); + assert_eq!(format_to_mime("md").unwrap(), "text/markdown"); + assert_eq!(format_to_mime("markdown").unwrap(), "text/markdown"); + assert_eq!(format_to_mime("zip").unwrap(), "application/zip"); + } + + #[test] + fn test_format_to_mime_spreadsheets() { + assert_eq!( + format_to_mime("xlsx").unwrap(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + assert_eq!( + format_to_mime("ods").unwrap(), + "application/vnd.oasis.opendocument.spreadsheet" + ); + assert_eq!(format_to_mime("csv").unwrap(), "text/csv"); + assert_eq!( + format_to_mime("tsv").unwrap(), + "text/tab-separated-values" + ); + } + + #[test] + fn test_format_to_mime_presentations() { + assert_eq!( + format_to_mime("pptx").unwrap(), + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ); + assert_eq!( + format_to_mime("odp").unwrap(), + "application/vnd.oasis.opendocument.presentation" + ); + } + + #[test] + fn test_format_to_mime_images() { + assert_eq!(format_to_mime("png").unwrap(), "image/png"); + assert_eq!(format_to_mime("jpg").unwrap(), "image/jpeg"); + assert_eq!(format_to_mime("jpeg").unwrap(), "image/jpeg"); + assert_eq!(format_to_mime("svg").unwrap(), "image/svg+xml"); + } + + #[test] + fn test_format_to_mime_case_insensitive() { + assert_eq!(format_to_mime("PDF").unwrap(), "application/pdf"); + assert_eq!(format_to_mime("Docx").unwrap(), format_to_mime("docx").unwrap()); + } + + #[test] + fn test_format_to_mime_unsupported() { + assert!(format_to_mime("mp4").is_err()); + assert!(format_to_mime("").is_err()); + assert!(format_to_mime("unknown").is_err()); + } + + #[test] + fn test_export_command_requires_format() { + use clap::{Arg, Command}; + let cmd = Command::new("export") + .arg(Arg::new("file-id").required(true).index(1)) + .arg(Arg::new("format").long("format").short('f').required(true)); + assert!(cmd.try_get_matches_from(["export", "abc123"]).is_err()); + } + + #[test] + fn test_export_command_parses_args() { + use clap::{Arg, Command}; + let cmd = Command::new("export") + .arg(Arg::new("file-id").required(true).index(1)) + .arg(Arg::new("format").long("format").short('f').required(true)) + .arg(Arg::new("output").long("output").short('o')); + let m = cmd + .try_get_matches_from(["export", "abc123", "--format", "pdf", "-o", "out.pdf"]) + .unwrap(); + assert_eq!(m.get_one::("file-id").unwrap(), "abc123"); + assert_eq!(m.get_one::("format").unwrap(), "pdf"); + assert_eq!(m.get_one::("output").unwrap(), "out.pdf"); + } +} diff --git a/crates/google-workspace-cli/src/helpers/drive/mod.rs b/crates/google-workspace-cli/src/helpers/drive/mod.rs new file mode 100644 index 00000000..28175c7a --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/drive/mod.rs @@ -0,0 +1,392 @@ +// 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 super::Helper; +pub mod download; +pub mod export; +pub mod move_file; +pub mod upload; + +use download::handle_download; +use export::handle_export; +use move_file::handle_move; +use upload::handle_upload; + +pub(super) use crate::auth; +pub(super) use crate::error::GwsError; +pub(super) use crate::executor; +pub(super) use anyhow::Context; +pub(super) use clap::{Arg, ArgMatches, Command}; +pub(super) use google_workspace::validate::encode_path_segment; +pub(super) use serde_json::{json, Value}; +use std::future::Future; +use std::pin::Pin; + +pub struct DriveHelper; + +pub(super) const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive"; +pub(super) const DRIVE_READONLY_SCOPE: &str = + "https://www.googleapis.com/auth/drive.readonly"; + +/// MIME type prefix for Google Workspace native documents (Docs, Sheets, Slides, etc.). +/// Files with this prefix cannot be downloaded directly -- they must be exported. +pub(super) const GOOGLE_APPS_MIME_PREFIX: &str = "application/vnd.google-apps."; + +impl Helper for DriveHelper { + fn inject_commands( + &self, + mut cmd: Command, + _doc: &crate::discovery::RestDescription, + ) -> Command { + cmd = cmd + .subcommand( + Command::new("+upload") + .about("[Helper] Upload a file with automatic metadata") + .arg( + Arg::new("file") + .help("Path to file to upload") + .required(true) + .index(1), + ) + .arg( + Arg::new("parent") + .long("parent") + .help("Parent folder ID") + .value_name("ID"), + ) + .arg( + Arg::new("name") + .long("name") + .help("Target filename (defaults to source filename)") + .value_name("NAME"), + ) + .after_help( + "\ +EXAMPLES: + gws drive +upload ./report.pdf + gws drive +upload ./report.pdf --parent FOLDER_ID + gws drive +upload ./data.csv --name 'Sales Data.csv' + +TIPS: + MIME type is detected automatically. + Filename is inferred from the local path unless --name is given.", + ), + ) + .subcommand( + Command::new("+download") + .about("[Helper] Download a file by ID") + .arg( + Arg::new("file-id") + .help("The Drive file ID to download") + .required(true) + .index(1), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .help("Output file path (defaults to original filename in current directory)") + .value_name("PATH"), + ) + .after_help( + "\ +EXAMPLES: + gws drive +download FILE_ID + gws drive +download FILE_ID --output ./report.pdf + +TIPS: + Downloads the binary content of non-Google-Workspace files (PDFs, images, etc.). + For Google Docs/Sheets/Slides, use +export instead. + The original filename is fetched automatically from Drive metadata.", + ), + ) + .subcommand( + Command::new("+export") + .about("[Helper] Export a Google Workspace document to a local file") + .arg( + Arg::new("file-id") + .help("The Drive file ID to export") + .required(true) + .index(1), + ) + .arg( + Arg::new("format") + .long("format") + .short('f') + .help("Export format (e.g., pdf, docx, xlsx, pptx, csv, tsv, txt, html, md, odt, ods, odp, rtf, epub, zip)") + .required(true) + .value_name("FORMAT"), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .help("Output file path (defaults to original filename with new extension)") + .value_name("PATH"), + ) + .after_help( + "\ +EXAMPLES: + gws drive +export FILE_ID --format pdf + gws drive +export FILE_ID --format docx --output ./report.docx + gws drive +export FILE_ID -f xlsx -o ./data.xlsx + +SUPPORTED FORMATS: + Documents: pdf, docx, odt, rtf, txt, html, epub, md, zip + Spreadsheets: xlsx, ods, csv, tsv, pdf, zip + Presentations: pptx, odp, pdf + Drawings: png, jpg, svg, pdf + +TIPS: + Only works with Google Workspace files (Docs, Sheets, Slides). + For regular files (PDFs, images), use +download instead.", + ), + ) + .subcommand( + Command::new("+move") + .about("[Helper] Move a file to a different folder") + .arg( + Arg::new("file-id") + .help("The Drive file ID to move") + .required(true) + .index(1), + ) + .arg( + Arg::new("to") + .long("to") + .help("Destination folder ID") + .required(true) + .value_name("FOLDER_ID"), + ) + .after_help( + "\ +EXAMPLES: + gws drive +move FILE_ID --to FOLDER_ID + +TIPS: + Moves the file from its current parent folder(s) to the destination folder. + The file's current parent(s) are determined automatically.", + ), + ); + cmd + } + + fn handle<'a>( + &'a self, + doc: &'a crate::discovery::RestDescription, + matches: &'a ArgMatches, + _sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if let Some(m) = matches.subcommand_matches("+upload") { + handle_upload(doc, m).await?; + return Ok(true); + } + if let Some(m) = matches.subcommand_matches("+download") { + handle_download(m).await?; + return Ok(true); + } + if let Some(m) = matches.subcommand_matches("+export") { + handle_export(m).await?; + return Ok(true); + } + if let Some(m) = matches.subcommand_matches("+move") { + handle_move(m).await?; + return Ok(true); + } + Ok(false) + }) + } +} + +// --------------------------------------------------------------------------- +// Shared utilities +// --------------------------------------------------------------------------- + +/// Fetch file metadata from the Drive API. +/// +/// The `fields` parameter controls which metadata fields are returned +/// (e.g., `"name,mimeType,size"` or `"id,name,parents"`). +pub(super) async fn fetch_file_metadata( + client: &reqwest::Client, + token: &str, + encoded_id: &str, + fields: &str, +) -> Result { + let meta_url = format!( + "https://www.googleapis.com/drive/v3/files/{}", + encoded_id, + ); + let meta_resp = crate::client::send_with_retry(|| { + client + .get(&meta_url) + .query(&[("fields", fields)]) + .bearer_auth(token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch file metadata: {e}")))?; + + if !meta_resp.status().is_success() { + let status = meta_resp.status(); + let body = meta_resp.text().await.unwrap_or_default(); + return Err(GwsError::Api { + code: status.as_u16(), + message: body, + reason: "metadata_fetch_failed".to_string(), + enable_url: None, + }); + } + + meta_resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse metadata: {e}"))) +} + +/// Stream a response body to a file, returning the total bytes written. +pub(super) async fn stream_to_file( + response: reqwest::Response, + path: &std::path::Path, +) -> Result { + use futures_util::StreamExt; + use tokio::io::AsyncWriteExt; + + let mut file = tokio::fs::File::create(path) + .await + .context("Failed to create output file")?; + + let mut stream = response.bytes_stream(); + let mut total_bytes: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + file.write_all(&chunk) + .await + .context("Failed to write to file")?; + total_bytes += chunk.len() as u64; + } + + file.flush().await.context("Failed to flush file")?; + Ok(total_bytes) +} + +/// Resolve the output file path. +/// +/// If `output` is provided, it is used as-is (or with the default name +/// appended if it ends with a path separator). Otherwise the `default_name` +/// (typically the remote filename) is used relative to the current directory. +/// +/// The `default_name` is treated as untrusted (it comes from the Drive API), +/// so only the final path component (`.file_name()`) is used to prevent +/// directory traversal via crafted filenames like `../../evil` or `/etc/passwd`. +pub(super) fn resolve_output_path( + output: Option<&str>, + default_name: &str, +) -> Result { + // Extract only the filename component from the untrusted remote name + // to prevent path traversal (e.g., "../../.ssh/keys" -> ".ssh/keys" is + // still dangerous, but file_name() yields just "keys"). + let safe_name = std::path::Path::new(default_name) + .file_name() + .unwrap_or(std::ffi::OsStr::new("download")); + + match output { + Some(p) => { + let path = std::path::PathBuf::from(p); + // If the output path looks like a directory (ends with separator), append the safe name + if p.ends_with('/') || p.ends_with('\\') { + Ok(path.join(safe_name)) + } else { + Ok(path) + } + } + None => Ok(std::path::PathBuf::from(safe_name)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_output_path_none() { + let path = resolve_output_path(None, "report.pdf").unwrap(); + assert_eq!(path, std::path::PathBuf::from("report.pdf")); + } + + #[test] + fn test_resolve_output_path_explicit() { + let path = resolve_output_path(Some("my-file.pdf"), "report.pdf").unwrap(); + assert_eq!(path, std::path::PathBuf::from("my-file.pdf")); + } + + #[test] + fn test_resolve_output_path_dir_trailing_slash() { + let path = resolve_output_path(Some("output/"), "report.pdf").unwrap(); + assert_eq!(path, std::path::PathBuf::from("output/report.pdf")); + } + + #[test] + fn test_resolve_output_path_strips_traversal_from_remote_name() { + // Malicious remote filename with path traversal + let path = resolve_output_path(None, "../../.ssh/authorized_keys").unwrap(); + assert_eq!(path, std::path::PathBuf::from("authorized_keys")); + } + + #[test] + fn test_resolve_output_path_strips_absolute_remote_name() { + let path = resolve_output_path(None, "/etc/passwd").unwrap(); + assert_eq!(path, std::path::PathBuf::from("passwd")); + } + + #[test] + fn test_resolve_output_path_dir_with_traversal_remote_name() { + let path = resolve_output_path(Some("output/"), "../../evil.txt").unwrap(); + assert_eq!(path, std::path::PathBuf::from("output/evil.txt")); + } + + #[test] + fn test_google_apps_mime_prefix_matches_workspace_types() { + let workspace_types = [ + "application/vnd.google-apps.document", + "application/vnd.google-apps.spreadsheet", + "application/vnd.google-apps.presentation", + "application/vnd.google-apps.drawing", + "application/vnd.google-apps.form", + ]; + for mime in workspace_types { + assert!( + mime.starts_with(GOOGLE_APPS_MIME_PREFIX), + "{mime} should match Google Apps prefix" + ); + } + } + + #[test] + fn test_google_apps_mime_prefix_rejects_regular_files() { + let regular_types = [ + "application/pdf", + "image/png", + "text/plain", + "application/octet-stream", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ]; + for mime in regular_types { + assert!( + !mime.starts_with(GOOGLE_APPS_MIME_PREFIX), + "{mime} should not match Google Apps prefix" + ); + } + } +} diff --git a/crates/google-workspace-cli/src/helpers/drive/move_file.rs b/crates/google-workspace-cli/src/helpers/drive/move_file.rs new file mode 100644 index 00000000..c51f39ce --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/drive/move_file.rs @@ -0,0 +1,136 @@ +// 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 super::*; + +/// Handle the `+move` subcommand. +pub(super) async fn handle_move(matches: &ArgMatches) -> Result<(), GwsError> { + let file_id = matches.get_one::("file-id").unwrap(); + let dest_folder = matches.get_one::("to").unwrap(); + + let dry_run = matches.get_flag("dry-run"); + + if dry_run { + let info = json!({ + "dry_run": true, + "action": "move", + "file_id": file_id, + "destination": dest_folder, + }); + println!( + "{}", + serde_json::to_string_pretty(&info).unwrap_or_default() + ); + return Ok(()); + } + + let token = auth::get_token(&[DRIVE_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Drive auth failed: {e}")))?; + + let client = crate::client::build_client()?; + let encoded_id = encode_path_segment(file_id); + + // Step 1: Get current parents + let metadata = fetch_file_metadata(&client, &token, &encoded_id, "id,name,parents").await?; + + let current_parents: Vec<&str> = metadata + .get("parents") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + // Filter out the destination folder from removal to avoid the Drive API + // error when the same ID appears in both addParents and removeParents. + let is_already_in_dest = current_parents.contains(&dest_folder.as_str()); + let remove_parents: String = current_parents + .iter() + .filter(|&&p| p != dest_folder) + .copied() + .collect::>() + .join(","); + + // Step 2: Move by updating parents + let update_url = format!( + "https://www.googleapis.com/drive/v3/files/{}", + encoded_id, + ); + + let update_resp = crate::client::send_with_retry(|| { + let mut query = vec![("fields", "id,name,parents")]; + if !is_already_in_dest { + query.push(("addParents", dest_folder.as_str())); + } + if !remove_parents.is_empty() { + query.push(("removeParents", remove_parents.as_str())); + } + + client + .patch(&update_url) + .query(&query) + .bearer_auth(&token) + .header("Content-Type", "application/json") + .body("{}") + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Move request failed: {e}")))?; + + if !update_resp.status().is_success() { + let status = update_resp.status(); + let body = update_resp.text().await.unwrap_or_default(); + return Err(GwsError::Api { + code: status.as_u16(), + message: body, + reason: "move_failed".to_string(), + enable_url: None, + }); + } + + let result: Value = update_resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse move response: {e}")))?; + + println!( + "{}", + serde_json::to_string_pretty(&result).unwrap_or_default() + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use clap::{Arg, Command}; + + fn move_cmd() -> Command { + Command::new("move") + .arg(Arg::new("file-id").required(true).index(1)) + .arg(Arg::new("to").long("to").required(true)) + } + + #[test] + fn test_move_command_requires_to() { + assert!(move_cmd().try_get_matches_from(["move", "abc123"]).is_err()); + } + + #[test] + fn test_move_command_parses_args() { + let m = move_cmd() + .try_get_matches_from(["move", "abc123", "--to", "folder456"]) + .unwrap(); + assert_eq!(m.get_one::("file-id").unwrap(), "abc123"); + assert_eq!(m.get_one::("to").unwrap(), "folder456"); + } +} diff --git a/crates/google-workspace-cli/src/helpers/drive/upload.rs b/crates/google-workspace-cli/src/helpers/drive/upload.rs new file mode 100644 index 00000000..19ccee6b --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/drive/upload.rs @@ -0,0 +1,135 @@ +// 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 super::*; +use std::path::Path; + +/// Handle the `+upload` subcommand. +pub(super) async fn handle_upload( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let file_path = matches.get_one::("file").unwrap(); + let parent_id = matches.get_one::("parent"); + let name_arg = matches.get_one::("name"); + + let filename = determine_filename(file_path, name_arg.map(|s| s.as_str()))?; + + let files_res = doc + .resources + .get("files") + .ok_or_else(|| GwsError::Discovery("Resource 'files' not found".to_string()))?; + let create_method = files_res + .methods + .get("create") + .ok_or_else(|| GwsError::Discovery("Method 'files.create' not found".to_string()))?; + + let metadata = build_upload_metadata(&filename, parent_id.map(|s| s.as_str())); + let body_str = metadata.to_string(); + + let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); + let (token, auth_method) = match auth::get_token(&scopes).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!("Drive auth failed: {e}"))), + }; + + executor::execute_method( + doc, + create_method, + None, + Some(&body_str), + token.as_deref(), + auth_method, + None, + Some(executor::UploadSource::File { + path: file_path, + content_type: None, + }), + matches.get_flag("dry-run"), + &executor::PaginationConfig::default(), + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + false, + ) + .await?; + + Ok(()) +} + +fn determine_filename(file_path: &str, name_arg: Option<&str>) -> Result { + if let Some(n) = name_arg { + Ok(n.to_string()) + } else { + Path::new(file_path) + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .ok_or_else(|| GwsError::Validation("Invalid file path".to_string())) + } +} + +fn build_upload_metadata(filename: &str, parent_id: Option<&str>) -> Value { + let mut metadata = json!({ + "name": filename + }); + + if let Some(parent) = parent_id { + metadata["parents"] = json!([parent]); + } + + metadata +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_determine_filename_explicit() { + assert_eq!( + determine_filename("path/to/file.txt", Some("custom.txt")).unwrap(), + "custom.txt" + ); + } + + #[test] + fn test_determine_filename_from_path() { + assert_eq!( + determine_filename("path/to/file.txt", None).unwrap(), + "file.txt" + ); + } + + #[test] + fn test_determine_filename_invalid_path() { + assert!(determine_filename("", None).is_err()); + assert!(determine_filename("/", None).is_err()); + } + + #[test] + fn test_build_metadata_no_parent() { + let meta = build_upload_metadata("file.txt", None); + assert_eq!(meta["name"], "file.txt"); + assert!(meta.get("parents").is_none()); + } + + #[test] + fn test_build_metadata_with_parent() { + let meta = build_upload_metadata("file.txt", Some("folder123")); + assert_eq!(meta["name"], "file.txt"); + assert_eq!(meta["parents"][0], "folder123"); + } +}