use std::fs::File;
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use clap::{Parser, Subcommand};
use rand::RngCore;
use serde::Deserialize;
use tempfile::NamedTempFile;
use katzenpost_thin_client::persistent::PigeonholeClient;
use katzenpost_thin_client::{Config, ThinClient};
const MAX_NAME_LEN: usize = 255;
#[derive(Debug, thiserror::Error)]
pub enum FileNameError {
#[error("path has no file name component")]
NoFileName,
#[error("file name is not valid UTF-8")]
NotUtf8,
#[error("file name is reserved: {0:?}")]
Reserved(String),
#[error("file name is empty")]
Empty,
#[error("file name exceeds 255 bytes")]
TooLong,
#[error("file name contains a path separator")]
PathSeparator,
#[error("file name contains a control character")]
ControlChar,
#[error("destination escapes target directory")]
EscapesDir,
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub fn sanitize_for_receive(name: &str, dest_dir: &Path) -> Result<PathBuf, FileNameError> {
if name.is_empty() {
return Err(FileNameError::Empty);
}
if name.len() > MAX_NAME_LEN {
return Err(FileNameError::TooLong);
}
if name == "." || name == ".." {
return Err(FileNameError::Reserved(name.to_string()));
}
if name.contains('/') || name.contains('\\') {
return Err(FileNameError::PathSeparator);
}
if name.chars().any(|c| c.is_control()) {
return Err(FileNameError::ControlChar);
}
let dest_canon = dest_dir.canonicalize()?;
let candidate = dest_canon.join(name);
match candidate.parent() {
Some(parent) if parent == dest_canon.as_path() => Ok(candidate),
_ => Err(FileNameError::EscapesDir),
}
}
pub fn strip_for_send(path: &Path) -> Result<String, FileNameError> {
let basename = path.file_name().ok_or(FileNameError::NoFileName)?;
let name = basename.to_str().ok_or(FileNameError::NotUtf8)?.to_owned();
if name == "." || name == ".." {
return Err(FileNameError::Reserved(name));
}
Ok(name)
}
#[derive(serde::Serialize, serde::Deserialize)]
struct FileMetaData {
name: String,
size: u64,
}
#[derive(Parser)]
#[command(name = "pigeonhole-cp")]
#[command(about = "Katzenpost pigeonhole file copy tool")]
#[command(
long_about = "A CLI tool for sending/receiving files to/from pigeonhole channels.\n\n\
Similar to rcp or scp:\n\
- Read a file off disk and send it to a Pigeonhole channel (send mode)\n\
- Read from a channel and write file to disk (receive mode)\n\n"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Genkey {
#[arg(short, long)]
config: PathBuf,
},
Send {
#[arg(short, long)]
config: PathBuf,
#[arg(short, long)]
write_cap: String,
#[arg(short, long)]
index: String,
#[arg(short, long)]
file: PathBuf,
#[arg(long)]
no_copy: bool,
},
Receive {
#[arg(short, long)]
config: PathBuf,
#[arg(short, long)]
read_cap: String,
#[arg(short, long)]
index: String,
#[arg(short, long)]
dest_dir: PathBuf,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
env_logger::init();
let cli = Cli::parse();
match cli.command {
Commands::Genkey { config } => run_genkey(config).await,
Commands::Send { config, write_cap, index, file, no_copy } => {
run_send(config, write_cap, index, file, !no_copy).await
}
Commands::Receive { config, read_cap, index, dest_dir } => {
run_receive(config, read_cap, index, dest_dir).await
}
}
}
async fn init_client(config_path: PathBuf) -> Result<Arc<ThinClient>, Box<dyn std::error::Error>> {
let cfg = Config::new(config_path.to_str().ok_or("Invalid config path")?)?;
let client = ThinClient::new(cfg).await?;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
install_shutdown_handler(Arc::clone(&client));
Ok(client)
}
fn install_shutdown_handler(client: Arc<ThinClient>) {
tokio::spawn(async move {
wait_for_shutdown_signal().await;
eprintln!("\npigeonhole-cp: received shutdown signal, closing thin client");
client.stop().await;
std::process::exit(130);
});
}
async fn wait_for_shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
async fn run_genkey(config: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let client = init_client(config).await?;
let mut seed = [0u8; 32];
rand::thread_rng().fill_bytes(&mut seed);
let kp = client.new_keypair(&seed).await?;
println!("Read Capability (share with recipient):");
println!("{}\n", BASE64.encode(&kp.read_cap));
println!("Write Capability (keep secret):");
println!("{}\n", BASE64.encode(&kp.write_cap));
println!("First Index:");
println!("{}", BASE64.encode(&kp.first_message_index));
Ok(())
}
async fn run_send(
config: PathBuf,
write_cap_b64: String,
next_index_b64: String,
input_file: PathBuf,
copy: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let write_cap = BASE64.decode(&write_cap_b64)?;
let next_index = BASE64.decode(&next_index_b64)?;
let meta = std::fs::metadata(&input_file)?;
if !meta.is_file() {
return Err(format!("input must be a regular file: {:?}", input_file).into());
}
let total_len = meta.len();
let file_name = strip_for_send(&input_file)?;
let header = serde_cbor::to_vec(&FileMetaData { name: file_name, size: total_len })?;
let client = init_client(config).await?;
let pigeonhole = PigeonholeClient::new_in_memory(client.clone())?;
if copy {
send_copy(&pigeonhole, &write_cap, &next_index, &input_file, total_len, &header).await
} else {
send_direct(&pigeonhole, &write_cap, &next_index, &input_file, total_len, &header).await
}
}
async fn send_direct(
pigeonhole: &PigeonholeClient,
write_cap: &[u8],
next_index: &[u8],
input_file: &Path,
total_len: u64,
header: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
let box_payload_size = pigeonhole
.thin_client()
.pigeonhole_geometry()
.max_plaintext_payload_length;
if header.len() >= box_payload_size {
return Err(format!(
"FileMetaData header ({} bytes) leaves no room for payload in a {}-byte box",
header.len(),
box_payload_size
)
.into());
}
let mut input_reader = BufReader::new(File::open(input_file)?);
let mut writer = pigeonhole.load_write_channel("pigeonhole-cp", write_cap, next_index)?;
let start = std::time::Instant::now();
let first_room = box_payload_size - header.len();
let mut first_chunk = vec![0u8; first_room];
let n = read_fill(&mut input_reader, &mut first_chunk)?;
let mut first_box = Vec::with_capacity(header.len() + n);
first_box.extend_from_slice(header);
first_box.extend_from_slice(&first_chunk[..n]);
writer.send(&first_box).await?;
let mut bytes_sent = n as u64;
let mut box_count = 1usize;
let mut chunk_buf = vec![0u8; box_payload_size];
while bytes_sent < total_len {
let n = read_fill(&mut input_reader, &mut chunk_buf)?;
if n == 0 {
break;
}
writer.send(&chunk_buf[..n]).await?;
bytes_sent += n as u64;
box_count += 1;
}
if bytes_sent != total_len {
return Err(format!(
"short read: file claimed {} bytes, only sent {}",
total_len, bytes_sent
)
.into());
}
print_throughput("direct", bytes_sent, box_count, start.elapsed());
Ok(())
}
async fn send_copy(
pigeonhole: &PigeonholeClient,
write_cap: &[u8],
next_index: &[u8],
input_file: &Path,
total_len: u64,
header: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
const COPY_PAYLOAD_LIMIT: u64 = 9 * 1024 * 1024;
let total_payload_len = header.len() as u64 + total_len;
if total_payload_len > COPY_PAYLOAD_LIMIT {
return Err(format!(
"payload of {} bytes exceeds COPY mode limit of {} bytes; rerun with --no-copy to stream it",
total_payload_len, COPY_PAYLOAD_LIMIT
)
.into());
}
let mut payload = Vec::with_capacity(total_payload_len as usize);
payload.extend_from_slice(header);
File::open(input_file)?.read_to_end(&mut payload)?;
let start = std::time::Instant::now();
let mut builder = pigeonhole.copy_stream_builder().await?;
builder.add_payload(&payload, write_cap, next_index, true).await?;
let boxes = builder.finish().await?;
print_throughput("copy", total_len, boxes as usize, start.elapsed());
Ok(())
}
fn print_throughput(mode: &str, bytes: u64, boxes: usize, elapsed: std::time::Duration) {
let secs = elapsed.as_secs_f64();
let boxes_per_sec = if secs > 0.0 { boxes as f64 / secs } else { 0.0 };
let kib_per_sec = if secs > 0.0 { (bytes as f64 / secs) / 1024.0 } else { 0.0 };
println!(
"sent {} bytes in {} box(es) ({}) in {:.3}s: {:.2} boxes/s, {:.1} KiB/s",
bytes, boxes, mode, secs, boxes_per_sec, kib_per_sec
);
}
fn read_fill<R: Read>(reader: &mut R, buf: &mut [u8]) -> std::io::Result<usize> {
let mut total = 0;
while total < buf.len() {
match reader.read(&mut buf[total..])? {
0 => break,
n => total += n,
}
}
Ok(total)
}
async fn run_receive(
config: PathBuf,
read_cap_b64: String,
next_index_b64: String,
dest_dir: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let read_cap = BASE64.decode(&read_cap_b64)?;
let next_index = BASE64.decode(&next_index_b64)?;
let client = init_client(config).await?;
let pigeonhole = PigeonholeClient::new_in_memory(client.clone())?;
let mut reader =
pigeonhole.load_read_channel("pigeonhole-cp", &read_cap, &next_index)?;
let first_box = reader.receive().await?;
let mut deserializer = serde_cbor::Deserializer::from_slice(&first_box);
let metadata = FileMetaData::deserialize(&mut deserializer)?;
let header_end = deserializer.byte_offset();
let file_part = &first_box[header_end..];
let final_path = sanitize_for_receive(&metadata.name, &dest_dir)?;
let parent = final_path
.parent()
.expect("sanitize_for_receive guarantees a parent");
let mut tmp = NamedTempFile::new_in(parent)?;
let mut remaining: u64 = metadata.size;
let take = (file_part.len() as u64).min(remaining) as usize;
tmp.write_all(&file_part[..take])?;
remaining -= take as u64;
let mut box_count = 1usize;
while remaining > 0 {
let chunk = reader.receive().await?;
let take = (chunk.len() as u64).min(remaining) as usize;
tmp.write_all(&chunk[..take])?;
remaining -= take as u64;
box_count += 1;
}
tmp.as_file().sync_all()?;
tmp.persist_noclobber(&final_path).map_err(|e| e.error)?;
println!(
"received {} bytes in {} box(es) -> {}",
metadata.size,
box_count,
final_path.display()
);
Ok(())
}