use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use blake3::Hash;
use crate::chunking::{
Blake3Hasher, Chunk, ChunkEvent, FastCDCChunker, PipelineConfig, cdc_params_for_size,
chunk_root, process_file, sane_cdc_params,
};
use crate::index::{FileIndex, load_index, load_index_with_cdc};
use crate::protocol::framing::{self, FrameError};
use crate::protocol::message::{
self, ErrorCode, FLAG_RESUMING, FLAG_TRUST_TIMES, Hello, ManifestChunk, Message, MessageError,
PlanEntry, Role,
};
use crate::reconstruction::{ChunkSource, FileExt, ReconstructionTask, Reconstructor};
thread_local! {
static DECOMPRESSOR: std::cell::RefCell<zstd::bulk::Decompressor<'static>> =
std::cell::RefCell::new(
zstd::bulk::Decompressor::new()
.expect("zstd decompressor context allocation failed"),
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LocalOrigin {
Temp { offset: u64, length: usize },
OldFile { offset: u64, length: usize },
}
#[derive(Debug)]
pub enum SyncError {
Peer(ErrorCode, String),
Io(io::Error),
Protocol(String),
Eof,
VerificationFailed,
}
impl std::fmt::Display for SyncError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncError::Peer(code, msg) => write!(f, "peer error {:?}: {}", code, msg),
SyncError::Io(e) => write!(f, "io error: {}", e),
SyncError::Protocol(msg) => write!(f, "protocol violation: {}", msg),
SyncError::Eof => write!(f, "connection closed unexpectedly"),
SyncError::VerificationFailed => write!(f, "verification failed"),
}
}
}
impl From<io::Error> for SyncError {
fn from(e: io::Error) -> Self {
SyncError::Io(e)
}
}
impl From<FrameError> for SyncError {
fn from(e: FrameError) -> Self {
match e {
FrameError::Eof => SyncError::Eof,
FrameError::Truncated => SyncError::Protocol("truncated frame".into()),
FrameError::Io(e) => SyncError::Io(e),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Progress {
Scanning(u64, u64),
WarmStart(usize),
Diffing,
Transferring(u64, u64),
Receiving(u64, u64),
Verifying,
NothingToDo,
WouldChange,
CreatedEmpty,
}
pub struct SendRequest {
pub path: PathBuf,
pub display_path: String,
pub mtime: Option<SystemTime>,
pub force_checksum: bool,
pub eager: bool,
pub dry_run: bool,
pub tree: bool,
}
#[derive(Clone)]
pub struct ReceiveRequest {
pub path: PathBuf,
pub local_source: PathBuf,
pub force_checksum: bool,
pub tree: bool,
pub apply_mtime: Option<(u64, u32)>,
pub apply_mode: Option<u32>,
pub eager: bool,
}
#[derive(Debug)]
pub enum SyncOutcome {
InSync,
Transferred { bytes: u64, chunks: usize },
WouldChange,
}
fn unix_secs(t: Option<SystemTime>) -> u64 {
t.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn send_error_and_finish(
out: &mut (impl Write + ?Sized),
code: ErrorCode,
msg: &str,
) -> SyncError {
let frame = message::encode_error(code, msg);
let _ = frame.and_then(|f| f.encode(out));
let _ = out.flush();
SyncError::Peer(code, msg.to_string())
}
fn drain_for_close(stream: &mut impl Read) -> io::Result<()> {
let mut sink = [0u8; 8192];
let mut total = 0usize;
loop {
match stream.read(&mut sink) {
Ok(0) => return Ok(()), Ok(n) => {
total += n;
if total > 64 * 1024 * 1024 {
return Ok(());
}
}
Err(e) if e.kind() == io::ErrorKind::ConnectionReset => return Ok(()),
Err(e) => return Err(e),
}
}
}
fn validate_hello(peer: &Hello, ours_role: Role) -> Result<Hello, SyncError> {
if peer.version != super::VERSION {
return Err(SyncError::Peer(
ErrorCode::Version,
format!(
"unsupported protocol version {} (this peer speaks {})",
peer.version,
super::VERSION
),
));
}
if peer.role == ours_role {
return Err(SyncError::Protocol(format!(
"role conflict: both peers are {:?}",
ours_role
)));
}
Ok(peer.clone())
}
impl From<MessageError> for SyncError {
fn from(e: MessageError) -> Self {
match e {
MessageError::Frame(FrameError::Eof) => SyncError::Eof,
MessageError::Frame(FrameError::Truncated) => {
SyncError::Protocol("truncated frame".into())
}
MessageError::Frame(FrameError::Io(e)) => SyncError::Io(e),
MessageError::PeerError(code, msg) => SyncError::Peer(code, msg),
MessageError::UnexpectedType(t) => {
SyncError::Protocol(format!("unexpected frame type 0x{:02X}", t))
}
MessageError::Malformed(why) => {
SyncError::Protocol(format!("malformed frame: {}", why))
}
}
}
}
pub fn send<S>(
stream: &mut S,
request: &SendRequest,
pipeline: &PipelineConfig,
progress: &mut dyn FnMut(Progress),
) -> Result<SyncOutcome, SyncError>
where
S: Read + Write,
{
let file = File::open(&request.path)?;
let file_size = file.metadata()?.len();
let mtime = request
.mtime
.or_else(|| file.metadata().ok().and_then(|m| m.modified().ok()));
drop(file);
let (mtime_secs, mtime_nanos) = mtime
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| (d.as_secs(), d.subsec_nanos()))
.unwrap_or((0, 0));
progress(Progress::Scanning(0, file_size));
let cdc = cdc_params_for_size(file_size);
let cdc_u32 = (cdc.0 as u32, cdc.1 as u32, cdc.2 as u32);
let warm = if request.force_checksum {
None
} else {
load_index_with_cdc(&request.path, cdc_u32)
};
let warmed = warm.is_some();
let (source_chunks, root) = match warm {
Some(idx) => {
progress(Progress::WarmStart(idx.chunks.len()));
(idx.chunks, idx.root)
}
None => {
let (_scan_stats, chunks) =
scan_with_progress(&request.path, pipeline, cdc, |hashed| {
progress(Progress::Scanning(hashed, file_size))
})?;
let root = chunk_root(&chunks);
(chunks, root)
}
};
progress(Progress::Diffing);
let hello_flags = (if request.force_checksum {
0
} else {
FLAG_TRUST_TIMES
}) | if request.eager {
message::FLAG_EAGER
} else {
0
} | if request.dry_run {
message::FLAG_DRY_RUN
} else {
0
};
let hello = Hello {
version: super::VERSION,
flags: hello_flags,
role: Role::Sender,
cdc_min: cdc.0 as u32,
cdc_avg: cdc.1 as u32,
cdc_max: cdc.2 as u32,
file_size,
file_mtime: mtime_secs,
file_mtime_nanos: mtime_nanos,
path: request.display_path.clone(),
};
let trust_own = (hello.flags & FLAG_TRUST_TIMES) != 0;
let hello_size = hello.file_size;
let hello_mtime = hello.file_mtime;
let hello_mtime_nanos = hello.file_mtime_nanos;
hello.into_frame()?.encode(stream)?;
stream.flush()?;
let peer = message::read_typed::<Hello>(stream)?;
let peer = match validate_hello(&peer, Role::Sender) {
Ok(h) => h,
Err(SyncError::Protocol(msg)) => {
return Err(send_error_and_finish(stream, ErrorCode::RoleConflict, &msg));
}
Err(SyncError::Peer(code, msg)) => {
return Err(send_error_and_finish(stream, code, &msg));
}
Err(other) => return Err(other),
};
if request.dry_run {
let marker = framing::read_frame(stream)?;
match marker.ftype {
framing::T_DRY_RUN_ACK => {}
framing::T_MANIFEST_CHUNK | framing::T_MANIFEST_DONE => {
let _ = send_error_and_finish(
stream,
ErrorCode::Aborted,
"peer does not support --dry-run (no DRY_RUN_ACK after HELLO); aborted before any transfer",
);
return Err(SyncError::Protocol(
"peer does not support --dry-run (old ffsync?)".into(),
));
}
framing::T_ERROR => {
let (code, msg) = message::read_error_payload(&marker)?;
return Err(SyncError::Peer(code, msg));
}
other => {
return Err(SyncError::Protocol(format!(
"expected DRY_RUN_ACK or manifest, got 0x{:02X}",
other
)));
}
}
}
let eager_active = request.eager && peer.flags & message::FLAG_EAGER != 0;
if request.eager && !eager_active {
let _ = send_error_and_finish(
stream,
ErrorCode::Version,
"peer did not accept eager diff (its HELLO lacks flag bit 1)",
);
return Err(SyncError::Peer(
ErrorCode::Version,
"peer did not accept eager diff (its HELLO lacks flag bit 1); upgrade the peer or drop --eager".into(),
));
}
let trust = trust_own && (peer.flags & FLAG_TRUST_TIMES) != 0;
if trust
&& hello_size == peer.file_size
&& hello_mtime != 0
&& hello_mtime == peer.file_mtime
&& hello_mtime_nanos == peer.file_mtime_nanos
{
message::encode_empty(framing::T_NOT_MODIFIED)?.encode(stream)?;
stream.flush()?;
if request.tree {
progress(Progress::NothingToDo);
return Ok(SyncOutcome::InSync);
}
let _ = drain_for_close(stream);
progress(Progress::NothingToDo);
return Ok(SyncOutcome::InSync);
}
if request.tree {
message::encode_empty(framing::T_FILE_SYNC)?.encode(stream)?;
stream.flush()?;
}
let mut manifest: HashMap<Hash, u64> = HashMap::new();
let mut manifest_chunks: Vec<(Hash, u64, u32)> = Vec::new();
let mut eager_file: Option<File> = None;
let mut eager_compressor: Option<zstd::bulk::Compressor<'static>> = None;
let mut eager_sent: u64 = 0;
let mut eager_scan: usize = 0; let mut eager_offered: std::collections::HashSet<Hash> = std::collections::HashSet::new();
let remote_total_hint: u64 = source_chunks.iter().map(|c| c.length as u64).sum();
let mut offer_eager_chunk = |chunk: &Chunk,
manifest: &HashMap<Hash, u64>,
consumed_upto: u64,
manifest_done: bool,
eager_sent: u64,
stream: &mut dyn Write|
-> Result<u64, SyncError> {
if !eager_active {
return Ok(0);
}
let covered = manifest.contains_key(&chunk.hash);
if covered || (!manifest_done && chunk.offset + 2 * cdc.1 as u64 > consumed_upto) {
return Ok(0); }
let file = eager_file.get_or_insert_with(|| {
File::open(&request.path).expect("sender file open for eager DATA")
});
let compressor = eager_compressor.get_or_insert_with(|| {
zstd::bulk::Compressor::new(3).expect("zstd context for eager DATA")
});
let mut buf = Vec::with_capacity(chunk.length);
use std::io::{Read as _, Seek as _};
file.seek(io::SeekFrom::Start(chunk.offset))?;
buf.resize(chunk.length, 0);
file.read_exact(&mut buf)?;
let compressed = compressor.compress(&buf)?;
message::encode_data(chunk.hash, &compressed)?.encode(stream)?;
stream.flush()?;
eager_offered.insert(chunk.hash);
let sent_now = eager_sent + chunk.length as u64;
progress(Progress::Transferring(sent_now, remote_total_hint));
Ok(chunk.length as u64)
};
loop {
let frame = framing::read_frame(stream)?;
match frame.ftype {
framing::T_MANIFEST_CHUNK => {
let entry = ManifestChunk::from_frame(frame)
.map_err(|e| SyncError::Protocol(format!("bad manifest chunk: {}", e)))?;
manifest.insert(entry.hash, entry.offset);
manifest_chunks.push((entry.hash, entry.offset, entry.length));
let consumed = entry.offset + entry.length as u64;
for chunk in source_chunks[eager_scan..]
.iter()
.take_while(|c| c.offset < consumed)
{
let offered =
offer_eager_chunk(chunk, &manifest, consumed, false, eager_sent, stream)?;
eager_sent += offered;
}
eager_scan = eager_scan
+ source_chunks[eager_scan..]
.iter()
.take_while(|c| c.offset < consumed)
.count();
}
framing::T_MANIFEST_DONE => {
for chunk in &source_chunks[eager_scan..] {
let offered = offer_eager_chunk(chunk, &manifest, 0, true, eager_sent, stream)?;
eager_sent += offered;
}
break;
}
other => {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
&format!("expected manifest frame, got 0x{:02X}", other),
));
}
}
}
let mut plan: Vec<PlanEntry> = Vec::with_capacity(source_chunks.len());
let mut remote_total: u64 = 0;
for chunk in &source_chunks {
match manifest.get(&chunk.hash) {
Some(peer_offset) => plan.push(PlanEntry::Local {
target_offset: chunk.offset,
length: chunk.length as u32,
source_offset: *peer_offset,
hash: chunk.hash,
}),
None => {
plan.push(PlanEntry::Remote {
target_offset: chunk.offset,
length: chunk.length as u32,
hash: chunk.hash,
});
remote_total += chunk.length as u64;
}
}
}
let layout_reproduced = !plan.is_empty()
&& plan.len() == manifest_chunks.len()
&& plan
.iter()
.zip(manifest_chunks.iter())
.all(|(e, (_, off, len))| match e {
PlanEntry::Local {
target_offset,
length,
..
} => target_offset == off && length == len,
_ => false,
});
for entry in &plan {
entry.into_frame()?.encode(stream)?;
}
message::encode_empty(framing::T_PLAN_DONE)?.encode(stream)?;
stream.flush()?;
let mut sent: u64 = 0;
let mut sent_chunks: usize = 0;
if remote_total > 0 && !request.dry_run {
sent = eager_sent;
let mut file = File::open(&request.path)?;
let mut chunk_buf = Vec::new();
let mut compressor = zstd::bulk::Compressor::new(3)?;
for entry in &plan {
let (target_offset, length, hash) = match entry {
PlanEntry::Remote {
target_offset,
length,
hash,
} => (*target_offset, *length as usize, *hash),
_ => continue,
};
if eager_offered.contains(&hash) {
continue;
}
chunk_buf.clear();
chunk_buf.resize(length, 0);
use std::io::{Read as _, Seek as _};
file.seek(io::SeekFrom::Start(target_offset))?;
file.read_exact(&mut chunk_buf)?;
let compressed = compressor.compress(&chunk_buf)?;
message::encode_data(hash, &compressed)?.encode(stream)?;
sent += length as u64;
sent_chunks += 1;
progress(Progress::Transferring(sent, remote_total));
}
stream.flush()?;
}
if !request.dry_run {
message::encode_verify(root)?.encode(stream)?;
stream.flush()?;
}
let frame = framing::read_frame(stream)?;
match frame.ftype {
framing::T_COMMIT_ACK => {
if !request.dry_run {
let index = FileIndex::from_scan(
source_chunks,
file_size,
crate::index::stat_mtime(&request.path).unwrap_or((0, 0)),
cdc_u32,
);
let _ = crate::index::write_index(&request.path, &index);
}
if plan.is_empty() || layout_reproduced {
Ok(SyncOutcome::InSync)
} else {
Ok(SyncOutcome::Transferred {
bytes: sent,
chunks: sent_chunks,
})
}
}
framing::T_NOT_MODIFIED => {
let ack = framing::read_frame(stream)?;
if ack.ftype != framing::T_DRY_RUN_ACK {
return Err(SyncError::Protocol(format!(
"expected DRY_RUN_ACK after NOT_MODIFIED, got 0x{:02X}",
ack.ftype
)));
}
if request.dry_run {
progress(Progress::WouldChange);
return Ok(SyncOutcome::WouldChange);
}
Err(SyncError::Protocol(
"receiver sent NOT_MODIFIED where a commit was expected".into(),
))
}
framing::T_ERROR => {
let (code, msg) = message::read_error_payload(&frame)?;
if warmed && code == ErrorCode::HashMismatch {
crate::index::remove_index(&request.path);
}
Err(SyncError::Peer(code, msg))
}
other => Err(SyncError::Protocol(format!(
"expected COMMIT_ACK, got 0x{:02X}",
other
))),
}
}
pub fn receive<S>(
stream: &mut S,
request: &ReceiveRequest,
pipeline: &PipelineConfig,
progress: &mut dyn FnMut(Progress),
) -> Result<SyncOutcome, SyncError>
where
S: Read + Write,
{
let temp_path = Reconstructor::temp_path_for(&request.path);
let resuming = temp_path.exists();
if let Some(parent) = request.path.parent()
&& let Ok(entries) = std::fs::read_dir(parent)
{
for entry in entries.flatten() {
let p = entry.path();
let Some(name) = p.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(base) = name.strip_suffix(".ffsync-index") else {
continue;
};
if base.is_empty() {
continue;
}
let target = parent.join(base);
if !target.exists() {
let _ = std::fs::remove_file(&p);
}
}
}
let local_meta = std::fs::metadata(&request.local_source).ok();
let local_size = local_meta.as_ref().map(|m| m.len()).unwrap_or(0);
let local_mtime = local_meta.as_ref().and_then(|m| m.modified().ok());
let mut flags = if request.force_checksum {
0
} else {
FLAG_TRUST_TIMES
};
if resuming {
flags |= FLAG_RESUMING;
}
if request.eager {
flags |= message::FLAG_EAGER;
}
let hello = Hello {
version: super::VERSION,
flags,
role: Role::Receiver,
cdc_min: cdc_params_for_size(local_size).0 as u32,
cdc_avg: cdc_params_for_size(local_size).1 as u32,
cdc_max: cdc_params_for_size(local_size).2 as u32,
file_size: local_size,
file_mtime: unix_secs(local_mtime),
file_mtime_nanos: local_mtime
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.subsec_nanos())
.unwrap_or(0),
path: request
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default(),
};
hello.into_frame()?.encode(stream)?;
stream.flush()?;
let peer = message::read_typed::<Hello>(stream)?;
let peer = match validate_hello(&peer, Role::Receiver) {
Ok(h) => h,
Err(SyncError::Protocol(msg)) => {
return Err(send_error_and_finish(stream, ErrorCode::RoleConflict, &msg));
}
Err(SyncError::Peer(code, msg)) => {
return Err(send_error_and_finish(stream, code, &msg));
}
Err(other) => return Err(other),
};
if peer.flags & message::FLAG_DRY_RUN != 0 {
message::encode_empty(framing::T_DRY_RUN_ACK)?.encode(stream)?;
stream.flush()?;
}
let peer_mtime = if request.tree {
Some((peer.file_mtime, peer.file_mtime_nanos))
} else {
None
};
let request = ReceiveRequest {
apply_mtime: request.apply_mtime.or(peer_mtime),
eager: request.eager,
..(*request).clone()
};
let cdc = if sane_cdc_params((peer.cdc_min, peer.cdc_avg, peer.cdc_max)) {
(
peer.cdc_min as usize,
peer.cdc_avg as usize,
peer.cdc_max as usize,
)
} else {
cdc_params_for_size(local_size)
};
let cdc_u32 = (cdc.0 as u32, cdc.1 as u32, cdc.2 as u32);
let warm_idx = if local_size > 0 && !request.force_checksum {
load_index(&request.local_source)
} else {
None
};
let (local_chunks, local_root) = match warm_idx {
Some(idx) if idx.validate(&request.local_source, cdc_u32).is_some() => {
progress(Progress::WarmStart(idx.chunks.len()));
(idx.chunks, Some(idx.root))
}
_ => {
if local_size > 0 {
progress(Progress::Scanning(0, local_size));
let (_stats, chunks) =
scan_with_progress(&request.local_source, pipeline, cdc, |hashed| {
progress(Progress::Scanning(hashed, local_size))
})?;
let root = chunk_root(&chunks);
(chunks, Some(root))
} else {
(Vec::new(), None)
}
}
};
let temp_chunks: Vec<Chunk> = if resuming {
let tsize = std::fs::metadata(&temp_path)?.len();
progress(Progress::Scanning(0, tsize));
let (_stats, chunks) = scan_with_progress(&temp_path, pipeline, cdc, |hashed| {
progress(Progress::Scanning(hashed, tsize))
})?;
chunks
} else {
Vec::new()
};
let mut manifest_map: HashMap<Hash, LocalOrigin> = HashMap::new();
for c in &temp_chunks {
manifest_map.entry(c.hash).or_insert(LocalOrigin::Temp {
offset: c.offset,
length: c.length,
});
}
for c in &local_chunks {
manifest_map.entry(c.hash).or_insert(LocalOrigin::OldFile {
offset: c.offset,
length: c.length,
});
}
if request.tree {
let frame = framing::read_frame(stream)?;
match frame.ftype {
framing::T_NOT_MODIFIED => {
progress(Progress::NothingToDo);
return Ok(SyncOutcome::InSync);
}
framing::T_FILE_SYNC => {} framing::T_ERROR => {
let (code, msg) = message::read_error_payload(&frame)?;
return Err(SyncError::Peer(code, msg));
}
other => {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
&format!("expected NOT_MODIFIED or FILE_SYNC, got 0x{:02X}", other),
));
}
}
}
let eager_active = request.eager && peer.flags & message::FLAG_EAGER != 0;
let mut eager_buf: HashMap<Hash, Vec<u8>> = HashMap::new();
let mut eager_bytes: usize = 0;
const EAGER_BUF_CAP: usize = 64 * 1024 * 1024;
for chunk in &temp_chunks {
let frame = message::ManifestChunk {
hash: chunk.hash,
offset: chunk.offset,
length: chunk.length as u32,
}
.into_frame()?;
if frame.encode(stream).and_then(|_| stream.flush()).is_err() {
break;
}
}
let covered: HashSet<Hash> = temp_chunks.iter().map(|c| c.hash).collect();
for chunk in &local_chunks {
if covered.contains(&chunk.hash) {
continue;
}
let frame = message::ManifestChunk {
hash: chunk.hash,
offset: chunk.offset,
length: chunk.length as u32,
}
.into_frame()?;
if frame.encode(stream).and_then(|_| stream.flush()).is_err() {
break;
}
}
let manifest_done = message::encode_empty(framing::T_MANIFEST_DONE)?;
if manifest_done
.encode(stream)
.and_then(|_| stream.flush())
.is_err()
{
}
let _ = stream.flush();
let mut plan: Vec<PlanEntry> = Vec::new();
let mut remote_total: u64 = 0;
loop {
let frame = framing::read_frame(stream)?;
match frame.ftype {
framing::T_MANIFEST_CHUNK | framing::T_MANIFEST_DONE => {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"unexpected manifest frame after MANIFEST_DONE",
));
}
framing::T_DATA if eager_active => {
if frame.payload.len() < 32 {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"eager DATA frame too short",
));
}
let mut h = [0u8; 32];
h.copy_from_slice(&frame.payload[..32]);
let hash = Hash::from_bytes(h);
let compressed = frame.payload[32..].to_vec();
let len = compressed.len();
if eager_bytes + len > EAGER_BUF_CAP {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"eager buffer overflow (64 MiB cap, §9.2); retry without --eager",
));
}
eager_bytes += len;
eager_buf.insert(hash, compressed);
}
framing::T_PLAN_LOCAL | framing::T_PLAN_REMOTE => {
let entry = PlanEntry::from_frame(frame)
.map_err(|e| SyncError::Protocol(format!("bad plan entry: {}", e)))?;
if let PlanEntry::Remote { length, .. } = entry {
remote_total += length as u64;
}
plan.push(entry);
}
framing::T_PLAN_DONE => break,
framing::T_NOT_MODIFIED => {
progress(Progress::NothingToDo);
return Ok(SyncOutcome::InSync);
}
framing::T_ERROR => {
let (code, msg) = message::read_error_payload(&frame)?;
return Err(SyncError::Peer(code, msg));
}
other => {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
&format!("expected plan frame, got 0x{:02X}", other),
));
}
}
}
let dry_run = peer.flags & message::FLAG_DRY_RUN != 0;
let plan_reproduces_local_layout = !plan.is_empty()
&& plan.iter().all(|e| matches!(e, PlanEntry::Local { .. }))
&& plan.len() == local_chunks.len()
&& plan.iter().zip(local_chunks.iter()).all(|(e, c)| match e {
PlanEntry::Local {
target_offset,
length,
..
} => *target_offset == c.offset && *length as usize == c.length,
_ => false,
});
if dry_run {
let would_change =
peer.file_size == 0 || (!plan.is_empty() && !plan_reproduces_local_layout);
if would_change {
message::encode_empty(framing::T_NOT_MODIFIED)?.encode(stream)?;
stream.flush()?;
message::encode_empty(framing::T_DRY_RUN_ACK)?.encode(stream)?;
stream.flush()?;
progress(Progress::WouldChange);
return Ok(SyncOutcome::WouldChange);
}
message::encode_empty(framing::T_COMMIT_ACK)?.encode(stream)?;
stream.flush()?;
progress(Progress::NothingToDo);
return Ok(SyncOutcome::InSync);
}
let target_size = peer.file_size;
let plan_reproduces_local_layout = !plan.is_empty()
&& plan.iter().all(|e| matches!(e, PlanEntry::Local { .. }))
&& plan.len() == local_chunks.len()
&& plan.iter().zip(local_chunks.iter()).all(|(e, c)| match e {
PlanEntry::Local {
target_offset,
length,
..
} => *target_offset == c.offset && *length as usize == c.length,
_ => false,
});
if !plan.is_empty() && !plan_reproduces_local_layout {
let reconstructor = if resuming {
Reconstructor::open_existing(&request.path, &request.local_source, target_size)?
} else {
Reconstructor::new(&request.path, &request.local_source, target_size)?
};
let mut scratch: Vec<u8> = Vec::new();
let mut received: u64 = 0;
let mut temp_read: Option<File> = None;
let mut local_read: Option<File> = None;
for entry in &plan {
match *entry {
PlanEntry::Local {
target_offset,
length,
source_offset: _,
hash,
} => {
let origin = manifest_map.get(&hash).ok_or_else(|| {
send_error_and_finish(
stream,
ErrorCode::BadFrame,
"plan references a chunk not in our manifest",
)
})?;
let offset = match origin {
LocalOrigin::Temp { offset, .. } => *offset,
LocalOrigin::OldFile { offset, .. } => *offset,
};
if target_offset + length as u64 > target_size {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"plan chunk extends beyond the declared file size",
));
}
let source_file = match origin {
LocalOrigin::Temp { .. } => {
if temp_read.is_none() {
temp_read = Some(File::open(&temp_path)?);
}
temp_read.as_mut().expect("temp read handle just opened")
}
LocalOrigin::OldFile { .. } => {
if local_read.is_none() {
local_read = Some(File::open(&request.local_source)?);
}
local_read.as_mut().expect("local read handle just opened")
}
};
scratch.clear();
scratch.resize(length as usize, 0);
source_file.read_at(&mut scratch[..length as usize], offset)?;
let actual = blake3::hash(&scratch[..length as usize]);
if actual != hash {
if matches!(origin, LocalOrigin::Temp { .. }) {
let _ = std::fs::remove_file(&temp_path);
}
crate::index::remove_index(&request.local_source);
return Err(send_error_and_finish(
stream,
ErrorCode::HashMismatch,
"local chunk content does not match its hash",
));
}
let task = ReconstructionTask {
target_offset,
source: ChunkSource {
offset: 0,
length: length as usize,
},
};
reconstructor.write_chunk(&task, &scratch)?;
}
PlanEntry::Remote {
target_offset,
length,
hash,
} => {
let compressed = match eager_buf.get(&hash) {
Some(c) => c.clone(),
None => {
let (data_hash, compressed) = message::read_data(stream)?;
if data_hash != hash {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"DATA hash does not match plan",
));
}
compressed
}
};
let decompressed = DECOMPRESSOR.with(|d| {
let mut decompressor = d.borrow_mut();
decompressor.decompress(&compressed, length as usize)
})?;
if decompressed.len() != length as usize {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"decompressed length mismatch",
));
}
if target_offset + length as u64 > target_size {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"plan chunk extends beyond the declared file size",
));
}
let task = ReconstructionTask {
target_offset,
source: ChunkSource {
offset: 0,
length: length as usize,
},
};
reconstructor.write_chunk(&task, &decompressed)?;
received += length as u64;
progress(Progress::Receiving(received, remote_total));
}
}
}
progress(Progress::Verifying);
let verify_root = message::read_verify(stream)?;
let temp_chunks = scan_file_plain(&temp_path, pipeline, cdc)?;
let temp_root = chunk_root(&temp_chunks);
if temp_root != verify_root {
let _ = std::fs::remove_file(&temp_path);
return Err(send_error_and_finish(
stream,
ErrorCode::HashMismatch,
"reconstructed file hash mismatch",
));
}
reconstructor.sync_temp()?;
clear_readonly_best_effort(&request.path);
std::fs::rename(&temp_path, &request.path)?;
fsync_parent_dir_best_effort(&request.path);
apply_commit_mode(&request.path, request.apply_mode)?;
apply_commit_mtime(&request)?;
let _ = crate::index::write_index(
&request.path,
&FileIndex::from_scan(
temp_chunks,
target_size,
crate::index::stat_mtime(&request.path).unwrap_or((0, 0)),
cdc_u32,
),
);
message::encode_empty(framing::T_COMMIT_ACK)?.encode(stream)?;
stream.flush()?;
return Ok(SyncOutcome::Transferred {
bytes: received,
chunks: plan.len(),
});
}
let verify_root = message::read_verify(stream)?;
if target_size == 0 {
if verify_root != chunk_root(&[]) {
return Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"sender reports zero size but a non-empty root",
));
}
if resuming {
let _ = std::fs::remove_file(&temp_path);
}
clear_readonly_best_effort(&request.path);
std::fs::File::create(&request.path)?.set_len(0)?;
apply_commit_mode(&request.path, request.apply_mode)?;
apply_commit_mtime(&request)?;
let _ = crate::index::write_index(
&request.path,
&FileIndex::from_scan(
Vec::new(),
0,
crate::index::stat_mtime(&request.path).unwrap_or((0, 0)),
cdc_u32,
),
);
message::encode_empty(framing::T_COMMIT_ACK)?.encode(stream)?;
stream.flush()?;
progress(Progress::CreatedEmpty);
return Ok(SyncOutcome::Transferred {
bytes: 0,
chunks: 0,
});
}
let (state_root, state_desc, state_chunks): (Option<Hash>, &str, Vec<Chunk>) = if resuming {
let chunks = scan_file_plain(&temp_path, pipeline, cdc)?;
(Some(chunk_root(&chunks)), "temp", chunks)
} else {
(local_root, "old file", Vec::new())
};
match state_root {
Some(root) if root == verify_root => {
if resuming {
let reconstructor = Reconstructor::open_existing(
&request.path,
&request.local_source,
target_size,
)?;
reconstructor.sync_temp()?;
drop(reconstructor);
clear_readonly_best_effort(&request.path);
std::fs::rename(&temp_path, &request.path)?;
fsync_parent_dir_best_effort(&request.path);
apply_commit_mode(&request.path, request.apply_mode)?;
apply_commit_mtime(&request)?;
let _ = crate::index::write_index(
&request.path,
&FileIndex::from_scan(
state_chunks,
target_size,
crate::index::stat_mtime(&request.path).unwrap_or((0, 0)),
cdc_u32,
),
);
} else if request.local_source == request.path {
apply_commit_mode(&request.path, request.apply_mode)?;
apply_commit_mtime(&request)?;
let _ = crate::index::write_index(
&request.path,
&FileIndex::from_scan(
local_chunks.clone(),
local_size,
crate::index::stat_mtime(&request.local_source).unwrap_or((0, 0)),
cdc_u32,
),
);
}
message::encode_empty(framing::T_COMMIT_ACK)?.encode(stream)?;
stream.flush()?;
progress(Progress::NothingToDo);
Ok(SyncOutcome::InSync)
}
Some(_) => Err(send_error_and_finish(
stream,
ErrorCode::HashMismatch,
&format!(
"content differs despite empty plan (stale {} manifest?)",
state_desc
),
)),
None => {
if target_size == 0 {
if resuming {
std::fs::remove_file(&temp_path).ok();
}
clear_readonly_best_effort(&request.path);
std::fs::File::create(&request.path)?.set_len(0)?;
apply_commit_mode(&request.path, request.apply_mode)?;
apply_commit_mtime(&request)?;
let _ = crate::index::write_index(
&request.path,
&FileIndex::from_scan(
Vec::new(),
0,
crate::index::stat_mtime(&request.path).unwrap_or((0, 0)),
cdc_u32,
),
);
message::encode_empty(framing::T_COMMIT_ACK)?.encode(stream)?;
stream.flush()?;
progress(Progress::CreatedEmpty);
return Ok(SyncOutcome::Transferred {
bytes: 0,
chunks: 0,
});
}
Err(send_error_and_finish(
stream,
ErrorCode::BadFrame,
"empty plan without a local file",
))
}
}
}
#[cfg(windows)]
fn clear_readonly_best_effort(path: &Path) {
if let Ok(meta) = std::fs::metadata(path)
&& meta.is_file()
{
let mut perms = meta.permissions();
if perms.readonly() {
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
let _ = std::fs::set_permissions(path, perms);
}
}
}
#[cfg(not(windows))]
fn clear_readonly_best_effort(_path: &Path) {}
#[cfg(unix)]
fn fsync_parent_dir_best_effort(path: &Path) {
if let Some(parent) = path.parent()
&& let Ok(dir) = std::fs::File::open(parent)
{
let _ = dir.sync_all();
}
}
#[cfg(not(unix))]
fn fsync_parent_dir_best_effort(_path: &Path) {}
fn apply_commit_mode(path: &Path, mode: Option<u32>) -> io::Result<()> {
let Some(bits) = mode.filter(|m| *m != 0) else {
return Ok(()); };
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let f = std::fs::OpenOptions::new().write(true).open(path)?;
f.set_permissions(std::fs::Permissions::from_mode(bits))?;
Ok(())
}
#[cfg(windows)]
{
let write_denied = bits & 0o222 != 0;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_readonly(write_denied);
std::fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(any(unix, windows)))]
{
let _ = (path, bits);
Ok(())
}
}
fn apply_commit_mtime(request: &ReceiveRequest) -> io::Result<()> {
let Some((secs, nanos)) = request.apply_mtime else {
return Ok(());
};
if secs == 0 && nanos == 0 {
return Ok(()); }
let f = std::fs::OpenOptions::new()
.write(true)
.open(&request.path)?;
f.set_modified(UNIX_EPOCH + std::time::Duration::new(secs, nanos))
}
fn scan_with_progress(
path: &Path,
pipeline: &PipelineConfig,
cdc: (usize, usize, usize),
mut on_progress: impl FnMut(u64),
) -> Result<(u64, Vec<Chunk>), SyncError> {
let rx = process_file(
path,
move || FastCDCChunker::new(cdc.0, cdc.1, cdc.2),
Blake3Hasher,
pipeline,
)?;
let mut hashed: u64 = 0;
let mut chunks = Vec::new();
loop {
match rx.recv() {
Ok(ChunkEvent::Chunk(chunk)) => {
hashed += chunk.length as u64;
chunks.push(chunk);
on_progress(hashed);
}
Ok(ChunkEvent::Done) => {}
Err(_) => break,
}
}
chunks.sort_by_key(|c| c.offset);
Ok((hashed, chunks))
}
fn scan_file_plain(
path: &Path,
pipeline: &PipelineConfig,
cdc: (usize, usize, usize),
) -> Result<Vec<Chunk>, SyncError> {
let (_, chunks) = scan_with_progress(path, pipeline, cdc, |_| {})?;
Ok(chunks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hello_accepts_eager_bit_decline_is_session_level() {
let peer = Hello {
version: super::super::VERSION,
flags: message::FLAG_EAGER,
role: Role::Receiver,
cdc_min: 16384,
cdc_avg: 65536,
cdc_max: 262144,
file_size: 1,
file_mtime: 0,
file_mtime_nanos: 0,
path: "x".into(),
};
assert!(validate_hello(&peer, Role::Sender).is_ok());
}
}