1use std::collections::{BTreeSet, HashMap};
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use grain_id::GrainId;
11
12use crate::entry::{Content, Entry, PathUpdate};
13use crate::error::{Error, Result};
14use crate::filter::SyncFilter;
15use crate::hash::ContentHash;
16use crate::hlc::Clock;
17use crate::id::ReplicaId;
18use crate::merge;
19use crate::paths;
20use crate::report::{Conflict, PauseReason, Report, ScanOutcome, SkipReason, Skipped};
21use crate::state::{DiskState, PathState};
22use crate::store::{Meta, ReplicaStore};
23use crate::vv::{Dot, VersionVector};
24
25pub const DEFAULT_MAX_FILE_SIZE: u64 = 64 * 1024 * 1024;
27
28const RACY_NS: i64 = 2_000_000_000;
30
31pub trait ContentSource {
33 fn fetch(&self, hash: &ContentHash) -> Option<Vec<u8>>;
34}
35
36#[derive(Clone, Debug)]
38pub struct ReplicaConfig {
39 pub app_name: String,
40 pub root: PathBuf,
41 pub device_id: GrainId,
43 pub max_file_size: u64,
44 pub store_path: PathBuf,
45 pub staging_dir: PathBuf,
46}
47
48impl ReplicaConfig {
49 pub fn new(
51 app_name: impl Into<String>,
52 root: impl Into<PathBuf>,
53 device_id: GrainId,
54 state_dir: &Path,
55 ) -> Self {
56 Self {
57 app_name: app_name.into(),
58 root: root.into(),
59 device_id,
60 max_file_size: DEFAULT_MAX_FILE_SIZE,
61 store_path: state_dir.join("sync.redb"),
62 staging_dir: state_dir.join("staging"),
63 }
64 }
65}
66
67pub struct Replica {
69 config: ReplicaConfig,
70 store: ReplicaStore,
71 meta: Meta,
72 clock: Arc<dyn Clock>,
73 #[cfg(any(test, feature = "test-util"))]
74 fault: Option<crate::testing::FaultPoint>,
75 #[cfg(any(test, feature = "test-util"))]
78 reconcile_hook: Option<Box<dyn FnOnce()>>,
79}
80
81fn now_ns() -> i64 {
82 SystemTime::now()
83 .duration_since(UNIX_EPOCH)
84 .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
85 .unwrap_or(0)
86}
87
88fn stamp_of(meta: &fs::Metadata) -> (i64, u64) {
89 let mtime = meta
90 .modified()
91 .ok()
92 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
93 .map(|d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
94 .unwrap_or(0);
95 (mtime, meta.len())
96}
97
98fn is_racy(disk: &DiskState) -> bool {
99 disk.mtime_ns.saturating_add(RACY_NS) >= disk.checked_ns
100}
101
102fn place(staged: &Path, dest: &Path) -> Result<()> {
105 if let Some(parent) = dest.parent() {
106 fs::create_dir_all(parent)?;
107 }
108 match fs::rename(staged, dest) {
109 Ok(()) => return Ok(()),
110 Err(e) if e.kind() == ErrorKind::CrossesDevices => {}
111 Err(e) => return Err(e.into()),
112 }
113 let name = dest
114 .file_name()
115 .map(|n| n.to_string_lossy().into_owned())
116 .unwrap_or_default();
117 let tmp = dest.with_file_name(format!(".{name}.sapphire-tmp"));
118 if let Err(e) = fs::copy(staged, &tmp).and_then(|_| fs::rename(&tmp, dest)) {
119 let _ = fs::remove_file(&tmp);
120 return Err(e.into());
121 }
122 let _ = fs::remove_file(staged);
125 Ok(())
126}
127
128#[derive(Debug, Default)]
134struct StoreIndex {
135 hash_of: HashMap<String, ContentHash>,
137 by_hash: HashMap<ContentHash, BTreeSet<String>>,
139 by_lower: HashMap<String, BTreeSet<String>>,
142}
143
144impl StoreIndex {
145 fn build(states: &[(String, PathState)]) -> Self {
146 let mut index = Self::default();
147 for (rel, state) in states {
148 index.record(rel, state.disk.hash);
149 }
150 index
151 }
152
153 fn record(&mut self, rel: &str, hash: Option<ContentHash>) {
155 if paths::CASE_INSENSITIVE_FS {
156 self.by_lower
157 .entry(rel.to_lowercase())
158 .or_default()
159 .insert(rel.to_owned());
160 }
161 let previous = match hash {
162 Some(hash) => self.hash_of.insert(rel.to_owned(), hash),
163 None => self.hash_of.remove(rel),
164 };
165 if let Some(previous) = previous
166 && Some(previous) != hash
167 && let Some(paths) = self.by_hash.get_mut(&previous)
168 {
169 paths.remove(rel);
170 if paths.is_empty() {
171 self.by_hash.remove(&previous);
172 }
173 }
174 if let Some(hash) = hash {
175 self.by_hash.entry(hash).or_default().insert(rel.to_owned());
176 }
177 }
178
179 fn case_twin(&self, rel: &str) -> Option<String> {
182 self.by_lower
183 .get(&rel.to_lowercase())?
184 .iter()
185 .find(|other| other.as_str() != rel && self.hash_of.contains_key(*other))
186 .cloned()
187 }
188
189 fn paths_with(&self, hash: &ContentHash) -> impl Iterator<Item = &String> {
191 self.by_hash.get(hash).into_iter().flatten()
192 }
193}
194
195fn tolerate_io(result: Result<()>, rel: &str, report: &mut Report) -> Result<()> {
198 match result {
199 Err(Error::Io(e)) => {
200 report.skipped.push(Skipped {
201 path: rel.to_owned(),
202 reason: SkipReason::Io(e.to_string()),
203 });
204 Ok(())
205 }
206 other => other,
207 }
208}
209
210impl Replica {
211 pub fn open(config: ReplicaConfig, clock: Arc<dyn Clock>) -> Result<Self> {
212 Self::open_inner(config, clock, None)
213 }
214
215 fn open_inner(
216 config: ReplicaConfig,
217 clock: Arc<dyn Clock>,
218 id: Option<ReplicaId>,
219 ) -> Result<Self> {
220 fs::create_dir_all(&config.staging_dir)?;
221 let root = config.root.to_string_lossy().into_owned();
222 let store = ReplicaStore::open_with_id(&config.store_path, &root, id)?;
223 let meta = store.meta()?;
224 Ok(Self {
225 config,
226 store,
227 meta,
228 clock,
229 #[cfg(any(test, feature = "test-util"))]
230 fault: None,
231 #[cfg(any(test, feature = "test-util"))]
232 reconcile_hook: None,
233 })
234 }
235
236 pub fn replica_id(&self) -> ReplicaId {
237 self.meta.replica_id
238 }
239
240 pub fn vv(&self) -> &VersionVector {
241 &self.meta.vv
242 }
243
244 pub fn config(&self) -> &ReplicaConfig {
245 &self.config
246 }
247
248 pub fn state(&self, path: &str) -> Result<Option<PathState>> {
249 self.store.get(path)
250 }
251
252 pub fn states(&self) -> Result<Vec<(String, PathState)>> {
253 self.store.all()
254 }
255
256 fn filter(&self) -> Result<SyncFilter> {
257 SyncFilter::load(&self.config.root, &self.config.app_name)
258 }
259
260 pub fn pause_reason(&self) -> Result<Option<PauseReason>> {
263 let root_ok = self.config.root.is_dir();
264 let marker_ok = self
265 .config
266 .root
267 .join(format!(".{}", self.config.app_name))
268 .is_dir();
269 if root_ok && marker_ok {
270 return Ok(None);
271 }
272 if !self.store.any_materialized()? {
273 return Ok(None);
274 }
275 Ok(Some(if root_ok {
276 PauseReason::MarkerMissing
277 } else {
278 PauseReason::RootMissing
279 }))
280 }
281
282 #[cfg(any(test, feature = "test-util"))]
283 fn fault_check(&mut self) -> Result<()> {
284 match self.fault.take() {
285 Some(crate::testing::FaultPoint::AfterCommitBeforeWrite) => Err(Error::InjectedFault),
286 None => Ok(()),
287 }
288 }
289
290 #[cfg(not(any(test, feature = "test-util")))]
291 fn fault_check(&mut self) -> Result<()> {
292 Ok(())
293 }
294
295 #[cfg(any(test, feature = "test-util"))]
296 fn run_reconcile_hook(&mut self) {
297 if let Some(hook) = self.reconcile_hook.take() {
298 hook();
299 }
300 }
301
302 #[cfg(not(any(test, feature = "test-util")))]
303 fn run_reconcile_hook(&mut self) {}
304
305 pub fn scan(&mut self) -> Result<ScanOutcome> {
307 if let Some(reason) = self.pause_reason()? {
308 return Ok(ScanOutcome::Paused(reason));
309 }
310 let filter = self.filter()?;
311 let mut report = Report::default();
312 let mut rels = BTreeSet::new();
313 if self.config.root.is_dir() {
314 let root = self.config.root.clone();
315 let walker = walkdir::WalkDir::new(&root)
316 .follow_links(false)
317 .into_iter()
318 .filter_entry(|e| {
319 e.depth() == 0
320 || paths::rel_from_native(&root, e.path())
321 .is_some_and(|rel| filter.allows(&rel, e.file_type().is_dir()))
322 });
323 for item in walker {
324 let item = match item {
328 Ok(item) => item,
329 Err(e) => {
330 let path = e
331 .path()
332 .and_then(|p| paths::rel_from_native(&root, p))
333 .unwrap_or_default();
334 report.skipped.push(Skipped {
335 path,
336 reason: SkipReason::Io(e.to_string()),
337 });
338 continue;
339 }
340 };
341 if item.depth() == 0 || item.file_type().is_dir() {
342 continue;
343 }
344 let Some(rel) = paths::rel_from_native(&root, item.path()) else {
345 continue;
346 };
347 if item.file_type().is_symlink() {
348 report.skipped.push(Skipped {
349 path: rel,
350 reason: SkipReason::Symlink,
351 });
352 continue;
353 }
354 rels.insert(rel);
355 }
356 }
357 let states = self.store.all()?;
358 let mut index = StoreIndex::build(&states);
359 for (rel, _) in states {
360 rels.insert(rel);
361 }
362 for rel in rels {
363 let result = self.reconcile_path(&rel, &filter, None, &mut index, &mut report);
364 tolerate_io(result, &rel, &mut report)?;
365 self.run_reconcile_hook();
366 }
367 Ok(ScanOutcome::Scanned(report))
368 }
369
370 pub fn delta_for(&self, peer: &VersionVector) -> Result<Vec<PathUpdate>> {
372 Ok(self
373 .store
374 .all()?
375 .into_iter()
376 .filter(|(_, s)| !peer.covers(&s.seen))
377 .map(|(path, s)| PathUpdate {
378 path,
379 versions: s.versions,
380 seen: s.seen,
381 })
382 .collect())
383 }
384
385 pub fn apply(&mut self, updates: &[PathUpdate], source: &dyn ContentSource) -> Result<Report> {
387 if let Some(reason) = self.pause_reason()? {
388 return Err(Error::Paused(reason));
389 }
390 let filter = self.filter()?;
391 let mut report = Report::default();
392 let mut index = StoreIndex::build(&self.store.all()?);
393 let now = self.clock.now_ms();
394 for update in updates {
395 let well_formed = paths::is_valid_rel(&update.path)
398 && !update.versions.is_empty()
399 && update
400 .versions
401 .iter()
402 .all(|v| v.path == update.path && v.dot.counter > 0);
403 if !well_formed {
404 tracing::warn!(path = %update.path, "ignoring a malformed path update");
405 continue;
406 }
407 for version in &update.versions {
408 self.meta.hlc = self.meta.hlc.observe(version.hlc, now);
409 }
410 let reconciled =
415 self.reconcile_path(&update.path, &filter, Some(source), &mut index, &mut report);
416 tolerate_io(reconciled, &update.path, &mut report)?;
417 let integrated = self.integrate(
418 update.clone(),
419 None,
420 &filter,
421 Some(source),
422 &mut index,
423 &mut report,
424 );
425 tolerate_io(integrated, &update.path, &mut report)?;
426 }
427 Ok(report)
428 }
429
430 pub fn commit_session(&mut self, peer: &VersionVector) -> Result<()> {
432 self.meta.vv.merge(peer);
433 self.store.commit(&self.meta, &[])
434 }
435
436 pub fn fetch_missing(&mut self, source: &dyn ContentSource) -> Result<Report> {
438 if let Some(reason) = self.pause_reason()? {
439 return Err(Error::Paused(reason));
440 }
441 let filter = self.filter()?;
442 let mut report = Report::default();
443 let states = self.store.all()?;
444 let mut index = StoreIndex::build(&states);
445 for (rel, _) in states {
446 let result = self.reconcile_path(&rel, &filter, Some(source), &mut index, &mut report);
447 tolerate_io(result, &rel, &mut report)?;
448 }
449 Ok(report)
450 }
451
452 pub fn read_content(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
454 self.content_with(hash, &StoreIndex::build(&self.store.all()?))
455 }
456
457 fn content_with(&self, hash: &ContentHash, index: &StoreIndex) -> Result<Option<Vec<u8>>> {
459 for rel in index.paths_with(hash) {
460 if let Ok(bytes) = fs::read(paths::to_native(&self.config.root, rel))
461 && ContentHash::of_bytes(&bytes) == *hash
462 {
463 return Ok(Some(bytes));
464 }
465 }
466 if let Ok(bytes) = fs::read(self.config.staging_dir.join(hash.to_hex()))
467 && ContentHash::of_bytes(&bytes) == *hash
468 {
469 return Ok(Some(bytes));
470 }
471 Ok(None)
472 }
473
474 fn next_entry(&mut self, rel: &str, content: Content, context: VersionVector) -> Entry {
475 self.meta.counter += 1;
476 self.meta.hlc = self.meta.hlc.tick(self.clock.now_ms());
477 let dot = Dot {
478 replica: self.meta.replica_id,
479 counter: self.meta.counter,
480 };
481 self.meta.vv.add_dot(&dot);
482 Entry {
483 path: rel.to_owned(),
484 content,
485 hlc: self.meta.hlc,
486 dot,
487 context,
488 author: self.config.device_id,
489 }
490 }
491
492 fn reconcile_path(
494 &mut self,
495 rel: &str,
496 filter: &SyncFilter,
497 source: Option<&dyn ContentSource>,
498 index: &mut StoreIndex,
499 report: &mut Report,
500 ) -> Result<()> {
501 if !filter.allows(rel, false) || !paths::representable(rel) {
502 return Ok(());
503 }
504 let abs = paths::to_native(&self.config.root, rel);
505 let state = self.store.get(rel)?;
506 let disk = state.as_ref().map(|s| s.disk.clone()).unwrap_or_default();
507 if disk.hash.is_none() && index.case_twin(rel).is_some() {
509 if let Some(state) = state {
510 self.settle(rel, state, filter, source, index, report)?;
511 }
512 return Ok(());
513 }
514 let fs_meta = match fs::symlink_metadata(&abs) {
515 Ok(m) => Some(m),
516 Err(e) if e.kind() == ErrorKind::NotFound => None,
517 Err(e) => return Err(e.into()),
518 };
519 let mut stamp = (0, 0);
520 let file_hash = match &fs_meta {
521 Some(m) if m.file_type().is_symlink() => {
522 report.skipped.push(Skipped {
523 path: rel.to_owned(),
524 reason: SkipReason::Symlink,
525 });
526 return Ok(());
527 }
528 Some(m) if m.is_file() => {
529 stamp = stamp_of(m);
530 if disk.hash.is_some() && stamp == (disk.mtime_ns, disk.len) && !is_racy(&disk) {
531 disk.hash
532 } else if m.len() > self.config.max_file_size {
533 report.skipped.push(Skipped {
534 path: rel.to_owned(),
535 reason: SkipReason::TooLarge {
536 len: m.len(),
537 max: self.config.max_file_size,
538 },
539 });
540 return Ok(());
541 } else {
542 Some(ContentHash::of_file(&abs)?)
543 }
544 }
545 _ => None,
546 };
547
548 if file_hash == disk.hash {
549 if let Some(mut state) = state {
550 if file_hash.is_some() && (stamp != (disk.mtime_ns, disk.len) || is_racy(&disk)) {
551 state.disk.mtime_ns = stamp.0;
552 state.disk.len = stamp.1;
553 state.disk.checked_ns = now_ns();
554 self.commit_state(rel, &state, index)?;
555 }
556 self.ensure_conflict_copies(rel, &state, filter, source, index, report)?;
557 self.settle(rel, state, filter, source, index, report)?;
558 }
559 return Ok(());
560 }
561
562 let content = match file_hash {
563 Some(hash) => Content::File { hash, len: stamp.1 },
564 None => Content::Tombstone,
565 };
566 if state.is_none() && content.is_tombstone() {
567 return Ok(());
568 }
569 if content.is_tombstone()
575 && disk.hash.is_some()
576 && let Some(reason) = self.pause_reason()?
577 {
578 return Err(Error::Paused(reason));
579 }
580 let entry = self.next_entry(rel, content, disk.seen.clone());
581 report.recorded.push(entry.clone());
582 let mut seen = entry.context.clone();
583 seen.add_dot(&entry.dot);
584 let new_disk = DiskState {
585 hash: file_hash,
586 seen: seen.clone(),
587 mtime_ns: stamp.0,
588 len: stamp.1,
589 checked_ns: now_ns(),
590 };
591 let update = PathUpdate {
592 path: rel.to_owned(),
593 versions: vec![entry],
594 seen,
595 };
596 self.integrate(update, Some(new_disk), filter, source, index, report)
597 }
598
599 fn integrate(
602 &mut self,
603 update: PathUpdate,
604 local_disk: Option<DiskState>,
605 filter: &SyncFilter,
606 source: Option<&dyn ContentSource>,
607 index: &mut StoreIndex,
608 report: &mut Report,
609 ) -> Result<()> {
610 let rel = update.path.clone();
611 let old = self.store.get(&rel)?;
612 let joined = merge::join(
613 old.as_ref().map(|s| (s.versions.as_slice(), &s.seen)),
614 &update.versions,
615 &update.seen,
616 );
617 let Some((versions, seen)) = joined else {
618 return Ok(());
619 };
620 let disk = match local_disk {
621 Some(d) => d,
622 None => old.map(|s| s.disk).unwrap_or_default(),
623 };
624 let state = PathState {
625 versions,
626 seen,
627 disk,
628 };
629 self.commit_state(&rel, &state, index)?;
630 report.changed += 1;
631 self.ensure_conflict_copies(&rel, &state, filter, source, index, report)?;
633 self.settle(&rel, state, filter, source, index, report)
634 }
635
636 fn ensure_conflict_copies(
638 &mut self,
639 rel: &str,
640 state: &PathState,
641 filter: &SyncFilter,
642 source: Option<&dyn ContentSource>,
643 index: &mut StoreIndex,
644 report: &mut Report,
645 ) -> Result<()> {
646 if state.versions.len() < 2 {
647 return Ok(());
648 }
649 let winner = state.winner().clone();
650 let losers: Vec<Entry> = state
651 .versions
652 .iter()
653 .filter(|v| v.dot != winner.dot && merge::needs_copy(v, &winner))
654 .cloned()
655 .collect();
656 for loser in losers {
657 let Content::File { hash, .. } = loser.content else {
658 continue;
659 };
660 let copy_rel = merge::conflict_path(rel, &loser.dot);
661 if !filter.allows(©_rel, false) {
662 report.skipped.push(Skipped {
663 path: copy_rel,
664 reason: SkipReason::Ignored,
665 });
666 continue;
667 }
668 if !paths::representable(©_rel) {
669 report.skipped.push(Skipped {
670 path: copy_rel,
671 reason: SkipReason::Unrepresentable,
672 });
673 continue;
674 }
675 if self.store.get(©_rel)?.is_some() {
676 continue;
677 }
678 let copy_abs = paths::to_native(&self.config.root, ©_rel);
679 let holds_the_loser = match fs::symlink_metadata(©_abs) {
683 Ok(m) if m.is_file() && ContentHash::of_file(©_abs)? == hash => true,
684 Ok(_) => {
688 report.skipped.push(Skipped {
689 path: copy_rel,
690 reason: SkipReason::Occupied,
691 });
692 continue;
693 }
694 Err(e) if e.kind() == ErrorKind::NotFound => false,
695 Err(e) => return Err(e.into()),
696 };
697 if !holds_the_loser {
698 let on_disk = if state.disk.hash == Some(hash) {
703 Some(fs::read(paths::to_native(&self.config.root, rel))?)
704 } else {
705 None
706 };
707 let bytes = match on_disk {
708 Some(bytes) => Some(bytes),
709 None => match self.content_with(&hash, index)? {
710 Some(bytes) => Some(bytes),
711 None => source.and_then(|s| s.fetch(&hash)),
712 },
713 };
714 let Some(bytes) = bytes.filter(|b| ContentHash::of_bytes(b) == hash) else {
715 report.skipped.push(Skipped {
716 path: copy_rel,
717 reason: SkipReason::ContentUnavailable,
718 });
719 continue;
720 };
721 let staged = self
722 .config
723 .staging_dir
724 .join(format!("{}.copy", hash.to_hex()));
725 fs::write(&staged, &bytes)?;
726 place(&staged, ©_abs)?;
727 report.conflicts.push(Conflict {
728 path: rel.to_owned(),
729 copy_path: copy_rel.clone(),
730 });
731 }
732 self.reconcile_path(©_rel, filter, source, index, report)?;
734 }
735 Ok(())
736 }
737
738 fn settle(
740 &mut self,
741 rel: &str,
742 mut state: PathState,
743 filter: &SyncFilter,
744 source: Option<&dyn ContentSource>,
745 index: &mut StoreIndex,
746 report: &mut Report,
747 ) -> Result<()> {
748 let winner = state.winner().clone();
749 if winner.content.hash() == state.disk.hash {
750 let seen = self.disk_seen(&state, filter)?;
751 if seen != state.disk.seen {
752 state.disk.seen = seen;
753 self.commit_state(rel, &state, index)?;
754 }
755 return Ok(());
756 }
757 if let Some(reason) = self.skip_reason(rel, &winner, filter, index)? {
758 report.skipped.push(Skipped {
759 path: rel.to_owned(),
760 reason,
761 });
762 return Ok(());
763 }
764 if self.occupied(rel, &state.disk)? {
765 report.skipped.push(Skipped {
766 path: rel.to_owned(),
767 reason: SkipReason::Occupied,
768 });
769 return Ok(());
770 }
771 let abs = paths::to_native(&self.config.root, rel);
772 match winner.content {
773 Content::Tombstone => {
774 self.fault_check()?;
775 match fs::remove_file(&abs) {
776 Ok(()) => {}
777 Err(e) if e.kind() == ErrorKind::NotFound => {}
778 Err(e) => return Err(e.into()),
779 }
780 state.disk = DiskState::default();
781 }
782 Content::File { hash, .. } => {
783 let Some(staged) = self.stage(&hash, source, index)? else {
784 report.skipped.push(Skipped {
785 path: rel.to_owned(),
786 reason: SkipReason::ContentUnavailable,
787 });
788 return Ok(());
789 };
790 self.fault_check()?;
791 place(&staged, &abs)?;
792 let (mtime_ns, len) = stamp_of(&fs::metadata(&abs)?);
793 state.disk = DiskState {
794 hash: Some(hash),
795 seen: VersionVector::new(),
796 mtime_ns,
797 len,
798 checked_ns: now_ns(),
799 };
800 }
801 }
802 state.disk.seen = self.disk_seen(&state, filter)?;
803 self.commit_state(rel, &state, index)
804 }
805
806 fn commit_state(&self, rel: &str, state: &PathState, index: &mut StoreIndex) -> Result<()> {
808 self.store
809 .commit(&self.meta, &[(rel.to_owned(), state.clone())])?;
810 index.record(rel, state.disk.hash);
811 Ok(())
812 }
813
814 fn disk_seen(&self, state: &PathState, filter: &SyncFilter) -> Result<VersionVector> {
821 let winner = state.winner();
822 let mut seen = state.seen.clone();
823 for loser in state
824 .versions
825 .iter()
826 .filter(|v| v.dot != winner.dot && merge::needs_copy(v, winner))
827 {
828 let copy_rel = merge::conflict_path(&loser.path, &loser.dot);
829 if !filter.allows(©_rel, false) || !paths::representable(©_rel) {
830 continue;
831 }
832 if self.store.get(©_rel)?.is_none() {
833 let slot = seen.0.entry(loser.dot.replica).or_insert(0);
834 *slot = (*slot).min(loser.dot.counter.saturating_sub(1));
835 }
836 }
837 Ok(seen)
838 }
839
840 fn skip_reason(
841 &self,
842 rel: &str,
843 winner: &Entry,
844 filter: &SyncFilter,
845 index: &StoreIndex,
846 ) -> Result<Option<SkipReason>> {
847 if !filter.allows(rel, false) {
848 return Ok(Some(SkipReason::Ignored));
849 }
850 if !paths::representable(rel) {
851 return Ok(Some(SkipReason::Unrepresentable));
852 }
853 if let Content::File { len, .. } = winner.content
854 && len > self.config.max_file_size
855 {
856 return Ok(Some(SkipReason::TooLarge {
857 len,
858 max: self.config.max_file_size,
859 }));
860 }
861 if !winner.content.is_tombstone()
862 && let Some(other) = index.case_twin(rel)
863 {
864 return Ok(Some(SkipReason::CaseCollision { other }));
865 }
866 Ok(None)
867 }
868
869 fn occupied(&self, rel: &str, disk: &DiskState) -> Result<bool> {
874 let abs = paths::to_native(&self.config.root, rel);
875 let meta = match fs::symlink_metadata(&abs) {
876 Ok(m) => m,
877 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false),
878 Err(e) => return Err(e.into()),
879 };
880 if !meta.is_file() {
881 return Ok(true);
883 }
884 if meta.len() > self.config.max_file_size {
885 return Ok(true);
886 }
887 let stamp = stamp_of(&meta);
888 let hash = if disk.hash.is_some() && stamp == (disk.mtime_ns, disk.len) && !is_racy(disk) {
889 disk.hash
890 } else {
891 Some(ContentHash::of_file(&abs)?)
892 };
893 Ok(hash != disk.hash)
894 }
895
896 fn stage(
898 &self,
899 hash: &ContentHash,
900 source: Option<&dyn ContentSource>,
901 index: &StoreIndex,
902 ) -> Result<Option<PathBuf>> {
903 let staged = self.config.staging_dir.join(hash.to_hex());
904 if staged.is_file() && ContentHash::of_file(&staged)? == *hash {
905 return Ok(Some(staged));
906 }
907 let bytes = match self.content_with(hash, index)? {
908 Some(bytes) => Some(bytes),
909 None => source.and_then(|s| s.fetch(hash)),
910 };
911 let Some(bytes) = bytes else {
912 return Ok(None);
913 };
914 if ContentHash::of_bytes(&bytes) != *hash {
915 tracing::warn!(%hash, "content source returned bytes with the wrong hash");
916 return Ok(None);
917 }
918 fs::write(&staged, &bytes)?;
919 Ok(Some(staged))
920 }
921}
922
923impl ContentSource for Replica {
924 fn fetch(&self, hash: &ContentHash) -> Option<Vec<u8>> {
925 self.read_content(hash).ok().flatten()
926 }
927}
928
929#[cfg(any(test, feature = "test-util"))]
930impl Replica {
931 pub fn open_with_replica_id(
933 config: ReplicaConfig,
934 clock: Arc<dyn Clock>,
935 id: ReplicaId,
936 ) -> Result<Self> {
937 Self::open_inner(config, clock, Some(id))
938 }
939
940 pub fn inject_fault(&mut self, point: crate::testing::FaultPoint) {
942 self.fault = Some(point);
943 }
944
945 pub fn inject_after_reconcile(&mut self, hook: Box<dyn FnOnce()>) {
947 self.reconcile_hook = Some(hook);
948 }
949
950 pub fn logical_dump(&self) -> Result<serde_json::Value> {
953 let mut path_states = serde_json::Map::new();
954 for (rel, s) in self.store.all()? {
955 path_states.insert(
956 rel,
957 serde_json::json!({
958 "versions": s.versions,
959 "seen": s.seen,
960 "disk_hash": s.disk.hash,
961 "disk_seen": s.disk.seen,
962 }),
963 );
964 }
965 let mut files = serde_json::Map::new();
966 if self.config.root.is_dir() {
967 for item in walkdir::WalkDir::new(&self.config.root).sort_by_file_name() {
968 let item = item.map_err(|e| Error::Io(std::io::Error::other(e)))?;
969 if !item.file_type().is_file() {
970 continue;
971 }
972 if let Some(rel) = paths::rel_from_native(&self.config.root, item.path()) {
973 files.insert(rel, serde_json::json!(ContentHash::of_file(item.path())?));
974 }
975 }
976 }
977 Ok(serde_json::json!({
978 "replica_id": self.meta.replica_id,
979 "vv": self.meta.vv,
980 "paths": path_states,
981 "files": files,
982 }))
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 fn hash(body: &str) -> ContentHash {
991 ContentHash::of_bytes(body.as_bytes())
992 }
993
994 #[test]
995 fn the_index_follows_a_path_from_one_hash_to_another() {
996 let mut index = StoreIndex::default();
997 index.record("a.txt", Some(hash("one")));
998 index.record("b.txt", Some(hash("one")));
999 assert_eq!(
1000 index.paths_with(&hash("one")).collect::<Vec<_>>(),
1001 vec!["a.txt", "b.txt"]
1002 );
1003
1004 index.record("a.txt", Some(hash("two")));
1005 assert_eq!(
1006 index.paths_with(&hash("one")).collect::<Vec<_>>(),
1007 vec!["b.txt"],
1008 "the stale hash no longer points at a.txt"
1009 );
1010 assert_eq!(
1011 index.paths_with(&hash("two")).collect::<Vec<_>>(),
1012 vec!["a.txt"]
1013 );
1014
1015 index.record("a.txt", None);
1017 assert_eq!(index.paths_with(&hash("two")).count(), 0);
1018 }
1019
1020 #[test]
1021 fn the_index_finds_a_materialized_case_twin() {
1022 let mut index = StoreIndex::default();
1023 index.record("A.txt", Some(hash("upper")));
1024 index.record("a.txt", None);
1025 let twin = index.case_twin("a.txt");
1026 if paths::CASE_INSENSITIVE_FS {
1027 assert_eq!(twin.as_deref(), Some("A.txt"));
1028 assert_eq!(index.case_twin("A.txt"), None, "a.txt is not on disk");
1029 assert_eq!(index.case_twin("other.txt"), None);
1030 } else {
1031 assert_eq!(twin, None, "no case twins on a case-sensitive filesystem");
1032 }
1033 }
1034}