use std::path::PathBuf;
use dialoguer::{Confirm, Input, theme::ColorfulTheme};
use crate::commands::FAILURE_EXIT_CODE;
use crate::commands::keyring::store::Store;
use crate::core::crypto::Aes256GcmSivEncryptor;
use crate::core::job::Job;
use crate::core::keyring::credentials;
use crate::core::wizard::WizardInput;
use super::DecryptFilesJob;
struct InputDirInput {
flag: Option<PathBuf>,
}
impl WizardInput for InputDirInput {
type Value = PathBuf;
fn flag_value(&self) -> Option<Result<PathBuf, String>> {
self.flag.clone().map(Ok)
}
fn prompt(&self) -> Result<PathBuf, String> {
Input::<String>::new()
.with_prompt("Input directory (containing .enc files)")
.interact_text()
.map(PathBuf::from)
.map_err(|err| format!("failed to read input directory: {err}"))
}
fn non_interactive_fallback(&self) -> Result<PathBuf, String> {
Err("--input-dir is required when not running interactively".to_string())
}
}
struct OutputDirInput {
flag: Option<PathBuf>,
}
impl WizardInput for OutputDirInput {
type Value = PathBuf;
fn flag_value(&self) -> Option<Result<PathBuf, String>> {
self.flag.clone().map(Ok)
}
fn prompt(&self) -> Result<PathBuf, String> {
Input::<String>::new()
.with_prompt("Output directory (decrypted files written here)")
.interact_text()
.map(PathBuf::from)
.map_err(|err| format!("failed to read output directory: {err}"))
}
fn non_interactive_fallback(&self) -> Result<PathBuf, String> {
Err("--output-dir is required when not running interactively".to_string())
}
}
struct EncryptionKeyInput<'a> {
flag: Option<String>,
store: &'a Store,
}
impl WizardInput for EncryptionKeyInput<'_> {
type Value = String;
fn flag_value(&self) -> Option<Result<String, String>> {
self.flag.clone().map(Ok)
}
fn prompt(&self) -> Result<String, String> {
self.store
.prompt_select_encryption_key()
.map(|key| key.alias.clone())
}
fn non_interactive_fallback(&self) -> Result<String, String> {
Err("--encryption-key is required when not running interactively".to_string())
}
}
struct ConcurrencyInput {
flag: Option<usize>,
}
impl WizardInput for ConcurrencyInput {
type Value = usize;
fn flag_value(&self) -> Option<Result<usize, String>> {
self.flag.map(|value| Ok(value.max(1)))
}
fn prompt(&self) -> Result<usize, String> {
let value = Input::<usize>::new()
.with_prompt("Concurrency")
.default(4)
.interact_text()
.map_err(|err| format!("failed to read concurrency: {err}"))?;
Ok(value.max(1))
}
fn non_interactive_fallback(&self) -> Result<usize, String> {
Err("--concurrency is required when not running interactively".to_string())
}
}
struct ConfirmInput {
yes: bool,
}
impl WizardInput for ConfirmInput {
type Value = bool;
fn flag_value(&self) -> Option<Result<bool, String>> {
self.yes.then_some(Ok(true))
}
fn prompt(&self) -> Result<bool, String> {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Proceed?")
.default(true)
.interact()
.map_err(|err| format!("failed to read confirmation: {err}"))
}
fn non_interactive_fallback(&self) -> Result<bool, String> {
Err(
"confirmation is required when not running interactively (pass --yes to skip)"
.to_string(),
)
}
}
fn fail(message: impl std::fmt::Display) -> i32 {
eprintln!("Error: {message}");
FAILURE_EXIT_CODE
}
pub fn dispatch(
input_dir: Option<PathBuf>,
output_dir: Option<PathBuf>,
encryption_key: Option<String>,
concurrency: Option<usize>,
yes: bool,
) -> i32 {
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(err) => return fail(format!("failed to start async runtime: {err}")),
};
runtime.block_on(dispatch_async(
input_dir,
output_dir,
encryption_key,
concurrency,
yes,
))
}
async fn dispatch_async(
input_dir: Option<PathBuf>,
output_dir: Option<PathBuf>,
encryption_key: Option<String>,
concurrency: Option<usize>,
yes: bool,
) -> i32 {
let input_dir = match (InputDirInput { flag: input_dir }).resolve() {
Ok(path) => path,
Err(err) => return fail(err),
};
let output_dir = match (OutputDirInput { flag: output_dir }).resolve() {
Ok(path) => path,
Err(err) => return fail(err),
};
if let (Ok(a), Ok(b)) = (input_dir.canonicalize(), output_dir.canonicalize())
&& a == b
{
return fail("--input-dir and --output-dir must not be the same directory");
}
let keyring_store_path = match Store::default_path() {
Ok(path) => path,
Err(err) => return fail(err),
};
let keyring_store = match Store::load(&keyring_store_path) {
Ok(store) => store,
Err(err) => return fail(err),
};
let alias = match (EncryptionKeyInput {
flag: encryption_key,
store: &keyring_store,
})
.resolve()
{
Ok(alias) => alias,
Err(err) => return fail(err),
};
let key_hex = match credentials::get_secret(&alias) {
Ok(secret) => secret,
Err(err) => return fail(err),
};
let encryptor = match Aes256GcmSivEncryptor::from_hex_key(&key_hex) {
Ok(encryptor) => encryptor,
Err(err) => return fail(err),
};
let job = DecryptFilesJob {
input_dir,
output_dir,
encryptor,
};
let plan = match job.gather().await {
Ok(plan) => plan,
Err(err) => return fail(err),
};
if plan.is_empty() {
println!("No encrypted files found in {}.", job.input_dir.display());
return 0;
}
println!("{} encrypted file(s) found.", plan.len());
let concurrency = match (ConcurrencyInput { flag: concurrency }).resolve() {
Ok(value) => value,
Err(err) => return fail(err),
};
match (ConfirmInput { yes }).resolve() {
Ok(true) => {}
Ok(false) => {
println!("Cancelled.");
return 0;
}
Err(err) => return fail(err),
}
match job.run(plan, concurrency).await {
Ok(summary) => {
println!(
"Decrypted {} file(s), {} failed.",
summary.decrypted, summary.failed
);
if summary.failed > 0 {
FAILURE_EXIT_CODE
} else {
0
}
}
Err(err) => fail(err),
}
}