1use 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
13pub const RIPSYNC_DIR: &str = ".ripsync";
15pub const MANIFEST_FILE: &str = "manifest.bin";
17pub const JOURNAL_FILE: &str = "manifest.journal";
19const FORMAT_VERSION: u32 = 3;
20const COMPACT_BYTES: u64 = 64 * 1024 * 1024;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Kind {
25 File,
27 Dir,
29 Symlink,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct ManifestEntry {
36 pub kind: Kind,
38 pub size: u64,
40 pub mtime_s: i64,
42 pub mtime_ns: u32,
44 pub ino: u64,
46 pub dev: u64,
48 pub mode: u32,
50 pub hash: Option<[u8; 32]>,
52 pub target: Option<PathBuf>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct Manifest {
59 pub version: u32,
61 pub entries: HashMap<PathBuf, ManifestEntry>,
63}
64
65#[derive(Debug, Serialize, Deserialize)]
66enum Delta {
67 Upsert(PathBuf, ManifestEntry),
68 Delete(PathBuf),
69}
70
71#[must_use]
73pub fn manifest_path(dst: &Path) -> PathBuf {
74 dst.join(RIPSYNC_DIR).join(MANIFEST_FILE)
75}
76
77#[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 #[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 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 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 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 #[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 assert!(metadata.is_dir());
516 }
517 }
518}