//! tp — tesseras-paste CLI client. //! //! Sends commands to the `tpd` daemon over a Unix socket. //! Reads paste content from stdin (put) and writes it to //! stdout (get). Large pastes (> 8 KiB) are automatically //! split into chunks on the client side. use std::collections::BTreeMap; use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; #[path = "../base58.rs"] mod base58; #[path = "../crypto.rs"] mod crypto; #[path = "../sandbox.rs"] mod sandbox; /// Maximum paste size: 1.44 MB (floppy disk). const MAX_PASTE: usize = 1_440 * 1024; /// Chunk size matching the DHT fragment limit. const CHUNK_SIZE: usize = 8 * 1024; fn default_socket() -> PathBuf { PathBuf::from("/var/tesseras-paste/daemon.sock") } fn labels_path() -> PathBuf { if let Ok(home) = std::env::var("HOME") { PathBuf::from(home).join(".config/tp/labels") } else { PathBuf::from("/tmp/tp-labels") } } fn load_labels(path: &PathBuf) -> BTreeMap { let mut map = BTreeMap::new(); let data = match std::fs::read_to_string(path) { Ok(d) => d, Err(_) => return map, }; for line in data.lines() { if let Some((key, label)) = line.split_once('\t') { map.insert(key.to_string(), label.to_string()); } } map } fn save_labels(path: &PathBuf, labels: &BTreeMap) { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } let mut buf = String::new(); for (key, label) in labels { buf.push_str(key); buf.push('\t'); buf.push_str(label); buf.push('\n'); } if let Err(e) = std::fs::write(path, buf.as_bytes()) { eprintln!("warning: could not save labels: {e}"); } } fn usage() { eprintln!("usage: tp [-s sock] [-v] [args]"); eprintln!(); eprintln!("commands:"); eprintln!(" put [-t ttl] [-p] [-l label]"); eprintln!(" read stdin, store paste"); eprintln!(" -p public (no encryption)"); eprintln!(" -l attach a label"); eprintln!(" get retrieve paste to stdout"); eprintln!(" del delete paste"); eprintln!(" pin pin (never expires)"); eprintln!(" unpin unpin"); eprintln!(" list list labeled pastes"); eprintln!(" label add or update a label"); eprintln!(" status show daemon status"); eprintln!(); eprintln!(" -s sock Unix socket path"); eprintln!(" -v verbose output"); eprintln!(" -t ttl time-to-live (e.g. 24h 30m 3600)"); } fn parse_ttl(s: &str) -> Result { let s = s.trim(); if let Some(h) = s.strip_suffix('h') { h.parse::() .map(|v| v * 3600) .map_err(|e| e.to_string()) } else if let Some(m) = s.strip_suffix('m') { m.parse::().map(|v| v * 60).map_err(|e| e.to_string()) } else if let Some(sec) = s.strip_suffix('s') { sec.parse::().map_err(|e| e.to_string()) } else { s.parse::().map_err(|e| e.to_string()) } } /// Send a request over the socket and read the response. /// Returns the data on OK, or exits on ERR. fn send_recv( stream: &UnixStream, reader: &mut BufReader<&UnixStream>, request: &str, verbose: bool, ) -> String { if verbose { eprintln!(">> {}", request.trim()); } if let Err(e) = (stream as &UnixStream).write_all(request.as_bytes()) { eprintln!("error: writing to socket: {e}"); std::process::exit(1); } let mut line = String::new(); match reader.read_line(&mut line) { Ok(0) => { eprintln!("error: daemon closed connection"); std::process::exit(1); } Err(e) => { eprintln!("error: reading from socket: {e}"); std::process::exit(1); } _ => {} } if verbose { eprintln!("<< {}", line.trim()); } if let Some(data) = line.trim().strip_prefix("OK ") { data.to_string() } else if let Some(msg) = line.trim().strip_prefix("ERR ") { eprintln!("error: {msg}"); std::process::exit(1); } else { eprintln!("error: unexpected response: {}", line.trim()); std::process::exit(1); } } fn main() { let args: Vec = std::env::args().collect(); let mut sock_path = default_socket(); let mut verbose = false; let mut cmd_start = 1; // Parse global options before command let mut i = 1; while i < args.len() { match args[i].as_str() { "-s" => { i += 1; sock_path = args.get(i).map(PathBuf::from).unwrap_or_else(|| { eprintln!("error: -s requires path"); std::process::exit(1); }); cmd_start = i + 1; } "-v" => { verbose = true; cmd_start = i + 1; } "-h" | "--help" => { usage(); return; } _ => break, } i += 1; } let cmd_args = &args[cmd_start..]; if cmd_args.is_empty() { usage(); std::process::exit(1); } let command = &cmd_args[0]; // Handle client-only commands before sandboxing match command.as_str() { "list" => { let lpath = labels_path(); let labels = load_labels(&lpath); if labels.is_empty() { eprintln!("no labels"); } else { for (key, label) in &labels { println!("{key}\t{label}"); } } return; } "label" => { let key = cmd_args.get(1).unwrap_or_else(|| { eprintln!("error: label requires a key and text"); std::process::exit(1); }); let text: String = cmd_args[2..].join(" "); if text.is_empty() { eprintln!("error: label requires text"); std::process::exit(1); } let lpath = labels_path(); let mut labels = load_labels(&lpath); labels.insert(key.clone(), text); save_labels(&lpath, &labels); return; } _ => {} } // ── Parse command into request(s) ────────────────── // For most commands we build a single request string. // The "put" command may use chunked mode (multiple requests). enum PutData { Single(String), Chunked { data: Vec, ttl_secs: u64, enc_key: Option<[u8; crypto::KEY_SIZE]>, }, } let mut is_get = false; let mut put_label: Option = None; let mut del_key: Option = None; let put_data = match command.as_str() { "put" => { let mut ttl = "24h".to_string(); let mut public = false; let mut label: Option = None; let mut j = 1; while j < cmd_args.len() { match cmd_args[j].as_str() { "-t" => { j += 1; if j < cmd_args.len() { ttl = cmd_args[j].clone(); } } "-p" => public = true, "-l" => { j += 1; if j < cmd_args.len() { label = Some(cmd_args[j].clone()); } } _ => {} } j += 1; } let ttl_secs = match parse_ttl(&ttl) { Ok(s) => s, Err(e) => { eprintln!("error: bad TTL: {e}"); std::process::exit(1); } }; let mut content = Vec::new(); match std::io::stdin() .take((MAX_PASTE + 1) as u64) .read_to_end(&mut content) { Ok(0) => { eprintln!("error: empty input"); std::process::exit(1); } Ok(n) if n > MAX_PASTE => { eprintln!("error: input exceeds 1.44 MB limit"); std::process::exit(1); } Err(e) => { eprintln!("error: reading stdin: {e}"); std::process::exit(1); } _ => {} } put_label = label; if content.len() <= CHUNK_SIZE { // Small paste — single PUT/PUTP let cmd = if public { "PUTP" } else { "PUT" }; PutData::Single(format!( "{cmd} {ttl_secs} {}\n", base58::encode(&content) )) } else { // Large paste — client-side chunking let (data, enc_key) = if public { (content, None) } else { let key = crypto::generate_key(); let encrypted = crypto::encrypt(&key, &content); (encrypted, Some(key)) }; PutData::Chunked { data, ttl_secs, enc_key, } } } "get" => { let key = cmd_args.get(1).unwrap_or_else(|| { eprintln!("error: get requires a key"); std::process::exit(1); }); is_get = true; PutData::Single(format!("GET {key}\n")) } "del" => { let key = cmd_args.get(1).unwrap_or_else(|| { eprintln!("error: del requires a key"); std::process::exit(1); }); del_key = Some(key.clone()); PutData::Single(format!("DEL {key}\n")) } "pin" => { let key = cmd_args.get(1).unwrap_or_else(|| { eprintln!("error: pin requires a key"); std::process::exit(1); }); PutData::Single(format!("PIN {key}\n")) } "unpin" => { let key = cmd_args.get(1).unwrap_or_else(|| { eprintln!("error: unpin requires a key"); std::process::exit(1); }); PutData::Single(format!("UNPIN {key}\n")) } "status" => PutData::Single("STATUS\n".to_string()), other => { eprintln!("unknown command: {other}"); usage(); std::process::exit(1); } }; // ── Sandbox ───────────────────────────────────── let lpath = labels_path(); sandbox::do_unveil(&sock_path, "rw"); if let Some(parent) = lpath.parent() { let _ = std::fs::create_dir_all(parent); sandbox::do_unveil(parent, "rwc"); } sandbox::unveil_lock(); sandbox::do_pledge("stdio unix rpath wpath cpath"); if verbose { eprintln!("socket: {}", sock_path.display()); } let stream = match UnixStream::connect(&sock_path) { Ok(s) => s, Err(e) => { eprintln!("error: cannot connect to {}: {e}", sock_path.display()); eprintln!("hint: is tpd running?"); std::process::exit(1); } }; stream .set_read_timeout(Some(std::time::Duration::from_secs(60))) .ok(); let mut reader = BufReader::new(&stream); match put_data { PutData::Chunked { data, ttl_secs, enc_key, } => { // ── Chunked put ───────────────────────── let n_chunks = (data.len() + CHUNK_SIZE - 1) / CHUNK_SIZE; if verbose { eprintln!( "chunked: {} bytes, {} chunks", data.len(), n_chunks, ); } // Send each chunk via PUTC let mut chunk_hashes: Vec> = Vec::new(); for (i, chunk) in data.chunks(CHUNK_SIZE).enumerate() { let req = format!( "PUTC {} {}\n", ttl_secs, base58::encode(chunk), ); let hash_b58 = send_recv(&stream, &mut reader, &req, verbose); let hash = base58::decode(&hash_b58).unwrap_or_else(|| { eprintln!("error: invalid hash from daemon"); std::process::exit(1); }); if verbose { eprintln!("chunk {}/{}: {hash_b58}", i + 1, n_chunks); } chunk_hashes.push(hash); } // Build manifest: count(u16 BE) || hash1 || hash2 || ... let count = chunk_hashes.len() as u16; let mut manifest = Vec::with_capacity(2 + 32 * chunk_hashes.len()); manifest.extend_from_slice(&count.to_be_bytes()); for hash in &chunk_hashes { manifest.extend_from_slice(hash); } let req = format!( "PUTM {} {}\n", ttl_secs, base58::encode(&manifest), ); let manifest_hash = send_recv(&stream, &mut reader, &req, verbose); let key_str = match enc_key { Some(key) => { format!("{manifest_hash}#{}", base58::encode(&key)) } None => manifest_hash, }; println!("{key_str}"); // Save label if let Some(ref label) = put_label { let mut labels = load_labels(&lpath); labels.insert(key_str, label.clone()); save_labels(&lpath, &labels); } } PutData::Single(request) => { // ── Single request ────────────────────── let data = send_recv(&stream, &mut reader, &request, verbose); if is_get { match base58::decode(&data) { Some(bytes) => { if let Err(e) = std::io::stdout().write_all(&bytes) { eprintln!("error: writing to stdout: {e}"); std::process::exit(1); } } None => println!("{data}"), } } else { println!("{data}"); } // Save label on successful put if let Some(ref label) = put_label { let mut labels = load_labels(&lpath); labels.insert(data.to_string(), label.clone()); save_labels(&lpath, &labels); } // Remove label on successful del if let Some(ref key) = del_key { let mut labels = load_labels(&lpath); if labels.remove(key).is_some() { save_labels(&lpath, &labels); } } } } }