diff --git a/src/attachments.rs b/src/attachments.rs deleted file mode 100644 index becd1f7..0000000 --- a/src/attachments.rs +++ /dev/null @@ -1,1534 +0,0 @@ -use crate::config::ConfigReport; -use crate::store; -use crate::time::current_epoch_seconds; -use crate::workspace::WorkspacePaths; -use crate::{configured_paths, gmail_client_for_config}; -use anyhow::Result; -use sanitize_filename::sanitize; -use serde::Serialize; -use std::fs; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::{Component, Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; -use thiserror::Error; -use tokio::task::spawn_blocking; - -pub const DEFAULT_ATTACHMENT_LIST_LIMIT: usize = 50; -const TEMP_PATH_RETRY_LIMIT: usize = 8; - -#[derive(Debug, Clone)] -pub struct AttachmentListRequest { - pub thread_id: Option, - pub message_id: Option, - pub filename: Option, - pub mime_type: Option, - pub fetched_only: bool, - pub limit: usize, -} - -#[derive(Debug, Error)] -pub enum AttachmentServiceError { - #[error("no active Gmail account found; run `mailroom auth login` first")] - NoActiveAccount, - #[error("attachment `{attachment_key}` was not found in the local mailbox catalog")] - AttachmentNotFound { attachment_key: String }, - #[error("attachment list limit must be greater than zero")] - InvalidLimit, - #[error("attachment vault path `{relative_path}` is invalid")] - InvalidVaultPath { relative_path: String }, - #[error("export destination already exists with different content: {path}")] - DestinationConflict { path: PathBuf }, - #[error("failed to join blocking attachment task: {source}")] - BlockingTask { - #[source] - source: tokio::task::JoinError, - }, - #[error("failed to create directory {path}: {source}")] - CreateDirectory { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to write file {path}: {source}")] - WriteFile { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to read file {path}: {source}")] - ReadFile { - path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to copy file from {source_path} to {destination_path}: {source}")] - CopyFile { - source_path: PathBuf, - destination_path: PathBuf, - #[source] - source: std::io::Error, - }, - #[error("failed to persist attachment store state: {source}")] - StoreWrite { - #[source] - source: anyhow::Error, - }, - #[error("failed to read attachment state from local mailbox store: {source}")] - StoreRead { - #[source] - source: store::mailbox::MailboxReadError, - }, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AttachmentListReport { - pub account_id: String, - pub thread_id: Option, - pub message_id: Option, - pub filename: Option, - pub mime_type: Option, - pub fetched_only: bool, - pub limit: usize, - pub items: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AttachmentShowReport { - pub account_id: String, - pub attachment: store::mailbox::AttachmentDetailRecord, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AttachmentFetchReport { - pub account_id: String, - pub attachment_key: String, - pub message_id: String, - pub thread_id: String, - pub filename: String, - pub mime_type: String, - pub size_bytes: i64, - pub content_hash: String, - pub vault_relative_path: String, - pub vault_path: PathBuf, - pub downloaded: bool, - pub fetched_at_epoch_s: i64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AttachmentExportReport { - pub account_id: String, - pub attachment_key: String, - pub message_id: String, - pub thread_id: String, - pub filename: String, - pub content_hash: String, - pub source_vault_path: PathBuf, - pub destination_path: PathBuf, - pub copied: bool, - pub exported_at_epoch_s: i64, -} - -impl AttachmentListReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("account_id={}", self.account_id); - println!("items={}", self.items.len()); - for item in &self.items { - println!( - "{}\t{}\t{}\tfetched={}\texports={}", - item.attachment_key, - item.filename, - item.mime_type, - item.vault_relative_path.is_some(), - item.export_count, - ); - } - } - Ok(()) - } -} - -impl AttachmentShowReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - let attachment = &self.attachment; - println!("account_id={}", self.account_id); - println!("attachment_key={}", attachment.attachment_key); - println!("message_id={}", attachment.message_id); - println!("thread_id={}", attachment.thread_id); - println!("filename={}", attachment.filename); - println!("mime_type={}", attachment.mime_type); - println!("size_bytes={}", attachment.size_bytes); - println!("fetched={}", attachment.vault_relative_path.is_some()); - println!("export_count={}", attachment.export_count); - match &attachment.vault_relative_path { - Some(path) => println!("vault_relative_path={path}"), - None => println!("vault_relative_path="), - } - } - Ok(()) - } -} - -impl AttachmentFetchReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("account_id={}", self.account_id); - println!("attachment_key={}", self.attachment_key); - println!("message_id={}", self.message_id); - println!("thread_id={}", self.thread_id); - println!("filename={}", self.filename); - println!("mime_type={}", self.mime_type); - println!("size_bytes={}", self.size_bytes); - println!("content_hash={}", self.content_hash); - println!("downloaded={}", self.downloaded); - println!("vault_relative_path={}", self.vault_relative_path); - println!("vault_path={}", self.vault_path.display()); - println!("fetched_at_epoch_s={}", self.fetched_at_epoch_s); - } - Ok(()) - } -} - -impl AttachmentExportReport { - pub fn print(&self, json: bool) -> Result<()> { - if json { - crate::cli_output::print_json_success(self)?; - } else { - println!("account_id={}", self.account_id); - println!("attachment_key={}", self.attachment_key); - println!("filename={}", self.filename); - println!("content_hash={}", self.content_hash); - println!("copied={}", self.copied); - println!("source_vault_path={}", self.source_vault_path.display()); - println!("destination_path={}", self.destination_path.display()); - println!("exported_at_epoch_s={}", self.exported_at_epoch_s); - } - Ok(()) - } -} - -pub async fn list( - config_report: &ConfigReport, - request: AttachmentListRequest, -) -> Result { - if request.limit == 0 { - return Err(AttachmentServiceError::InvalidLimit.into()); - } - - init_store_task(config_report).await?; - let account_id = resolve_attachment_account_id_task(config_report).await?; - let database_path = config_report.config.store.database_path.clone(); - let busy_timeout_ms = config_report.config.store.busy_timeout_ms; - let query = store::mailbox::AttachmentListQuery { - account_id: account_id.clone(), - thread_id: request.thread_id.clone(), - message_id: request.message_id.clone(), - filename: request.filename.clone(), - mime_type: request.mime_type.clone(), - fetched_only: request.fetched_only, - limit: request.limit, - }; - let items = spawn_blocking(move || { - store::mailbox::list_attachments(&database_path, busy_timeout_ms, &query) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })? - .map_err(|source| AttachmentServiceError::StoreRead { source })?; - - Ok(AttachmentListReport { - account_id, - thread_id: request.thread_id, - message_id: request.message_id, - filename: request.filename, - mime_type: request.mime_type, - fetched_only: request.fetched_only, - limit: request.limit, - items, - }) -} - -pub async fn show( - config_report: &ConfigReport, - attachment_key: String, -) -> Result { - init_store_task(config_report).await?; - let account_id = resolve_attachment_account_id_task(config_report).await?; - let detail = load_attachment_detail(config_report, &account_id, &attachment_key).await?; - - Ok(AttachmentShowReport { - account_id, - attachment: detail, - }) -} - -pub async fn fetch( - config_report: &ConfigReport, - attachment_key: String, -) -> Result { - init_store_task(config_report).await?; - let account_id = resolve_attachment_account_id_task(config_report).await?; - let workspace_paths = configured_paths(config_report)?; - ensure_runtime_dirs_task(workspace_paths.clone()).await?; - let detail = load_attachment_detail(config_report, &account_id, &attachment_key).await?; - - let existing_report = spawn_blocking({ - let workspace_paths = workspace_paths.clone(); - let account_id = account_id.clone(); - let detail = detail.clone(); - move || existing_vault_report(&workspace_paths, &account_id, &detail) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })??; - if let Some(existing_report) = existing_report { - return Ok(existing_report); - } - - let gmail_client = gmail_client_for_config(config_report)?; - let bytes = gmail_client - .get_attachment_bytes( - &detail.message_id, - &detail.part_id, - detail.gmail_attachment_id.as_deref(), - ) - .await?; - let fetched_at_epoch_s = current_epoch_seconds()?; - let vault_write = spawn_blocking({ - let vault_dir = workspace_paths.vault_dir.clone(); - move || write_vault_bytes(&vault_dir, bytes) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })??; - - let database_path = config_report.config.store.database_path.clone(); - let busy_timeout_ms = config_report.config.store.busy_timeout_ms; - let update = store::mailbox::AttachmentVaultStateUpdate { - account_id: account_id.clone(), - attachment_key: detail.attachment_key.clone(), - content_hash: vault_write.content_hash.clone(), - relative_path: vault_write.relative_path.clone(), - size_bytes: vault_write.size_bytes, - fetched_at_epoch_s, - }; - let update_result = spawn_blocking(move || { - store::mailbox::set_attachment_vault_state(&database_path, busy_timeout_ms, &update) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })?; - if let Err(error) = update_result { - return Err(map_mailbox_write_error(error).into()); - } - - Ok(AttachmentFetchReport { - account_id, - attachment_key: detail.attachment_key, - message_id: detail.message_id, - thread_id: detail.thread_id, - filename: detail.filename, - mime_type: detail.mime_type, - size_bytes: detail.size_bytes, - content_hash: vault_write.content_hash, - vault_relative_path: vault_write.relative_path, - vault_path: vault_write.path, - downloaded: true, - fetched_at_epoch_s, - }) -} - -pub async fn export( - config_report: &ConfigReport, - attachment_key: String, - destination: Option, -) -> Result { - let fetched = fetch(config_report, attachment_key).await?; - let workspace_paths = configured_paths(config_report)?; - let filename = export_filename(&fetched.filename, &fetched.attachment_key); - let destination_path = spawn_blocking({ - let workspace_paths = workspace_paths.clone(); - let thread_id = fetched.thread_id.clone(); - let message_id = fetched.message_id.clone(); - let attachment_key = fetched.attachment_key.clone(); - let filename = filename.clone(); - move || { - resolve_export_destination_path( - &workspace_paths, - &thread_id, - &message_id, - &attachment_key, - &filename, - destination, - ) - } - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })??; - let exported_at_epoch_s = current_epoch_seconds()?; - let copy_result = spawn_blocking({ - let source_path = fetched.vault_path.clone(); - let destination_path = destination_path.clone(); - let content_hash = fetched.content_hash.clone(); - move || copy_from_vault(&source_path, &destination_path, &content_hash) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })??; - - let database_path = config_report.config.store.database_path.clone(); - let busy_timeout_ms = config_report.config.store.busy_timeout_ms; - let event = store::mailbox::AttachmentExportEventInput { - account_id: fetched.account_id.clone(), - attachment_key: fetched.attachment_key.clone(), - message_id: fetched.message_id.clone(), - thread_id: fetched.thread_id.clone(), - destination_path: destination_path.display().to_string(), - content_hash: fetched.content_hash.clone(), - exported_at_epoch_s, - }; - let record_result = spawn_blocking(move || { - store::mailbox::record_attachment_export(&database_path, busy_timeout_ms, &event) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })?; - if let Err(error) = record_result { - if copy_result.copied { - cleanup_export_file_task(destination_path.clone()).await; - } - return Err(map_mailbox_write_error(error).into()); - } - - Ok(AttachmentExportReport { - account_id: fetched.account_id, - attachment_key: fetched.attachment_key, - message_id: fetched.message_id, - thread_id: fetched.thread_id, - filename: fetched.filename, - content_hash: fetched.content_hash, - source_vault_path: fetched.vault_path, - destination_path, - copied: copy_result.copied, - exported_at_epoch_s, - }) -} - -async fn resolve_attachment_account_id_task(config_report: &ConfigReport) -> Result { - let database_path = config_report.config.store.database_path.clone(); - let busy_timeout_ms = config_report.config.store.busy_timeout_ms; - spawn_blocking(move || resolve_attachment_account_id(&database_path, busy_timeout_ms)) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })? -} - -async fn init_store_task(config_report: &ConfigReport) -> Result<()> { - let config_report = config_report.clone(); - spawn_blocking(move || store::init(&config_report)) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })? - .map(|_| ()) -} - -async fn ensure_runtime_dirs_task(workspace_paths: WorkspacePaths) -> Result<()> { - spawn_blocking(move || workspace_paths.ensure_runtime_dirs()) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })? - .map(|_| ()) -} - -fn resolve_attachment_account_id(database_path: &Path, busy_timeout_ms: u64) -> Result { - if let Some(active_account) = store::accounts::get_active(database_path, busy_timeout_ms)? { - return Ok(active_account.account_id); - } - - if let Some(mailbox) = store::mailbox::inspect_mailbox(database_path, busy_timeout_ms)? - && let Some(sync_state) = mailbox.sync_state - { - return Ok(sync_state.account_id); - } - - Err(AttachmentServiceError::NoActiveAccount.into()) -} - -async fn load_attachment_detail( - config_report: &ConfigReport, - account_id: &str, - attachment_key: &str, -) -> Result { - let database_path = config_report.config.store.database_path.clone(); - let busy_timeout_ms = config_report.config.store.busy_timeout_ms; - let account_id = account_id.to_owned(); - let attachment_key_owned = attachment_key.to_owned(); - let detail = spawn_blocking(move || { - store::mailbox::get_attachment_detail( - &database_path, - busy_timeout_ms, - &account_id, - &attachment_key_owned, - ) - }) - .await - .map_err(|source| AttachmentServiceError::BlockingTask { source })? - .map_err(|source| AttachmentServiceError::StoreRead { source })?; - - detail.ok_or_else(|| { - AttachmentServiceError::AttachmentNotFound { - attachment_key: attachment_key.to_owned(), - } - .into() - }) -} - -fn existing_vault_report( - workspace_paths: &WorkspacePaths, - account_id: &str, - detail: &store::mailbox::AttachmentDetailRecord, -) -> Result> { - let Some(relative_path) = detail.vault_relative_path.as_deref() else { - return Ok(None); - }; - let Some(content_hash) = detail.vault_content_hash.clone() else { - return Ok(None); - }; - let Some(fetched_at_epoch_s) = detail.vault_fetched_at_epoch_s else { - return Ok(None); - }; - let path = resolve_vault_relative_path(workspace_paths, relative_path)?; - if !path.exists() { - return Ok(None); - } - let metadata = fs::metadata(&path).map_err(|source| AttachmentServiceError::ReadFile { - path: path.clone(), - source, - })?; - if !metadata.is_file() { - return Ok(None); - } - if let Some(expected_size) = detail.vault_size_bytes { - let observed_size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); - if observed_size != expected_size { - return Ok(None); - } - } - if hash_file_blake3(&path)? != content_hash { - return Ok(None); - } - - Ok(Some(AttachmentFetchReport { - account_id: account_id.to_owned(), - attachment_key: detail.attachment_key.clone(), - message_id: detail.message_id.clone(), - thread_id: detail.thread_id.clone(), - filename: detail.filename.clone(), - mime_type: detail.mime_type.clone(), - size_bytes: detail.size_bytes, - content_hash, - vault_relative_path: relative_path.to_owned(), - vault_path: path, - downloaded: false, - fetched_at_epoch_s, - })) -} - -fn resolve_vault_relative_path( - workspace_paths: &WorkspacePaths, - relative_path: &str, -) -> Result { - let path = Path::new(relative_path); - if path.is_absolute() - || path.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) - { - return Err(AttachmentServiceError::InvalidVaultPath { - relative_path: relative_path.to_owned(), - } - .into()); - } - - Ok(workspace_paths.vault_dir.join(path)) -} - -fn export_filename(filename: &str, attachment_key: &str) -> String { - let trimmed = filename.trim(); - if !trimmed.is_empty() { - let sanitized = sanitize(trimmed).to_string(); - if !sanitized.trim().is_empty() { - return sanitized; - } - } - format!( - "attachment-{}.bin", - sanitize_non_empty(attachment_key, "attachment") - ) -} - -fn default_export_path( - workspace_paths: &WorkspacePaths, - thread_id: &str, - message_id: &str, - attachment_key: &str, - filename: &str, -) -> PathBuf { - workspace_paths - .exports_dir - .join(sanitize_non_empty(thread_id, "thread")) - .join(format!( - "{}--{}--{}", - sanitize_non_empty(message_id, "message"), - sanitize_non_empty(attachment_key, "attachment"), - filename - )) -} - -fn resolve_export_destination_path( - workspace_paths: &WorkspacePaths, - thread_id: &str, - message_id: &str, - attachment_key: &str, - filename: &str, - destination: Option, -) -> Result { - Ok(match destination { - Some(path) if path.is_dir() => path.join(filename), - Some(path) => path, - None => default_export_path( - workspace_paths, - thread_id, - message_id, - attachment_key, - filename, - ), - }) -} - -fn sanitize_non_empty(value: &str, fallback: &str) -> String { - let sanitized = sanitize(value).to_string(); - if sanitized.trim().is_empty() { - return fallback.to_owned(); - } - sanitized -} - -struct VaultWriteResult { - content_hash: String, - relative_path: String, - path: PathBuf, - size_bytes: i64, -} - -fn write_vault_bytes(vault_dir: &Path, bytes: Vec) -> Result { - let content_hash = blake3::hash(&bytes).to_hex().to_string(); - let relative_path = format!("blake3/{}/{}", &content_hash[..2], &content_hash); - let path = vault_dir.join(&relative_path); - let parent = path - .parent() - .ok_or_else(|| AttachmentServiceError::InvalidVaultPath { - relative_path: relative_path.clone(), - })?; - fs::create_dir_all(parent).map_err(|source| AttachmentServiceError::CreateDirectory { - path: parent.to_path_buf(), - source, - })?; - write_vault_file_atomically(&path, &bytes)?; - let size_bytes = i64::try_from(bytes.len()).unwrap_or(i64::MAX); - - Ok(VaultWriteResult { - content_hash, - relative_path, - path, - size_bytes, - }) -} - -fn write_vault_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| AttachmentServiceError::WriteFile { - path: path.to_path_buf(), - source: std::io::Error::other("vault path has no parent"), - })?; - let (mut temp_file, temp_path) = create_unique_vault_temp_file(parent, path)?; - let write_result = (|| -> Result<()> { - temp_file - .write_all(bytes) - .map_err(|source| AttachmentServiceError::WriteFile { - path: temp_path.clone(), - source, - })?; - temp_file - .sync_all() - .map_err(|source| AttachmentServiceError::WriteFile { - path: temp_path.clone(), - source, - })?; - drop(temp_file); - harden_vault_file_permissions(&temp_path)?; - persist_vault_temp_file(&temp_path, path)?; - harden_vault_file_permissions(path) - })(); - - if write_result.is_err() { - let _ = fs::remove_file(&temp_path); - } - - write_result -} - -fn create_unique_vault_temp_file(parent: &Path, path: &Path) -> Result<(fs::File, PathBuf)> { - for attempt in 0..TEMP_PATH_RETRY_LIMIT { - let temp_path = unique_vault_temp_path(parent, path, attempt)?; - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - { - Ok(file) => return Ok((file, temp_path)), - Err(source) - if source.kind() == std::io::ErrorKind::AlreadyExists - && attempt + 1 < TEMP_PATH_RETRY_LIMIT => - { - continue; - } - Err(source) => { - return Err(AttachmentServiceError::WriteFile { - path: temp_path, - source, - } - .into()); - } - } - } - - Err(AttachmentServiceError::WriteFile { - path: path.to_path_buf(), - source: std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - "failed to create a unique temporary vault file", - ), - } - .into()) -} - -fn unique_vault_temp_path(parent: &Path, path: &Path, attempt: usize) -> Result { - let file_name = path - .file_name() - .ok_or_else(|| AttachmentServiceError::WriteFile { - path: path.to_path_buf(), - source: std::io::Error::other("vault path has no filename"), - })?; - let now_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - Ok(parent.join(format!( - ".{}.tmp-{}-{now_nanos}-{attempt}", - file_name.to_string_lossy(), - std::process::id() - ))) -} - -#[cfg(windows)] -fn unique_vault_backup_path(parent: &Path, path: &Path) -> Result { - let file_name = path - .file_name() - .ok_or_else(|| AttachmentServiceError::WriteFile { - path: path.to_path_buf(), - source: std::io::Error::other("vault path has no filename"), - })?; - let now_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - Ok(parent.join(format!( - ".{}.bak-{}-{now_nanos}", - file_name.to_string_lossy(), - std::process::id() - ))) -} - -fn persist_vault_temp_file(tmp_path: &Path, destination: &Path) -> Result<()> { - #[cfg(windows)] - { - let parent = destination - .parent() - .ok_or_else(|| AttachmentServiceError::WriteFile { - path: destination.to_path_buf(), - source: std::io::Error::other("vault path has no parent"), - })?; - let backup_path = unique_vault_backup_path(parent, destination)?; - let moved_destination_to_backup = if destination.exists() { - fs::rename(destination, &backup_path).map_err(|source| { - AttachmentServiceError::WriteFile { - path: destination.to_path_buf(), - source, - } - })?; - true - } else { - false - }; - - match fs::rename(tmp_path, destination) { - Ok(()) => { - if moved_destination_to_backup { - fs::remove_file(&backup_path).map_err(|source| { - AttachmentServiceError::WriteFile { - path: backup_path, - source, - } - })?; - } - return Ok(()); - } - Err(source) => { - if moved_destination_to_backup { - let _ = fs::rename(&backup_path, destination); - } - return Err(AttachmentServiceError::WriteFile { - path: destination.to_path_buf(), - source, - } - .into()); - } - } - } - - fs::rename(tmp_path, destination).map_err(|source| AttachmentServiceError::WriteFile { - path: destination.to_path_buf(), - source, - })?; - Ok(()) -} - -#[derive(Debug)] -struct CopyFromVaultResult { - copied: bool, -} - -fn copy_from_vault( - source_path: &Path, - destination_path: &Path, - content_hash: &str, -) -> Result { - let parent = - destination_path - .parent() - .ok_or_else(|| AttachmentServiceError::CreateDirectory { - path: destination_path.to_path_buf(), - source: std::io::Error::other("destination path has no parent"), - })?; - fs::create_dir_all(parent).map_err(|source| AttachmentServiceError::CreateDirectory { - path: parent.to_path_buf(), - source, - })?; - - let mut source_file = - fs::File::open(source_path).map_err(|source| AttachmentServiceError::CopyFile { - source_path: source_path.to_path_buf(), - destination_path: destination_path.to_path_buf(), - source, - })?; - let mut destination_file = match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(destination_path) - { - Ok(file) => file, - Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => { - let existing_hash = hash_file_blake3(destination_path)?; - if existing_hash == content_hash { - return Ok(CopyFromVaultResult { copied: false }); - } - return Err(AttachmentServiceError::DestinationConflict { - path: destination_path.to_path_buf(), - } - .into()); - } - Err(source) => { - return Err(AttachmentServiceError::CopyFile { - source_path: source_path.to_path_buf(), - destination_path: destination_path.to_path_buf(), - source, - } - .into()); - } - }; - let write_result = (|| -> Result<()> { - std::io::copy(&mut source_file, &mut destination_file).map_err(|source| { - AttachmentServiceError::CopyFile { - source_path: source_path.to_path_buf(), - destination_path: destination_path.to_path_buf(), - source, - } - })?; - destination_file - .sync_all() - .map_err(|source| AttachmentServiceError::CopyFile { - source_path: source_path.to_path_buf(), - destination_path: destination_path.to_path_buf(), - source, - })?; - Ok(()) - })(); - if write_result.is_err() { - let _ = fs::remove_file(destination_path); - } - write_result?; - Ok(CopyFromVaultResult { copied: true }) -} - -async fn cleanup_export_file_task(destination_path: PathBuf) { - let cleanup_path = destination_path.clone(); - let join_result = spawn_blocking(move || fs::remove_file(&cleanup_path)).await; - match join_result { - Ok(Ok(())) => {} - Ok(Err(error)) if error.kind() == std::io::ErrorKind::NotFound => {} - Ok(Err(error)) => eprintln!( - "warning: failed to remove exported attachment after persistence failure: {} ({error})", - destination_path.display() - ), - Err(error) => eprintln!( - "warning: failed to join export cleanup task for {}: {error}", - destination_path.display() - ), - } -} - -fn hash_file_blake3(path: &Path) -> Result { - let mut file = fs::File::open(path).map_err(|source| AttachmentServiceError::ReadFile { - path: path.to_path_buf(), - source, - })?; - let mut hasher = blake3::Hasher::new(); - hasher - .update_reader(&mut file) - .map_err(|source| AttachmentServiceError::ReadFile { - path: path.to_path_buf(), - source, - })?; - Ok(hasher.finalize().to_hex().to_string()) -} - -fn map_mailbox_write_error(error: store::mailbox::MailboxWriteError) -> AttachmentServiceError { - match error { - store::mailbox::MailboxWriteError::AttachmentNotFound { attachment_key, .. } => { - AttachmentServiceError::AttachmentNotFound { attachment_key } - } - error => AttachmentServiceError::StoreWrite { - source: error.into(), - }, - } -} - -#[cfg(unix)] -fn harden_vault_file_permissions(path: &Path) -> Result<()> { - fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| { - AttachmentServiceError::WriteFile { - path: path.to_path_buf(), - source, - } - .into() - }) -} - -#[cfg(not(unix))] -fn harden_vault_file_permissions(_path: &Path) -> Result<()> { - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - AttachmentListRequest, AttachmentServiceError, copy_from_vault, default_export_path, - existing_vault_report, export, export_filename, hash_file_blake3, list, - map_mailbox_write_error, resolve_vault_relative_path, show, write_vault_bytes, - }; - use crate::config::resolve; - use crate::store::mailbox::{AttachmentDetailRecord, GmailAttachmentUpsertInput}; - use crate::store::{accounts, init}; - use crate::workspace::WorkspacePaths; - use std::fs; - use std::path::PathBuf; - use std::time::Instant; - use tempfile::TempDir; - - #[test] - fn export_filename_falls_back_when_gmail_filename_is_blank() { - assert_eq!(export_filename("", "m-1:2"), "attachment-m-12.bin"); - } - - #[test] - fn export_filename_falls_back_when_sanitized_filename_is_empty() { - assert_eq!(export_filename("///", "m-1:2"), "attachment-m-12.bin"); - } - - #[test] - fn default_export_path_uses_thread_and_message_partitions() { - let paths = WorkspacePaths::from_repo_root(PathBuf::from("/tmp/mailroom")); - let path = default_export_path(&paths, "thread-1", "message-1", "m-1:1.2", "note.pdf"); - - assert_eq!( - path, - PathBuf::from("/tmp/mailroom/.mailroom/exports/thread-1/message-1--m-11.2--note.pdf") - ); - } - - #[test] - fn default_export_path_falls_back_when_partition_ids_sanitize_to_empty() { - let paths = WorkspacePaths::from_repo_root(PathBuf::from("/tmp/mailroom")); - let path = default_export_path(&paths, "///", "\\\\", "///", "note.pdf"); - - assert_eq!( - path, - PathBuf::from("/tmp/mailroom/.mailroom/exports/thread/message--attachment--note.pdf") - ); - } - - #[test] - fn default_export_path_is_unique_per_attachment_key() { - let paths = WorkspacePaths::from_repo_root(PathBuf::from("/tmp/mailroom")); - let first = default_export_path(&paths, "thread-1", "message-1", "m-1:1.1", "note.pdf"); - let second = default_export_path(&paths, "thread-1", "message-1", "m-1:1.2", "note.pdf"); - - assert_ne!(first, second); - } - - #[test] - fn resolve_vault_relative_path_rejects_parent_traversal() { - let paths = WorkspacePaths::from_repo_root(PathBuf::from("/tmp/mailroom")); - let error = resolve_vault_relative_path(&paths, "../escape.bin").unwrap_err(); - - assert!(error.to_string().contains("invalid")); - } - - #[test] - fn existing_vault_report_requires_hash_match_before_reuse() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - - let relative_path = "blake3/ab/abc123"; - let vault_path = paths.vault_dir.join(relative_path); - fs::create_dir_all(vault_path.parent().unwrap()).unwrap(); - fs::write(&vault_path, b"hello").unwrap(); - - let report = existing_vault_report( - &paths, - "gmail:operator@example.com", - &detail_with_vault(relative_path, "invalid-hash", 5), - ) - .unwrap(); - - assert!(report.is_none()); - } - - #[test] - fn existing_vault_report_reuses_matching_vault_file() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - - let bytes = b"hello"; - let content_hash = blake3::hash(bytes).to_hex().to_string(); - let relative_path = format!("blake3/{}/{}", &content_hash[..2], content_hash); - let vault_path = paths.vault_dir.join(&relative_path); - fs::create_dir_all(vault_path.parent().unwrap()).unwrap(); - fs::write(&vault_path, bytes).unwrap(); - - let report = existing_vault_report( - &paths, - "gmail:operator@example.com", - &detail_with_vault(&relative_path, &content_hash, 5), - ) - .unwrap(); - - assert!(report.is_some()); - assert!(!report.unwrap().downloaded); - } - - #[test] - fn write_vault_bytes_rewrites_existing_hash_path_blob() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - - let expected_bytes = b"hello".to_vec(); - let content_hash = blake3::hash(&expected_bytes).to_hex().to_string(); - let relative_path = format!("blake3/{}/{}", &content_hash[..2], content_hash); - let vault_path = paths.vault_dir.join(&relative_path); - fs::create_dir_all(vault_path.parent().unwrap()).unwrap(); - fs::write(&vault_path, b"corrupt").unwrap(); - - let write = write_vault_bytes(&paths.vault_dir, expected_bytes.clone()).unwrap(); - - assert_eq!(write.path, vault_path); - assert_eq!(fs::read(&vault_path).unwrap(), expected_bytes); - assert_eq!(hash_file_blake3(&vault_path).unwrap(), write.content_hash); - assert_eq!(write.size_bytes, 5); - } - - #[test] - fn map_vault_state_write_error_maps_missing_rows_to_attachment_not_found() { - let mapped = map_mailbox_write_error( - crate::store::mailbox::MailboxWriteError::AttachmentNotFound { - account_id: String::from("gmail:operator@example.com"), - attachment_key: String::from("m-1:1.2"), - }, - ); - assert!(matches!( - mapped, - super::AttachmentServiceError::AttachmentNotFound { attachment_key } - if attachment_key == "m-1:1.2" - )); - } - - #[test] - fn copy_from_vault_returns_destination_conflict_for_different_existing_bytes() { - let temp_dir = TempDir::new().unwrap(); - let source_path = temp_dir.path().join("source.bin"); - let destination_path = temp_dir.path().join("exports/export.bin"); - fs::create_dir_all(destination_path.parent().unwrap()).unwrap(); - fs::write(&source_path, b"hello").unwrap(); - fs::write(&destination_path, b"world").unwrap(); - - let error = copy_from_vault( - &source_path, - &destination_path, - blake3::hash(b"hello").to_hex().as_ref(), - ) - .unwrap_err(); - - assert!(matches!( - error.downcast_ref::(), - Some(AttachmentServiceError::DestinationConflict { path }) - if path == &destination_path - )); - } - - #[tokio::test] - async fn list_returns_invalid_limit_when_limit_is_zero() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - let config_report = resolve(&paths).unwrap(); - - let error = list( - &config_report, - AttachmentListRequest { - thread_id: None, - message_id: None, - filename: None, - mime_type: None, - fetched_only: false, - limit: 0, - }, - ) - .await - .unwrap_err(); - - assert!(matches!( - error.downcast_ref::(), - Some(AttachmentServiceError::InvalidLimit) - )); - } - - #[tokio::test] - async fn show_returns_no_active_account_without_account_state() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - let config_report = resolve(&paths).unwrap(); - - let error = show(&config_report, String::from("m-1:1.2")) - .await - .unwrap_err(); - - assert!(matches!( - error.downcast_ref::(), - Some(AttachmentServiceError::NoActiveAccount) - )); - } - - #[tokio::test] - async fn export_returns_destination_conflict_for_existing_different_file() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - init(&config_report).unwrap(); - accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("100"), - messages_total: 1, - threads_total: 1, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap(); - crate::store::mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &[crate::store::mailbox::GmailMessageUpsertInput { - account_id: String::from("gmail:operator@example.com"), - message_id: String::from("m-1"), - thread_id: String::from("t-1"), - history_id: String::from("101"), - internal_date_epoch_ms: 1_700_000_000_000, - snippet: String::from("Attachment fixture"), - subject: String::from("Fixture"), - from_header: String::from("Fixture "), - from_address: Some(String::from("fixture@example.com")), - recipient_headers: String::from("operator@example.com"), - to_header: String::from("operator@example.com"), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 256, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: vec![GmailAttachmentUpsertInput { - attachment_key: String::from("m-1:1.2"), - part_id: String::from("1.2"), - gmail_attachment_id: Some(String::from("att-1")), - filename: String::from("fixture.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 5, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }], - }], - 100, - ) - .unwrap(); - let vault_write = write_vault_bytes(&paths.vault_dir, b"hello".to_vec()).unwrap(); - crate::store::mailbox::set_attachment_vault_state( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &crate::store::mailbox::AttachmentVaultStateUpdate { - account_id: String::from("gmail:operator@example.com"), - attachment_key: String::from("m-1:1.2"), - content_hash: vault_write.content_hash.clone(), - relative_path: vault_write.relative_path, - size_bytes: vault_write.size_bytes, - fetched_at_epoch_s: 101, - }, - ) - .unwrap(); - - let destination_path = temp_dir.path().join("exports/conflict.bin"); - fs::create_dir_all(destination_path.parent().unwrap()).unwrap(); - fs::write(&destination_path, b"world").unwrap(); - - let error = export( - &config_report, - String::from("m-1:1.2"), - Some(destination_path.clone()), - ) - .await - .unwrap_err(); - - assert!(matches!( - error.downcast_ref::(), - Some(AttachmentServiceError::DestinationConflict { path }) - if path == &destination_path - )); - } - - #[tokio::test] - async fn export_removes_copied_file_when_event_persistence_fails() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - init(&config_report).unwrap(); - accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("100"), - messages_total: 1, - threads_total: 1, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap(); - crate::store::mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &[crate::store::mailbox::GmailMessageUpsertInput { - account_id: String::from("gmail:operator@example.com"), - message_id: String::from("m-1"), - thread_id: String::from("t-1"), - history_id: String::from("101"), - internal_date_epoch_ms: 1_700_000_000_000, - snippet: String::from("Attachment fixture"), - subject: String::from("Fixture"), - from_header: String::from("Fixture "), - from_address: Some(String::from("fixture@example.com")), - recipient_headers: String::from("operator@example.com"), - to_header: String::from("operator@example.com"), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 256, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: vec![GmailAttachmentUpsertInput { - attachment_key: String::from("m-1:1.2"), - part_id: String::from("1.2"), - gmail_attachment_id: Some(String::from("att-1")), - filename: String::from("fixture.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 5, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }], - }], - 100, - ) - .unwrap(); - let vault_write = write_vault_bytes(&paths.vault_dir, b"hello".to_vec()).unwrap(); - crate::store::mailbox::set_attachment_vault_state( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &crate::store::mailbox::AttachmentVaultStateUpdate { - account_id: String::from("gmail:operator@example.com"), - attachment_key: String::from("m-1:1.2"), - content_hash: vault_write.content_hash.clone(), - relative_path: vault_write.relative_path, - size_bytes: vault_write.size_bytes, - fetched_at_epoch_s: 101, - }, - ) - .unwrap(); - let connection = - rusqlite::Connection::open(&config_report.config.store.database_path).unwrap(); - connection - .execute_batch( - " - CREATE TRIGGER fail_attachment_export_event_insert - BEFORE INSERT ON attachment_export_events - BEGIN - SELECT RAISE(FAIL, 'forced export event failure'); - END; - ", - ) - .unwrap(); - - let destination_path = temp_dir.path().join("exports/export.bin"); - let error = export( - &config_report, - String::from("m-1:1.2"), - Some(destination_path.clone()), - ) - .await - .unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some(AttachmentServiceError::StoreWrite { .. }) - ), - "expected store write error, got {error:#}" - ); - assert!(!destination_path.exists()); - } - - #[tokio::test] - async fn export_preserves_preexisting_matching_file_when_event_persistence_fails() { - let temp_dir = TempDir::new().unwrap(); - let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); - paths.ensure_runtime_dirs().unwrap(); - let config_report = resolve(&paths).unwrap(); - init(&config_report).unwrap(); - accounts::upsert_active( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &accounts::UpsertAccountInput { - email_address: String::from("operator@example.com"), - history_id: String::from("100"), - messages_total: 1, - threads_total: 1, - access_scope: String::from("scope:a"), - refreshed_at_epoch_s: 100, - }, - ) - .unwrap(); - crate::store::mailbox::upsert_messages( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &[crate::store::mailbox::GmailMessageUpsertInput { - account_id: String::from("gmail:operator@example.com"), - message_id: String::from("m-1"), - thread_id: String::from("t-1"), - history_id: String::from("101"), - internal_date_epoch_ms: 1_700_000_000_000, - snippet: String::from("Attachment fixture"), - subject: String::from("Fixture"), - from_header: String::from("Fixture "), - from_address: Some(String::from("fixture@example.com")), - recipient_headers: String::from("operator@example.com"), - to_header: String::from("operator@example.com"), - cc_header: String::new(), - bcc_header: String::new(), - reply_to_header: String::new(), - size_estimate: 256, - automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), - label_ids: vec![String::from("INBOX")], - label_names_text: String::from("INBOX"), - attachments: vec![GmailAttachmentUpsertInput { - attachment_key: String::from("m-1:1.2"), - part_id: String::from("1.2"), - gmail_attachment_id: Some(String::from("att-1")), - filename: String::from("fixture.bin"), - mime_type: String::from("application/octet-stream"), - size_bytes: 5, - content_disposition: Some(String::from("attachment")), - content_id: None, - is_inline: false, - }], - }], - 100, - ) - .unwrap(); - let vault_bytes = b"hello".to_vec(); - let vault_write = write_vault_bytes(&paths.vault_dir, vault_bytes.clone()).unwrap(); - crate::store::mailbox::set_attachment_vault_state( - &config_report.config.store.database_path, - config_report.config.store.busy_timeout_ms, - &crate::store::mailbox::AttachmentVaultStateUpdate { - account_id: String::from("gmail:operator@example.com"), - attachment_key: String::from("m-1:1.2"), - content_hash: vault_write.content_hash.clone(), - relative_path: vault_write.relative_path, - size_bytes: vault_write.size_bytes, - fetched_at_epoch_s: 101, - }, - ) - .unwrap(); - let connection = - rusqlite::Connection::open(&config_report.config.store.database_path).unwrap(); - connection - .execute_batch( - " - CREATE TRIGGER fail_attachment_export_event_insert - BEFORE INSERT ON attachment_export_events - BEGIN - SELECT RAISE(FAIL, 'forced export event failure'); - END; - ", - ) - .unwrap(); - - let destination_path = temp_dir.path().join("exports/preexisting.bin"); - fs::create_dir_all(destination_path.parent().unwrap()).unwrap(); - fs::write(&destination_path, &vault_bytes).unwrap(); - - let error = export( - &config_report, - String::from("m-1:1.2"), - Some(destination_path.clone()), - ) - .await - .unwrap_err(); - - assert!( - matches!( - error.downcast_ref::(), - Some(AttachmentServiceError::StoreWrite { .. }) - ), - "expected store write error, got {error:#}" - ); - assert_eq!(fs::read(&destination_path).unwrap(), vault_bytes); - } - - #[test] - #[ignore = "benchmark harness; run manually with: cargo test benchmark_attachment_export_hash_compare_tiers -- --ignored --nocapture"] - fn benchmark_attachment_export_hash_compare_tiers() { - const COPY_ITERATIONS: usize = 8; - const HASH_COMPARE_ITERATIONS: usize = 20; - let tiers = [ - ("small", 64 * 1024_usize), - ("medium", 1024 * 1024_usize), - ("large", 8 * 1024 * 1024_usize), - ]; - - for (tier_name, size_bytes) in tiers { - let temp_dir = TempDir::new().unwrap(); - let source_path = temp_dir.path().join("source.bin"); - let destination_path = temp_dir.path().join("exports/export.bin"); - fs::write(&source_path, vec![0xAC_u8; size_bytes]).unwrap(); - let source_hash = hash_file_blake3(&source_path).unwrap(); - - let copy_started_at = Instant::now(); - for _ in 0..COPY_ITERATIONS { - if destination_path.exists() { - fs::remove_file(&destination_path).unwrap(); - } - let copied = - copy_from_vault(&source_path, &destination_path, &source_hash).unwrap(); - assert!(copied.copied); - } - let copy_elapsed = copy_started_at.elapsed(); - - let compare_started_at = Instant::now(); - for _ in 0..HASH_COMPARE_ITERATIONS { - let copied = - copy_from_vault(&source_path, &destination_path, &source_hash).unwrap(); - assert!(!copied.copied); - } - let compare_elapsed = compare_started_at.elapsed(); - - let copy_avg_ms = copy_elapsed.as_secs_f64() * 1_000.0 / COPY_ITERATIONS as f64; - let compare_avg_ms = - compare_elapsed.as_secs_f64() * 1_000.0 / HASH_COMPARE_ITERATIONS as f64; - println!( - "{{\"bench\":\"attachment_lane.export\",\"tier\":\"{tier_name}\",\"size_bytes\":{size_bytes},\"copy_avg_ms\":{copy_avg_ms:.3},\"hash_compare_avg_ms\":{compare_avg_ms:.3}}}" - ); - } - } - - fn detail_with_vault( - relative_path: &str, - content_hash: &str, - vault_size_bytes: i64, - ) -> AttachmentDetailRecord { - AttachmentDetailRecord { - attachment_key: String::from("m-1:1.2"), - message_id: String::from("m-1"), - thread_id: String::from("t-1"), - part_id: String::from("1.2"), - gmail_attachment_id: Some(String::from("att-1")), - filename: String::from("statement.pdf"), - mime_type: String::from("application/pdf"), - size_bytes: 5, - content_disposition: None, - content_id: None, - is_inline: false, - internal_date_epoch_ms: 1_700_000_000_000, - subject: String::from("Statement"), - from_header: String::from("Billing "), - vault_content_hash: Some(content_hash.to_owned()), - vault_relative_path: Some(relative_path.to_owned()), - vault_size_bytes: Some(vault_size_bytes), - vault_fetched_at_epoch_s: Some(101), - export_count: 0, - } - } -} diff --git a/src/attachments/error.rs b/src/attachments/error.rs new file mode 100644 index 0000000..0de126b --- /dev/null +++ b/src/attachments/error.rs @@ -0,0 +1,57 @@ +use crate::store; +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AttachmentServiceError { + #[error("no active Gmail account found; run `mailroom auth login` first")] + NoActiveAccount, + #[error("attachment `{attachment_key}` was not found in the local mailbox catalog")] + AttachmentNotFound { attachment_key: String }, + #[error("attachment list limit must be greater than zero")] + InvalidLimit, + #[error("attachment vault path `{relative_path}` is invalid")] + InvalidVaultPath { relative_path: String }, + #[error("export destination already exists with different content: {path}")] + DestinationConflict { path: PathBuf }, + #[error("failed to join blocking attachment task: {source}")] + BlockingTask { + #[source] + source: tokio::task::JoinError, + }, + #[error("failed to create directory {path}: {source}")] + CreateDirectory { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to write file {path}: {source}")] + WriteFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to read file {path}: {source}")] + ReadFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to copy file from {source_path} to {destination_path}: {source}")] + CopyFile { + source_path: PathBuf, + destination_path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to persist attachment store state: {source}")] + StoreWrite { + #[source] + source: anyhow::Error, + }, + #[error("failed to read attachment state from local mailbox store: {source}")] + StoreRead { + #[source] + source: store::mailbox::MailboxReadError, + }, +} diff --git a/src/attachments/export.rs b/src/attachments/export.rs new file mode 100644 index 0000000..ed7959a --- /dev/null +++ b/src/attachments/export.rs @@ -0,0 +1,163 @@ +use super::error::AttachmentServiceError; +use super::vault::hash_file_blake3; +use crate::workspace::WorkspacePaths; +use anyhow::Result; +use sanitize_filename::sanitize; +use std::fs; +use std::path::{Path, PathBuf}; +use tokio::task::spawn_blocking; + +pub(super) fn export_filename(filename: &str, attachment_key: &str) -> String { + let trimmed = filename.trim(); + if !trimmed.is_empty() { + let sanitized = sanitize(trimmed).to_string(); + if !sanitized.trim().is_empty() { + return sanitized; + } + } + format!( + "attachment-{}.bin", + sanitize_non_empty(attachment_key, "attachment") + ) +} + +pub(super) fn default_export_path( + workspace_paths: &WorkspacePaths, + thread_id: &str, + message_id: &str, + attachment_key: &str, + filename: &str, +) -> PathBuf { + workspace_paths + .exports_dir + .join(sanitize_non_empty(thread_id, "thread")) + .join(format!( + "{}--{}--{}", + sanitize_non_empty(message_id, "message"), + sanitize_non_empty(attachment_key, "attachment"), + filename + )) +} + +pub(super) fn resolve_export_destination_path( + workspace_paths: &WorkspacePaths, + thread_id: &str, + message_id: &str, + attachment_key: &str, + filename: &str, + destination: Option, +) -> Result { + Ok(match destination { + Some(path) if path.is_dir() => path.join(filename), + Some(path) => path, + None => default_export_path( + workspace_paths, + thread_id, + message_id, + attachment_key, + filename, + ), + }) +} + +fn sanitize_non_empty(value: &str, fallback: &str) -> String { + let sanitized = sanitize(value).to_string(); + if sanitized.trim().is_empty() { + return fallback.to_owned(); + } + sanitized +} + +#[derive(Debug)] +pub(super) struct CopyFromVaultResult { + pub(super) copied: bool, +} + +pub(super) fn copy_from_vault( + source_path: &Path, + destination_path: &Path, + content_hash: &str, +) -> Result { + let parent = + destination_path + .parent() + .ok_or_else(|| AttachmentServiceError::CreateDirectory { + path: destination_path.to_path_buf(), + source: std::io::Error::other("destination path has no parent"), + })?; + fs::create_dir_all(parent).map_err(|source| AttachmentServiceError::CreateDirectory { + path: parent.to_path_buf(), + source, + })?; + + let mut source_file = + fs::File::open(source_path).map_err(|source| AttachmentServiceError::CopyFile { + source_path: source_path.to_path_buf(), + destination_path: destination_path.to_path_buf(), + source, + })?; + let mut destination_file = match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(destination_path) + { + Ok(file) => file, + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => { + let existing_hash = hash_file_blake3(destination_path)?; + if existing_hash == content_hash { + return Ok(CopyFromVaultResult { copied: false }); + } + return Err(AttachmentServiceError::DestinationConflict { + path: destination_path.to_path_buf(), + } + .into()); + } + Err(source) => { + return Err(AttachmentServiceError::CopyFile { + source_path: source_path.to_path_buf(), + destination_path: destination_path.to_path_buf(), + source, + } + .into()); + } + }; + let write_result = (|| -> Result<()> { + std::io::copy(&mut source_file, &mut destination_file).map_err(|source| { + AttachmentServiceError::CopyFile { + source_path: source_path.to_path_buf(), + destination_path: destination_path.to_path_buf(), + source, + } + })?; + destination_file + .sync_all() + .map_err(|source| AttachmentServiceError::CopyFile { + source_path: source_path.to_path_buf(), + destination_path: destination_path.to_path_buf(), + source, + })?; + Ok(()) + })(); + if write_result.is_err() { + let _ = fs::remove_file(destination_path); + } + write_result?; + Ok(CopyFromVaultResult { copied: true }) +} + +pub(super) async fn cleanup_export_file_task(destination_path: PathBuf) { + let cleanup_path = destination_path.clone(); + let join_result = spawn_blocking(move || fs::remove_file(&cleanup_path)).await; + match join_result { + Ok(Ok(())) => {} + Ok(Err(error)) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(Err(error)) => eprintln!( + "warning: failed to remove exported attachment after persistence failure: {} ({error})", + destination_path.display() + ), + Err(error) => eprintln!( + "warning: failed to join export cleanup task for {}: {error}", + destination_path.display() + ), + } +} diff --git a/src/attachments/mod.rs b/src/attachments/mod.rs new file mode 100644 index 0000000..5bc493b --- /dev/null +++ b/src/attachments/mod.rs @@ -0,0 +1,26 @@ +mod error; +mod export; +mod reports; +mod service; +mod vault; + +#[cfg(test)] +mod tests; + +pub use error::AttachmentServiceError; +pub use reports::{ + AttachmentExportReport, AttachmentFetchReport, AttachmentListReport, AttachmentShowReport, +}; +pub use service::{export, fetch, list, show}; + +pub const DEFAULT_ATTACHMENT_LIST_LIMIT: usize = 50; + +#[derive(Debug, Clone)] +pub struct AttachmentListRequest { + pub thread_id: Option, + pub message_id: Option, + pub filename: Option, + pub mime_type: Option, + pub fetched_only: bool, + pub limit: usize, +} diff --git a/src/attachments/reports.rs b/src/attachments/reports.rs new file mode 100644 index 0000000..99b764a --- /dev/null +++ b/src/attachments/reports.rs @@ -0,0 +1,138 @@ +use crate::store; +use anyhow::Result; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize)] +pub struct AttachmentListReport { + pub account_id: String, + pub thread_id: Option, + pub message_id: Option, + pub filename: Option, + pub mime_type: Option, + pub fetched_only: bool, + pub limit: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AttachmentShowReport { + pub account_id: String, + pub attachment: store::mailbox::AttachmentDetailRecord, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AttachmentFetchReport { + pub account_id: String, + pub attachment_key: String, + pub message_id: String, + pub thread_id: String, + pub filename: String, + pub mime_type: String, + pub size_bytes: i64, + pub content_hash: String, + pub vault_relative_path: String, + pub vault_path: PathBuf, + pub downloaded: bool, + pub fetched_at_epoch_s: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AttachmentExportReport { + pub account_id: String, + pub attachment_key: String, + pub message_id: String, + pub thread_id: String, + pub filename: String, + pub content_hash: String, + pub source_vault_path: PathBuf, + pub destination_path: PathBuf, + pub copied: bool, + pub exported_at_epoch_s: i64, +} + +impl AttachmentListReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("account_id={}", self.account_id); + println!("items={}", self.items.len()); + for item in &self.items { + println!( + "{}\t{}\t{}\tfetched={}\texports={}", + item.attachment_key, + item.filename, + item.mime_type, + item.vault_relative_path.is_some(), + item.export_count, + ); + } + } + Ok(()) + } +} + +impl AttachmentShowReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + let attachment = &self.attachment; + println!("account_id={}", self.account_id); + println!("attachment_key={}", attachment.attachment_key); + println!("message_id={}", attachment.message_id); + println!("thread_id={}", attachment.thread_id); + println!("filename={}", attachment.filename); + println!("mime_type={}", attachment.mime_type); + println!("size_bytes={}", attachment.size_bytes); + println!("fetched={}", attachment.vault_relative_path.is_some()); + println!("export_count={}", attachment.export_count); + match &attachment.vault_relative_path { + Some(path) => println!("vault_relative_path={path}"), + None => println!("vault_relative_path="), + } + } + Ok(()) + } +} + +impl AttachmentFetchReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("account_id={}", self.account_id); + println!("attachment_key={}", self.attachment_key); + println!("message_id={}", self.message_id); + println!("thread_id={}", self.thread_id); + println!("filename={}", self.filename); + println!("mime_type={}", self.mime_type); + println!("size_bytes={}", self.size_bytes); + println!("content_hash={}", self.content_hash); + println!("downloaded={}", self.downloaded); + println!("vault_relative_path={}", self.vault_relative_path); + println!("vault_path={}", self.vault_path.display()); + println!("fetched_at_epoch_s={}", self.fetched_at_epoch_s); + } + Ok(()) + } +} + +impl AttachmentExportReport { + pub fn print(&self, json: bool) -> Result<()> { + if json { + crate::cli_output::print_json_success(self)?; + } else { + println!("account_id={}", self.account_id); + println!("attachment_key={}", self.attachment_key); + println!("filename={}", self.filename); + println!("content_hash={}", self.content_hash); + println!("copied={}", self.copied); + println!("source_vault_path={}", self.source_vault_path.display()); + println!("destination_path={}", self.destination_path.display()); + println!("exported_at_epoch_s={}", self.exported_at_epoch_s); + } + Ok(()) + } +} diff --git a/src/attachments/service.rs b/src/attachments/service.rs new file mode 100644 index 0000000..2191f23 --- /dev/null +++ b/src/attachments/service.rs @@ -0,0 +1,296 @@ +use super::export::{ + cleanup_export_file_task, copy_from_vault, export_filename, resolve_export_destination_path, +}; +use super::vault::{existing_vault_report, write_vault_bytes}; +use super::{ + AttachmentExportReport, AttachmentFetchReport, AttachmentListReport, AttachmentListRequest, + AttachmentServiceError, AttachmentShowReport, +}; +use crate::config::ConfigReport; +use crate::store; +use crate::time::current_epoch_seconds; +use crate::workspace::WorkspacePaths; +use crate::{configured_paths, gmail_client_for_config}; +use anyhow::Result; +use std::path::{Path, PathBuf}; +use tokio::task::spawn_blocking; + +pub async fn list( + config_report: &ConfigReport, + request: AttachmentListRequest, +) -> Result { + if request.limit == 0 { + return Err(AttachmentServiceError::InvalidLimit.into()); + } + + init_store_task(config_report).await?; + let account_id = resolve_attachment_account_id_task(config_report).await?; + let database_path = config_report.config.store.database_path.clone(); + let busy_timeout_ms = config_report.config.store.busy_timeout_ms; + let query = store::mailbox::AttachmentListQuery { + account_id: account_id.clone(), + thread_id: request.thread_id.clone(), + message_id: request.message_id.clone(), + filename: request.filename.clone(), + mime_type: request.mime_type.clone(), + fetched_only: request.fetched_only, + limit: request.limit, + }; + let items = spawn_blocking(move || { + store::mailbox::list_attachments(&database_path, busy_timeout_ms, &query) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })? + .map_err(|source| AttachmentServiceError::StoreRead { source })?; + + Ok(AttachmentListReport { + account_id, + thread_id: request.thread_id, + message_id: request.message_id, + filename: request.filename, + mime_type: request.mime_type, + fetched_only: request.fetched_only, + limit: request.limit, + items, + }) +} + +pub async fn show( + config_report: &ConfigReport, + attachment_key: String, +) -> Result { + init_store_task(config_report).await?; + let account_id = resolve_attachment_account_id_task(config_report).await?; + let detail = load_attachment_detail(config_report, &account_id, &attachment_key).await?; + + Ok(AttachmentShowReport { + account_id, + attachment: detail, + }) +} + +pub async fn fetch( + config_report: &ConfigReport, + attachment_key: String, +) -> Result { + init_store_task(config_report).await?; + let account_id = resolve_attachment_account_id_task(config_report).await?; + let workspace_paths = configured_paths(config_report)?; + ensure_runtime_dirs_task(workspace_paths.clone()).await?; + let detail = load_attachment_detail(config_report, &account_id, &attachment_key).await?; + + let existing_report = spawn_blocking({ + let workspace_paths = workspace_paths.clone(); + let account_id = account_id.clone(); + let detail = detail.clone(); + move || existing_vault_report(&workspace_paths, &account_id, &detail) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })??; + if let Some(existing_report) = existing_report { + return Ok(existing_report); + } + + let gmail_client = gmail_client_for_config(config_report)?; + let bytes = gmail_client + .get_attachment_bytes( + &detail.message_id, + &detail.part_id, + detail.gmail_attachment_id.as_deref(), + ) + .await?; + let fetched_at_epoch_s = current_epoch_seconds()?; + let vault_write = spawn_blocking({ + let vault_dir = workspace_paths.vault_dir.clone(); + move || write_vault_bytes(&vault_dir, bytes) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })??; + + let database_path = config_report.config.store.database_path.clone(); + let busy_timeout_ms = config_report.config.store.busy_timeout_ms; + let update = store::mailbox::AttachmentVaultStateUpdate { + account_id: account_id.clone(), + attachment_key: detail.attachment_key.clone(), + content_hash: vault_write.content_hash.clone(), + relative_path: vault_write.relative_path.clone(), + size_bytes: vault_write.size_bytes, + fetched_at_epoch_s, + }; + let update_result = spawn_blocking(move || { + store::mailbox::set_attachment_vault_state(&database_path, busy_timeout_ms, &update) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })?; + if let Err(error) = update_result { + return Err(map_mailbox_write_error(error).into()); + } + + Ok(AttachmentFetchReport { + account_id, + attachment_key: detail.attachment_key, + message_id: detail.message_id, + thread_id: detail.thread_id, + filename: detail.filename, + mime_type: detail.mime_type, + size_bytes: detail.size_bytes, + content_hash: vault_write.content_hash, + vault_relative_path: vault_write.relative_path, + vault_path: vault_write.path, + downloaded: true, + fetched_at_epoch_s, + }) +} + +pub async fn export( + config_report: &ConfigReport, + attachment_key: String, + destination: Option, +) -> Result { + let fetched = fetch(config_report, attachment_key).await?; + let workspace_paths = configured_paths(config_report)?; + let filename = export_filename(&fetched.filename, &fetched.attachment_key); + let destination_path = spawn_blocking({ + let workspace_paths = workspace_paths.clone(); + let thread_id = fetched.thread_id.clone(); + let message_id = fetched.message_id.clone(); + let attachment_key = fetched.attachment_key.clone(); + let filename = filename.clone(); + move || { + resolve_export_destination_path( + &workspace_paths, + &thread_id, + &message_id, + &attachment_key, + &filename, + destination, + ) + } + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })??; + let exported_at_epoch_s = current_epoch_seconds()?; + let copy_result = spawn_blocking({ + let source_path = fetched.vault_path.clone(); + let destination_path = destination_path.clone(); + let content_hash = fetched.content_hash.clone(); + move || copy_from_vault(&source_path, &destination_path, &content_hash) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })??; + + let database_path = config_report.config.store.database_path.clone(); + let busy_timeout_ms = config_report.config.store.busy_timeout_ms; + let event = store::mailbox::AttachmentExportEventInput { + account_id: fetched.account_id.clone(), + attachment_key: fetched.attachment_key.clone(), + message_id: fetched.message_id.clone(), + thread_id: fetched.thread_id.clone(), + destination_path: destination_path.display().to_string(), + content_hash: fetched.content_hash.clone(), + exported_at_epoch_s, + }; + let record_result = spawn_blocking(move || { + store::mailbox::record_attachment_export(&database_path, busy_timeout_ms, &event) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })?; + if let Err(error) = record_result { + if copy_result.copied { + cleanup_export_file_task(destination_path.clone()).await; + } + return Err(map_mailbox_write_error(error).into()); + } + + Ok(AttachmentExportReport { + account_id: fetched.account_id, + attachment_key: fetched.attachment_key, + message_id: fetched.message_id, + thread_id: fetched.thread_id, + filename: fetched.filename, + content_hash: fetched.content_hash, + source_vault_path: fetched.vault_path, + destination_path, + copied: copy_result.copied, + exported_at_epoch_s, + }) +} + +async fn resolve_attachment_account_id_task(config_report: &ConfigReport) -> Result { + let database_path = config_report.config.store.database_path.clone(); + let busy_timeout_ms = config_report.config.store.busy_timeout_ms; + spawn_blocking(move || resolve_attachment_account_id(&database_path, busy_timeout_ms)) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })? +} + +async fn init_store_task(config_report: &ConfigReport) -> Result<()> { + let config_report = config_report.clone(); + spawn_blocking(move || store::init(&config_report)) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })? + .map(|_| ()) +} + +async fn ensure_runtime_dirs_task(workspace_paths: WorkspacePaths) -> Result<()> { + spawn_blocking(move || workspace_paths.ensure_runtime_dirs()) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })? + .map(|_| ()) +} + +fn resolve_attachment_account_id(database_path: &Path, busy_timeout_ms: u64) -> Result { + if let Some(active_account) = store::accounts::get_active(database_path, busy_timeout_ms)? { + return Ok(active_account.account_id); + } + + if let Some(mailbox) = store::mailbox::inspect_mailbox(database_path, busy_timeout_ms)? + && let Some(sync_state) = mailbox.sync_state + { + return Ok(sync_state.account_id); + } + + Err(AttachmentServiceError::NoActiveAccount.into()) +} + +async fn load_attachment_detail( + config_report: &ConfigReport, + account_id: &str, + attachment_key: &str, +) -> Result { + let database_path = config_report.config.store.database_path.clone(); + let busy_timeout_ms = config_report.config.store.busy_timeout_ms; + let account_id = account_id.to_owned(); + let attachment_key_owned = attachment_key.to_owned(); + let detail = spawn_blocking(move || { + store::mailbox::get_attachment_detail( + &database_path, + busy_timeout_ms, + &account_id, + &attachment_key_owned, + ) + }) + .await + .map_err(|source| AttachmentServiceError::BlockingTask { source })? + .map_err(|source| AttachmentServiceError::StoreRead { source })?; + + detail.ok_or_else(|| { + AttachmentServiceError::AttachmentNotFound { + attachment_key: attachment_key.to_owned(), + } + .into() + }) +} + +pub(super) fn map_mailbox_write_error( + error: store::mailbox::MailboxWriteError, +) -> AttachmentServiceError { + match error { + store::mailbox::MailboxWriteError::AttachmentNotFound { attachment_key, .. } => { + AttachmentServiceError::AttachmentNotFound { attachment_key } + } + error => AttachmentServiceError::StoreWrite { + source: error.into(), + }, + } +} diff --git a/src/attachments/tests.rs b/src/attachments/tests.rs new file mode 100644 index 0000000..3c06e3e --- /dev/null +++ b/src/attachments/tests.rs @@ -0,0 +1,475 @@ +use super::export::{copy_from_vault, default_export_path, export_filename}; +use super::service::{export, list, map_mailbox_write_error, show}; +use super::vault::{ + existing_vault_report, hash_file_blake3, resolve_vault_relative_path, write_vault_bytes, +}; +use super::{AttachmentListRequest, AttachmentServiceError}; +use crate::config::resolve; +use crate::store::mailbox::{AttachmentDetailRecord, GmailAttachmentUpsertInput}; +use crate::store::{accounts, init}; +use crate::workspace::WorkspacePaths; +use std::fs; +use std::path::PathBuf; +use std::time::Instant; +use tempfile::TempDir; + +struct ExportTestFixture { + temp_dir: TempDir, + config_report: crate::config::ConfigReport, +} + +#[test] +fn export_filename_falls_back_when_gmail_filename_is_blank() { + assert_eq!(export_filename("", "m-1:2"), "attachment-m-12.bin"); +} + +#[test] +fn export_filename_falls_back_when_sanitized_filename_is_empty() { + assert_eq!(export_filename("///", "m-1:2"), "attachment-m-12.bin"); +} + +#[test] +fn default_export_path_uses_thread_and_message_partitions() { + let repo_root = PathBuf::from("mailroom-test-root"); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let path = default_export_path(&paths, "thread-1", "message-1", "m-1:1.2", "note.pdf"); + + assert_eq!( + path, + repo_root + .join(".mailroom") + .join("exports") + .join("thread-1") + .join("message-1--m-11.2--note.pdf") + ); +} + +#[test] +fn default_export_path_falls_back_when_partition_ids_sanitize_to_empty() { + let repo_root = PathBuf::from("mailroom-test-root"); + let paths = WorkspacePaths::from_repo_root(repo_root.clone()); + let path = default_export_path(&paths, "///", "\\\\", "///", "note.pdf"); + + assert_eq!( + path, + repo_root + .join(".mailroom") + .join("exports") + .join("thread") + .join("message--attachment--note.pdf") + ); +} + +#[test] +fn default_export_path_is_unique_per_attachment_key() { + let paths = WorkspacePaths::from_repo_root(PathBuf::from("/tmp/mailroom")); + let first = default_export_path(&paths, "thread-1", "message-1", "m-1:1.1", "note.pdf"); + let second = default_export_path(&paths, "thread-1", "message-1", "m-1:1.2", "note.pdf"); + + assert_ne!(first, second); +} + +#[test] +fn resolve_vault_relative_path_rejects_parent_traversal() { + let paths = WorkspacePaths::from_repo_root(PathBuf::from("/tmp/mailroom")); + let error = resolve_vault_relative_path(&paths, "../escape.bin").unwrap_err(); + + assert!(matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::InvalidVaultPath { relative_path }) + if relative_path == "../escape.bin" + )); +} + +#[test] +fn existing_vault_report_requires_hash_match_before_reuse() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + + let relative_path = "blake3/ab/abc123"; + let vault_path = paths.vault_dir.join(relative_path); + fs::create_dir_all(vault_path.parent().unwrap()).unwrap(); + fs::write(&vault_path, b"hello").unwrap(); + + let report = existing_vault_report( + &paths, + "gmail:operator@example.com", + &detail_with_vault(relative_path, "invalid-hash", 5), + ) + .unwrap(); + + assert!(report.is_none()); +} + +#[test] +fn existing_vault_report_reuses_matching_vault_file() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + + let bytes = b"hello"; + let content_hash = blake3::hash(bytes).to_hex().to_string(); + let relative_path = format!("blake3/{}/{}", &content_hash[..2], content_hash); + let vault_path = paths.vault_dir.join(&relative_path); + fs::create_dir_all(vault_path.parent().unwrap()).unwrap(); + fs::write(&vault_path, bytes).unwrap(); + + let report = existing_vault_report( + &paths, + "gmail:operator@example.com", + &detail_with_vault(&relative_path, &content_hash, 5), + ) + .unwrap(); + + assert!(report.is_some()); + assert!(!report.unwrap().downloaded); +} + +#[test] +fn write_vault_bytes_rewrites_existing_hash_path_blob() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + + let expected_bytes = b"hello".to_vec(); + let content_hash = blake3::hash(&expected_bytes).to_hex().to_string(); + let relative_path = format!("blake3/{}/{}", &content_hash[..2], &content_hash); + let vault_path = paths.vault_dir.join(&relative_path); + fs::create_dir_all(vault_path.parent().unwrap()).unwrap(); + fs::write(&vault_path, b"corrupt").unwrap(); + + let write = write_vault_bytes(&paths.vault_dir, expected_bytes.clone()).unwrap(); + + assert_eq!(write.path, vault_path); + assert_eq!(fs::read(&vault_path).unwrap(), expected_bytes); + assert_eq!(hash_file_blake3(&vault_path).unwrap(), write.content_hash); + assert_eq!(write.size_bytes, 5); +} + +#[test] +fn map_vault_state_write_error_maps_missing_rows_to_attachment_not_found() { + let mapped = map_mailbox_write_error( + crate::store::mailbox::MailboxWriteError::AttachmentNotFound { + account_id: String::from("gmail:operator@example.com"), + attachment_key: String::from("m-1:1.2"), + }, + ); + assert!(matches!( + mapped, + AttachmentServiceError::AttachmentNotFound { attachment_key } if attachment_key == "m-1:1.2" + )); +} + +#[test] +fn copy_from_vault_returns_destination_conflict_for_different_existing_bytes() { + let temp_dir = TempDir::new().unwrap(); + let source_path = temp_dir.path().join("source.bin"); + let destination_path = temp_dir.path().join("exports/export.bin"); + fs::create_dir_all(destination_path.parent().unwrap()).unwrap(); + fs::write(&source_path, b"hello").unwrap(); + fs::write(&destination_path, b"world").unwrap(); + + let error = copy_from_vault( + &source_path, + &destination_path, + blake3::hash(b"hello").to_hex().as_ref(), + ) + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::DestinationConflict { path }) + if path == &destination_path + )); +} + +#[tokio::test] +async fn list_returns_invalid_limit_when_limit_is_zero() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = resolve(&paths).unwrap(); + + let error = list( + &config_report, + AttachmentListRequest { + thread_id: None, + message_id: None, + filename: None, + mime_type: None, + fetched_only: false, + limit: 0, + }, + ) + .await + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::InvalidLimit) + )); +} + +#[tokio::test] +async fn show_returns_no_active_account_without_account_state() { + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + let config_report = resolve(&paths).unwrap(); + + let error = show(&config_report, String::from("m-1:1.2")) + .await + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::NoActiveAccount) + )); +} + +#[tokio::test] +async fn export_returns_destination_conflict_for_existing_different_file() { + let fixture = setup_export_test_fixture(b"hello".to_vec()); + let destination_path = fixture.temp_dir.path().join("exports/conflict.bin"); + fs::create_dir_all(destination_path.parent().unwrap()).unwrap(); + fs::write(&destination_path, b"world").unwrap(); + + let error = export( + &fixture.config_report, + String::from("m-1:1.2"), + Some(destination_path.clone()), + ) + .await + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::DestinationConflict { path }) + if path == &destination_path + )); +} + +#[tokio::test] +async fn export_removes_copied_file_when_event_persistence_fails() { + let fixture = setup_export_test_fixture(b"hello".to_vec()); + let connection = + rusqlite::Connection::open(&fixture.config_report.config.store.database_path).unwrap(); + connection + .execute_batch( + " + CREATE TRIGGER fail_attachment_export_event_insert + BEFORE INSERT ON attachment_export_events + BEGIN + SELECT RAISE(FAIL, 'forced export event failure'); + END; + ", + ) + .unwrap(); + + let destination_path = fixture.temp_dir.path().join("exports/export.bin"); + let error = export( + &fixture.config_report, + String::from("m-1:1.2"), + Some(destination_path.clone()), + ) + .await + .unwrap_err(); + + assert!( + matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::StoreWrite { .. }) + ), + "expected store write error, got {error:#}" + ); + assert!(!destination_path.exists()); +} + +#[tokio::test] +async fn export_preserves_preexisting_matching_file_when_event_persistence_fails() { + let vault_bytes = b"hello".to_vec(); + let fixture = setup_export_test_fixture(vault_bytes.clone()); + let connection = + rusqlite::Connection::open(&fixture.config_report.config.store.database_path).unwrap(); + connection + .execute_batch( + " + CREATE TRIGGER fail_attachment_export_event_insert + BEFORE INSERT ON attachment_export_events + BEGIN + SELECT RAISE(FAIL, 'forced export event failure'); + END; + ", + ) + .unwrap(); + + let destination_path = fixture.temp_dir.path().join("exports/preexisting.bin"); + fs::create_dir_all(destination_path.parent().unwrap()).unwrap(); + fs::write(&destination_path, &vault_bytes).unwrap(); + + let error = export( + &fixture.config_report, + String::from("m-1:1.2"), + Some(destination_path.clone()), + ) + .await + .unwrap_err(); + + assert!( + matches!( + error.downcast_ref::(), + Some(AttachmentServiceError::StoreWrite { .. }) + ), + "expected store write error, got {error:#}" + ); + assert_eq!(fs::read(&destination_path).unwrap(), vault_bytes); +} + +#[test] +#[ignore = "benchmark harness; run manually with: cargo test benchmark_attachment_export_hash_compare_tiers -- --ignored --nocapture"] +fn benchmark_attachment_export_hash_compare_tiers() { + const COPY_ITERATIONS: usize = 8; + const HASH_COMPARE_ITERATIONS: usize = 20; + let tiers = [ + ("small", 64 * 1024_usize), + ("medium", 1024 * 1024_usize), + ("large", 8 * 1024 * 1024_usize), + ]; + + for (tier_name, size_bytes) in tiers { + let temp_dir = TempDir::new().unwrap(); + let source_path = temp_dir.path().join("source.bin"); + let destination_path = temp_dir.path().join("exports/export.bin"); + fs::write(&source_path, vec![0xAC_u8; size_bytes]).unwrap(); + let source_hash = hash_file_blake3(&source_path).unwrap(); + + let copy_started_at = Instant::now(); + for _ in 0..COPY_ITERATIONS { + if destination_path.exists() { + fs::remove_file(&destination_path).unwrap(); + } + let copied = copy_from_vault(&source_path, &destination_path, &source_hash).unwrap(); + assert!(copied.copied); + } + let copy_elapsed = copy_started_at.elapsed(); + + let compare_started_at = Instant::now(); + for _ in 0..HASH_COMPARE_ITERATIONS { + let copied = copy_from_vault(&source_path, &destination_path, &source_hash).unwrap(); + assert!(!copied.copied); + } + let compare_elapsed = compare_started_at.elapsed(); + + let copy_avg_ms = copy_elapsed.as_secs_f64() * 1_000.0 / COPY_ITERATIONS as f64; + let compare_avg_ms = + compare_elapsed.as_secs_f64() * 1_000.0 / HASH_COMPARE_ITERATIONS as f64; + println!( + "{{\"bench\":\"attachment_lane.export\",\"tier\":\"{tier_name}\",\"size_bytes\":{size_bytes},\"copy_avg_ms\":{copy_avg_ms:.3},\"hash_compare_avg_ms\":{compare_avg_ms:.3}}}" + ); + } +} + +fn detail_with_vault( + relative_path: &str, + content_hash: &str, + vault_size_bytes: i64, +) -> AttachmentDetailRecord { + AttachmentDetailRecord { + attachment_key: String::from("m-1:1.2"), + message_id: String::from("m-1"), + thread_id: String::from("t-1"), + part_id: String::from("1.2"), + gmail_attachment_id: Some(String::from("att-1")), + filename: String::from("statement.pdf"), + mime_type: String::from("application/pdf"), + size_bytes: vault_size_bytes, + content_disposition: None, + content_id: None, + is_inline: false, + internal_date_epoch_ms: 1_700_000_000_000, + subject: String::from("Statement"), + from_header: String::from("Billing "), + vault_content_hash: Some(content_hash.to_owned()), + vault_relative_path: Some(relative_path.to_owned()), + vault_size_bytes: Some(vault_size_bytes), + vault_fetched_at_epoch_s: Some(101), + export_count: 0, + } +} + +fn setup_export_test_fixture(vault_bytes: Vec) -> ExportTestFixture { + let vault_size_bytes = i64::try_from(vault_bytes.len()).unwrap(); + let temp_dir = TempDir::new().unwrap(); + let paths = WorkspacePaths::from_repo_root(temp_dir.path().to_path_buf()); + paths.ensure_runtime_dirs().unwrap(); + let config_report = resolve(&paths).unwrap(); + init(&config_report).unwrap(); + accounts::upsert_active( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &accounts::UpsertAccountInput { + email_address: String::from("operator@example.com"), + history_id: String::from("100"), + messages_total: 1, + threads_total: 1, + access_scope: String::from("scope:a"), + refreshed_at_epoch_s: 100, + }, + ) + .unwrap(); + crate::store::mailbox::upsert_messages( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &[crate::store::mailbox::GmailMessageUpsertInput { + account_id: String::from("gmail:operator@example.com"), + message_id: String::from("m-1"), + thread_id: String::from("t-1"), + history_id: String::from("101"), + internal_date_epoch_ms: 1_700_000_000_000, + snippet: String::from("Attachment fixture"), + subject: String::from("Fixture"), + from_header: String::from("Fixture "), + from_address: Some(String::from("fixture@example.com")), + recipient_headers: String::from("operator@example.com"), + to_header: String::from("operator@example.com"), + cc_header: String::new(), + bcc_header: String::new(), + reply_to_header: String::new(), + size_estimate: 256, + automation_headers: crate::store::mailbox::GmailAutomationHeaders::default(), + label_ids: vec![String::from("INBOX")], + label_names_text: String::from("INBOX"), + attachments: vec![GmailAttachmentUpsertInput { + attachment_key: String::from("m-1:1.2"), + part_id: String::from("1.2"), + gmail_attachment_id: Some(String::from("att-1")), + filename: String::from("fixture.bin"), + mime_type: String::from("application/octet-stream"), + size_bytes: vault_size_bytes, + content_disposition: Some(String::from("attachment")), + content_id: None, + is_inline: false, + }], + }], + 100, + ) + .unwrap(); + let vault_write = write_vault_bytes(&paths.vault_dir, vault_bytes).unwrap(); + crate::store::mailbox::set_attachment_vault_state( + &config_report.config.store.database_path, + config_report.config.store.busy_timeout_ms, + &crate::store::mailbox::AttachmentVaultStateUpdate { + account_id: String::from("gmail:operator@example.com"), + attachment_key: String::from("m-1:1.2"), + content_hash: vault_write.content_hash, + relative_path: vault_write.relative_path, + size_bytes: vault_write.size_bytes, + fetched_at_epoch_s: 101, + }, + ) + .unwrap(); + + ExportTestFixture { + temp_dir, + config_report, + } +} diff --git a/src/attachments/vault.rs b/src/attachments/vault.rs new file mode 100644 index 0000000..6d29132 --- /dev/null +++ b/src/attachments/vault.rs @@ -0,0 +1,312 @@ +use super::error::AttachmentServiceError; +use super::reports::AttachmentFetchReport; +use crate::store; +use crate::workspace::WorkspacePaths; +use anyhow::Result; +use std::fs; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Component, Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TEMP_PATH_RETRY_LIMIT: usize = 8; + +pub(super) fn existing_vault_report( + workspace_paths: &WorkspacePaths, + account_id: &str, + detail: &store::mailbox::AttachmentDetailRecord, +) -> Result> { + let Some(relative_path) = detail.vault_relative_path.as_deref() else { + return Ok(None); + }; + let Some(content_hash) = detail.vault_content_hash.clone() else { + return Ok(None); + }; + let Some(fetched_at_epoch_s) = detail.vault_fetched_at_epoch_s else { + return Ok(None); + }; + let path = resolve_vault_relative_path(workspace_paths, relative_path)?; + if !path.exists() { + return Ok(None); + } + let metadata = fs::metadata(&path).map_err(|source| AttachmentServiceError::ReadFile { + path: path.clone(), + source, + })?; + if !metadata.is_file() { + return Ok(None); + } + if let Some(expected_size) = detail.vault_size_bytes { + let observed_size = i64::try_from(metadata.len()).unwrap_or(i64::MAX); + if observed_size != expected_size { + return Ok(None); + } + } + if hash_file_blake3(&path)? != content_hash { + return Ok(None); + } + + Ok(Some(AttachmentFetchReport { + account_id: account_id.to_owned(), + attachment_key: detail.attachment_key.clone(), + message_id: detail.message_id.clone(), + thread_id: detail.thread_id.clone(), + filename: detail.filename.clone(), + mime_type: detail.mime_type.clone(), + size_bytes: detail.size_bytes, + content_hash, + vault_relative_path: relative_path.to_owned(), + vault_path: path, + downloaded: false, + fetched_at_epoch_s, + })) +} + +pub(super) fn resolve_vault_relative_path( + workspace_paths: &WorkspacePaths, + relative_path: &str, +) -> Result { + let path = Path::new(relative_path); + if path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(AttachmentServiceError::InvalidVaultPath { + relative_path: relative_path.to_owned(), + } + .into()); + } + + Ok(workspace_paths.vault_dir.join(path)) +} + +#[derive(Debug)] +pub(super) struct VaultWriteResult { + pub(super) content_hash: String, + pub(super) relative_path: String, + pub(super) path: PathBuf, + pub(super) size_bytes: i64, +} + +pub(super) fn write_vault_bytes(vault_dir: &Path, bytes: Vec) -> Result { + let content_hash = blake3::hash(&bytes).to_hex().to_string(); + let relative_path = format!("blake3/{}/{}", &content_hash[..2], &content_hash); + let path = vault_dir.join(&relative_path); + let parent = path + .parent() + .ok_or_else(|| AttachmentServiceError::InvalidVaultPath { + relative_path: relative_path.clone(), + })?; + fs::create_dir_all(parent).map_err(|source| AttachmentServiceError::CreateDirectory { + path: parent.to_path_buf(), + source, + })?; + write_vault_file_atomically(&path, &bytes)?; + let size_bytes = i64::try_from(bytes.len()).unwrap_or(i64::MAX); + + Ok(VaultWriteResult { + content_hash, + relative_path, + path, + size_bytes, + }) +} + +pub(super) fn write_vault_file_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| AttachmentServiceError::WriteFile { + path: path.to_path_buf(), + source: std::io::Error::other("vault path has no parent"), + })?; + let (mut temp_file, temp_path) = create_unique_vault_temp_file(parent, path)?; + let write_result = (|| -> Result<()> { + temp_file + .write_all(bytes) + .map_err(|source| AttachmentServiceError::WriteFile { + path: temp_path.clone(), + source, + })?; + temp_file + .sync_all() + .map_err(|source| AttachmentServiceError::WriteFile { + path: temp_path.clone(), + source, + })?; + drop(temp_file); + harden_vault_file_permissions(&temp_path)?; + persist_vault_temp_file(&temp_path, path)?; + harden_vault_file_permissions(path) + })(); + + if write_result.is_err() { + let _ = fs::remove_file(&temp_path); + } + + write_result +} + +pub(super) fn create_unique_vault_temp_file( + parent: &Path, + path: &Path, +) -> Result<(fs::File, PathBuf)> { + for attempt in 0..TEMP_PATH_RETRY_LIMIT { + let temp_path = unique_vault_temp_path(parent, path, attempt)?; + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + { + Ok(file) => return Ok((file, temp_path)), + Err(source) + if source.kind() == std::io::ErrorKind::AlreadyExists + && attempt + 1 < TEMP_PATH_RETRY_LIMIT => + { + continue; + } + Err(source) => { + return Err(AttachmentServiceError::WriteFile { + path: temp_path, + source, + } + .into()); + } + } + } + + Err(AttachmentServiceError::WriteFile { + path: path.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "failed to create a unique temporary vault file", + ), + } + .into()) +} + +fn unique_vault_temp_path(parent: &Path, path: &Path, attempt: usize) -> Result { + let file_name = path + .file_name() + .ok_or_else(|| AttachmentServiceError::WriteFile { + path: path.to_path_buf(), + source: std::io::Error::other("vault path has no filename"), + })?; + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + Ok(parent.join(format!( + ".{}.tmp-{}-{now_nanos}-{attempt}", + file_name.to_string_lossy(), + std::process::id() + ))) +} + +#[cfg(windows)] +fn unique_vault_backup_path(parent: &Path, path: &Path) -> Result { + let file_name = path + .file_name() + .ok_or_else(|| AttachmentServiceError::WriteFile { + path: path.to_path_buf(), + source: std::io::Error::other("vault path has no filename"), + })?; + let now_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + Ok(parent.join(format!( + ".{}.bak-{}-{now_nanos}", + file_name.to_string_lossy(), + std::process::id() + ))) +} + +pub(super) fn persist_vault_temp_file(tmp_path: &Path, destination: &Path) -> Result<()> { + #[cfg(windows)] + { + let parent = destination + .parent() + .ok_or_else(|| AttachmentServiceError::WriteFile { + path: destination.to_path_buf(), + source: std::io::Error::other("vault path has no parent"), + })?; + let backup_path = unique_vault_backup_path(parent, destination)?; + let moved_destination_to_backup = if destination.exists() { + fs::rename(destination, &backup_path).map_err(|source| { + AttachmentServiceError::WriteFile { + path: destination.to_path_buf(), + source, + } + })?; + true + } else { + false + }; + + match fs::rename(tmp_path, destination) { + Ok(()) => { + if moved_destination_to_backup { + fs::remove_file(&backup_path).map_err(|source| { + AttachmentServiceError::WriteFile { + path: backup_path, + source, + } + })?; + } + return Ok(()); + } + Err(source) => { + if moved_destination_to_backup { + let _ = fs::rename(&backup_path, destination); + } + return Err(AttachmentServiceError::WriteFile { + path: destination.to_path_buf(), + source, + } + .into()); + } + } + } + + fs::rename(tmp_path, destination).map_err(|source| AttachmentServiceError::WriteFile { + path: destination.to_path_buf(), + source, + })?; + Ok(()) +} + +pub(super) fn hash_file_blake3(path: &Path) -> Result { + let mut file = fs::File::open(path).map_err(|source| AttachmentServiceError::ReadFile { + path: path.to_path_buf(), + source, + })?; + let mut hasher = blake3::Hasher::new(); + hasher + .update_reader(&mut file) + .map_err(|source| AttachmentServiceError::ReadFile { + path: path.to_path_buf(), + source, + })?; + Ok(hasher.finalize().to_hex().to_string()) +} + +#[cfg(unix)] +pub(super) fn harden_vault_file_permissions(path: &Path) -> Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|source| { + AttachmentServiceError::WriteFile { + path: path.to_path_buf(), + source, + } + .into() + }) +} + +#[cfg(not(unix))] +pub(super) fn harden_vault_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/src/auth/oauth_client.rs b/src/auth/oauth_client.rs deleted file mode 100644 index 636b66f..0000000 --- a/src/auth/oauth_client.rs +++ /dev/null @@ -1,1357 +0,0 @@ -use crate::config::{GmailConfig, WorkspaceConfig}; -use anyhow::{Context, Result, anyhow}; -use dialoguer::{Input, Password, Select, theme::ColorfulTheme}; -use secrecy::SecretString; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::io::{IsTerminal, stdin, stdout}; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use thiserror::Error; - -pub const GOOGLE_AUTH_OVERVIEW_URL: &str = "https://console.cloud.google.com/auth/overview"; -pub const GOOGLE_AUTH_CLIENTS_URL: &str = "https://console.cloud.google.com/auth/clients"; -pub const GOOGLE_GMAIL_API_URL: &str = - "https://console.cloud.google.com/apis/library/gmail.googleapis.com"; -const GOOGLE_AUTH_CERTS_URL: &str = "https://www.googleapis.com/oauth2/v1/certs"; -const DEFAULT_REDIRECT_URI: &str = "http://localhost"; -const DEFAULT_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; -const DEFAULT_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ResolvedOAuthClient { - pub client_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_secret: Option, -} - -#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ImportedOAuthClientSourceKind { - DownloadedJson, - ManualPaste, - GcloudAdc, -} - -impl ImportedOAuthClientSourceKind { - pub fn as_str(self) -> &'static str { - match self { - Self::DownloadedJson => "downloaded_json", - Self::ManualPaste => "manual_paste", - Self::GcloudAdc => "gcloud_adc", - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct ImportedOAuthClient { - pub source_kind: ImportedOAuthClientSourceKind, - #[serde(skip_serializing_if = "Option::is_none")] - pub source_path: Option, - pub oauth_client_path: PathBuf, - pub auto_discovered: bool, - pub client_id: String, - pub client_secret_present: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub project_id: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OAuthClientSource { - WorkspaceFile, - InlineConfig, - Unconfigured, -} - -impl OAuthClientSource { - pub fn as_str(self) -> &'static str { - match self { - Self::WorkspaceFile => "workspace_file", - Self::InlineConfig => "config", - Self::Unconfigured => "unconfigured", - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct PreparedOAuthClientImport { - imported_client: ImportedOAuthClient, - resolved_client: ResolvedOAuthClient, - stored_client: StoredOAuthClientFile, -} - -impl PreparedOAuthClientImport { - pub(crate) fn imported_client(&self) -> &ImportedOAuthClient { - &self.imported_client - } - - pub(crate) fn resolved_client(&self) -> &ResolvedOAuthClient { - &self.resolved_client - } -} - -#[derive(Debug, Clone)] -pub(crate) struct PreparedAdcOAuthClientImport { - client_import: PreparedOAuthClientImport, - refresh_token: SecretString, -} - -impl PreparedAdcOAuthClientImport { - pub(crate) fn imported_client(&self) -> &ImportedOAuthClient { - self.client_import.imported_client() - } - - pub(crate) fn resolved_client(&self) -> &ResolvedOAuthClient { - self.client_import.resolved_client() - } - - pub(crate) fn refresh_token(&self) -> &SecretString { - &self.refresh_token - } - - pub(crate) fn client_import(&self) -> &PreparedOAuthClientImport { - &self.client_import - } -} - -pub(crate) enum PreparedSetup { - UseExisting, - ImportClient(PreparedOAuthClientImport), - ImportAdc(PreparedAdcOAuthClientImport), -} - -#[derive(Debug, Error)] -pub enum OAuthClientError { - #[error( - "gmail OAuth client is not configured; run `mailroom auth setup` or set gmail.client_id" - )] - MissingClientConfiguration, - #[error("Google desktop-app credentials JSON was not found at {path}")] - MissingImportFile { path: PathBuf }, - #[error( - "Mailroom could not auto-discover a Google desktop-app credentials JSON. Pass `--credentials-file PATH` or run `mailroom auth setup` in an interactive terminal." - )] - MissingImportCandidate, - #[error( - "Mailroom found multiple candidate Google desktop-app credentials JSON files. Pass `--credentials-file PATH` to pick one." - )] - AmbiguousImportCandidate, - #[error( - "the Google credentials JSON is not a Desktop app client; create a Desktop app OAuth client and use its credentials" - )] - UnsupportedClientType, - #[error("the credentials JSON is missing required field `{0}`")] - MissingField(&'static str), - #[error("the imported OAuth client file is missing required field `{0}`")] - MissingStoredField(&'static str), - #[error( - "no interactive terminal is available; pass `--credentials-file PATH` or set gmail.client_id" - )] - PromptUnavailable, - #[error("the gcloud ADC file uses unsupported credential type `{0}`")] - UnsupportedAdcType(String), - #[error("the gcloud ADC file is missing required field `{0}`")] - MissingAdcField(&'static str), -} - -pub fn resolve(config: &GmailConfig, workspace: &WorkspaceConfig) -> Result { - let oauth_client_path = config.oauth_client_path(workspace); - if let Some(stored) = load_imported_client(&oauth_client_path)? { - return Ok(ResolvedOAuthClient { - client_id: stored.installed.client_id, - client_secret: normalize_optional_string(Some(stored.installed.client_secret)), - }); - } - - if let Some(client_id) = normalize_optional_string(config.client_id.clone()) { - return Ok(ResolvedOAuthClient { - client_id, - client_secret: normalize_optional_string(config.client_secret.clone()), - }); - } - - Err(OAuthClientError::MissingClientConfiguration.into()) -} - -pub fn oauth_client_source( - config: &GmailConfig, - workspace: &WorkspaceConfig, -) -> Result { - let oauth_client_path = config.oauth_client_path(workspace); - if load_imported_client(&oauth_client_path)?.is_some() { - return Ok(OAuthClientSource::WorkspaceFile); - } - - if normalize_optional_string(config.client_id.clone()).is_some() { - return Ok(OAuthClientSource::InlineConfig); - } - - Ok(OAuthClientSource::Unconfigured) -} - -#[cfg(test)] -pub fn oauth_client_exists(config: &GmailConfig, workspace: &WorkspaceConfig) -> Result { - let oauth_client_path = config.oauth_client_path(workspace); - if !oauth_client_path.is_file() { - return Ok(false); - } - - let _ = load_imported_client(&oauth_client_path)?; - Ok(true) -} - -#[cfg(test)] -pub fn import_google_desktop_client( - config: &GmailConfig, - workspace: &WorkspaceConfig, - credentials_file: Option, -) -> Result { - let prepared = prepare_google_desktop_client(config, workspace, credentials_file)?; - persist_prepared_google_desktop_client(&prepared)?; - Ok(prepared.imported_client) -} - -pub(crate) fn prepare_setup( - config: &GmailConfig, - workspace: &WorkspaceConfig, - credentials_file: Option, - json: bool, -) -> Result { - if credentials_file.is_some() { - return prepare_google_desktop_client(config, workspace, credentials_file) - .map(PreparedSetup::ImportClient); - } - - match oauth_client_source(config, workspace)? { - OAuthClientSource::WorkspaceFile | OAuthClientSource::InlineConfig => { - return Ok(PreparedSetup::UseExisting); - } - OAuthClientSource::Unconfigured => {} - } - - let candidates = normalize_candidate_paths(default_import_candidates()?); - let detected_adc = detect_adc_path(); - if should_use_interactive_setup(json, is_interactive_terminal()) { - prepare_interactive_setup(config, workspace, candidates, detected_adc) - } else { - prepare_noninteractive_setup(config, workspace, candidates, detected_adc) - } -} - -fn should_use_interactive_setup(json: bool, interactive_terminal: bool) -> bool { - !json && interactive_terminal -} - -pub(crate) fn persist_prepared_google_desktop_client( - prepared: &PreparedOAuthClientImport, -) -> Result<()> { - save_imported_client( - &prepared.imported_client.oauth_client_path, - &prepared.stored_client, - ) -} - -pub fn setup_guidance() -> String { - format!( - "Google-side setup checklist:\n\ - 1. Open {GOOGLE_AUTH_OVERVIEW_URL}\n\ - 2. Enable Gmail API at {GOOGLE_GMAIL_API_URL}\n\ - 3. Create a Desktop app OAuth client at {GOOGLE_AUTH_CLIENTS_URL}\n\ - 4. Either download the credentials JSON and run `mailroom auth setup --credentials-file /path/to/client_secret.json`, or run `mailroom auth setup` in an interactive terminal to paste the Client ID and optional Client Secret\n\ - 5. Advanced: if you already ran `gcloud auth application-default login` with Gmail scopes, `mailroom auth setup` can also import that existing ADC session" - ) -} - -pub(crate) fn prepare_google_desktop_client( - config: &GmailConfig, - workspace: &WorkspaceConfig, - credentials_file: Option, -) -> Result { - let discovery = discover_import_path(credentials_file)?; - let client = parse_google_desktop_client(&discovery.path, config)?; - Ok(prepare_client_import( - config, - workspace, - client, - ImportedOAuthClientSourceKind::DownloadedJson, - Some(discovery.path), - discovery.auto_discovered, - )) -} - -pub(crate) fn prepare_google_desktop_client_from_values( - config: &GmailConfig, - workspace: &WorkspaceConfig, - client_id: String, - client_secret: Option, -) -> Result { - let client = GoogleDesktopClient { - client_id: normalize_required_input_string(client_id, "client_id")?, - client_secret: normalize_optional_string(client_secret), - project_id: None, - auth_uri: config.auth_url.clone(), - token_uri: config.token_url.clone(), - auth_provider_x509_cert_url: GOOGLE_AUTH_CERTS_URL.to_owned(), - redirect_uris: vec![DEFAULT_REDIRECT_URI.to_owned()], - }; - - Ok(prepare_client_import( - config, - workspace, - client, - ImportedOAuthClientSourceKind::ManualPaste, - None, - false, - )) -} - -pub(crate) fn prepare_google_desktop_client_from_adc( - config: &GmailConfig, - workspace: &WorkspaceConfig, - adc_path: PathBuf, -) -> Result { - let adc = parse_authorized_user_adc(&adc_path)?; - let client = GoogleDesktopClient { - client_id: adc.client_id.clone(), - client_secret: adc.client_secret.clone(), - project_id: adc.quota_project_id.clone(), - auth_uri: config.auth_url.clone(), - token_uri: config.token_url.clone(), - auth_provider_x509_cert_url: GOOGLE_AUTH_CERTS_URL.to_owned(), - redirect_uris: vec![DEFAULT_REDIRECT_URI.to_owned()], - }; - let client_import = prepare_client_import( - config, - workspace, - client, - ImportedOAuthClientSourceKind::GcloudAdc, - Some(adc_path), - false, - ); - - Ok(PreparedAdcOAuthClientImport { - client_import, - refresh_token: SecretString::from(adc.refresh_token), - }) -} - -fn prepare_interactive_setup( - config: &GmailConfig, - workspace: &WorkspaceConfig, - candidates: Vec, - detected_adc: Option, -) -> Result { - let theme = ColorfulTheme::default(); - - if candidates.is_empty() && detected_adc.is_none() { - return prompt_manual_oauth_client(config, workspace, &theme) - .map(PreparedSetup::ImportClient); - } - - let mut choices = Vec::new(); - let mut labels = Vec::new(); - - for candidate in candidates { - labels.push(format!( - "Use downloaded Desktop app JSON: {}", - candidate.display() - )); - choices.push(InteractiveSetupChoice::DownloadedJson(candidate)); - } - - labels.push(String::from( - "Paste Client ID and optional Client Secret into the CLI", - )); - choices.push(InteractiveSetupChoice::ManualPaste); - - if let Some(adc_path) = detected_adc { - labels.push(format!( - "Import existing gcloud ADC auth: {}", - adc_path.display() - )); - choices.push(InteractiveSetupChoice::GcloudAdc(adc_path)); - } - - let selection = Select::with_theme(&theme) - .with_prompt("Choose how Mailroom should configure Gmail OAuth") - .default(0) - .items(&labels) - .interact()?; - - match choices.into_iter().nth(selection) { - Some(InteractiveSetupChoice::DownloadedJson(path)) => { - prepare_google_desktop_client(config, workspace, Some(path)) - .map(PreparedSetup::ImportClient) - } - Some(InteractiveSetupChoice::ManualPaste) => { - prompt_manual_oauth_client(config, workspace, &theme).map(PreparedSetup::ImportClient) - } - Some(InteractiveSetupChoice::GcloudAdc(path)) => { - prepare_google_desktop_client_from_adc(config, workspace, path) - .map(PreparedSetup::ImportAdc) - } - None => Err(anyhow!("interactive setup selection was out of bounds")), - } -} - -fn prepare_noninteractive_setup( - config: &GmailConfig, - workspace: &WorkspaceConfig, - candidates: Vec, - detected_adc: Option, -) -> Result { - match candidates.as_slice() { - [path] => prepare_google_desktop_client(config, workspace, Some(path.clone())) - .map(PreparedSetup::ImportClient), - [] => match detected_adc { - Some(adc_path) => prepare_google_desktop_client_from_adc(config, workspace, adc_path) - .map(PreparedSetup::ImportAdc), - None => Err(anyhow!( - "{}\n\n{}", - OAuthClientError::MissingImportCandidate, - setup_guidance() - )), - }, - _ => { - let listed = candidates - .iter() - .map(|path| format!("- {}", path.display())) - .collect::>() - .join("\n"); - Err(anyhow!( - "{}\n{}\n\n{}", - OAuthClientError::AmbiguousImportCandidate, - listed, - setup_guidance() - )) - } - } -} - -fn prompt_manual_oauth_client( - config: &GmailConfig, - workspace: &WorkspaceConfig, - theme: &ColorfulTheme, -) -> Result { - if !is_interactive_terminal() { - return Err(OAuthClientError::PromptUnavailable.into()); - } - - let client_id: String = Input::with_theme(theme) - .with_prompt("Google OAuth Client ID") - .validate_with(|value: &String| -> Result<(), &str> { - if value.trim().is_empty() { - Err("Client ID cannot be empty") - } else { - Ok(()) - } - }) - .interact_text()?; - - let client_secret = Password::with_theme(theme) - .with_prompt("Google OAuth Client Secret (optional, press enter to skip)") - .allow_empty_password(true) - .interact()?; - - prepare_google_desktop_client_from_values( - config, - workspace, - client_id.trim().to_owned(), - normalize_optional_string(Some(client_secret)), - ) -} - -fn prepare_client_import( - config: &GmailConfig, - workspace: &WorkspaceConfig, - client: GoogleDesktopClient, - source_kind: ImportedOAuthClientSourceKind, - source_path: Option, - auto_discovered: bool, -) -> PreparedOAuthClientImport { - let oauth_client_path = config.oauth_client_path(workspace); - let stored_client = StoredOAuthClientFile { - installed: StoredInstalledOAuthClient { - client_id: client.client_id.clone(), - client_secret: client.client_secret.clone().unwrap_or_default(), - project_id: client.project_id.clone(), - auth_uri: client.auth_uri.clone(), - token_uri: client.token_uri.clone(), - auth_provider_x509_cert_url: client.auth_provider_x509_cert_url.clone(), - redirect_uris: client.redirect_uris.clone(), - }, - }; - - PreparedOAuthClientImport { - imported_client: ImportedOAuthClient { - source_kind, - source_path, - oauth_client_path, - auto_discovered, - client_id: client.client_id.clone(), - client_secret_present: client.client_secret.is_some(), - project_id: client.project_id, - }, - resolved_client: ResolvedOAuthClient { - client_id: client.client_id, - client_secret: client.client_secret, - }, - stored_client, - } -} - -fn load_imported_client(path: &Path) -> Result> { - let raw = match fs::read_to_string(path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(error) - .with_context(|| format!("failed to read OAuth client from {}", path.display())); - } - }; - - if let Ok(stored) = serde_json::from_str::(&raw) { - validate_stored_oauth_client(&stored)?; - return Ok(Some(stored)); - } - - let legacy: LegacyStoredOAuthClient = serde_json::from_str(&raw) - .with_context(|| format!("failed to parse OAuth client from {}", path.display()))?; - let stored = StoredOAuthClientFile { - installed: StoredInstalledOAuthClient { - client_id: normalize_required_input_string(legacy.client_id, "client_id")?, - client_secret: legacy.client_secret.unwrap_or_default(), - project_id: None, - auth_uri: DEFAULT_AUTH_URL.to_owned(), - token_uri: DEFAULT_TOKEN_URL.to_owned(), - auth_provider_x509_cert_url: GOOGLE_AUTH_CERTS_URL.to_owned(), - redirect_uris: vec![DEFAULT_REDIRECT_URI.to_owned()], - }, - }; - validate_stored_oauth_client(&stored)?; - Ok(Some(stored)) -} - -fn validate_stored_oauth_client(client: &StoredOAuthClientFile) -> Result<()> { - if client.installed.client_id.trim().is_empty() { - return Err(OAuthClientError::MissingStoredField("installed.client_id").into()); - } - if client.installed.auth_uri.trim().is_empty() { - return Err(OAuthClientError::MissingStoredField("installed.auth_uri").into()); - } - if client.installed.token_uri.trim().is_empty() { - return Err(OAuthClientError::MissingStoredField("installed.token_uri").into()); - } - - Ok(()) -} - -fn save_imported_client(path: &Path, client: &StoredOAuthClientFile) -> Result<()> { - let parent = path - .parent() - .with_context(|| format!("OAuth client path {} has no parent", path.display()))?; - fs::create_dir_all(parent)?; - set_owner_only_dir_permissions(parent)?; - - let payload = serde_json::to_vec_pretty(client)?; - let tmp_path = path.with_extension("tmp"); - fs::write(&tmp_path, payload)?; - set_owner_only_file_permissions(&tmp_path)?; - persist_tmp_file(&tmp_path, path)?; - Ok(()) -} - -fn discover_import_path(credentials_file: Option) -> Result { - if let Some(path) = credentials_file { - if !path.exists() { - return Err(OAuthClientError::MissingImportFile { path }.into()); - } - return Ok(ImportDiscovery { - path, - auto_discovered: false, - }); - } - - let candidates = normalize_candidate_paths(default_import_candidates()?); - - match candidates.as_slice() { - [] => Err(anyhow!( - "{}\n\n{}", - OAuthClientError::MissingImportCandidate, - setup_guidance() - )), - [path] => Ok(ImportDiscovery { - path: path.clone(), - auto_discovered: true, - }), - _ => { - let listed = candidates - .iter() - .map(|path| format!("- {}", path.display())) - .collect::>() - .join("\n"); - Err(anyhow!( - "{}\n{}\n\n{}", - OAuthClientError::AmbiguousImportCandidate, - listed, - setup_guidance() - )) - } - } -} - -fn default_import_candidates() -> Result> { - default_import_candidates_from_env( - &std::env::current_dir()?, - std::env::var_os("HOME").map(PathBuf::from), - std::env::var_os("USERPROFILE").map(PathBuf::from), - ) -} - -fn default_import_candidates_from_env( - current_dir: &Path, - home_dir: Option, - user_profile_dir: Option, -) -> Result> { - let mut candidates = collect_candidate_files(current_dir)?; - - for downloads_dir in default_download_dirs(home_dir, user_profile_dir) { - candidates.extend(collect_candidate_files(&downloads_dir)?); - } - - Ok(candidates) -} - -fn default_download_dirs( - home_dir: Option, - user_profile_dir: Option, -) -> Vec { - let mut downloads_dirs = Vec::new(); - - if let Some(home_dir) = home_dir { - downloads_dirs.push(home_dir.join("Downloads")); - downloads_dirs.push(home_dir.join("downloads")); - } - - if let Some(user_profile_dir) = user_profile_dir { - let downloads_dir = user_profile_dir.join("Downloads"); - if !downloads_dirs.contains(&downloads_dir) { - downloads_dirs.push(downloads_dir); - } - } - - downloads_dirs -} - -fn normalize_candidate_paths(mut candidates: Vec) -> Vec { - candidates.sort(); - candidates.dedup(); - candidates -} - -fn collect_candidate_files(dir: &Path) -> Result> { - let entries = match fs::read_dir(dir) { - Ok(entries) => entries, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(error) => { - return Err(error).with_context(|| { - format!( - "failed to inspect candidate credentials directory {}", - dir.display() - ) - }); - } - }; - - let mut candidates = Vec::new(); - for entry in entries { - let entry = entry?; - if !entry.file_type()?.is_file() { - continue; - } - - let file_name = entry.file_name(); - let file_name = file_name.to_string_lossy(); - if file_name.starts_with("client_secret_") && file_name.ends_with(".json") { - candidates.push(entry.path()); - } - } - - Ok(candidates) -} - -fn parse_google_desktop_client(path: &Path, config: &GmailConfig) -> Result { - let raw = fs::read_to_string(path).with_context(|| { - format!( - "failed to read Google credentials JSON from {}", - path.display() - ) - })?; - let parsed: DownloadedGoogleCredentials = serde_json::from_str(&raw).with_context(|| { - format!( - "failed to parse Google credentials JSON from {}", - path.display() - ) - })?; - - let installed = parsed - .installed - .ok_or(OAuthClientError::UnsupportedClientType)?; - - Ok(GoogleDesktopClient { - client_id: normalize_required_option_string(installed.client_id, "installed.client_id")?, - client_secret: normalize_optional_string(installed.client_secret), - project_id: normalize_optional_string(installed.project_id), - auth_uri: installed - .auth_uri - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| config.auth_url.clone()), - token_uri: installed - .token_uri - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| config.token_url.clone()), - auth_provider_x509_cert_url: installed - .auth_provider_x509_cert_url - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| GOOGLE_AUTH_CERTS_URL.to_owned()), - redirect_uris: normalize_redirect_uris(installed.redirect_uris), - }) -} - -fn parse_authorized_user_adc(path: &Path) -> Result { - let raw = fs::read_to_string(path) - .with_context(|| format!("failed to read gcloud ADC file from {}", path.display()))?; - let parsed: AuthorizedUserAdcFile = serde_json::from_str(&raw) - .with_context(|| format!("failed to parse gcloud ADC file from {}", path.display()))?; - - let credential_type = parsed - .credential_type - .unwrap_or_else(|| String::from("unknown")); - if credential_type != "authorized_user" { - return Err(OAuthClientError::UnsupportedAdcType(credential_type).into()); - } - - Ok(AuthorizedUserAdc { - client_id: normalize_required_adc_field(parsed.client_id, "client_id")?, - client_secret: normalize_optional_string(parsed.client_secret), - refresh_token: normalize_required_adc_field(parsed.refresh_token, "refresh_token")?, - quota_project_id: normalize_optional_string(parsed.quota_project_id), - }) -} - -fn detect_adc_path() -> Option { - detect_adc_path_from_env( - std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS").map(PathBuf::from), - std::env::var_os("HOME").map(PathBuf::from), - ) -} - -fn detect_adc_path_from_env( - adc_env_path: Option, - home_dir: Option, -) -> Option { - if let Some(adc_path) = adc_env_path - && adc_path.exists() - { - return Some(adc_path); - } - - let home_dir = home_dir?; - let well_known = home_dir.join(".config/gcloud/application_default_credentials.json"); - if well_known.exists() { - return Some(well_known); - } - - None -} - -fn normalize_optional_string(value: Option) -> Option { - value.and_then(|value| { - let trimmed = value.trim().to_owned(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) -} - -fn normalize_required_option_string(value: Option, field: &'static str) -> Result { - normalize_optional_string(value).ok_or_else(|| OAuthClientError::MissingField(field).into()) -} - -fn normalize_required_adc_field(value: Option, field: &'static str) -> Result { - normalize_optional_string(value).ok_or_else(|| OAuthClientError::MissingAdcField(field).into()) -} - -fn normalize_required_input_string(value: String, field: &'static str) -> Result { - let trimmed = value.trim().to_owned(); - if trimmed.is_empty() { - return Err(OAuthClientError::MissingField(field).into()); - } - Ok(trimmed) -} - -fn normalize_redirect_uris(redirect_uris: Option>) -> Vec { - let cleaned = redirect_uris - .unwrap_or_default() - .into_iter() - .filter_map(|uri| { - let trimmed = uri.trim().to_owned(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } - }) - .collect::>(); - - if cleaned.is_empty() { - vec![DEFAULT_REDIRECT_URI.to_owned()] - } else { - cleaned - } -} - -fn is_interactive_terminal() -> bool { - stdin().is_terminal() && stdout().is_terminal() -} - -#[cfg(unix)] -fn set_owner_only_dir_permissions(path: &Path) -> Result<()> { - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; - Ok(()) -} - -#[cfg(not(unix))] -fn set_owner_only_dir_permissions(_path: &Path) -> Result<()> { - Ok(()) -} - -#[cfg(unix)] -fn set_owner_only_file_permissions(path: &Path) -> Result<()> { - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; - Ok(()) -} - -#[cfg(not(unix))] -fn set_owner_only_file_permissions(_path: &Path) -> Result<()> { - Ok(()) -} - -fn persist_tmp_file(tmp_path: &Path, destination: &Path) -> Result<()> { - #[cfg(windows)] - { - if destination.exists() { - fs::remove_file(destination)?; - } - } - - fs::rename(tmp_path, destination)?; - Ok(()) -} - -#[derive(Debug, Clone)] -struct ImportDiscovery { - path: PathBuf, - auto_discovered: bool, -} - -#[derive(Debug, Clone)] -struct GoogleDesktopClient { - client_id: String, - client_secret: Option, - project_id: Option, - auth_uri: String, - token_uri: String, - auth_provider_x509_cert_url: String, - redirect_uris: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredOAuthClientFile { - installed: StoredInstalledOAuthClient, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredInstalledOAuthClient { - client_id: String, - #[serde(default)] - client_secret: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - project_id: Option, - auth_uri: String, - token_uri: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - auth_provider_x509_cert_url: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - redirect_uris: Vec, -} - -#[derive(Debug, Deserialize)] -struct DownloadedGoogleCredentials { - installed: Option, -} - -#[derive(Debug, Deserialize)] -struct DownloadedInstalledClient { - client_id: Option, - client_secret: Option, - project_id: Option, - auth_uri: Option, - token_uri: Option, - auth_provider_x509_cert_url: Option, - redirect_uris: Option>, -} - -#[derive(Debug, Deserialize)] -struct LegacyStoredOAuthClient { - client_id: String, - client_secret: Option, -} - -#[derive(Debug, Deserialize)] -struct AuthorizedUserAdcFile { - #[serde(rename = "type")] - credential_type: Option, - client_id: Option, - client_secret: Option, - refresh_token: Option, - quota_project_id: Option, -} - -#[derive(Debug)] -struct AuthorizedUserAdc { - client_id: String, - client_secret: Option, - refresh_token: String, - quota_project_id: Option, -} - -#[derive(Debug)] -enum InteractiveSetupChoice { - DownloadedJson(PathBuf), - ManualPaste, - GcloudAdc(PathBuf), -} - -#[cfg(test)] -mod tests { - use super::{ - GOOGLE_AUTH_CLIENTS_URL, GOOGLE_AUTH_OVERVIEW_URL, ImportedOAuthClient, - ImportedOAuthClientSourceKind, OAuthClientError, OAuthClientSource, PreparedSetup, - default_download_dirs, detect_adc_path_from_env, import_google_desktop_client, - normalize_candidate_paths, oauth_client_exists, oauth_client_source, - prepare_google_desktop_client_from_adc, prepare_google_desktop_client_from_values, - prepare_noninteractive_setup, resolve, setup_guidance, should_use_interactive_setup, - }; - use crate::config::{GmailConfig, WorkspaceConfig}; - use secrecy::ExposeSecret; - use std::fs; - use std::path::PathBuf; - use tempfile::TempDir; - - fn workspace_for(temp_dir: &TempDir) -> WorkspaceConfig { - let root = temp_dir.path().join(".mailroom"); - WorkspaceConfig { - runtime_root: root.clone(), - auth_dir: root.join("auth"), - cache_dir: root.join("cache"), - state_dir: root.join("state"), - vault_dir: root.join("vault"), - exports_dir: root.join("exports"), - logs_dir: root.join("logs"), - } - } - - fn gmail_config() -> GmailConfig { - GmailConfig { - client_id: None, - client_secret: None, - auth_url: String::from("https://accounts.google.com/o/oauth2/v2/auth"), - token_url: String::from("https://oauth2.googleapis.com/token"), - api_base_url: String::from("https://gmail.googleapis.com/gmail/v1"), - listen_host: String::from("127.0.0.1"), - listen_port: 0, - open_browser: true, - request_timeout_secs: 30, - scopes: vec![String::from("scope:a")], - } - } - - #[test] - fn imported_client_becomes_the_resolved_oauth_source() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - let config = gmail_config(); - let credentials_path = temp_dir.path().join("client_secret_test.json"); - fs::write( - &credentials_path, - r#"{ - "installed": { - "client_id": "desktop-client.apps.googleusercontent.com", - "client_secret": "desktop-secret", - "project_id": "mailroom-dev", - "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "redirect_uris": ["http://localhost"] - } -}"#, - ) - .unwrap(); - - let imported = - import_google_desktop_client(&config, &workspace, Some(credentials_path)).unwrap(); - let resolved = resolve(&config, &workspace).unwrap(); - - assert_eq!( - imported, - ImportedOAuthClient { - source_kind: ImportedOAuthClientSourceKind::DownloadedJson, - source_path: Some(temp_dir.path().join("client_secret_test.json")), - oauth_client_path: workspace.auth_dir.join("gmail-oauth-client.json"), - auto_discovered: false, - client_id: String::from("desktop-client.apps.googleusercontent.com"), - client_secret_present: true, - project_id: Some(String::from("mailroom-dev")), - } - ); - assert_eq!( - resolved.client_id, - "desktop-client.apps.googleusercontent.com" - ); - assert_eq!(resolved.client_secret.as_deref(), Some("desktop-secret")); - assert!(oauth_client_exists(&config, &workspace).unwrap()); - - let saved = fs::read_to_string(workspace.auth_dir.join("gmail-oauth-client.json")).unwrap(); - assert!(saved.contains("\"installed\"")); - assert!(saved.contains("\"project_id\": \"mailroom-dev\"")); - } - - #[test] - fn manual_paste_preparation_builds_standard_google_client_file() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - let config = gmail_config(); - - let prepared = prepare_google_desktop_client_from_values( - &config, - &workspace, - String::from("manual-client.apps.googleusercontent.com"), - Some(String::from("manual-secret")), - ) - .unwrap(); - - assert_eq!( - prepared.imported_client().source_kind, - ImportedOAuthClientSourceKind::ManualPaste - ); - assert_eq!( - prepared.resolved_client().client_id, - "manual-client.apps.googleusercontent.com" - ); - assert_eq!( - prepared.resolved_client().client_secret.as_deref(), - Some("manual-secret") - ); - } - - #[test] - fn adc_import_extracts_client_and_refresh_token() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - let config = gmail_config(); - let adc_path = temp_dir.path().join("application_default_credentials.json"); - fs::write( - &adc_path, - r#"{ - "type": "authorized_user", - "client_id": "adc-client.apps.googleusercontent.com", - "client_secret": "adc-secret", - "refresh_token": "adc-refresh-token", - "quota_project_id": "adc-project" -}"#, - ) - .unwrap(); - - let prepared = - prepare_google_desktop_client_from_adc(&config, &workspace, adc_path.clone()).unwrap(); - - assert_eq!( - prepared.imported_client().source_kind, - ImportedOAuthClientSourceKind::GcloudAdc - ); - assert_eq!( - prepared.imported_client().source_path.as_ref(), - Some(&adc_path) - ); - assert_eq!( - prepared.resolved_client().client_id, - "adc-client.apps.googleusercontent.com" - ); - assert_eq!( - prepared.refresh_token().expose_secret(), - "adc-refresh-token" - ); - } - - #[test] - fn detect_adc_path_ignores_missing_env_path_and_falls_back_to_well_known_adc() { - let temp_dir = TempDir::new().unwrap(); - let missing_env_path = temp_dir.path().join("missing-adc.json"); - let well_known_adc = temp_dir - .path() - .join(".config/gcloud/application_default_credentials.json"); - fs::create_dir_all(well_known_adc.parent().unwrap()).unwrap(); - fs::write(&well_known_adc, "{}").unwrap(); - - let detected = - detect_adc_path_from_env(Some(missing_env_path), Some(temp_dir.path().into())); - - assert_eq!(detected, Some(well_known_adc)); - } - - #[test] - fn noninteractive_setup_uses_adc_when_no_downloaded_json_is_available() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - let config = gmail_config(); - let adc_path = temp_dir.path().join("application_default_credentials.json"); - fs::write( - &adc_path, - r#"{ - "type": "authorized_user", - "client_id": "adc-client.apps.googleusercontent.com", - "client_secret": "adc-secret", - "refresh_token": "adc-refresh-token" -}"#, - ) - .unwrap(); - - let prepared = - prepare_noninteractive_setup(&config, &workspace, Vec::new(), Some(adc_path)).unwrap(); - - match prepared { - PreparedSetup::ImportAdc(prepared) => { - assert_eq!( - prepared.imported_client().source_kind, - ImportedOAuthClientSourceKind::GcloudAdc - ); - assert_eq!( - prepared.resolved_client().client_id, - "adc-client.apps.googleusercontent.com" - ); - assert_eq!( - prepared.refresh_token().expose_secret(), - "adc-refresh-token" - ); - } - PreparedSetup::UseExisting | PreparedSetup::ImportClient(_) => { - panic!("expected non-interactive setup to import the ADC path") - } - } - } - - #[test] - fn source_falls_back_to_unconfigured_when_workspace_file_disappears() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - - assert_eq!( - oauth_client_source(&gmail_config(), &workspace).unwrap(), - OAuthClientSource::Unconfigured - ); - } - - #[test] - fn prepare_setup_deduplicates_discovered_candidate_paths() { - let candidate = PathBuf::from("/tmp/client_secret_duplicate.json"); - let normalized = normalize_candidate_paths(vec![candidate.clone(), candidate.clone()]); - - assert_eq!(normalized, vec![candidate]); - } - - #[test] - fn default_download_dirs_only_include_download_locations() { - let home_dir = PathBuf::from("/home/tester"); - let user_profile_dir = PathBuf::from("C:/Users/tester"); - let downloads_dirs = default_download_dirs(Some(home_dir), Some(user_profile_dir)); - - assert_eq!( - downloads_dirs, - vec![ - PathBuf::from("/home/tester/Downloads"), - PathBuf::from("/home/tester/downloads"), - PathBuf::from("C:/Users/tester/Downloads"), - ] - ); - } - - #[test] - fn json_setup_never_uses_the_interactive_wizard() { - assert!(!should_use_interactive_setup(true, true)); - assert!(!should_use_interactive_setup(true, false)); - assert!(should_use_interactive_setup(false, true)); - assert!(!should_use_interactive_setup(false, false)); - } - - #[test] - fn legacy_stored_client_file_is_still_resolved() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - fs::create_dir_all(&workspace.auth_dir).unwrap(); - fs::write( - workspace.auth_dir.join("gmail-oauth-client.json"), - r#"{ - "client_id": "legacy-client.apps.googleusercontent.com", - "client_secret": "legacy-secret" -}"#, - ) - .unwrap(); - - let resolved = resolve(&gmail_config(), &workspace).unwrap(); - - assert_eq!( - resolved.client_id, - "legacy-client.apps.googleusercontent.com" - ); - assert_eq!(resolved.client_secret.as_deref(), Some("legacy-secret")); - } - - #[test] - fn imported_client_takes_precedence_over_inline_config() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - fs::create_dir_all(&workspace.auth_dir).unwrap(); - fs::write( - workspace.auth_dir.join("gmail-oauth-client.json"), - r#"{ - "installed": { - "client_id": "imported-client.apps.googleusercontent.com", - "client_secret": "imported-secret", - "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", - "token_uri": "https://oauth2.googleapis.com/token" - } -}"#, - ) - .unwrap(); - - let resolved = resolve( - &GmailConfig { - client_id: Some(String::from("inline-client.apps.googleusercontent.com")), - client_secret: Some(String::from("inline-secret")), - ..gmail_config() - }, - &workspace, - ) - .unwrap(); - - assert_eq!( - resolved.client_id, - "imported-client.apps.googleusercontent.com" - ); - assert_eq!(resolved.client_secret.as_deref(), Some("imported-secret")); - } - - #[test] - fn inline_config_reports_config_source_without_workspace_file() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - let config = GmailConfig { - client_id: Some(String::from("inline-client.apps.googleusercontent.com")), - client_secret: Some(String::from("inline-secret")), - ..gmail_config() - }; - - let source = oauth_client_source(&config, &workspace).unwrap(); - - assert_eq!(source, OAuthClientSource::InlineConfig); - assert!(!oauth_client_exists(&config, &workspace).unwrap()); - } - - #[test] - fn source_reports_workspace_file_and_validates_it() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - fs::create_dir_all(&workspace.auth_dir).unwrap(); - fs::write( - workspace.auth_dir.join("gmail-oauth-client.json"), - r#"{ - "installed": { - "client_id": "saved-client.apps.googleusercontent.com", - "client_secret": "", - "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", - "token_uri": "https://oauth2.googleapis.com/token" - } -}"#, - ) - .unwrap(); - - assert_eq!( - oauth_client_source(&gmail_config(), &workspace).unwrap(), - OAuthClientSource::WorkspaceFile - ); - } - - #[test] - fn malformed_workspace_file_returns_a_validation_error() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - fs::create_dir_all(&workspace.auth_dir).unwrap(); - fs::write( - workspace.auth_dir.join("gmail-oauth-client.json"), - r#"{ - "installed": { - "client_id": "", - "client_secret": "", - "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", - "token_uri": "https://oauth2.googleapis.com/token" - } -}"#, - ) - .unwrap(); - - let error = oauth_client_source(&gmail_config(), &workspace).unwrap_err(); - assert!(matches!( - error.downcast_ref::(), - Some(OAuthClientError::MissingStoredField("installed.client_id")) - )); - } - - #[test] - fn unsupported_adc_type_is_rejected() { - let temp_dir = TempDir::new().unwrap(); - let workspace = workspace_for(&temp_dir); - let config = gmail_config(); - let adc_path = temp_dir.path().join("application_default_credentials.json"); - fs::write( - &adc_path, - r#"{ - "type": "service_account", - "client_id": "adc-client.apps.googleusercontent.com", - "client_secret": "adc-secret", - "refresh_token": "adc-refresh-token" -}"#, - ) - .unwrap(); - - let error = - prepare_google_desktop_client_from_adc(&config, &workspace, adc_path).unwrap_err(); - assert!(matches!( - error.downcast_ref::(), - Some(OAuthClientError::UnsupportedAdcType(kind)) if kind == "service_account" - )); - } - - #[test] - fn setup_guidance_points_to_console_urls_and_interactive_setup() { - let guidance = setup_guidance(); - - assert!(guidance.contains(GOOGLE_AUTH_OVERVIEW_URL)); - assert!(guidance.contains(GOOGLE_AUTH_CLIENTS_URL)); - assert!(guidance.contains("mailroom auth setup")); - assert!(guidance.contains("interactive terminal")); - } -} diff --git a/src/auth/oauth_client/import.rs b/src/auth/oauth_client/import.rs new file mode 100644 index 0000000..6071279 --- /dev/null +++ b/src/auth/oauth_client/import.rs @@ -0,0 +1,371 @@ +use super::interactive::{ + is_interactive_terminal, prepare_interactive_setup, should_use_interactive_setup, +}; +use super::resolve::{ + OAuthClientError, OAuthClientSource, ResolvedOAuthClient, oauth_client_source, +}; +use super::storage::{ + default_import_candidates, detect_adc_path, discover_import_path, normalize_optional_string, + normalize_required_input_string, normalize_required_option_string, parse_authorized_user_adc, + save_imported_client, +}; +use super::types::{ + DownloadedGoogleCredentials, StoredInstalledOAuthClient, StoredOAuthClientFile, +}; +use super::{ + DEFAULT_REDIRECT_URI, GOOGLE_AUTH_CERTS_URL, GOOGLE_AUTH_CLIENTS_URL, GOOGLE_AUTH_OVERVIEW_URL, + GOOGLE_GMAIL_API_URL, +}; +use crate::config::{GmailConfig, WorkspaceConfig}; +use anyhow::{Context, Result, anyhow}; +use secrecy::SecretString; +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ImportedOAuthClientSourceKind { + DownloadedJson, + ManualPaste, + GcloudAdc, +} + +impl ImportedOAuthClientSourceKind { + pub fn as_str(self) -> &'static str { + match self { + Self::DownloadedJson => "downloaded_json", + Self::ManualPaste => "manual_paste", + Self::GcloudAdc => "gcloud_adc", + } + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ImportedOAuthClient { + pub source_kind: ImportedOAuthClientSourceKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_path: Option, + pub oauth_client_path: PathBuf, + pub auto_discovered: bool, + pub client_id: String, + pub client_secret_present: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct PreparedOAuthClientImport { + imported_client: ImportedOAuthClient, + resolved_client: ResolvedOAuthClient, + stored_client: StoredOAuthClientFile, +} + +impl PreparedOAuthClientImport { + pub(crate) fn imported_client(&self) -> &ImportedOAuthClient { + &self.imported_client + } + + pub(crate) fn resolved_client(&self) -> &ResolvedOAuthClient { + &self.resolved_client + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PreparedAdcOAuthClientImport { + client_import: PreparedOAuthClientImport, + refresh_token: SecretString, +} + +impl PreparedAdcOAuthClientImport { + pub(crate) fn imported_client(&self) -> &ImportedOAuthClient { + self.client_import.imported_client() + } + + pub(crate) fn resolved_client(&self) -> &ResolvedOAuthClient { + self.client_import.resolved_client() + } + + pub(crate) fn refresh_token(&self) -> &SecretString { + &self.refresh_token + } + + pub(crate) fn client_import(&self) -> &PreparedOAuthClientImport { + &self.client_import + } +} + +pub(crate) enum PreparedSetup { + UseExisting, + ImportClient(PreparedOAuthClientImport), + ImportAdc(PreparedAdcOAuthClientImport), +} + +#[cfg(test)] +pub(crate) fn import_google_desktop_client( + config: &GmailConfig, + workspace: &WorkspaceConfig, + credentials_file: Option, +) -> Result { + let prepared = prepare_google_desktop_client(config, workspace, credentials_file)?; + persist_prepared_google_desktop_client(&prepared)?; + Ok(prepared.imported_client) +} + +pub(crate) fn prepare_setup( + config: &GmailConfig, + workspace: &WorkspaceConfig, + credentials_file: Option, + json: bool, +) -> Result { + if credentials_file.is_some() { + return prepare_google_desktop_client(config, workspace, credentials_file) + .map(PreparedSetup::ImportClient); + } + + match oauth_client_source(config, workspace)? { + OAuthClientSource::WorkspaceFile | OAuthClientSource::InlineConfig => { + return Ok(PreparedSetup::UseExisting); + } + OAuthClientSource::Unconfigured => {} + } + + let candidates = super::storage::normalize_candidate_paths(default_import_candidates()?); + let detected_adc = detect_adc_path(); + if should_use_interactive_setup(json, is_interactive_terminal()) { + prepare_interactive_setup(config, workspace, candidates, detected_adc) + } else { + prepare_noninteractive_setup(config, workspace, candidates, detected_adc) + } +} + +pub(crate) fn persist_prepared_google_desktop_client( + prepared: &PreparedOAuthClientImport, +) -> Result<()> { + save_imported_client( + &prepared.imported_client.oauth_client_path, + &prepared.stored_client, + ) +} + +pub(crate) fn setup_guidance() -> String { + format!( + "Google-side setup checklist:\n\ + 1. Open {GOOGLE_AUTH_OVERVIEW_URL}\n\ + 2. Enable Gmail API at {GOOGLE_GMAIL_API_URL}\n\ + 3. Create a Desktop app OAuth client at {GOOGLE_AUTH_CLIENTS_URL}\n\ + 4. Either download the credentials JSON and run `mailroom auth setup --credentials-file /path/to/client_secret.json`, or run `mailroom auth setup` in an interactive terminal to paste the Client ID and optional Client Secret\n\ + 5. Advanced: if you already ran `gcloud auth application-default login` with Gmail scopes, `mailroom auth setup` can also import that existing ADC session" + ) +} + +pub(super) fn prepare_google_desktop_client( + config: &GmailConfig, + workspace: &WorkspaceConfig, + credentials_file: Option, +) -> Result { + let discovery = discover_import_path(credentials_file)?; + let client = parse_google_desktop_client(&discovery.path, config)?; + Ok(prepare_client_import( + config, + workspace, + client, + ImportedOAuthClientSourceKind::DownloadedJson, + Some(discovery.path), + discovery.auto_discovered, + )) +} + +pub(super) fn prepare_google_desktop_client_from_values( + config: &GmailConfig, + workspace: &WorkspaceConfig, + client_id: String, + client_secret: Option, +) -> Result { + let client = GoogleDesktopClient { + client_id: normalize_required_input_string(client_id, "client_id")?, + client_secret: normalize_optional_string(client_secret), + project_id: None, + auth_uri: config.auth_url.clone(), + token_uri: config.token_url.clone(), + auth_provider_x509_cert_url: GOOGLE_AUTH_CERTS_URL.to_owned(), + redirect_uris: vec![DEFAULT_REDIRECT_URI.to_owned()], + }; + + Ok(prepare_client_import( + config, + workspace, + client, + ImportedOAuthClientSourceKind::ManualPaste, + None, + false, + )) +} + +pub(super) fn prepare_google_desktop_client_from_adc( + config: &GmailConfig, + workspace: &WorkspaceConfig, + adc_path: PathBuf, +) -> Result { + let adc = parse_authorized_user_adc(&adc_path)?; + let client = GoogleDesktopClient { + client_id: adc.client_id.clone(), + client_secret: adc.client_secret.clone(), + project_id: adc.quota_project_id.clone(), + auth_uri: config.auth_url.clone(), + token_uri: config.token_url.clone(), + auth_provider_x509_cert_url: GOOGLE_AUTH_CERTS_URL.to_owned(), + redirect_uris: vec![DEFAULT_REDIRECT_URI.to_owned()], + }; + let client_import = prepare_client_import( + config, + workspace, + client, + ImportedOAuthClientSourceKind::GcloudAdc, + Some(adc_path), + false, + ); + + Ok(PreparedAdcOAuthClientImport { + client_import, + refresh_token: SecretString::from(adc.refresh_token), + }) +} + +pub(super) fn prepare_noninteractive_setup( + config: &GmailConfig, + workspace: &WorkspaceConfig, + candidates: Vec, + detected_adc: Option, +) -> Result { + match candidates.as_slice() { + [path] => prepare_google_desktop_client(config, workspace, Some(path.clone())) + .map(PreparedSetup::ImportClient), + [] => match detected_adc { + Some(adc_path) => prepare_google_desktop_client_from_adc(config, workspace, adc_path) + .map(PreparedSetup::ImportAdc), + None => Err(anyhow!( + "{}\n\n{}", + OAuthClientError::MissingImportCandidate, + setup_guidance() + )), + }, + _ => { + let listed = candidates + .iter() + .map(|path| format!("- {}", path.display())) + .collect::>() + .join("\n"); + Err(anyhow!( + "{}\n{}\n\n{}", + OAuthClientError::AmbiguousImportCandidate, + listed, + setup_guidance() + )) + } + } +} + +fn prepare_client_import( + config: &GmailConfig, + workspace: &WorkspaceConfig, + client: GoogleDesktopClient, + source_kind: ImportedOAuthClientSourceKind, + source_path: Option, + auto_discovered: bool, +) -> PreparedOAuthClientImport { + let oauth_client_path = config.oauth_client_path(workspace); + let stored_client = StoredOAuthClientFile { + installed: StoredInstalledOAuthClient { + client_id: client.client_id.clone(), + client_secret: client.client_secret.clone().unwrap_or_default(), + project_id: client.project_id.clone(), + auth_uri: client.auth_uri.clone(), + token_uri: client.token_uri.clone(), + auth_provider_x509_cert_url: client.auth_provider_x509_cert_url.clone(), + redirect_uris: client.redirect_uris.clone(), + }, + }; + + PreparedOAuthClientImport { + imported_client: ImportedOAuthClient { + source_kind, + source_path, + oauth_client_path, + auto_discovered, + client_id: client.client_id.clone(), + client_secret_present: client.client_secret.is_some(), + project_id: client.project_id, + }, + resolved_client: ResolvedOAuthClient { + client_id: client.client_id, + client_secret: client.client_secret, + }, + stored_client, + } +} + +fn parse_google_desktop_client(path: &Path, config: &GmailConfig) -> Result { + let raw = fs::read_to_string(path).with_context(|| { + format!( + "failed to read Google credentials JSON from {}", + path.display() + ) + })?; + let parsed: DownloadedGoogleCredentials = serde_json::from_str(&raw).with_context(|| { + format!( + "failed to parse Google credentials JSON from {}", + path.display() + ) + })?; + + let installed = parsed + .installed + .ok_or(OAuthClientError::UnsupportedClientType)?; + + Ok(GoogleDesktopClient { + client_id: normalize_required_option_string(installed.client_id, "installed.client_id")?, + client_secret: normalize_optional_string(installed.client_secret), + project_id: normalize_optional_string(installed.project_id), + auth_uri: normalize_optional_string(installed.auth_uri) + .unwrap_or_else(|| config.auth_url.clone()), + token_uri: normalize_optional_string(installed.token_uri) + .unwrap_or_else(|| config.token_url.clone()), + auth_provider_x509_cert_url: normalize_optional_string( + installed.auth_provider_x509_cert_url, + ) + .unwrap_or_else(|| GOOGLE_AUTH_CERTS_URL.to_owned()), + redirect_uris: normalize_redirect_uris(installed.redirect_uris), + }) +} +fn normalize_redirect_uris(redirect_uris: Option>) -> Vec { + let cleaned = redirect_uris + .unwrap_or_default() + .into_iter() + .filter_map(|uri| { + let trimmed = uri.trim().to_owned(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) + .collect::>(); + + if cleaned.is_empty() { + vec![DEFAULT_REDIRECT_URI.to_owned()] + } else { + cleaned + } +} + +#[derive(Debug, Clone)] +struct GoogleDesktopClient { + client_id: String, + client_secret: Option, + project_id: Option, + auth_uri: String, + token_uri: String, + auth_provider_x509_cert_url: String, + redirect_uris: Vec, +} diff --git a/src/auth/oauth_client/interactive.rs b/src/auth/oauth_client/interactive.rs new file mode 100644 index 0000000..b4fbe61 --- /dev/null +++ b/src/auth/oauth_client/interactive.rs @@ -0,0 +1,117 @@ +use super::import::{ + PreparedOAuthClientImport, PreparedSetup, prepare_google_desktop_client, + prepare_google_desktop_client_from_adc, prepare_google_desktop_client_from_values, +}; +use super::storage::normalize_optional_string; +use crate::config::{GmailConfig, WorkspaceConfig}; +use anyhow::{Result, anyhow}; +use dialoguer::{Input, Password, Select, theme::ColorfulTheme}; +use std::io::{IsTerminal, stdin, stdout}; +use std::path::PathBuf; + +pub(super) fn prepare_interactive_setup( + config: &GmailConfig, + workspace: &WorkspaceConfig, + candidates: Vec, + detected_adc: Option, +) -> Result { + let theme = ColorfulTheme::default(); + + if candidates.is_empty() && detected_adc.is_none() { + return prompt_manual_oauth_client(config, workspace, &theme) + .map(PreparedSetup::ImportClient); + } + + let mut choices = Vec::new(); + let mut labels = Vec::new(); + + for candidate in candidates { + labels.push(format!( + "Use downloaded Desktop app JSON: {}", + candidate.display() + )); + choices.push(InteractiveSetupChoice::DownloadedJson(candidate)); + } + + labels.push(String::from( + "Paste Client ID and optional Client Secret into the CLI", + )); + choices.push(InteractiveSetupChoice::ManualPaste); + + if let Some(adc_path) = detected_adc { + labels.push(format!( + "Import existing gcloud ADC auth: {}", + adc_path.display() + )); + choices.push(InteractiveSetupChoice::GcloudAdc(adc_path)); + } + + let selection = Select::with_theme(&theme) + .with_prompt("Choose how Mailroom should configure Gmail OAuth") + .default(0) + .items(&labels) + .interact()?; + + match choices.into_iter().nth(selection) { + Some(InteractiveSetupChoice::DownloadedJson(path)) => { + prepare_google_desktop_client(config, workspace, Some(path)) + .map(PreparedSetup::ImportClient) + } + Some(InteractiveSetupChoice::ManualPaste) => { + prompt_manual_oauth_client(config, workspace, &theme).map(PreparedSetup::ImportClient) + } + Some(InteractiveSetupChoice::GcloudAdc(path)) => { + prepare_google_desktop_client_from_adc(config, workspace, path) + .map(PreparedSetup::ImportAdc) + } + None => Err(anyhow!("interactive setup selection was out of bounds")), + } +} + +pub(super) fn prompt_manual_oauth_client( + config: &GmailConfig, + workspace: &WorkspaceConfig, + theme: &ColorfulTheme, +) -> Result { + if !is_interactive_terminal() { + return Err(super::resolve::OAuthClientError::PromptUnavailable.into()); + } + + let client_id: String = Input::with_theme(theme) + .with_prompt("Google OAuth Client ID") + .validate_with(|value: &String| -> Result<(), &str> { + if value.trim().is_empty() { + Err("Client ID cannot be empty") + } else { + Ok(()) + } + }) + .interact_text()?; + + let client_secret = Password::with_theme(theme) + .with_prompt("Google OAuth Client Secret (optional, press enter to skip)") + .allow_empty_password(true) + .interact()?; + + prepare_google_desktop_client_from_values( + config, + workspace, + client_id.trim().to_owned(), + normalize_optional_string(Some(client_secret)), + ) +} + +pub(super) fn should_use_interactive_setup(json: bool, interactive_terminal: bool) -> bool { + !json && interactive_terminal +} + +pub(super) fn is_interactive_terminal() -> bool { + stdin().is_terminal() && stdout().is_terminal() +} + +#[derive(Debug)] +enum InteractiveSetupChoice { + DownloadedJson(PathBuf), + ManualPaste, + GcloudAdc(PathBuf), +} diff --git a/src/auth/oauth_client/mod.rs b/src/auth/oauth_client/mod.rs new file mode 100644 index 0000000..d8b7c02 --- /dev/null +++ b/src/auth/oauth_client/mod.rs @@ -0,0 +1,27 @@ +mod import; +mod interactive; +mod resolve; +mod storage; +mod types; + +#[cfg(test)] +mod tests; + +pub use import::ImportedOAuthClient; +#[allow(unused_imports)] +pub use import::ImportedOAuthClientSourceKind; +#[cfg(test)] +pub(crate) use import::setup_guidance; +pub(crate) use import::{PreparedSetup, persist_prepared_google_desktop_client, prepare_setup}; +pub use resolve::{ + OAuthClientError, OAuthClientSource, ResolvedOAuthClient, oauth_client_source, resolve, +}; + +pub const GOOGLE_AUTH_OVERVIEW_URL: &str = "https://console.cloud.google.com/auth/overview"; +pub const GOOGLE_AUTH_CLIENTS_URL: &str = "https://console.cloud.google.com/auth/clients"; +pub const GOOGLE_GMAIL_API_URL: &str = + "https://console.cloud.google.com/apis/library/gmail.googleapis.com"; +pub(crate) const GOOGLE_AUTH_CERTS_URL: &str = "https://www.googleapis.com/oauth2/v1/certs"; +pub(crate) const DEFAULT_REDIRECT_URI: &str = "http://localhost"; +pub(crate) const DEFAULT_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +pub(crate) const DEFAULT_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; diff --git a/src/auth/oauth_client/resolve.rs b/src/auth/oauth_client/resolve.rs new file mode 100644 index 0000000..0f028c7 --- /dev/null +++ b/src/auth/oauth_client/resolve.rs @@ -0,0 +1,112 @@ +use super::storage::{load_imported_client, normalize_optional_string}; +use crate::config::{GmailConfig, WorkspaceConfig}; +use anyhow::Result; +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ResolvedOAuthClient { + pub client_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OAuthClientSource { + WorkspaceFile, + InlineConfig, + Unconfigured, +} + +impl OAuthClientSource { + pub fn as_str(self) -> &'static str { + match self { + Self::WorkspaceFile => "workspace_file", + Self::InlineConfig => "config", + Self::Unconfigured => "unconfigured", + } + } +} + +#[derive(Debug, Error)] +pub enum OAuthClientError { + #[error( + "gmail OAuth client is not configured; run `mailroom auth setup` or set gmail.client_id" + )] + MissingClientConfiguration, + #[error("Google desktop-app credentials JSON was not found at {path}")] + MissingImportFile { path: std::path::PathBuf }, + #[error( + "Mailroom could not auto-discover a Google desktop-app credentials JSON. Pass `--credentials-file PATH` or run `mailroom auth setup` in an interactive terminal." + )] + MissingImportCandidate, + #[error( + "Mailroom found multiple candidate Google desktop-app credentials JSON files. Pass `--credentials-file PATH` to pick one." + )] + AmbiguousImportCandidate, + #[error( + "the Google credentials JSON is not a Desktop app client; create a Desktop app OAuth client and use its credentials" + )] + UnsupportedClientType, + #[error("the credentials JSON is missing required field `{0}`")] + MissingField(&'static str), + #[error("the imported OAuth client file is missing required field `{0}`")] + MissingStoredField(&'static str), + #[error( + "no interactive terminal is available; pass `--credentials-file PATH` or set gmail.client_id" + )] + PromptUnavailable, + #[error("the gcloud ADC file uses unsupported credential type `{0}`")] + UnsupportedAdcType(String), + #[error("the gcloud ADC file is missing required field `{0}`")] + MissingAdcField(&'static str), +} + +pub fn resolve(config: &GmailConfig, workspace: &WorkspaceConfig) -> Result { + let oauth_client_path = config.oauth_client_path(workspace); + if let Some(stored) = load_imported_client(&oauth_client_path)? { + return Ok(ResolvedOAuthClient { + client_id: stored.installed.client_id, + client_secret: normalize_optional_string(Some(stored.installed.client_secret)), + }); + } + + if let Some(client_id) = normalize_optional_string(config.client_id.clone()) { + return Ok(ResolvedOAuthClient { + client_id, + client_secret: normalize_optional_string(config.client_secret.clone()), + }); + } + + Err(OAuthClientError::MissingClientConfiguration.into()) +} + +pub fn oauth_client_source( + config: &GmailConfig, + workspace: &WorkspaceConfig, +) -> Result { + let oauth_client_path = config.oauth_client_path(workspace); + if load_imported_client(&oauth_client_path)?.is_some() { + return Ok(OAuthClientSource::WorkspaceFile); + } + + if normalize_optional_string(config.client_id.clone()).is_some() { + return Ok(OAuthClientSource::InlineConfig); + } + + Ok(OAuthClientSource::Unconfigured) +} + +#[cfg(test)] +pub(crate) fn oauth_client_exists( + config: &GmailConfig, + workspace: &WorkspaceConfig, +) -> Result { + let oauth_client_path = config.oauth_client_path(workspace); + if !oauth_client_path.is_file() { + return Ok(false); + } + + let _ = load_imported_client(&oauth_client_path)?; + Ok(true) +} diff --git a/src/auth/oauth_client/storage.rs b/src/auth/oauth_client/storage.rs new file mode 100644 index 0000000..5779266 --- /dev/null +++ b/src/auth/oauth_client/storage.rs @@ -0,0 +1,340 @@ +use super::resolve::OAuthClientError; +use super::types::{ + AuthorizedUserAdc, AuthorizedUserAdcFile, LegacyStoredOAuthClient, StoredInstalledOAuthClient, + StoredOAuthClientFile, +}; +use super::{DEFAULT_AUTH_URL, DEFAULT_REDIRECT_URI, DEFAULT_TOKEN_URL, GOOGLE_AUTH_CERTS_URL}; +use anyhow::{Context, Result, anyhow}; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +pub(super) fn load_imported_client(path: &Path) -> Result> { + let raw = match fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error) + .with_context(|| format!("failed to read OAuth client from {}", path.display())); + } + }; + + if let Ok(stored) = serde_json::from_str::(&raw) { + validate_stored_oauth_client(&stored)?; + return Ok(Some(stored)); + } + + let legacy: LegacyStoredOAuthClient = serde_json::from_str(&raw) + .with_context(|| format!("failed to parse OAuth client from {}", path.display()))?; + let stored = StoredOAuthClientFile { + installed: StoredInstalledOAuthClient { + client_id: normalize_required_input_string(legacy.client_id, "client_id")?, + client_secret: legacy.client_secret.unwrap_or_default(), + project_id: None, + auth_uri: DEFAULT_AUTH_URL.to_owned(), + token_uri: DEFAULT_TOKEN_URL.to_owned(), + auth_provider_x509_cert_url: GOOGLE_AUTH_CERTS_URL.to_owned(), + redirect_uris: vec![DEFAULT_REDIRECT_URI.to_owned()], + }, + }; + validate_stored_oauth_client(&stored)?; + Ok(Some(stored)) +} + +pub(super) fn normalize_optional_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +pub(super) fn normalize_required_option_string( + value: Option, + field: &'static str, +) -> Result { + normalize_optional_string(value).ok_or_else(|| OAuthClientError::MissingField(field).into()) +} + +pub(super) fn normalize_required_adc_field( + value: Option, + field: &'static str, +) -> Result { + normalize_optional_string(value).ok_or_else(|| OAuthClientError::MissingAdcField(field).into()) +} + +pub(super) fn normalize_required_input_string( + value: String, + field: &'static str, +) -> Result { + let trimmed = value.trim().to_owned(); + if trimmed.is_empty() { + return Err(OAuthClientError::MissingField(field).into()); + } + Ok(trimmed) +} + +pub(super) fn validate_stored_oauth_client(client: &StoredOAuthClientFile) -> Result<()> { + if client.installed.client_id.trim().is_empty() { + return Err(OAuthClientError::MissingStoredField("installed.client_id").into()); + } + if client.installed.auth_uri.trim().is_empty() { + return Err(OAuthClientError::MissingStoredField("installed.auth_uri").into()); + } + if client.installed.token_uri.trim().is_empty() { + return Err(OAuthClientError::MissingStoredField("installed.token_uri").into()); + } + + Ok(()) +} + +pub(super) fn save_imported_client(path: &Path, client: &StoredOAuthClientFile) -> Result<()> { + let parent = path + .parent() + .with_context(|| format!("OAuth client path {} has no parent", path.display()))?; + fs::create_dir_all(parent)?; + set_owner_only_dir_permissions(parent)?; + + let payload = serde_json::to_vec_pretty(client)?; + let tmp_path = path.with_extension("tmp"); + fs::write(&tmp_path, payload)?; + set_owner_only_file_permissions(&tmp_path)?; + persist_tmp_file(&tmp_path, path)?; + Ok(()) +} + +pub(super) fn discover_import_path(credentials_file: Option) -> Result { + if let Some(path) = credentials_file { + if !path.is_file() { + return Err(OAuthClientError::MissingImportFile { path }.into()); + } + return Ok(ImportDiscovery { + path, + auto_discovered: false, + }); + } + + let candidates = normalize_candidate_paths(default_import_candidates()?); + + match candidates.as_slice() { + [] => Err(anyhow!( + "{}\n\n{}", + OAuthClientError::MissingImportCandidate, + super::import::setup_guidance() + )), + [path] => Ok(ImportDiscovery { + path: path.clone(), + auto_discovered: true, + }), + _ => { + let listed = candidates + .iter() + .map(|path| format!("- {}", path.display())) + .collect::>() + .join("\n"); + Err(anyhow!( + "{}\n{}\n\n{}", + OAuthClientError::AmbiguousImportCandidate, + listed, + super::import::setup_guidance() + )) + } + } +} + +pub(super) fn default_import_candidates() -> Result> { + default_import_candidates_from_env( + &std::env::current_dir()?, + std::env::var_os("HOME").map(PathBuf::from), + std::env::var_os("USERPROFILE").map(PathBuf::from), + ) +} + +fn default_import_candidates_from_env( + current_dir: &Path, + home_dir: Option, + user_profile_dir: Option, +) -> Result> { + let mut candidates = collect_candidate_files(current_dir)?; + + for downloads_dir in default_download_dirs(home_dir, user_profile_dir) { + candidates.extend(collect_candidate_files(&downloads_dir)?); + } + + Ok(candidates) +} + +pub(super) fn default_download_dirs( + home_dir: Option, + user_profile_dir: Option, +) -> Vec { + let mut downloads_dirs = Vec::new(); + + if let Some(home_dir) = home_dir { + downloads_dirs.push(home_dir.join("Downloads")); + downloads_dirs.push(home_dir.join("downloads")); + } + + if let Some(user_profile_dir) = user_profile_dir { + let downloads_dir = user_profile_dir.join("Downloads"); + if !downloads_dirs.contains(&downloads_dir) { + downloads_dirs.push(downloads_dir); + } + } + + downloads_dirs +} + +pub(super) fn normalize_candidate_paths(mut candidates: Vec) -> Vec { + candidates.sort(); + candidates.dedup(); + candidates +} + +pub(super) fn collect_candidate_files(dir: &Path) -> Result> { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied + ) => + { + return Ok(Vec::new()); + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect candidate credentials directory {}", + dir.display() + ) + }); + } + }; + + let mut candidates = Vec::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => continue, + Err(error) => return Err(error.into()), + }; + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => continue, + Err(error) => return Err(error.into()), + }; + if !file_type.is_file() { + continue; + } + + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if file_name.starts_with("client_secret_") && file_name.ends_with(".json") { + candidates.push(entry.path()); + } + } + + Ok(candidates) +} + +pub(super) fn detect_adc_path() -> Option { + detect_adc_path_from_env( + std::env::var_os("GOOGLE_APPLICATION_CREDENTIALS").map(PathBuf::from), + std::env::var_os("HOME").map(PathBuf::from), + std::env::var_os("APPDATA").map(PathBuf::from), + ) +} + +pub(super) fn detect_adc_path_from_env( + adc_env_path: Option, + home_dir: Option, + appdata_dir: Option, +) -> Option { + if let Some(adc_path) = adc_env_path + && adc_path.exists() + { + return Some(adc_path); + } + + if let Some(home_dir) = home_dir { + let well_known = home_dir.join(".config/gcloud/application_default_credentials.json"); + if well_known.exists() { + return Some(well_known); + } + } + + if let Some(appdata_dir) = appdata_dir { + let well_known = appdata_dir.join("gcloud/application_default_credentials.json"); + if well_known.exists() { + return Some(well_known); + } + } + + None +} + +pub(super) fn parse_authorized_user_adc(path: &Path) -> Result { + let raw = fs::read_to_string(path) + .with_context(|| format!("failed to read gcloud ADC file from {}", path.display()))?; + let parsed: AuthorizedUserAdcFile = serde_json::from_str(&raw) + .with_context(|| format!("failed to parse gcloud ADC file from {}", path.display()))?; + + let credential_type = parsed + .credential_type + .unwrap_or_else(|| String::from("unknown")); + if credential_type != "authorized_user" { + return Err(OAuthClientError::UnsupportedAdcType(credential_type).into()); + } + + Ok(AuthorizedUserAdc { + client_id: normalize_required_adc_field(parsed.client_id, "client_id")?, + client_secret: normalize_optional_string(parsed.client_secret), + refresh_token: normalize_required_adc_field(parsed.refresh_token, "refresh_token")?, + quota_project_id: normalize_optional_string(parsed.quota_project_id), + }) +} + +#[cfg(unix)] +fn set_owner_only_dir_permissions(path: &Path) -> Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_owner_only_dir_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only_file_permissions(path: &Path) -> Result<()> { + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_owner_only_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +fn persist_tmp_file(tmp_path: &Path, destination: &Path) -> Result<()> { + #[cfg(windows)] + { + if destination.exists() { + fs::remove_file(destination)?; + } + } + + fs::rename(tmp_path, destination)?; + Ok(()) +} + +#[derive(Debug, Clone)] +pub(super) struct ImportDiscovery { + pub(super) path: PathBuf, + pub(super) auto_discovered: bool, +} diff --git a/src/auth/oauth_client/tests.rs b/src/auth/oauth_client/tests.rs new file mode 100644 index 0000000..e0a653b --- /dev/null +++ b/src/auth/oauth_client/tests.rs @@ -0,0 +1,497 @@ +use super::import::{ + import_google_desktop_client, prepare_google_desktop_client_from_adc, + prepare_google_desktop_client_from_values, prepare_noninteractive_setup, +}; +use super::interactive::should_use_interactive_setup; +use super::resolve::{ + OAuthClientError, OAuthClientSource, oauth_client_exists, oauth_client_source, resolve, +}; +use super::storage::{default_download_dirs, detect_adc_path_from_env, normalize_candidate_paths}; +use super::{ + GOOGLE_AUTH_CLIENTS_URL, GOOGLE_AUTH_OVERVIEW_URL, ImportedOAuthClient, + ImportedOAuthClientSourceKind, PreparedSetup, setup_guidance, +}; +use crate::config::{GmailConfig, WorkspaceConfig}; +use secrecy::ExposeSecret; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn workspace_for(temp_dir: &TempDir) -> WorkspaceConfig { + let root = temp_dir.path().join(".mailroom"); + WorkspaceConfig { + runtime_root: root.clone(), + auth_dir: root.join("auth"), + cache_dir: root.join("cache"), + state_dir: root.join("state"), + vault_dir: root.join("vault"), + exports_dir: root.join("exports"), + logs_dir: root.join("logs"), + } +} + +fn gmail_config() -> GmailConfig { + GmailConfig { + client_id: None, + client_secret: None, + auth_url: String::from("https://accounts.google.com/o/oauth2/v2/auth"), + token_url: String::from("https://oauth2.googleapis.com/token"), + api_base_url: String::from("https://gmail.googleapis.com/gmail/v1"), + listen_host: String::from("127.0.0.1"), + listen_port: 0, + open_browser: true, + request_timeout_secs: 30, + scopes: vec![String::from("scope:a")], + } +} + +#[test] +fn imported_client_becomes_the_resolved_oauth_source() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + let credentials_path = temp_dir.path().join("client_secret_test.json"); + fs::write( + &credentials_path, + r#"{ + "installed": { + "client_id": "desktop-client.apps.googleusercontent.com", + "client_secret": "desktop-secret", + "project_id": "mailroom-dev", + "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "redirect_uris": ["http://localhost"] + } +}"#, + ) + .unwrap(); + + let imported = + import_google_desktop_client(&config, &workspace, Some(credentials_path)).unwrap(); + let resolved = resolve(&config, &workspace).unwrap(); + + assert_eq!( + imported, + ImportedOAuthClient { + source_kind: ImportedOAuthClientSourceKind::DownloadedJson, + source_path: Some(temp_dir.path().join("client_secret_test.json")), + oauth_client_path: workspace.auth_dir.join("gmail-oauth-client.json"), + auto_discovered: false, + client_id: String::from("desktop-client.apps.googleusercontent.com"), + client_secret_present: true, + project_id: Some(String::from("mailroom-dev")), + } + ); + assert_eq!( + resolved.client_id, + "desktop-client.apps.googleusercontent.com" + ); + assert_eq!(resolved.client_secret.as_deref(), Some("desktop-secret")); + assert!(oauth_client_exists(&config, &workspace).unwrap()); + + let saved = fs::read_to_string(workspace.auth_dir.join("gmail-oauth-client.json")).unwrap(); + assert!(saved.contains("\"installed\"")); + assert!(saved.contains("\"project_id\": \"mailroom-dev\"")); +} + +#[test] +fn imported_client_rejects_directory_credentials_path() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + let credentials_dir = temp_dir.path().join("client_secret_dir"); + fs::create_dir_all(&credentials_dir).unwrap(); + + let error = import_google_desktop_client(&config, &workspace, Some(credentials_dir.clone())) + .unwrap_err(); + + assert!(matches!( + error.downcast_ref::(), + Some(OAuthClientError::MissingImportFile { path }) if path == &credentials_dir + )); +} + +#[test] +fn imported_client_overwrites_existing_workspace_file() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + fs::create_dir_all(&workspace.auth_dir).unwrap(); + fs::write( + workspace.auth_dir.join("gmail-oauth-client.json"), + r#"{ + "installed": { + "client_id": "stale-client.apps.googleusercontent.com", + "client_secret": "stale-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } +}"#, + ) + .unwrap(); + let credentials_path = temp_dir.path().join("client_secret_updated.json"); + fs::write( + &credentials_path, + r#"{ + "installed": { + "client_id": "updated-client.apps.googleusercontent.com", + "client_secret": "updated-secret", + "project_id": "mailroom-updated", + "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "redirect_uris": ["http://localhost"] + } +}"#, + ) + .unwrap(); + + let imported = + import_google_desktop_client(&config, &workspace, Some(credentials_path)).unwrap(); + + assert_eq!( + imported.client_id, + "updated-client.apps.googleusercontent.com" + ); + let saved = fs::read_to_string(workspace.auth_dir.join("gmail-oauth-client.json")).unwrap(); + assert!(saved.contains("\"updated-client.apps.googleusercontent.com\"")); + assert!(saved.contains("\"mailroom-updated\"")); +} + +#[test] +fn manual_paste_preparation_builds_standard_google_client_file() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + + let prepared = prepare_google_desktop_client_from_values( + &config, + &workspace, + String::from("manual-client.apps.googleusercontent.com"), + Some(String::from("manual-secret")), + ) + .unwrap(); + + assert_eq!( + prepared.imported_client().source_kind, + ImportedOAuthClientSourceKind::ManualPaste + ); + assert_eq!( + prepared.resolved_client().client_id, + "manual-client.apps.googleusercontent.com" + ); + assert_eq!( + prepared.resolved_client().client_secret.as_deref(), + Some("manual-secret") + ); +} + +#[test] +fn adc_import_extracts_client_and_refresh_token() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + let adc_path = temp_dir.path().join("application_default_credentials.json"); + fs::write( + &adc_path, + r#"{ + "type": "authorized_user", + "client_id": "adc-client.apps.googleusercontent.com", + "client_secret": "adc-secret", + "refresh_token": "adc-refresh-token", + "quota_project_id": "adc-project" +}"#, + ) + .unwrap(); + + let prepared = + prepare_google_desktop_client_from_adc(&config, &workspace, adc_path.clone()).unwrap(); + + assert_eq!( + prepared.imported_client().source_kind, + ImportedOAuthClientSourceKind::GcloudAdc + ); + assert_eq!( + prepared.imported_client().source_path.as_ref(), + Some(&adc_path) + ); + assert_eq!( + prepared.resolved_client().client_id, + "adc-client.apps.googleusercontent.com" + ); + assert_eq!( + prepared.refresh_token().expose_secret(), + "adc-refresh-token" + ); +} + +#[test] +fn detect_adc_path_ignores_missing_env_path_and_falls_back_to_well_known_adc() { + let temp_dir = TempDir::new().unwrap(); + let missing_env_path = temp_dir.path().join("missing-adc.json"); + let well_known_adc = temp_dir + .path() + .join(".config/gcloud/application_default_credentials.json"); + fs::create_dir_all(well_known_adc.parent().unwrap()).unwrap(); + fs::write(&well_known_adc, "{}").unwrap(); + + let detected = + detect_adc_path_from_env(Some(missing_env_path), Some(temp_dir.path().into()), None); + + assert_eq!(detected, Some(well_known_adc)); +} + +#[test] +fn detect_adc_path_falls_back_to_windows_well_known_adc() { + let temp_dir = TempDir::new().unwrap(); + let appdata_dir = temp_dir.path().join("AppData/Roaming"); + let well_known_adc = appdata_dir.join("gcloud/application_default_credentials.json"); + fs::create_dir_all(well_known_adc.parent().unwrap()).unwrap(); + fs::write(&well_known_adc, "{}").unwrap(); + + let detected = detect_adc_path_from_env(None, None, Some(appdata_dir)); + + assert_eq!(detected, Some(well_known_adc)); +} + +#[test] +fn noninteractive_setup_uses_adc_when_no_downloaded_json_is_available() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + let adc_path = temp_dir.path().join("application_default_credentials.json"); + fs::write( + &adc_path, + r#"{ + "type": "authorized_user", + "client_id": "adc-client.apps.googleusercontent.com", + "client_secret": "adc-secret", + "refresh_token": "adc-refresh-token" +}"#, + ) + .unwrap(); + + let prepared = + prepare_noninteractive_setup(&config, &workspace, Vec::new(), Some(adc_path)).unwrap(); + + match prepared { + PreparedSetup::ImportAdc(prepared) => { + assert_eq!( + prepared.imported_client().source_kind, + ImportedOAuthClientSourceKind::GcloudAdc + ); + assert_eq!( + prepared.resolved_client().client_id, + "adc-client.apps.googleusercontent.com" + ); + assert_eq!( + prepared.refresh_token().expose_secret(), + "adc-refresh-token" + ); + } + PreparedSetup::UseExisting | PreparedSetup::ImportClient(_) => { + panic!("expected non-interactive setup to import the ADC path") + } + } +} + +#[test] +fn source_falls_back_to_unconfigured_when_workspace_file_disappears() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + + assert_eq!( + oauth_client_source(&gmail_config(), &workspace).unwrap(), + OAuthClientSource::Unconfigured + ); +} + +#[test] +fn prepare_setup_deduplicates_discovered_candidate_paths() { + let candidate = PathBuf::from("/tmp/client_secret_duplicate.json"); + let normalized = normalize_candidate_paths(vec![candidate.clone(), candidate.clone()]); + + assert_eq!(normalized, vec![candidate]); +} + +#[test] +fn default_download_dirs_only_include_download_locations() { + let home_dir = PathBuf::from("/home/tester"); + let user_profile_dir = PathBuf::from("C:/Users/tester"); + let downloads_dirs = default_download_dirs(Some(home_dir), Some(user_profile_dir)); + + assert_eq!( + downloads_dirs, + vec![ + PathBuf::from("/home/tester/Downloads"), + PathBuf::from("/home/tester/downloads"), + PathBuf::from("C:/Users/tester/Downloads"), + ] + ); +} + +#[test] +fn json_setup_never_uses_the_interactive_wizard() { + assert!(!should_use_interactive_setup(true, true)); + assert!(!should_use_interactive_setup(true, false)); + assert!(should_use_interactive_setup(false, true)); + assert!(!should_use_interactive_setup(false, false)); +} + +#[test] +fn legacy_stored_client_file_is_still_resolved() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + fs::create_dir_all(&workspace.auth_dir).unwrap(); + fs::write( + workspace.auth_dir.join("gmail-oauth-client.json"), + r#"{ + "client_id": "legacy-client.apps.googleusercontent.com", + "client_secret": "legacy-secret" +}"#, + ) + .unwrap(); + + let resolved = resolve(&gmail_config(), &workspace).unwrap(); + + assert_eq!( + resolved.client_id, + "legacy-client.apps.googleusercontent.com" + ); + assert_eq!(resolved.client_secret.as_deref(), Some("legacy-secret")); +} + +#[test] +fn imported_client_takes_precedence_over_inline_config() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + fs::create_dir_all(&workspace.auth_dir).unwrap(); + fs::write( + workspace.auth_dir.join("gmail-oauth-client.json"), + r#"{ + "installed": { + "client_id": "imported-client.apps.googleusercontent.com", + "client_secret": "imported-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } +}"#, + ) + .unwrap(); + + let resolved = resolve( + &GmailConfig { + client_id: Some(String::from("inline-client.apps.googleusercontent.com")), + client_secret: Some(String::from("inline-secret")), + ..gmail_config() + }, + &workspace, + ) + .unwrap(); + + assert_eq!( + resolved.client_id, + "imported-client.apps.googleusercontent.com" + ); + assert_eq!(resolved.client_secret.as_deref(), Some("imported-secret")); +} + +#[test] +fn inline_config_reports_config_source_without_workspace_file() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = GmailConfig { + client_id: Some(String::from("inline-client.apps.googleusercontent.com")), + client_secret: Some(String::from("inline-secret")), + ..gmail_config() + }; + + let source = oauth_client_source(&config, &workspace).unwrap(); + + assert_eq!(source, OAuthClientSource::InlineConfig); + assert!(!oauth_client_exists(&config, &workspace).unwrap()); +} + +#[test] +fn source_reports_workspace_file_and_validates_it() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + fs::create_dir_all(&workspace.auth_dir).unwrap(); + fs::write( + workspace.auth_dir.join("gmail-oauth-client.json"), + r#"{ + "installed": { + "client_id": "saved-client.apps.googleusercontent.com", + "client_secret": "", + "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } +}"#, + ) + .unwrap(); + + assert_eq!( + oauth_client_source(&gmail_config(), &workspace).unwrap(), + OAuthClientSource::WorkspaceFile + ); +} + +#[test] +fn malformed_workspace_file_returns_a_validation_error() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + fs::create_dir_all(&workspace.auth_dir).unwrap(); + fs::write( + workspace.auth_dir.join("gmail-oauth-client.json"), + r#"{ + "installed": { + "client_id": "", + "client_secret": "", + "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } +}"#, + ) + .unwrap(); + + let error = oauth_client_source(&gmail_config(), &workspace).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(OAuthClientError::MissingStoredField("installed.client_id")) + )); +} + +#[test] +fn unsupported_adc_type_is_rejected() { + let temp_dir = TempDir::new().unwrap(); + let workspace = workspace_for(&temp_dir); + let config = gmail_config(); + let adc_path = temp_dir.path().join("application_default_credentials.json"); + fs::write( + &adc_path, + r#"{ + "type": "service_account", + "client_id": "adc-client.apps.googleusercontent.com", + "client_secret": "adc-secret", + "refresh_token": "adc-refresh-token" +}"#, + ) + .unwrap(); + + let error = prepare_google_desktop_client_from_adc(&config, &workspace, adc_path).unwrap_err(); + assert!(matches!( + error.downcast_ref::(), + Some(OAuthClientError::UnsupportedAdcType(kind)) if kind == "service_account" + )); +} + +#[test] +fn setup_guidance_points_to_console_urls_and_interactive_setup() { + let guidance = setup_guidance(); + + assert!(guidance.contains(GOOGLE_AUTH_OVERVIEW_URL)); + assert!(guidance.contains(GOOGLE_AUTH_CLIENTS_URL)); + assert!(guidance.contains("mailroom auth setup")); + assert!(guidance.contains("interactive terminal")); +} diff --git a/src/auth/oauth_client/types.rs b/src/auth/oauth_client/types.rs new file mode 100644 index 0000000..4ba4afc --- /dev/null +++ b/src/auth/oauth_client/types.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct StoredOAuthClientFile { + pub(super) installed: StoredInstalledOAuthClient, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct StoredInstalledOAuthClient { + pub(super) client_id: String, + #[serde(default)] + pub(super) client_secret: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) project_id: Option, + pub(super) auth_uri: String, + pub(super) token_uri: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub(super) auth_provider_x509_cert_url: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(super) redirect_uris: Vec, +} + +#[derive(Debug, Deserialize)] +pub(super) struct DownloadedGoogleCredentials { + pub(super) installed: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct DownloadedInstalledClient { + pub(super) client_id: Option, + pub(super) client_secret: Option, + pub(super) project_id: Option, + pub(super) auth_uri: Option, + pub(super) token_uri: Option, + pub(super) auth_provider_x509_cert_url: Option, + pub(super) redirect_uris: Option>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LegacyStoredOAuthClient { + pub(super) client_id: String, + pub(super) client_secret: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AuthorizedUserAdcFile { + #[serde(rename = "type")] + pub(super) credential_type: Option, + pub(super) client_id: Option, + pub(super) client_secret: Option, + pub(super) refresh_token: Option, + pub(super) quota_project_id: Option, +} + +#[derive(Debug)] +pub(super) struct AuthorizedUserAdc { + pub(super) client_id: String, + pub(super) client_secret: Option, + pub(super) refresh_token: String, + pub(super) quota_project_id: Option, +}