Skip to main content

runsync_transfer/
resume.rs

1//! Resume support.
2//!
3//! The receiver keeps a bitmap of chunks it already holds, persisted next to
4//! the `.part` file. On reconnect it sends that bitmap and the sender skips
5//! those chunks.
6//!
7//! The ordering here is the part that matters. A chunk is only recorded as
8//! present *after* the data file has been flushed to stable storage. Recording
9//! first and flushing later would produce a state file claiming chunks that a
10//! power loss silently discarded — a resumed transfer would then complete with
11//! a hole in the middle and a hash mismatch, or worse, no hash configured and
12//! quiet corruption.
13
14use crate::error::{Error, Result};
15use crate::io::WriteHandle;
16use std::path::{Path, PathBuf};
17
18pub const STATE_SUFFIX: &str = ".rst-state";
19const STATE_MAGIC: &[u8; 4] = b"RSTS";
20const STATE_VERSION: u16 = 1;
21const STATE_HEADER_LEN: usize = 4 + 2 + 4 + 8 + 8 + 32;
22
23/// One bit per chunk, LSB first within each byte.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ChunkBitmap {
26    bits: Vec<u8>,
27    len: u64,
28    set_count: u64,
29}
30
31impl ChunkBitmap {
32    pub fn new(len: u64) -> Self {
33        Self {
34            bits: vec![0u8; len.div_ceil(8) as usize],
35            len,
36            set_count: 0,
37        }
38    }
39
40    /// Rebuild from raw bytes, rejecting a buffer that does not match `len`.
41    pub fn from_bytes(bits: Vec<u8>, len: u64) -> Result<Self> {
42        let expect = len.div_ceil(8) as usize;
43        if bits.len() != expect {
44            return Err(Error::protocol(format!(
45                "bitmap is {} bytes, expected {expect} for {len} chunks",
46                bits.len()
47            )));
48        }
49        // Bits beyond `len` must be zero, or the count would be wrong and a
50        // peer could claim chunks that do not exist.
51        if len % 8 != 0 {
52            if let Some(last) = bits.last() {
53                let valid = (len % 8) as u32;
54                if last >> valid != 0 {
55                    return Err(Error::protocol("bitmap has bits set past the chunk count"));
56                }
57            }
58        }
59        let set_count = bits.iter().map(|b| b.count_ones() as u64).sum();
60        Ok(Self {
61            bits,
62            len,
63            set_count,
64        })
65    }
66
67    pub fn as_bytes(&self) -> &[u8] {
68        &self.bits
69    }
70
71    pub fn len(&self) -> u64 {
72        self.len
73    }
74
75    pub fn is_empty(&self) -> bool {
76        self.len == 0
77    }
78
79    #[inline]
80    pub fn get(&self, i: u64) -> bool {
81        if i >= self.len {
82            return false;
83        }
84        self.bits[(i / 8) as usize] & (1 << (i % 8)) != 0
85    }
86
87    /// Mark chunk `i` present. Returns true if this call changed it.
88    #[inline]
89    pub fn set(&mut self, i: u64) -> bool {
90        if i >= self.len {
91            return false;
92        }
93        let byte = &mut self.bits[(i / 8) as usize];
94        let mask = 1u8 << (i % 8);
95        if *byte & mask != 0 {
96            return false;
97        }
98        *byte |= mask;
99        self.set_count += 1;
100        true
101    }
102
103    pub fn count(&self) -> u64 {
104        self.set_count
105    }
106
107    pub fn is_complete(&self) -> bool {
108        self.set_count == self.len
109    }
110
111    /// Indices of chunks still missing.
112    pub fn missing(&self) -> impl Iterator<Item = u64> + '_ {
113        (0..self.len).filter(move |&i| !self.get(i))
114    }
115
116    pub fn fill(&mut self) {
117        for b in self.bits.iter_mut() {
118            *b = 0xFF;
119        }
120        if self.len % 8 != 0 {
121            let valid = (self.len % 8) as u32;
122            if let Some(last) = self.bits.last_mut() {
123                *last = (1u8 << valid) - 1;
124            }
125        }
126        self.set_count = self.len;
127    }
128}
129
130/// On-disk resume state for one file.
131pub struct ResumeState {
132    path: PathBuf,
133    bitmap: ChunkBitmap,
134    size: u64,
135    chunk_size: u32,
136    /// Chunks recorded since the last durable checkpoint.
137    dirty: u64,
138}
139
140impl ResumeState {
141    pub fn state_path(final_path: &Path) -> PathBuf {
142        let mut s = final_path.as_os_str().to_os_string();
143        s.push(STATE_SUFFIX);
144        PathBuf::from(s)
145    }
146
147    /// Load existing state, or start empty.
148    ///
149    /// Any mismatch — different size, different chunk size, a truncated or
150    /// corrupt file — is treated as "no state", which costs a retransfer and
151    /// never risks a corrupt result.
152    pub fn load_or_new(final_path: &Path, size: u64, chunk_size: u32) -> Self {
153        let path = Self::state_path(final_path);
154        let chunks = if chunk_size == 0 {
155            0
156        } else {
157            size.div_ceil(chunk_size as u64)
158        };
159
160        if let Some(bm) = Self::try_load(&path, size, chunk_size, chunks) {
161            return Self {
162                path,
163                bitmap: bm,
164                size,
165                chunk_size,
166                dirty: 0,
167            };
168        }
169        Self {
170            path,
171            bitmap: ChunkBitmap::new(chunks),
172            size,
173            chunk_size,
174            dirty: 0,
175        }
176    }
177
178    fn try_load(path: &Path, size: u64, chunk_size: u32, chunks: u64) -> Option<ChunkBitmap> {
179        let raw = std::fs::read(path).ok()?;
180        if raw.len() < STATE_HEADER_LEN || &raw[0..4] != STATE_MAGIC {
181            return None;
182        }
183        if u16::from_le_bytes(raw[4..6].try_into().ok()?) != STATE_VERSION {
184            return None;
185        }
186        if u32::from_le_bytes(raw[6..10].try_into().ok()?) != chunk_size {
187            return None;
188        }
189        if u64::from_le_bytes(raw[10..18].try_into().ok()?) != size {
190            return None;
191        }
192        let n = u64::from_le_bytes(raw[18..26].try_into().ok()?);
193        if n != chunks {
194            return None;
195        }
196        let stored_hash = &raw[26..58];
197        let body = &raw[STATE_HEADER_LEN..];
198        // Detect a torn write from a crash mid-checkpoint.
199        if blake3::hash(body).as_bytes() != stored_hash {
200            tracing::warn!(path = %path.display(), "resume state failed its checksum; starting over");
201            return None;
202        }
203        ChunkBitmap::from_bytes(body.to_vec(), chunks).ok()
204    }
205
206    pub fn bitmap(&self) -> &ChunkBitmap {
207        &self.bitmap
208    }
209
210    pub fn has(&self, chunk: u64) -> bool {
211        self.bitmap.get(chunk)
212    }
213
214    /// Record a chunk as written but not yet durable.
215    pub fn record(&mut self, chunk: u64) {
216        if self.bitmap.set(chunk) {
217            self.dirty += 1;
218        }
219    }
220
221    pub fn pending(&self) -> u64 {
222        self.dirty
223    }
224
225    pub fn is_complete(&self) -> bool {
226        self.bitmap.is_complete()
227    }
228
229    /// Flush data, then persist the bitmap. Never the other way round.
230    pub fn checkpoint(&mut self, handle: &WriteHandle) -> Result<()> {
231        if self.dirty == 0 {
232            return Ok(());
233        }
234        handle.sync()?;
235        self.persist()?;
236        self.dirty = 0;
237        Ok(())
238    }
239
240    fn persist(&self) -> Result<()> {
241        let body = self.bitmap.as_bytes();
242        let mut out = Vec::with_capacity(STATE_HEADER_LEN + body.len());
243        out.extend_from_slice(STATE_MAGIC);
244        out.extend_from_slice(&STATE_VERSION.to_le_bytes());
245        out.extend_from_slice(&self.chunk_size.to_le_bytes());
246        out.extend_from_slice(&self.size.to_le_bytes());
247        out.extend_from_slice(&self.bitmap.len().to_le_bytes());
248        out.extend_from_slice(blake3::hash(body).as_bytes());
249        out.extend_from_slice(body);
250
251        // Write to a temp file and rename, so a crash cannot leave a half-written
252        // state file that the checksum would then have to catch.
253        let tmp = self.path.with_extension("tmp");
254        std::fs::write(&tmp, &out)?;
255        std::fs::rename(&tmp, &self.path)?;
256        Ok(())
257    }
258
259    /// Remove the state file once the transfer is committed.
260    pub fn clear(&self) {
261        let _ = std::fs::remove_file(&self.path);
262        let _ = std::fs::remove_file(self.path.with_extension("tmp"));
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn bitmap_set_get_count() {
272        let mut b = ChunkBitmap::new(20);
273        assert_eq!(b.count(), 0);
274        assert!(!b.is_complete());
275        assert!(b.set(0));
276        assert!(!b.set(0), "setting twice must not double-count");
277        assert!(b.set(19));
278        assert!(b.get(0) && b.get(19));
279        assert!(!b.get(5));
280        assert_eq!(b.count(), 2);
281        assert!(!b.set(20), "out of range set is a no-op");
282        assert!(!b.get(20));
283        assert_eq!(b.missing().count(), 18);
284        b.fill();
285        assert!(b.is_complete());
286        assert_eq!(b.count(), 20);
287        assert_eq!(b.missing().count(), 0);
288    }
289
290    #[test]
291    fn bitmap_roundtrips_and_rejects_bad_input() {
292        let mut b = ChunkBitmap::new(100);
293        for i in (0..100).step_by(3) {
294            b.set(i);
295        }
296        let restored = ChunkBitmap::from_bytes(b.as_bytes().to_vec(), 100).unwrap();
297        assert_eq!(restored, b);
298        assert_eq!(restored.count(), b.count());
299
300        assert!(ChunkBitmap::from_bytes(vec![0u8; 3], 100).is_err());
301        // Bits past the end would inflate the count.
302        assert!(ChunkBitmap::from_bytes(vec![0xFF; 13], 100).is_err());
303    }
304
305    #[test]
306    fn bitmap_scales_to_a_100gb_file() {
307        // 100 GiB at 1 MiB chunks.
308        let chunks = 100 * 1024u64;
309        let mut b = ChunkBitmap::new(chunks);
310        assert_eq!(b.as_bytes().len(), 12_800, "12.8 KB of state for 100 GiB");
311        for i in 0..chunks {
312            b.set(i);
313        }
314        assert!(b.is_complete());
315    }
316
317    #[test]
318    fn state_survives_a_reload() {
319        let tmp = tempfile::tempdir().unwrap();
320        let dest = tmp.path().join("f.bin");
321        let h = WriteHandle::open(&dest, 10_000, false).unwrap();
322
323        let mut s = ResumeState::load_or_new(&dest, 10_000, 1000);
324        assert_eq!(s.bitmap().len(), 10);
325        for c in [0u64, 1, 2, 7] {
326            h.write_at(c * 1000, &[1u8; 1000]).unwrap();
327            s.record(c);
328        }
329        assert_eq!(s.pending(), 4);
330        s.checkpoint(&h).unwrap();
331        assert_eq!(s.pending(), 0);
332
333        let s2 = ResumeState::load_or_new(&dest, 10_000, 1000);
334        assert_eq!(s2.bitmap().count(), 4);
335        for c in [0u64, 1, 2, 7] {
336            assert!(s2.has(c));
337        }
338        assert!(!s2.has(3));
339    }
340
341    #[test]
342    fn state_is_discarded_when_the_file_changed() {
343        let tmp = tempfile::tempdir().unwrap();
344        let dest = tmp.path().join("f.bin");
345        let h = WriteHandle::open(&dest, 10_000, false).unwrap();
346        let mut s = ResumeState::load_or_new(&dest, 10_000, 1000);
347        s.record(0);
348        s.checkpoint(&h).unwrap();
349
350        // Different size: the old bitmap describes a different file.
351        let s2 = ResumeState::load_or_new(&dest, 20_000, 1000);
352        assert_eq!(s2.bitmap().count(), 0);
353        // Different chunking: bit indices would mean something else.
354        let s3 = ResumeState::load_or_new(&dest, 10_000, 4096);
355        assert_eq!(s3.bitmap().count(), 0);
356    }
357
358    #[test]
359    fn corrupt_state_falls_back_to_a_full_transfer() {
360        let tmp = tempfile::tempdir().unwrap();
361        let dest = tmp.path().join("f.bin");
362        let h = WriteHandle::open(&dest, 10_000, false).unwrap();
363        let mut s = ResumeState::load_or_new(&dest, 10_000, 1000);
364        s.record(0);
365        s.record(1);
366        s.checkpoint(&h).unwrap();
367
368        // Flip a bit in the persisted bitmap body.
369        let p = ResumeState::state_path(&dest);
370        let mut raw = std::fs::read(&p).unwrap();
371        let last = raw.len() - 1;
372        raw[last] ^= 0xFF;
373        std::fs::write(&p, &raw).unwrap();
374
375        let s2 = ResumeState::load_or_new(&dest, 10_000, 1000);
376        assert_eq!(s2.bitmap().count(), 0, "corruption must not be trusted");
377    }
378
379    #[test]
380    fn clear_removes_the_sidecar() {
381        let tmp = tempfile::tempdir().unwrap();
382        let dest = tmp.path().join("f.bin");
383        let h = WriteHandle::open(&dest, 1000, false).unwrap();
384        let mut s = ResumeState::load_or_new(&dest, 1000, 1000);
385        s.record(0);
386        s.checkpoint(&h).unwrap();
387        assert!(ResumeState::state_path(&dest).exists());
388        s.clear();
389        assert!(!ResumeState::state_path(&dest).exists());
390    }
391}