Skip to main content

runsync_transfer/
index.rs

1//! A cache of per-chunk hashes, so a file that has not changed is never read.
2//!
3//! Delta sync needs both ends to know the hash of every chunk. Computing those
4//! means reading the whole file, which is fine once and absurd on the tenth
5//! sync of a tree that never changed: re-sending an unchanged 3.4 GiB tree
6//! moved 0.1 MiB but still spent 27 seconds hashing.
7//!
8//! So the hashes are remembered. An entry is trusted while the file's size and
9//! modification time both match what they were when it was hashed, which is the
10//! same bargain rsync and Syncthing make. It is a bargain, not a proof: a file
11//! edited within the timestamp's resolution *and* left at the same length would
12//! be missed. Two things bound the damage — the receiver verifies every
13//! completed file against the sender's hash root before committing it, so a
14//! stale entry surfaces as a failed transfer rather than a corrupt file, and
15//! `Config::trust_mtime` turns the whole thing off for callers who would rather
16//! pay the read.
17
18use crate::error::Result;
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22const MAGIC: &[u8; 4] = b"RSIX";
23const VERSION: u16 = 1;
24/// Where the cache lives, relative to the root it describes.
25pub const INDEX_FILE: &str = ".rst-index";
26
27#[derive(Clone)]
28struct Entry {
29    size: u64,
30    mtime: i64,
31    chunk_size: u32,
32    hashes: Vec<[u8; 32]>,
33}
34
35/// Chunk hashes for the files under one root.
36#[derive(Default)]
37pub struct ChunkIndex {
38    path: PathBuf,
39    entries: HashMap<String, Entry>,
40    dirty: bool,
41}
42
43impl ChunkIndex {
44    /// Load the index for `root`, or start empty if there is none to load.
45    ///
46    /// A corrupt or truncated index is treated as absent: the cost is re-
47    /// hashing, and trusting damaged hashes would be the one outcome worth
48    /// avoiding.
49    pub fn load(root: &Path) -> Self {
50        let path = root.join(INDEX_FILE);
51        let mut index = Self {
52            path,
53            entries: HashMap::new(),
54            dirty: false,
55        };
56        let Ok(raw) = std::fs::read(&index.path) else {
57            return index;
58        };
59        if let Some(entries) = decode(&raw) {
60            index.entries = entries;
61        } else {
62            tracing::warn!(path = %index.path.display(), "chunk index unreadable; rebuilding");
63        }
64        index
65    }
66
67    /// Hashes for a file, if the cache still describes it.
68    pub fn get(&self, rel: &str, size: u64, mtime: i64, chunk_size: u32) -> Option<&[[u8; 32]]> {
69        let e = self.entries.get(rel)?;
70        (e.size == size && e.mtime == mtime && e.chunk_size == chunk_size)
71            .then_some(e.hashes.as_slice())
72    }
73
74    pub fn insert(
75        &mut self,
76        rel: &str,
77        size: u64,
78        mtime: i64,
79        chunk_size: u32,
80        hashes: Vec<[u8; 32]>,
81    ) {
82        self.entries.insert(
83            rel.to_string(),
84            Entry {
85                size,
86                mtime,
87                chunk_size,
88                hashes,
89            },
90        );
91        self.dirty = true;
92    }
93
94    /// Forget everything not named in `keep`, so a long-lived index does not
95    /// accumulate entries for files that were deleted years ago.
96    pub fn retain(&mut self, keep: &std::collections::HashSet<String>) {
97        let before = self.entries.len();
98        self.entries.retain(|k, _| keep.contains(k));
99        if self.entries.len() != before {
100            self.dirty = true;
101        }
102    }
103
104    pub fn len(&self) -> usize {
105        self.entries.len()
106    }
107
108    pub fn is_empty(&self) -> bool {
109        self.entries.is_empty()
110    }
111
112    /// Write the index out, if anything changed.
113    ///
114    /// Through a temporary file and a rename, so an interrupted save leaves the
115    /// previous index rather than a half-written one.
116    pub fn save(&mut self) -> Result<()> {
117        if !self.dirty {
118            return Ok(());
119        }
120        if let Some(parent) = self.path.parent() {
121            std::fs::create_dir_all(parent)?;
122        }
123        let body = encode(&self.entries);
124        let tmp = self.path.with_extension("tmp");
125        std::fs::write(&tmp, &body)?;
126        std::fs::rename(&tmp, &self.path)?;
127        self.dirty = false;
128        Ok(())
129    }
130}
131
132fn encode(entries: &HashMap<String, Entry>) -> Vec<u8> {
133    let mut body = Vec::with_capacity(entries.len() * 96);
134    body.extend_from_slice(&(entries.len() as u32).to_le_bytes());
135    for (rel, e) in entries {
136        let p = rel.as_bytes();
137        body.extend_from_slice(&(p.len() as u16).to_le_bytes());
138        body.extend_from_slice(p);
139        body.extend_from_slice(&e.size.to_le_bytes());
140        body.extend_from_slice(&e.mtime.to_le_bytes());
141        body.extend_from_slice(&e.chunk_size.to_le_bytes());
142        body.extend_from_slice(&(e.hashes.len() as u32).to_le_bytes());
143        for h in &e.hashes {
144            body.extend_from_slice(h);
145        }
146    }
147    let mut out = Vec::with_capacity(body.len() + 38);
148    out.extend_from_slice(MAGIC);
149    out.extend_from_slice(&VERSION.to_le_bytes());
150    out.extend_from_slice(blake3::hash(&body).as_bytes());
151    out.extend_from_slice(&body);
152    out
153}
154
155fn decode(raw: &[u8]) -> Option<HashMap<String, Entry>> {
156    if raw.len() < 38 || &raw[0..4] != MAGIC {
157        return None;
158    }
159    if u16::from_le_bytes(raw[4..6].try_into().ok()?) != VERSION {
160        return None;
161    }
162    let body = &raw[38..];
163    // A torn write is indistinguishable from a valid short index without this.
164    if blake3::hash(body).as_bytes() != &raw[6..38] {
165        return None;
166    }
167
168    let mut pos = 0usize;
169    let mut take = |n: usize| -> Option<&[u8]> {
170        let end = pos.checked_add(n)?;
171        let s = body.get(pos..end)?;
172        pos = end;
173        Some(s)
174    };
175    let count = u32::from_le_bytes(take(4)?.try_into().ok()?) as usize;
176    if count > 8_000_000 {
177        return None;
178    }
179    let mut map = HashMap::with_capacity(count.min(4096));
180    for _ in 0..count {
181        let plen = u16::from_le_bytes(take(2)?.try_into().ok()?) as usize;
182        let rel = String::from_utf8(take(plen)?.to_vec()).ok()?;
183        let size = u64::from_le_bytes(take(8)?.try_into().ok()?);
184        let mtime = i64::from_le_bytes(take(8)?.try_into().ok()?);
185        let chunk_size = u32::from_le_bytes(take(4)?.try_into().ok()?);
186        let n = u32::from_le_bytes(take(4)?.try_into().ok()?) as usize;
187        if n > (1 << 26) {
188            return None;
189        }
190        let mut hashes = Vec::with_capacity(n.min(4096));
191        for _ in 0..n {
192            let mut h = [0u8; 32];
193            h.copy_from_slice(take(32)?);
194            hashes.push(h);
195        }
196        map.insert(
197            rel,
198            Entry {
199                size,
200                mtime,
201                chunk_size,
202                hashes,
203            },
204        );
205    }
206    Some(map)
207}
208
209/// The modification time of `meta`, in **nanoseconds** since the epoch.
210///
211/// Nanoseconds rather than seconds, and the difference matters: at one-second
212/// resolution a file rewritten within the same second at the same length would
213/// be taken for unchanged, and on a fast machine that is not a rare event but
214/// an ordinary one. At nanosecond resolution the window closes to whatever the
215/// filesystem's timestamp granularity actually is.
216pub fn mtime_of(meta: &std::fs::Metadata) -> i64 {
217    meta.modified()
218        .ok()
219        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
220        .map(|d| d.as_nanos().min(i64::MAX as u128) as i64)
221        .unwrap_or(0)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn hashes(n: usize) -> Vec<[u8; 32]> {
229        (0..n).map(|i| [i as u8; 32]).collect()
230    }
231
232    #[test]
233    fn survives_a_save_and_load() {
234        let tmp = tempfile::tempdir().unwrap();
235        let mut ix = ChunkIndex::load(tmp.path());
236        assert!(ix.is_empty());
237        ix.insert("a/b.bin", 1000, 42, 1024, hashes(4));
238        ix.save().unwrap();
239
240        let ix2 = ChunkIndex::load(tmp.path());
241        assert_eq!(ix2.len(), 1);
242        assert_eq!(ix2.get("a/b.bin", 1000, 42, 1024).unwrap().len(), 4);
243    }
244
245    /// The whole safety of the cache rests on this being fine-grained: at
246    /// second resolution, two writes of the same length in the same second are
247    /// indistinguishable, and the second one would be silently skipped.
248    #[test]
249    fn mtime_resolution_is_finer_than_a_second() {
250        let tmp = tempfile::tempdir().unwrap();
251        let p = tmp.path().join("f");
252        std::fs::write(&p, b"first").unwrap();
253        let a = mtime_of(&std::fs::metadata(&p).unwrap());
254        std::thread::sleep(std::time::Duration::from_millis(5));
255        std::fs::write(&p, b"secnd").unwrap();
256        let b = mtime_of(&std::fs::metadata(&p).unwrap());
257        assert_ne!(
258            a, b,
259            "two same-length writes 5ms apart were indistinguishable"
260        );
261    }
262
263    #[test]
264    fn a_changed_file_is_not_trusted() {
265        let tmp = tempfile::tempdir().unwrap();
266        let mut ix = ChunkIndex::load(tmp.path());
267        ix.insert("f", 1000, 42, 1024, hashes(4));
268
269        assert!(ix.get("f", 1000, 42, 1024).is_some());
270        // Any of size, mtime or chunking differing invalidates the entry.
271        assert!(ix.get("f", 1001, 42, 1024).is_none());
272        assert!(ix.get("f", 1000, 43, 1024).is_none());
273        assert!(ix.get("f", 1000, 42, 4096).is_none());
274        assert!(ix.get("other", 1000, 42, 1024).is_none());
275    }
276
277    #[test]
278    fn a_corrupt_index_is_discarded_rather_than_trusted() {
279        let tmp = tempfile::tempdir().unwrap();
280        let mut ix = ChunkIndex::load(tmp.path());
281        ix.insert("f", 1000, 42, 1024, hashes(8));
282        ix.save().unwrap();
283
284        let p = tmp.path().join(INDEX_FILE);
285        let mut raw = std::fs::read(&p).unwrap();
286        let last = raw.len() - 1;
287        raw[last] ^= 0xFF;
288        std::fs::write(&p, &raw).unwrap();
289
290        let ix2 = ChunkIndex::load(tmp.path());
291        assert!(ix2.is_empty(), "damaged hashes must never be trusted");
292
293        // Truncation too.
294        std::fs::write(&p, &raw[..20]).unwrap();
295        assert!(ChunkIndex::load(tmp.path()).is_empty());
296        // And rubbish that is not an index at all.
297        std::fs::write(&p, b"not an index").unwrap();
298        assert!(ChunkIndex::load(tmp.path()).is_empty());
299    }
300
301    #[test]
302    fn retain_drops_files_that_are_gone() {
303        let tmp = tempfile::tempdir().unwrap();
304        let mut ix = ChunkIndex::load(tmp.path());
305        ix.insert("keep", 1, 1, 1024, hashes(1));
306        ix.insert("gone", 1, 1, 1024, hashes(1));
307        let keep: std::collections::HashSet<String> = ["keep".to_string()].into_iter().collect();
308        ix.retain(&keep);
309        assert_eq!(ix.len(), 1);
310        assert!(ix.get("keep", 1, 1, 1024).is_some());
311    }
312
313    #[test]
314    fn saving_is_a_no_op_when_nothing_changed() {
315        let tmp = tempfile::tempdir().unwrap();
316        let mut ix = ChunkIndex::load(tmp.path());
317        ix.save().unwrap();
318        assert!(!tmp.path().join(INDEX_FILE).exists(), "nothing to write");
319    }
320}