use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use ffsync::chunking::PipelineConfig;
use ffsync::protocol::sync::{
Progress, ReceiveRequest, SendRequest, SyncError, SyncOutcome, receive, send,
};
use ffsync::protocol::tree_sync::{
TreeOutcome, TreeProgress, TreeReceiveRequest, TreeSendRequest, tree_receive, tree_send,
};
const DEFAULT_TIMEOUT_SECS: u64 = 300;
const USAGE: &str = "\
ffsync: fast chunk-level file sync for big files and fast links
Usage:
ffsync send <file|dir> <host:port> [--checksum] [--no-delete]
[--plaintext] [--trust <fp>]
ffsync receive <file|dir> <port> [--checksum] [--from <old-file>]
[--no-delete] [--plaintext] [--trust <fp>]
ffsync trust <fingerprint> pin a peer fingerprint (TOFU first contact)
ffsync untrust <fingerprint> remove a pinned fingerprint
ffsync fingerprints show this peer's identity + all pins
ffsync --help | --version
Options:
--checksum skip no metadata shortcuts; always compare chunk roots
(rsync -c semantics). Also disables the warm-start index.
In tree mode this applies to every file in the tree.
--from <old-file> receive (single-file only): serve local chunks from
<old-file> instead of <file> (useful when the target
does not exist yet)
--no-delete tree mode: keep files that exist only on the receiver
(default IS to delete them — the sender's tree is
authoritative, like rsync --delete)
--plaintext disable TLS (LAN use or inside an SSH tunnel); D16
--trust <fp> non-interactive first contact: pin <fp> instead of
prompting; also overrides which pinned peer to expect
-h, --help print this help
-V, --version print version
Tree mode (v0.3): pass a directory instead of a file. The CONTENTS of the
directory are synced into the destination directory ('dir' and 'dir/' mean
the same thing — no rsync trailing-slash semantics). Files that match in
size and modification time are skipped; changed files transfer deltas;
files that exist only on the receiver are deleted unless --no-delete.
Deletions happen only after every transfer succeeded.
Security (D16): TLS 1.3 with self-signed certificates is on by default.
Both peers authenticate mutually by cert fingerprint — compare the
fingerprints printed on first contact out-of-band (in person, chat, ...),
confirm, and every later sync verifies silently. A mismatch refuses
loudly and syncs nothing.
Notes:
<host:port> — for IPv6 use the bracketed form, e.g. [::1]:7788.
receive binds IPv4 (0.0.0.0) only; IPv6 destinations need a dual-stack
proxy or an IPv4 address for now.
Port 0 on receive binds an OS-assigned port; the actual port is printed.
";
fn die(msg: &str) -> ! {
eprintln!("error: {}", msg);
eprintln!("try 'ffsync --help'");
std::process::exit(2);
}
struct ProgressDisplay {
last_phase: Option<Progress>,
start: Instant,
last_print: Instant,
}
impl ProgressDisplay {
fn new() -> Self {
Self {
last_phase: None,
start: Instant::now(),
last_print: Instant::now(),
}
}
fn elapsed(&self) -> Duration {
self.start.elapsed()
}
fn update(&mut self, p: Progress) {
let phase_changed = match (&self.last_phase, &p) {
(None, _) => true,
(Some(a), b) => std::mem::discriminant(a) != std::mem::discriminant(b),
};
let now = Instant::now();
if !phase_changed && now - self.last_print < Duration::from_millis(200) {
return;
}
self.last_phase = Some(p);
self.last_print = now;
let elapsed = now - self.start;
let line = match p {
Progress::Scanning(hashed, total) => {
let pct = hashed.checked_mul(100).map_or(1000, |v| v / total.max(1));
format!(
"hashing {:>6.1}% ({}/{} bytes, {} elapsed)",
pct as f64 / 10.0,
fmt_bytes(hashed),
fmt_bytes(total),
fmt_secs(elapsed)
)
}
Progress::WarmStart(chunks) => {
format!(
"warm loaded {} chunks from index, scan skipped",
chunks
)
}
Progress::Diffing => "diffing comparing chunk manifests".to_string(),
Progress::Transferring(sent, total) => {
let pct = sent.checked_mul(100).map_or(1000, |v| v / total.max(1));
format!(
"sending {:>6.1}% ({}/{} bytes, {} elapsed)",
pct as f64 / 10.0,
fmt_bytes(sent),
fmt_bytes(total),
fmt_secs(elapsed)
)
}
Progress::Receiving(received, total) => {
let pct = received.checked_mul(100).map_or(1000, |v| v / total.max(1));
format!(
"receiving {:>6.1}% ({}/{} bytes, {} elapsed)",
pct as f64 / 10.0,
fmt_bytes(received),
fmt_bytes(total),
fmt_secs(elapsed)
)
}
Progress::Verifying => "verifying comparing final root hash".to_string(),
Progress::NothingToDo => "up to date".to_string(),
};
eprint!("\r{}", line);
}
fn finish(&mut self) {
eprintln!();
}
}
fn fmt_bytes(n: u64) -> String {
const KIB: f64 = 1024.0;
const MIB: f64 = 1024.0 * KIB;
const GIB: f64 = 1024.0 * MIB;
let f = n as f64;
if f >= GIB {
format!("{:.2} GiB", f / GIB)
} else if f >= MIB {
format!("{:.2} MiB", f / MIB)
} else if f >= KIB {
format!("{:.2} KiB", f / KIB)
} else {
format!("{} B", n)
}
}
fn fmt_secs(d: Duration) -> String {
let s = d.as_secs_f64();
if s >= 60.0 {
format!("{:.0}m{:.0}s", s / 60.0, s % 60.0)
} else if s >= 1.0 {
format!("{:.1}s", s)
} else {
format!("{:.0} ms", s * 1000.0)
}
}
fn parse_host_port(s: &str) -> (String, u16) {
let (host, port) = if let Some(rest) = s.strip_prefix('[') {
let Some(end) = rest.find(']') else {
die("address must be [host]:port for IPv6");
};
let host = &rest[..end];
let after = &rest[end + 1..];
if !after.starts_with(':') {
die("address must be [host]:port for IPv6");
}
(host, &after[1..])
} else {
s.rsplit_once(':')
.unwrap_or_else(|| die("address must be host:port (use [host]:port for IPv6)"))
};
let port: u16 = port.parse().unwrap_or_else(|_| die("invalid port"));
if host.is_empty() {
die("address must be host:port");
}
(host.to_string(), port)
}
fn report_outcome(role: &str, outcome: SyncOutcome, display: &mut ProgressDisplay) {
display.finish();
match outcome {
SyncOutcome::InSync => println!("{}: already up to date", role),
SyncOutcome::Transferred { bytes, chunks } => {
let elapsed = display.elapsed();
let throughput = if elapsed.as_secs_f64() > 0.0 {
format!(
" at {}/s",
fmt_bytes((bytes as f64 / elapsed.as_secs_f64()) as u64)
)
} else {
String::new()
};
println!(
"{}: transferred {} in {} chunks{} in {}",
role,
fmt_bytes(bytes),
chunks,
throughput,
fmt_secs(elapsed)
);
}
}
}
fn set_stream_timeouts(stream: &TcpStream, secs: u64) {
let d = Some(Duration::from_secs(secs));
let _ = stream.set_read_timeout(d);
let _ = stream.set_write_timeout(d);
}
fn run_send(
file: PathBuf,
addr: String,
force_checksum: bool,
plaintext: bool,
trust_flag: Option<String>,
no_delete: bool,
) -> std::io::Result<()> {
let tree = file.is_dir();
if tree {
if no_delete {
return Err(std::io::Error::other(
"--no-delete applies to receive only (the sender never deletes)",
));
}
return run_tree_send(file, addr, force_checksum, plaintext, trust_flag);
}
if !file.is_file() {
return Err(std::io::Error::other(format!(
"source file not found: {}",
file.display()
)));
}
let (host, port) = parse_host_port(&addr);
println!("connecting to {host}:{port} ...");
let tcp = TcpStream::connect((host.as_str(), port)).map_err(|e| {
std::io::Error::other(format!(
"cannot connect to {host}:{port}: {e} (is the receiver running?)"
))
})?;
tcp.set_nodelay(true).ok();
set_stream_timeouts(&tcp, DEFAULT_TIMEOUT_SECS);
let mut stream: Box<dyn ReadWrite> = if plaintext {
Box::new(tcp)
} else {
let (identity, created) = ffsync::transport::Identity::load_or_create()?;
if created {
println!("identity created: {}", identity.fingerprint());
println!("(share this fingerprint out-of-band so peers can pin it)");
}
let policy = resolve_pin_policy(trust_flag.as_deref(), &host)?;
let (tls, presented) = ffsync::transport::client_session(
tcp,
&identity,
&policy,
rustls_pki_types_server_name(&host)?,
)?;
enforce_first_use(&policy, &presented, format!("{host}:{port}"))?;
Box::new(tls)
};
let display = &mut ProgressDisplay::new();
let request = SendRequest {
path: file.clone(),
display_path: file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
mtime: None,
force_checksum,
tree: false,
};
let pipeline = PipelineConfig::default();
let outcome = match send(&mut stream, &request, &pipeline, &mut |p| display.update(p)) {
Ok(o) => o,
Err(e) => {
display.finish();
return Err(io_err(&e));
}
};
report_outcome("send", outcome, display);
Ok(())
}
fn io_err(e: &SyncError) -> std::io::Error {
std::io::Error::other(e.to_string())
}
fn run_receive(
file: PathBuf,
port: u16,
force_checksum: bool,
from: Option<PathBuf>,
plaintext: bool,
trust_flag: Option<String>,
no_delete: bool,
) -> std::io::Result<()> {
if file.is_dir() {
if from.is_some() {
return Err(std::io::Error::other(
"--from is single-file only (tree sync has no old-file concept)",
));
}
return run_tree_receive(file, port, force_checksum, plaintext, trust_flag, no_delete);
}
let local_source = from.unwrap_or_else(|| file.clone());
if !local_source.is_file() {
return Err(std::io::Error::other(format!(
"local source not found: {} (create the file or pass --from <old-file>)",
local_source.display()
)));
}
let listener = TcpListener::bind(("0.0.0.0", port))?;
let bound = listener.local_addr()?.port();
if port == 0 {
println!("listening on 0.0.0.0:{bound} (OS-assigned port) ...");
} else {
println!("listening on 0.0.0.0:{bound} ...");
}
let (tcp, peer) = listener.accept()?;
println!("peer connected: {peer}");
tcp.set_nodelay(true).ok();
set_stream_timeouts(&tcp, DEFAULT_TIMEOUT_SECS);
let mut stream: Box<dyn ReadWrite> = if plaintext {
Box::new(tcp)
} else {
let (identity, created) = ffsync::transport::Identity::load_or_create()?;
if created {
println!("identity created: {}", identity.fingerprint());
println!("(share this fingerprint out-of-band so peers can pin it)");
}
let policy = resolve_pin_policy(trust_flag.as_deref(), &peer.to_string())?;
let (tls, presented) = ffsync::transport::server_session(tcp, &identity, &policy)?;
enforce_first_use(&policy, &presented, peer.to_string())?;
println!(
"peer identity: {}",
ffsync::transport::format_fingerprint(&presented)
);
Box::new(tls)
};
let display = &mut ProgressDisplay::new();
let request = ReceiveRequest {
path: file.clone(),
local_source,
force_checksum,
tree: false,
apply_mtime: None,
};
let pipeline = PipelineConfig::default();
let outcome = match receive(&mut stream, &request, &pipeline, &mut |p| display.update(p)) {
Ok(o) => o,
Err(e) => {
display.finish();
return Err(io_err(&e));
}
};
report_outcome("receive", outcome, display);
Ok(())
}
fn run_tree_send(
root: PathBuf,
addr: String,
force_checksum: bool,
plaintext: bool,
trust_flag: Option<String>,
) -> std::io::Result<()> {
let (host, port) = parse_host_port(&addr);
println!("connecting to {host}:{port} ...");
let tcp = TcpStream::connect((host.as_str(), port)).map_err(|e| {
std::io::Error::other(format!(
"cannot connect to {host}:{port}: {e} (is the receiver running?)"
))
})?;
tcp.set_nodelay(true).ok();
set_stream_timeouts(&tcp, DEFAULT_TIMEOUT_SECS);
let mut stream = connect_authenticated(tcp, &host, true, plaintext, trust_flag)?;
let display = &mut TreeDisplay::new();
let request = TreeSendRequest {
root,
force_checksum,
no_delete: false, };
let pipeline = PipelineConfig::default();
let outcome = match tree_send(&mut stream, &request, &pipeline, &mut |p| display.update(p)) {
Ok(o) => o,
Err(e) => {
display.finish();
return Err(io_err(&e));
}
};
report_tree_outcome("send", &outcome, display);
Ok(())
}
fn run_tree_receive(
root: PathBuf,
port: u16,
force_checksum: bool,
plaintext: bool,
trust_flag: Option<String>,
no_delete: bool,
) -> std::io::Result<()> {
if !root.exists() {
std::fs::create_dir_all(&root)?;
}
let listener = TcpListener::bind(("0.0.0.0", port))?;
let bound = listener.local_addr()?.port();
println!("listening on 0.0.0.0:{bound} (tree mode) ...");
let (tcp, peer) = listener.accept()?;
println!("peer connected: {peer}");
tcp.set_nodelay(true).ok();
set_stream_timeouts(&tcp, DEFAULT_TIMEOUT_SECS);
let mut stream = connect_authenticated(tcp, &peer.to_string(), false, plaintext, trust_flag)?;
let display = &mut TreeDisplay::new();
let request = TreeReceiveRequest {
root,
force_checksum,
no_delete,
};
let pipeline = PipelineConfig::default();
let outcome = match tree_receive(&mut stream, &request, &pipeline, &mut |p| display.update(p)) {
Ok(o) => o,
Err(e) => {
display.finish();
return Err(io_err(&e));
}
};
report_tree_outcome("receive", &outcome, display);
Ok(())
}
fn connect_authenticated(
tcp: TcpStream,
peer_label: &str,
client_role: bool,
plaintext: bool,
trust_flag: Option<String>,
) -> std::io::Result<Box<dyn ReadWrite>> {
if plaintext {
return Ok(Box::new(tcp));
}
let (identity, created) = ffsync::transport::Identity::load_or_create()?;
if created {
println!("identity created: {}", identity.fingerprint());
println!("(share this fingerprint out-of-band so peers can pin it)");
}
let policy = resolve_pin_policy(trust_flag.as_deref(), peer_label)?;
let (tls, presented) = if client_role {
let (tls, presented) = ffsync::transport::client_session(
tcp,
&identity,
&policy,
rustls_pki_types_server_name(peer_label)?,
)?;
(Box::new(tls) as Box<dyn ReadWrite>, presented)
} else {
let (tls, presented) = ffsync::transport::server_session(tcp, &identity, &policy)?;
(Box::new(tls) as Box<dyn ReadWrite>, presented)
};
enforce_first_use(&policy, &presented, peer_label.to_string())?;
println!(
"peer identity: {}",
ffsync::transport::format_fingerprint(&presented)
);
Ok(tls)
}
trait ReadWrite: Read + Write {}
impl<T: Read + Write> ReadWrite for T {}
struct TreeDisplay {
current_file: Option<String>,
last_print: Instant,
}
impl TreeDisplay {
fn new() -> Self {
Self {
current_file: None,
last_print: Instant::now(),
}
}
fn update(&mut self, p: TreeProgress) {
match p {
TreeProgress::Scanning => {
self.set_line("scanning walking the tree (metadata only)".to_string());
}
TreeProgress::FileStart(_idx, _total) => {
}
TreeProgress::FileProgress { rel_path, progress } => {
self.current_file = Some(rel_path);
self.render_file_progress(progress);
}
TreeProgress::FileDone(idx, total) => {
self.set_line(format!("done {}/{} files synced", idx + 1, total));
}
TreeProgress::Deleting(n, bytes) => {
self.set_line(format!("deleting {} removed ({})", n, fmt_bytes(bytes)));
}
}
}
fn render_file_progress(&mut self, p: Progress) {
let now = Instant::now();
if !matches!(p, Progress::NothingToDo) && now - self.last_print < Duration::from_millis(200)
{
return;
}
self.last_print = now;
let file = self.current_file.clone().unwrap_or_default();
let inner = match p {
Progress::Scanning(hashed, total) => {
let pct = hashed.checked_mul(100).map_or(1000, |v| v / total.max(1));
format!("hashing {:.1}% ({})", pct as f64 / 10.0, fmt_bytes(hashed))
}
Progress::WarmStart(chunks) => format!("warm ({} chunks from index)", chunks),
Progress::Diffing => "diffing".to_string(),
Progress::Transferring(sent, total) => {
let pct = sent.checked_mul(100).map_or(1000, |v| v / total.max(1));
format!("sending {:.1}% ({})", pct as f64 / 10.0, fmt_bytes(sent))
}
Progress::Receiving(received, total) => {
let pct = received.checked_mul(100).map_or(1000, |v| v / total.max(1));
format!(
"receiving {:.1}% ({})",
pct as f64 / 10.0,
fmt_bytes(received)
)
}
Progress::Verifying => "verifying".to_string(),
Progress::NothingToDo => "up to date".to_string(),
};
self.set_line(format!("sync {} {}", file, inner));
}
fn set_line(&mut self, line: String) {
eprint!("\r{}", line);
}
fn finish(&mut self) {
eprintln!();
}
}
fn report_tree_outcome(role: &str, outcome: &TreeOutcome, display: &mut TreeDisplay) {
display.finish();
let mut parts = Vec::new();
parts.push(format!("{} files", outcome.files));
if outcome.transferred_bytes > 0 {
parts.push(format!(
"{} transferred",
fmt_bytes(outcome.transferred_bytes)
));
}
if outcome.in_sync > 0 {
parts.push(format!("{} up to date", outcome.in_sync));
}
if outcome.deleted > 0 {
parts.push(format!(
"{} deleted ({})",
outcome.deleted,
fmt_bytes(outcome.deleted_bytes)
));
}
println!("{}: {}", role, parts.join(", "));
}
fn resolve_pin_policy(
trust_flag: Option<&str>,
_peer_label: &str,
) -> std::io::Result<ffsync::transport::PinPolicy> {
match trust_flag {
Some(fp) => {
let raw = ffsync::transport::parse_fingerprint(fp)
.ok_or_else(|| std::io::Error::other(format!("not a SHA-256 fingerprint: {fp}")))?;
Ok(ffsync::transport::PinPolicy::Pinned(vec![raw]))
}
None => {
let pins = ffsync::transport::load_pins()?;
if pins.is_empty() {
Ok(ffsync::transport::PinPolicy::FirstUse)
} else {
Ok(ffsync::transport::PinPolicy::Pinned(pins))
}
}
}
}
fn rustls_pki_types_server_name(
host: &str,
) -> std::io::Result<rustls::pki_types::ServerName<'static>> {
use rustls::pki_types::ServerName;
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
return Ok(ServerName::IpAddress(ip.into()));
}
ServerName::try_from(host.to_string())
.map_err(|e| std::io::Error::other(format!("invalid host: {e}")))
}
fn enforce_first_use(
policy: &ffsync::transport::PinPolicy,
presented: &[u8],
peer_label: String,
) -> std::io::Result<()> {
if !matches!(policy, ffsync::transport::PinPolicy::FirstUse) {
return Ok(());
}
let shown = ffsync::transport::format_fingerprint(presented);
println!();
println!("first contact with {peer_label}");
println!("peer fingerprint: {shown}");
print!("trust this peer? [y/N] ");
use std::io::Write as _;
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
let yes = matches!(answer.trim(), "y" | "Y" | "yes" | "Yes" | "YES");
if !yes {
return Err(std::io::Error::other(format!(
"peer not trusted — nothing was synced. To pin this fingerprint \
non-interactively: ffsync trust {shown} (or pass --trust {shown})"
)));
}
ffsync::transport::add_pin(presented)?;
println!("fingerprint pinned (pins.conf); future syncs verify silently");
Ok(())
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() {
die("no command given");
}
match args[0].as_str() {
"--help" | "-h" | "help" => {
print!("{}", USAGE);
}
"--version" | "-V" | "version" => {
println!("ffsync {}", env!("CARGO_PKG_VERSION"));
}
"send" => {
let mut positional: Vec<String> = Vec::new();
let mut force_checksum = false;
let mut plaintext = false;
let mut trust_flag = None;
let mut no_delete = false;
let mut it = args[1..].iter();
while let Some(a) = it.next() {
match a.as_str() {
"--checksum" => force_checksum = true,
"--plaintext" => plaintext = true,
"--trust" => {
let v = it
.next()
.unwrap_or_else(|| die("--trust requires a fingerprint"));
trust_flag = Some(v.to_string());
}
"--no-delete" => no_delete = true,
"--help" | "-h" => {
print!("{}", USAGE);
return;
}
"--version" | "-V" => {
println!("ffsync {}", env!("CARGO_PKG_VERSION"));
return;
}
other => positional.push(other.to_string()),
}
}
if positional.len() != 2 {
die("send requires <file|dir> <host:port>");
}
let file = PathBuf::from(&positional[0]);
let addr = positional[1].clone();
run_send(file, addr, force_checksum, plaintext, trust_flag, no_delete).unwrap_or_else(
|e| {
eprintln!("error: {}", e);
std::process::exit(1);
},
);
}
"receive" => {
let mut positional: Vec<String> = Vec::new();
let mut force_checksum = false;
let mut plaintext = false;
let mut trust_flag = None;
let mut from = None;
let mut no_delete = false;
let mut it = args[1..].iter();
while let Some(a) = it.next() {
match a.as_str() {
"--checksum" => force_checksum = true,
"--plaintext" => plaintext = true,
"--trust" => {
let v = it
.next()
.unwrap_or_else(|| die("--trust requires a fingerprint"));
trust_flag = Some(v.to_string());
}
"--from" => {
let v = it.next().unwrap_or_else(|| die("--from requires a path"));
from = Some(PathBuf::from(v));
}
"--no-delete" => no_delete = true,
"--help" | "-h" => {
print!("{}", USAGE);
return;
}
"--version" | "-V" => {
println!("ffsync {}", env!("CARGO_PKG_VERSION"));
return;
}
other => positional.push(other.to_string()),
}
}
if positional.len() != 2 {
die("receive requires <file|dir> <port>");
}
let file = PathBuf::from(&positional[0]);
let port: u16 = positional[1]
.parse()
.unwrap_or_else(|_| die(&format!("invalid port: {}", positional[1])));
run_receive(
file,
port,
force_checksum,
from,
plaintext,
trust_flag,
no_delete,
)
.unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(1);
});
}
"trust" => {
let fp = args
.get(1)
.unwrap_or_else(|| die("trust requires a fingerprint (see 'ffsync fingerprints')"));
let raw = ffsync::transport::parse_fingerprint(fp)
.unwrap_or_else(|| die(&format!("not a SHA-256 fingerprint: {fp}")));
ffsync::transport::add_pin(&raw).unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(1);
});
println!("pinned {}", ffsync::transport::format_fingerprint(&raw));
}
"untrust" => {
let fp = args
.get(1)
.unwrap_or_else(|| die("untrust requires a fingerprint"));
let raw = ffsync::transport::parse_fingerprint(fp)
.unwrap_or_else(|| die(&format!("not a SHA-256 fingerprint: {fp}")));
ffsync::transport::remove_pin(&raw).unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(1);
});
println!("unpinned {}", ffsync::transport::format_fingerprint(&raw));
}
"fingerprints" => {
match ffsync::transport::Identity::load_or_create() {
Ok((id, _)) => println!("this peer: {}", id.fingerprint()),
Err(e) => {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
match ffsync::transport::load_pins() {
Ok(pins) => {
if pins.is_empty() {
println!("pinned peers: (none)");
} else {
println!("pinned peers:");
for p in pins {
println!(" {}", ffsync::transport::format_fingerprint(&p));
}
}
}
Err(e) => {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
}
other => die(&format!("unknown command: {} (try 'ffsync --help')", other)),
}
}