Skip to main content

ripsync_core/
index.rs

1//! Persistent destination index: an atomic v3 snapshot plus append-only deltas.
2
3use std::io::{self, Read, Write};
4use std::path::{Path, PathBuf};
5
6use std::collections::HashMap;
7use serde::{Deserialize, Serialize};
8
9use crate::meta::{FileTypeKind, canonical_root, contained_target, meta_min};
10use crate::plan::{Action, SyncPlan};
11use crate::walk::{Entry, EntryKind};
12
13/// Directory under the destination root that holds ripsync metadata.
14pub const RIPSYNC_DIR: &str = ".ripsync";
15/// Snapshot filename within [`RIPSYNC_DIR`].
16pub const MANIFEST_FILE: &str = "manifest.bin";
17/// Delta journal filename within [`RIPSYNC_DIR`].
18pub const JOURNAL_FILE: &str = "manifest.journal";
19const FORMAT_VERSION: u32 = 3;
20const COMPACT_BYTES: u64 = 64 * 1024 * 1024;
21
22/// Recorded manifest entry kind.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Kind {
25    /// Regular file.
26    File,
27    /// Directory.
28    Dir,
29    /// Symbolic link.
30    Symlink,
31}
32
33/// One recorded destination entry.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ManifestEntry {
36    /// Entry kind.
37    pub kind: Kind,
38    /// File length in bytes.
39    pub size: u64,
40    /// Modification time, whole seconds.
41    pub mtime_s: i64,
42    /// Modification time, nanosecond part.
43    pub mtime_ns: u32,
44    /// Destination inode number.
45    pub ino: u64,
46    /// Destination device id.
47    pub dev: u64,
48    /// Permission and type bits.
49    pub mode: u32,
50    /// Strong hash, when created with `--checksum`.
51    pub hash: Option<[u8; 32]>,
52    /// Symlink target.
53    pub target: Option<PathBuf>,
54}
55
56/// Loaded manifest, keyed by destination-relative path.
57#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct Manifest {
59    /// On-disk format version.
60    pub version: u32,
61    /// Recorded entries.
62    pub entries: HashMap<PathBuf, ManifestEntry>,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66enum Delta {
67    Upsert(PathBuf, ManifestEntry),
68    Delete(PathBuf),
69}
70
71/// Path to the snapshot under `dst`.
72#[must_use]
73pub fn manifest_path(dst: &Path) -> PathBuf {
74    dst.join(RIPSYNC_DIR).join(MANIFEST_FILE)
75}
76
77/// Path to the append-only journal under `dst`.
78#[must_use]
79pub fn journal_path(dst: &Path) -> PathBuf {
80    dst.join(RIPSYNC_DIR).join(JOURNAL_FILE)
81}
82
83fn hash_file(path: &Path) -> io::Result<[u8; 32]> {
84    let mut file = std::fs::File::open(path)?;
85    let mut hasher = blake3::Hasher::new();
86    let mut buf = vec![0_u8; 1024 * 1024];
87    loop {
88        let read = file.read(&mut buf)?;
89        if read == 0 {
90            return Ok(*hasher.finalize().as_bytes());
91        }
92        hasher.update(&buf[..read]);
93    }
94}
95
96fn metadata_dir(dst: &Path) -> crate::Result<(PathBuf, PathBuf)> {
97    let root = canonical_root(dst)?;
98    let requested = root.join(RIPSYNC_DIR);
99    match std::fs::symlink_metadata(&requested) {
100        Ok(meta) if !meta.is_dir() => return Err(crate::Error::Containment(requested)),
101        Ok(_) => {}
102        Err(error) if error.kind() == io::ErrorKind::NotFound => {
103            std::fs::create_dir(&requested)
104                .map_err(|create_error| crate::Error::io(&requested, create_error))?;
105            #[cfg(unix)]
106            {
107                use std::os::unix::fs::PermissionsExt;
108                std::fs::set_permissions(&requested, std::fs::Permissions::from_mode(0o700))
109                    .map_err(|error| crate::Error::io(&requested, error))?;
110            }
111        }
112        Err(error) => return Err(crate::Error::io(&requested, error)),
113    }
114    let dir =
115        std::fs::canonicalize(&requested).map_err(|error| crate::Error::io(&requested, error))?;
116    if !dir.starts_with(&root) {
117        return Err(crate::Error::Containment(requested));
118    }
119    Ok((root, dir))
120}
121
122fn entry_from_destination(entry: &Entry, dst: &Path, hash: bool) -> crate::Result<ManifestEntry> {
123    let path = dst.join(&entry.rel);
124    let meta = meta_min(&path)?;
125    let (kind, target) = match entry.kind {
126        EntryKind::File => (Kind::File, None),
127        EntryKind::Dir => (Kind::Dir, None),
128        EntryKind::Symlink(_) => (
129            Kind::Symlink,
130            Some(std::fs::read_link(&path).map_err(|error| crate::Error::io(&path, error))?),
131        ),
132    };
133    let digest = if hash && kind == Kind::File {
134        Some(hash_file(&path).map_err(|error| crate::Error::io(&path, error))?)
135    } else {
136        None
137    };
138    Ok(ManifestEntry {
139        kind,
140        size: meta.len,
141        mtime_s: meta.mtime.unix_seconds(),
142        mtime_ns: meta.mtime.nanoseconds(),
143        ino: meta.ino,
144        dev: meta.dev,
145        mode: meta.mode,
146        hash: digest,
147        target,
148    })
149}
150
151impl Manifest {
152    /// Load a compatible snapshot and replay complete, valid journal records.
153    #[must_use]
154    pub fn load(dst: &Path) -> Option<Self> {
155        let root = std::fs::canonicalize(dst).ok()?;
156        let snapshot = manifest_path(&root);
157        let parent = std::fs::canonicalize(snapshot.parent()?).ok()?;
158        if !parent.starts_with(&root) {
159            return None;
160        }
161        let bytes = std::fs::read(snapshot).ok()?;
162        let mut manifest: Self = bincode::deserialize(&bytes).ok()?;
163        if manifest.version != FORMAT_VERSION {
164            return None;
165        }
166        manifest.replay_journal(&journal_path(&root));
167        Some(manifest)
168    }
169
170    fn replay_journal(&mut self, path: &Path) {
171        let Ok(mut file) = std::fs::File::open(path) else {
172            return;
173        };
174        loop {
175            let mut length = [0_u8; 4];
176            if file.read_exact(&mut length).is_err() {
177                break;
178            }
179            let length = u32::from_le_bytes(length) as usize;
180            let mut expected = [0_u8; 32];
181            if file.read_exact(&mut expected).is_err() {
182                break;
183            }
184            let mut payload = vec![0_u8; length];
185            if file.read_exact(&mut payload).is_err()
186                || *blake3::hash(&payload).as_bytes() != expected
187            {
188                break;
189            }
190            let Ok(delta) = bincode::deserialize::<Delta>(&payload) else {
191                break;
192            };
193            match delta {
194                Delta::Upsert(path, entry) => {
195                    self.entries.insert(path, entry);
196                }
197                Delta::Delete(path) => {
198                    self.entries.remove(&path);
199                }
200            }
201        }
202    }
203
204    /// Build a complete initial snapshot from the applied plan.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error when a destination entry cannot be inspected or hashed.
209    pub fn from_destination(plan: &SyncPlan, dst: &Path, hash: bool) -> crate::Result<Self> {
210        let mut entries = HashMap::with_capacity(plan.actions.len());
211        for planned in &plan.actions {
212            entries.insert(
213                planned.entry.rel.clone(),
214                entry_from_destination(&planned.entry, dst, hash)?,
215            );
216        }
217        Ok(Self {
218            version: FORMAT_VERSION,
219            entries,
220        })
221    }
222
223    /// Persist an initial snapshot or append only changed/deleted records.
224    ///
225    /// # Errors
226    ///
227    /// Returns an error when destination metadata cannot be read or the
228    /// snapshot/journal cannot be written safely.
229    pub fn persist_after_plan(plan: &SyncPlan, dst: &Path, hash: bool) -> crate::Result<()> {
230        let Some(mut manifest) = Self::load(dst) else {
231            return Self::from_destination(plan, dst, hash)?.save_snapshot(dst);
232        };
233        let mut deltas = Vec::new();
234        for planned in &plan.actions {
235            if planned.action == Action::Skip {
236                continue;
237            }
238            let record = entry_from_destination(&planned.entry, dst, hash)?;
239            manifest
240                .entries
241                .insert(planned.entry.rel.clone(), record.clone());
242            deltas.push(Delta::Upsert(planned.entry.rel.clone(), record));
243        }
244        for deletion in &plan.deletions {
245            if manifest.entries.remove(&deletion.rel).is_some() {
246                deltas.push(Delta::Delete(deletion.rel.clone()));
247            }
248        }
249        Self::append(dst, &deltas)?;
250        if Self::needs_compaction(dst) {
251            manifest.save_snapshot(dst)?;
252        }
253        Ok(())
254    }
255
256    fn append(dst: &Path, deltas: &[Delta]) -> crate::Result<()> {
257        if deltas.is_empty() {
258            return Ok(());
259        }
260        let (root, dir) = metadata_dir(dst)?;
261        let path = contained_target(&root, &dir.join(JOURNAL_FILE))?;
262        let mut file = std::fs::OpenOptions::new()
263            .create(true)
264            .append(true)
265            .open(&path)
266            .map_err(|error| crate::Error::io(&path, error))?;
267        for delta in deltas {
268            let payload = bincode::serialize(delta)
269                .map_err(|error| crate::Error::Pattern(format!("manifest journal: {error}")))?;
270            let length = u32::try_from(payload.len())
271                .map_err(|_| crate::Error::Pattern("manifest journal record too large".into()))?;
272            file.write_all(&length.to_le_bytes())
273                .and_then(|()| file.write_all(blake3::hash(&payload).as_bytes()))
274                .and_then(|()| file.write_all(&payload))
275                .map_err(|error| crate::Error::io(&path, error))?;
276        }
277        file.sync_data()
278            .map_err(|error| crate::Error::io(&path, error))
279    }
280
281    fn needs_compaction(dst: &Path) -> bool {
282        let journal = std::fs::metadata(journal_path(dst))
283            .map(|meta| meta.len())
284            .unwrap_or(0);
285        let snapshot = std::fs::metadata(manifest_path(dst))
286            .map(|meta| meta.len())
287            .unwrap_or(0);
288        journal > COMPACT_BYTES || (snapshot > 0 && journal > snapshot / 10)
289    }
290
291    /// Atomically replace the full snapshot and clear the journal.
292    ///
293    /// # Errors
294    ///
295    /// Returns an error when the metadata directory is unsafe or the snapshot
296    /// cannot be written and renamed.
297    pub fn save(&self, dst: &Path) -> crate::Result<()> {
298        self.save_snapshot(dst)
299    }
300
301    fn save_snapshot(&self, dst: &Path) -> crate::Result<()> {
302        let (root, dir) = metadata_dir(dst)?;
303        let bytes = bincode::serialize(self)
304            .map_err(|error| crate::Error::Pattern(format!("manifest: {error}")))?;
305        let tmp = dir.join(format!(".manifest-tmp-{:016x}", rand::random::<u64>()));
306        std::fs::write(&tmp, bytes).map_err(|error| crate::Error::io(&tmp, error))?;
307        let final_path = contained_target(&root, &dir.join(MANIFEST_FILE))?;
308        if let Err(error) = std::fs::rename(&tmp, &final_path) {
309            let _ = std::fs::remove_file(&tmp);
310            return Err(crate::Error::io(&final_path, error));
311        }
312        let journal = contained_target(&root, &dir.join(JOURNAL_FILE))?;
313        match std::fs::remove_file(&journal) {
314            Ok(()) => {}
315            Err(error) if error.kind() == io::ErrorKind::NotFound => {}
316            Err(error) => return Err(crate::Error::io(&journal, error)),
317        }
318        Ok(())
319    }
320
321    /// Classify against recorded and live destination state.
322    #[must_use]
323    pub fn classify(&self, entry: &Entry, checksum: bool, src: &Path, dst: &Path) -> Action {
324        let Some(recorded) = self.entries.get(&entry.rel) else {
325            return Action::Copy;
326        };
327        let destination = dst.join(&entry.rel);
328        match (&entry.kind, recorded.kind) {
329            (EntryKind::Dir, Kind::Dir) => action_from_match(
330                metadata_matches(entry, recorded)
331                    && destination_matches(recorded, &destination, None),
332            ),
333            (EntryKind::Symlink(target), Kind::Symlink) => action_from_match(
334                recorded.target.as_ref() == Some(target)
335                    && metadata_matches(entry, recorded)
336                    && destination_matches(recorded, &destination, Some(target)),
337            ),
338            (EntryKind::File, Kind::File) => {
339                if !metadata_matches(entry, recorded)
340                    || !destination_matches(recorded, &destination, None)
341                {
342                    return Action::Update;
343                }
344                if checksum {
345                    match (recorded.hash, hash_file(&src.join(&entry.rel)).ok()) {
346                        (Some(expected), Some(live)) if expected == live => Action::Skip,
347                        _ => Action::Update,
348                    }
349                } else {
350                    Action::Skip
351                }
352            }
353            _ => Action::Update,
354        }
355    }
356}
357
358fn metadata_matches(entry: &Entry, recorded: &ManifestEntry) -> bool {
359    entry.len == recorded.size
360        && entry.mtime.unix_seconds() == recorded.mtime_s
361        && entry.mtime.nanoseconds() == recorded.mtime_ns
362        && entry.mode & 0o7777 == recorded.mode & 0o7777
363}
364
365fn action_from_match(matches: bool) -> Action {
366    if matches {
367        Action::Skip
368    } else {
369        Action::Update
370    }
371}
372
373fn destination_matches(
374    recorded: &ManifestEntry,
375    path: &Path,
376    symlink_target: Option<&PathBuf>,
377) -> bool {
378    let Ok(meta) = meta_min(path) else {
379        return false;
380    };
381    let kind_matches = matches!(
382        (recorded.kind, meta.kind),
383        (Kind::File, FileTypeKind::File)
384            | (Kind::Dir, FileTypeKind::Dir)
385            | (Kind::Symlink, FileTypeKind::Symlink)
386    );
387    if !kind_matches || meta.ino != recorded.ino || meta.dev != recorded.dev {
388        return false;
389    }
390    if (recorded.kind == Kind::File && meta.len != recorded.size)
391        || meta.mtime.unix_seconds() != recorded.mtime_s
392        || meta.mtime.nanoseconds() != recorded.mtime_ns
393        || meta.mode & 0o7777 != recorded.mode & 0o7777
394    {
395        return false;
396    }
397    match symlink_target {
398        Some(expected) => std::fs::read_link(path).is_ok_and(|target| target == *expected),
399        None => true,
400    }
401}
402
403    #[cfg(test)]
404    mod tests {
405        use std::io::Write;
406        use std::path::Path;
407
408        use std::collections::HashMap;
409
410        use super::{Delta, Manifest, ManifestEntry, RIPSYNC_DIR};
411
412    #[test]
413    fn incomplete_journal_tail_is_ignored() {
414        let temp = tempfile::tempdir().unwrap();
415        let dst = temp.path().join("dst");
416        std::fs::create_dir_all(&dst).unwrap();
417        Manifest {
418            version: 3,
419            entries: HashMap::new(),
420        }
421        .save(&dst)
422        .unwrap();
423        let journal = super::journal_path(&dst);
424        std::fs::write(&journal, [12, 0, 0, 0, 1, 2, 3]).unwrap();
425        assert!(Manifest::load(&dst).is_some());
426        let _ = Delta::Delete("unused".into());
427    }
428
429    #[test]
430    fn valid_journal_replays_and_old_versions_miss() {
431        let temp = tempfile::tempdir().unwrap();
432        let dst = temp.path().join("dst");
433        std::fs::create_dir_all(&dst).unwrap();
434        Manifest {
435            version: 3,
436            entries: HashMap::new(),
437        }
438        .save(&dst)
439        .unwrap();
440        let delta = Delta::Upsert(
441            "file".into(),
442            ManifestEntry {
443                kind: super::Kind::File,
444                size: 4,
445                mtime_s: 1,
446                mtime_ns: 2,
447                ino: 3,
448                dev: 4,
449                mode: 0o100_644,
450                hash: None,
451                target: None,
452            },
453        );
454        let payload = bincode::serialize(&delta).unwrap();
455        let mut journal = std::fs::File::create(super::journal_path(&dst)).unwrap();
456        journal
457            .write_all(&u32::try_from(payload.len()).unwrap().to_le_bytes())
458            .unwrap();
459        journal
460            .write_all(blake3::hash(&payload).as_bytes())
461            .unwrap();
462        journal.write_all(&payload).unwrap();
463        assert!(
464            Manifest::load(&dst)
465                .unwrap()
466                .entries
467                .contains_key(Path::new("file"))
468        );
469
470        Manifest {
471            version: 2,
472            entries: HashMap::new(),
473        }
474        .save(&dst)
475        .unwrap();
476        assert!(Manifest::load(&dst).is_none());
477    }
478
479    #[test]
480    fn oversized_journal_requests_compaction() {
481        let temp = tempfile::tempdir().unwrap();
482        let dst = temp.path().join("dst");
483        std::fs::create_dir_all(&dst).unwrap();
484        let manifest = Manifest {
485            version: 3,
486            entries: HashMap::new(),
487        };
488        manifest.save(&dst).unwrap();
489        let journal = std::fs::File::create(super::journal_path(&dst)).unwrap();
490        journal.set_len(super::COMPACT_BYTES + 1).unwrap();
491        assert!(Manifest::needs_compaction(&dst));
492    }
493
494    #[test]
495    fn ripsync_dir_has_restrictive_permissions() {
496        let temp = tempfile::tempdir().unwrap();
497        let dst = temp.path().join("dst");
498        std::fs::create_dir_all(&dst).unwrap();
499        let manifest = Manifest {
500            version: 3,
501            entries: HashMap::new(),
502        };
503        manifest.save(&dst).unwrap();
504        let ripsync_dir = dst.join(RIPSYNC_DIR);
505        let metadata = std::fs::metadata(&ripsync_dir).unwrap();
506        #[cfg(unix)]
507        {
508            use std::os::unix::fs::PermissionsExt;
509            let mode = metadata.permissions().mode() & 0o777;
510            assert_eq!(mode, 0o700, ".ripsync dir should have 0o700 permissions");
511        }
512        #[cfg(not(unix))]
513        {
514            // On non-Unix platforms, just verify the directory exists
515            assert!(metadata.is_dir());
516        }
517    }
518}