use crate::{ClientConfig, Result};
use base64::{Engine as _, engine::general_purpose::STANDARD as Base64Engine};
use clap::Subcommand;
use cull_gmail::Error;
use std::fs;
use std::path::Path;
#[derive(clap::Parser, Debug)]
pub struct TokenCli {
#[command(subcommand)]
command: TokenCommand,
}
#[derive(Subcommand, Debug)]
pub enum TokenCommand {
Export,
Import,
}
impl TokenCli {
pub async fn run(&self, client_config: &ClientConfig) -> Result<()> {
match &self.command {
TokenCommand::Export => export_tokens(client_config).await,
TokenCommand::Import => import_tokens(client_config).await,
}
}
}
async fn export_tokens(config: &ClientConfig) -> Result<()> {
let token_path = Path::new(config.persist_path());
let mut token_data = std::collections::HashMap::new();
if token_path.is_file() {
let filename = token_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::FileIo("Invalid token filename".to_string()))?;
let content = fs::read_to_string(token_path)
.map_err(|e| Error::FileIo(format!("Failed to read token file: {e}")))?;
token_data.insert(filename.to_string(), content);
} else if token_path.is_dir() {
for entry in fs::read_dir(token_path).map_err(|e| Error::FileIo(e.to_string()))? {
let entry = entry.map_err(|e| Error::FileIo(e.to_string()))?;
let path = entry.path();
if path.is_file() {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| Error::FileIo("Invalid filename in token cache".to_string()))?;
let content = fs::read_to_string(&path).map_err(|e| {
Error::FileIo(format!("Failed to read token file {filename}: {e}"))
})?;
token_data.insert(filename.to_string(), content);
}
}
} else {
return Err(Error::TokenNotFound(format!(
"Token cache not found: {}",
token_path.display()
)));
}
if token_data.is_empty() {
return Err(Error::TokenNotFound(
"No token data found in cache".to_string(),
));
}
let json_data = serde_json::to_string(&token_data)
.map_err(|e| Error::SerializationError(format!("Failed to serialize token data: {e}")))?;
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(json_data.as_bytes())
.map_err(|e| Error::SerializationError(format!("Failed to compress token data: {e}")))?;
let compressed_data = encoder
.finish()
.map_err(|e| Error::SerializationError(format!("Failed to finalize compression: {e}")))?;
let encoded = Base64Engine.encode(&compressed_data);
println!("{encoded}");
Ok(())
}
pub async fn import_tokens(config: &ClientConfig) -> Result<()> {
let token_env = std::env::var("CULL_GMAIL_TOKEN_CACHE").map_err(|_| {
Error::TokenNotFound("CULL_GMAIL_TOKEN_CACHE environment variable not set".to_string())
})?;
restore_tokens_from_string(&token_env, config.persist_path())?;
log::info!("Tokens successfully imported from environment variable");
Ok(())
}
pub fn restore_tokens_from_string(token_string: &str, persist_path: &str) -> Result<()> {
let compressed_data = Base64Engine.decode(token_string.trim()).map_err(|e| {
Error::SerializationError(format!("Failed to decode base64 token data: {e}"))
})?;
use flate2::read::GzDecoder;
use std::io::Read;
let mut decoder = GzDecoder::new(compressed_data.as_slice());
let mut json_data = String::new();
decoder
.read_to_string(&mut json_data)
.map_err(|e| Error::SerializationError(format!("Failed to decompress token data: {e}")))?;
let token_files: std::collections::HashMap<String, String> =
serde_json::from_str(&json_data)
.map_err(|e| Error::SerializationError(format!("Failed to parse token JSON: {e}")))?;
let token_path = Path::new(persist_path);
let file_count = token_files.len();
if file_count == 1
&& token_files.keys().next().map(|k| k.as_str())
== token_path.file_name().and_then(|n| n.to_str())
{
let content = token_files.into_values().next().unwrap();
if let Some(parent) = token_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Error::FileIo(format!(
"Failed to create token directory {}: {}",
parent.display(),
e
))
})?;
}
fs::write(token_path, &content)
.map_err(|e| Error::FileIo(format!("Failed to write token file: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(token_path)
.map_err(|e| Error::FileIo(format!("Failed to get file metadata: {e}")))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(token_path, perms)
.map_err(|e| Error::FileIo(format!("Failed to set file permissions: {e}")))?;
}
} else {
fs::create_dir_all(token_path).map_err(|e| {
Error::FileIo(format!(
"Failed to create token directory {persist_path}: {e}"
))
})?;
for (filename, content) in token_files {
let file_path = token_path.join(&filename);
fs::write(&file_path, &content).map_err(|e| {
Error::FileIo(format!("Failed to write token file {filename}: {e}"))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&file_path)
.map_err(|e| Error::FileIo(format!("Failed to get file metadata: {e}")))?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(&file_path, perms)
.map_err(|e| Error::FileIo(format!("Failed to set file permissions: {e}")))?;
}
}
}
log::info!("Restored {file_count} token files to {persist_path}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn test_token_export_import_cycle() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let token_dir = temp_dir.path().join("gmail1");
fs::create_dir_all(&token_dir).expect("Failed to create token dir");
let mut test_files = HashMap::new();
test_files.insert(
"tokencache.json".to_string(),
r#"{"access_token":"test_access","refresh_token":"test_refresh"}"#.to_string(),
);
test_files.insert(
"metadata.json".to_string(),
r#"{"created":"2023-01-01","expires":"2023-12-31"}"#.to_string(),
);
for (filename, content) in &test_files {
fs::write(token_dir.join(filename), content).expect("Failed to write test token file");
}
let config = crate::ClientConfig::builder()
.with_client_id("test")
.with_config_path(temp_dir.path().to_str().unwrap())
.build();
let result = tokio_test::block_on(export_tokens(&config));
assert!(result.is_ok(), "Export should succeed");
}
#[test]
fn test_restore_tokens_from_string() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let persist_path = temp_dir.path().join("gmail1").to_string_lossy().to_string();
let mut token_data = HashMap::new();
token_data.insert("test.json".to_string(), r#"{"token":"value"}"#.to_string());
let json_str = serde_json::to_string(&token_data).unwrap();
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(json_str.as_bytes()).unwrap();
let compressed = encoder.finish().unwrap();
let encoded = Base64Engine.encode(&compressed);
let result = restore_tokens_from_string(&encoded, &persist_path);
assert!(result.is_ok(), "Restore should succeed: {result:?}");
let restored_path = Path::new(&persist_path).join("test.json");
assert!(restored_path.exists(), "Token file should be restored");
let restored_content = fs::read_to_string(restored_path).unwrap();
assert_eq!(restored_content, r#"{"token":"value"}"#);
}
#[test]
fn test_missing_token_directory() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let config = crate::ClientConfig::builder()
.with_client_id("test")
.with_config_path(temp_dir.path().join("nonexistent").to_str().unwrap())
.build();
let result = tokio_test::block_on(export_tokens(&config));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::TokenNotFound(_)));
}
#[test]
fn test_invalid_base64_restore() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let persist_path = temp_dir.path().to_string_lossy().to_string();
let result = restore_tokens_from_string("invalid-base64!", &persist_path);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::SerializationError(_)));
}
}