use clap::{arg, value_parser, Arg, Command};
use digest::generic_array::GenericArray;
use rpassword::prompt_password;
use std::collections::HashSet;
use std::fs::File;
use std::io::Read;
use std::path::PathBuf;
use std::process;
use bitbottle::*;
use bitbottle::cli::*;
const ABOUT: &str =
"Read or unpack a bitbottle file archive.";
fn conjure_args() -> Vec<Arg> {
vec![
arg!(
-d --dest <PATH> "unpack archive into a new folder (default is the archive name)"
).required(false).value_parser(value_parser!(PathBuf)),
arg!(-i --info "list the contents without unpacking"),
arg!(--check "validate that the block storage is correct and complete without unpacking"),
arg!(--dump "display bottle structure without unpacking"),
arg!(
-s --decrypt <FILE> "decrypt the archive using an ssh-ed25519 private key file"
).required(false).value_parser(value_parser!(PathBuf)),
arg!(-p --password "decrypt the archive with a password"),
arg!(--"pipe-password" "the archive password or ssh private key file password will be provided as a linefeed-terminated string on stdin"),
arg!(
-r --verify <FILE> ... "verify that the archive is signed by one of these ssh-ed25519 public key files"
).required(false).value_parser(value_parser!(PathBuf)),
arg!(--old "allow older encrypted archives that are missing key commitment"),
arg!(-v --verbose "describe what's happening as the bitbottle is written"),
arg!(-q --quiet "don't display progress bars or explanatory information"),
arg!([FILE] "read archive from file instead of stdin").value_parser(value_parser!(PathBuf)),
]
}
static mut VERBOSE: bool = false;
static mut QUIET: bool = false;
macro_rules! verbose {
($($arg:tt)*) => ({
if unsafe { VERBOSE } {
eprintln!($($arg)*);
}
})
}
macro_rules! info {
($($arg:tt)*) => ({
if !unsafe { QUIET } {
eprintln!($($arg)*);
}
})
}
fn is_verbose() -> bool {
unsafe { VERBOSE }
}
fn is_quiet() -> bool {
unsafe { QUIET }
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Mode {
Unpack,
Info,
CheckOnly,
Dump,
}
fn dump_archive(reader: Box<dyn Read>, mut options: ArchiveReaderOptions) -> BottleResult<()> {
options.read_blocks = false;
let mut indent_size = 0;
for event in ArchiveReader::new(reader, options)? {
match event {
ArchiveReaderEvent::Error(error) => {
return Err(error);
},
ArchiveReaderEvent::BottleCap(cap) => {
for line in indent(cap.dump(), indent_size) { info!("{}", line); }
indent_size += 4;
},
ArchiveReaderEvent::BottleEnd(bottle_type) => {
indent_size -= 4;
info!("{}", indent_line(&format!("---- End bottle type {}", bottle_type as u8), indent_size));
},
ArchiveReaderEvent::Compressed(algorithm) => {
info!("{}", indent_line(&format!("Compressed: {algorithm:?}"), indent_size));
},
ArchiveReaderEvent::Encrypted(info) => {
for encrypted_key in info.public_encrypted_keys.iter() {
info!("{}", indent_line("Data stream:", indent_size + 4));
for line in indent(encrypted_key.header.dump(), 8) { info!("{}", line); }
}
},
ArchiveReaderEvent::Signed { algorithm, public_key, signed_by, verify_key_name } => {
let key_name = match verify_key_name {
Some(n) => format!("{n} (aka: {signed_by})"),
None => format!("\"{signed_by}\""),
};
info!("{}", indent_line(&format!("Signed by {key_name}"), indent_size));
info!("{}", indent_line(&format!("Signature: {algorithm:?} {}", hex::encode(public_key)), indent_size));
},
ArchiveReaderEvent::FileListStart { hash_type, block_count, file_count } => {
info!("{}", indent_line(
&format!("Files: {file_count} files, {block_count} blocks ({hash_type:?})"),
indent_size
));
},
ArchiveReaderEvent::FileAtlas(atlas) => {
for line in indent(atlas.borrow().bottle_cap.dump(), indent_size) { info!("{}", line); }
},
ArchiveReaderEvent::FileBlock { size, hash, bottle_cap, .. } => {
for line in indent(bottle_cap.dump(), indent_size) { info!("{}", line); }
info!("{}", indent_line(&format!("Block: {} ({} bytes)", hex::encode(hash), size), indent_size + 4));
},
ArchiveReaderEvent::UnknownBottleData { size, data } => {
if let Some(data) = data {
info!("{}", indent_line(&format!("Data stream: {}", hex::encode(&data[..size])), indent_size + 4));
} else {
let size_str = format!("{} bytes ({})", size, to_binary_si(size as f64));
info!("{}", indent_line(&format!("Data stream: {size_str}"), indent_size + 4));
}
},
_ => (),
}
}
Ok(())
}
fn display_basic_info(event: &ArchiveReaderEvent, verifying: bool) {
match event {
ArchiveReaderEvent::Compressed(algorithm) => {
verbose!("Bitbottle compressed with {:?}", algorithm);
},
ArchiveReaderEvent::Encrypted(info) => {
let key_count = info.public_encrypted_keys.len();
info!("Bitbottle encrypted with {:?}{}{}",
info.algorithm,
if info.argon.is_some() { ", password" } else { "" },
if key_count > 0 {
format!(", {} public key{} ({:?})",
key_count, if key_count > 1 { "s" } else { "" }, info.public_key_algorithm)
} else {
String::new()
},
);
verbose!(" Block size: {}", to_binary_si((1 << info.block_size_bits) as f64));
if let Some(argon) = info.argon {
verbose!(" Password encryption: ARGON2ID (time={}, mem={}, par={})",
argon.time_cost, to_binary_si((1 << argon.memory_cost_bits) as f64), argon.parallelism);
}
for encrypted_key in &info.public_encrypted_keys {
let archaic = encrypted_key.public_key.as_bytes(false).iter().all(|b| *b == 0);
let mut name = encrypted_key.public_key.name().to_string();
if archaic { name.insert_str(0, "[old] "); }
verbose!(" Encrypted for: {} ({})", pad_truncate(&name, 24),
hex::encode(encrypted_key.public_key.as_bytes(archaic)));
}
},
ArchiveReaderEvent::Signed { algorithm, public_key, signed_by, verify_key_name } => {
match verify_key_name {
Some(n) => {
info!("Bitbottle signed by {}",
if n == signed_by { n.to_string() } else { format!("{n} (aka: {signed_by})") }
)
},
None => info!("Bitbottle allegedly signed by \"{}\"", signed_by),
};
verbose!("Signature: {:?} {}", algorithm, hex::encode(public_key));
},
ArchiveReaderEvent::FileListDone { bad_path_list, stray_symlinks, .. } => {
if !bad_path_list.is_empty() || !stray_symlinks.is_empty() {
info!("");
if let Some((bad_path, new_path)) = bad_path_list.first() {
info!("*** WARNING: invalid path {:?} -- defanging to {:?}", bad_path, new_path);
}
if bad_path_list.len() > 1 {
info!(" ({} other similar path warnings)", bad_path_list.len() - 1);
}
for symlink in stray_symlinks {
info!("*** WARNING: ignoring invalid symlink {:?} -> {:?}", symlink.source, symlink.target);
}
info!("");
}
},
ArchiveReaderEvent::BottleEnd(BottleType::Signed) if verifying => {
info!("✅ Signature is VALID");
},
_ => (),
}
}
fn info_archive(
reader: Box<dyn Read>,
mut options: ArchiveReaderOptions,
orig_size: Option<u64>,
mode: Mode
) -> BottleResult<()> {
options.read_blocks = mode == Mode::CheckOnly;
let verifying = !options.verify_keys.is_empty();
let mut is_signed = false;
let progress = ProgressLine::new().to_shared();
progress.borrow_mut().show_ever = !is_quiet();
progress.borrow_mut().show_bar = true;
progress.borrow_mut().update(0f64, "Reading file list".to_string());
let mut total_hash_type: Option<HashType> = None;
let mut total_file_count = 0;
let mut files_so_far = 0;
let mut total_block_count = 0;
let mut total_file_list: Option<FileListRef> = None;
let mut digest = Hashing::new(HashType::SHA256);
let mut block_list: HashSet<Block> = HashSet::new();
for event in ArchiveReader::new(reader, options)? {
display_basic_info(&event, verifying);
match event {
ArchiveReaderEvent::Error(error) => {
return Err(error);
},
ArchiveReaderEvent::Signed { .. } => {
is_signed = true;
},
ArchiveReaderEvent::FileListStart { hash_type, block_count, file_count } => {
if verifying && !is_signed { return Err(BottleError::NoAcceptableSignature); }
total_hash_type = is_verbose().then_some(hash_type);
total_block_count = block_count;
total_file_count = file_count;
digest = Hashing::new(hash_type);
},
ArchiveReaderEvent::FileAtlas(_atlas) => {
files_so_far += 1;
let percent = files_so_far as f64 / total_file_count as f64;
progress.borrow_mut().complete(percent);
progress.borrow_mut().display();
},
ArchiveReaderEvent::FileListDone { file_list, .. } => {
progress.borrow_mut().clear();
total_file_list = Some(file_list);
let file_list = total_file_list.take().unwrap();
if mode == Mode::Info {
for line in display_file_list(total_hash_type, &file_list.borrow(), orig_size) {
info!("{}", line);
}
} else {
info!("{}", display_file_header(total_hash_type, &file_list.borrow(), orig_size));
for file in &file_list.borrow().files {
let file = file.borrow();
let size = file.contents.blocks.iter().fold(0u64, |sum, block| sum + block.size as u64);
if size != file.size {
eprintln!("ERROR: File size {} doesn't match file atlas block size {}", file.size, size);
}
}
progress.borrow_mut().update(0f64, "Checking block storage".to_string());
block_list = file_list.borrow().blocks.values().cloned().collect();
}
total_file_list = Some(file_list);
},
ArchiveReaderEvent::FileBlock { size, hash, data, .. } if data.is_some() => {
let data = data.unwrap();
digest.update(&data);
let my_hash = digest.finalize_reset();
let block = total_file_list.as_ref().and_then(|f|
f.borrow().blocks.get(GenericArray::from_slice(&hash)).cloned()
).unwrap();
if my_hash.to_vec() == hash && size == block.size {
progress.borrow_mut().clear();
verbose!("OK {}", hex::encode(&hash));
} else {
eprintln!("ERROR: Bad block: {} ({}) != {} ({})",
hex::encode(my_hash), size, hex::encode(&hash), block.size);
process::exit(1);
}
if !block_list.remove(&block) {
eprintln!("ERROR: Extra block: {}", hex::encode(hash));
process::exit(1);
}
let so_far = total_block_count - block_list.len();
let percent = so_far as f64 / total_block_count as f64;
progress.borrow_mut().update(percent, format!("Checking block {so_far}/{total_block_count}"));
progress.borrow_mut().display();
},
_ => (),
}
}
progress.borrow_mut().clear();
if mode == Mode::CheckOnly {
if !block_list.is_empty() {
eprintln!("ERROR: Undiscovered blocks in table of contents: {}", block_list.len());
process::exit(1);
}
eprintln!("Passed validation: all blocks are present and correct.");
}
Ok(())
}
fn extract_archive(
reader: Box<dyn Read>,
options: ArchiveReaderOptions,
dest_path: PathBuf,
orig_size: Option<u64>,
) -> BottleResult<()> {
let verifying = !options.verify_keys.is_empty();
let mut is_signed = false;
let progress = ProgressLine::new().to_shared();
progress.borrow_mut().show_ever = !is_quiet();
progress.borrow_mut().show_bar = true;
progress.borrow_mut().update(0f64, "Reading file list".to_string());
let mut total_hash_type: Option<HashType> = None;
let mut total_file_count = 0;
let mut files_so_far = 0;
let mut total_size = 0u64;
let mut total_bytes_read = 0u64;
let mut total_bytes_written = 0u64;
for event in SimpleArchiveExpander::new(reader, &dest_path, options)? {
display_basic_info(&event, verifying);
match event {
ArchiveReaderEvent::Error(error) => {
return Err(error);
},
ArchiveReaderEvent::Signed { .. } => {
is_signed = true;
},
ArchiveReaderEvent::FileListStart { hash_type, file_count, .. } => {
if verifying && !is_signed { return Err(BottleError::NoAcceptableSignature); }
total_hash_type = is_verbose().then_some(hash_type);
total_file_count = file_count;
},
ArchiveReaderEvent::FileAtlas(atlas) => {
if is_verbose() {
progress.borrow_mut().clear();
verbose!("{}", display_atlas_filename(&atlas.borrow()));
}
files_so_far += 1;
let percent = files_so_far as f64 / total_file_count as f64;
progress.borrow_mut().complete(percent);
progress.borrow_mut().display();
},
ArchiveReaderEvent::FileListDone { file_list, .. } => {
progress.borrow_mut().clear();
total_size = file_list.borrow().total_size();
verbose!("{}", display_file_header(total_hash_type, &file_list.borrow(), orig_size));
},
ArchiveReaderEvent::FileBlockWritten { size, write_count } => {
total_bytes_read += size as u64;
total_bytes_written += size as u64 * write_count as u64;
let percent = total_bytes_written as f64 / total_size as f64;
progress.borrow_mut().update(percent, format!("Extracting: {} -> {}/{}",
to_binary_si(total_bytes_read as f64), to_binary_si(total_bytes_written as f64),
to_binary_si(total_size as f64)));
},
_ => (),
}
}
progress.borrow_mut().clear();
info!("Extracted {} file(s) ({} bytes) to {}",
total_file_count, to_binary_si(total_size as f64), dest_path.to_str().unwrap());
Ok(())
}
fn get_password(path: Option<&PathBuf>) -> String {
let prompt = path.map(|f| format!("Password for {}: ", f.to_string_lossy())).unwrap_or("Password: ".to_string());
let password = prompt_password(prompt).unwrap_or_else(|e| {
eprintln!();
eprintln!("ERROR: {e}");
process::exit(1);
});
eprintln!();
password
}
fn read_secret_key_file(path: &PathBuf, password: Option<&str>) -> BottleResult<Box<dyn BottleSecretKey>> {
match Ed25519SecretKey::from_ssh_file(path.clone(), None) {
Err(BottleError::SshPasswordRequired) => (),
rv => { return rv.map(|k| k.boxed()); },
};
let password = password.map(|s| s.to_string()).unwrap_or_else(|| get_password(Some(path)));
Ed25519SecretKey::from_ssh_file(path.clone(), Some(&password)).map(|k| k.boxed())
}
fn wash_public_key(pk: Ed25519PublicKey) -> Box<dyn BottlePublicKey> {
Box::new(pk)
}
pub fn main() {
let features = format!("Built with support for: {}", get_feature_list().join(", "));
let args = Command::new("unbottle")
.version("0.10.0")
.author("Robey Pointer <robey@lag.net>")
.about(ABOUT)
.args(conjure_args())
.after_help(features)
.get_matches();
if args.get_flag("verbose") {
unsafe { VERBOSE = true; }
}
if args.get_flag("quiet") {
unsafe { QUIET = true; }
}
let check = args.get_flag("check");
let mut mode = if check { Mode::CheckOnly } else { Mode::Unpack };
if args.get_flag("info") {
mode = Mode::Info;
}
if args.get_flag("dump") {
mode = Mode::Dump;
}
let mut dest_path = args.get_one::<PathBuf>("dest").cloned();
let mut pipe_password = args.get_flag("pipe-password").then(|| {
let mut password = String::new();
std::io::stdin().read_line(&mut password).unwrap_or_else(|e| {
eprintln!("ERROR: Unable to read password from stdin: {e}");
process::exit(1);
});
password.trim_end_matches('\n').to_string()
});
let password = args.get_flag("password").then(|| pipe_password.take().unwrap_or_else(|| get_password(None)));
let sk_filename = args.get_one::<PathBuf>("decrypt");
let encryption_key = password.map(EncryptionKey::Password).unwrap_or(EncryptionKey::Generated);
let sk = sk_filename.map(|filename| {
read_secret_key_file(filename, pipe_password.as_deref()).inspect(|sk| {
verbose!("Decrypting with key: {}", sk.name());
})
}).transpose().unwrap_or_else(|e| {
eprintln!("ERROR: Unable to read secret key file: {e}");
process::exit(1);
});
let vk_filenames: Vec<PathBuf> = args.get_many::<PathBuf>("verify").unwrap_or_default().cloned().collect();
let vk = Ed25519PublicKey::from_ssh_files(vk_filenames.into_iter()).unwrap_or_else(|e| {
eprintln!("ERROR: Unable to read public key file: {e}");
process::exit(1);
});
let verify_keys: Vec<Box<dyn BottlePublicKey>> = vk.into_iter().map(wash_public_key).collect();
let mut orig_size: Option<u64> = None;
let reader: Box<dyn Read> = match args.get_one::<PathBuf>("FILE") {
Some(filename) => {
if dest_path.is_none() {
dest_path = filename.file_stem().map(|f| f.into());
if mode == Mode::Unpack {
verbose!("No destination path given; using: ./{}/", dest_path.as_ref().unwrap().display());
}
}
orig_size = filename.metadata().map(|m| m.len()).ok();
Box::new(File::open(filename).unwrap_or_else(|e| {
eprintln!("ERROR: Unable to read file {}: {}", filename.display(), e);
process::exit(1);
}))
},
None => {
Box::new(std::io::stdin())
},
};
let dest_path = dest_path.unwrap_or_else(|| {
eprintln!("ERROR: Destination path (-d) required");
process::exit(1);
});
let options = ArchiveReaderOptions {
secret_keys: Vec::from_iter(sk),
encryption_key: Some(encryption_key),
verify_keys,
allow_missing_key_commitment: args.get_flag("old"),
..ArchiveReaderOptions::default()
};
let ret = match mode {
Mode::Dump => dump_archive(reader, options),
Mode::Info | Mode::CheckOnly => info_archive(reader, options, orig_size, mode),
Mode::Unpack => extract_archive(reader, options, dest_path, orig_size),
};
ret.unwrap_or_else(|e| {
eprintln!("\nERROR: {e}");
process::exit(1);
});
}