Skip to main content

sapphire_framework_sync/
replica.rs

1//! A replica: one workspace root kept in sync with peers by joining path states.
2
3use 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
25/// Files larger than this are not synced unless configured otherwise.
26pub const DEFAULT_MAX_FILE_SIZE: u64 = 64 * 1024 * 1024;
27
28/// A file whose mtime is this close to when it was checked is re-hashed.
29const RACY_NS: i64 = 2_000_000_000;
30
31/// Where a replica gets bytes it does not have: a peer, in practice.
32pub trait ContentSource {
33    fn fetch(&self, hash: &ContentHash) -> Option<Vec<u8>>;
34}
35
36/// How a replica is set up.
37#[derive(Clone, Debug)]
38pub struct ReplicaConfig {
39    pub app_name: String,
40    pub root: PathBuf,
41    /// Recorded as `Entry::author` on local writes.
42    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    /// A config keeping the store and staging directory under `state_dir`.
50    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
67/// One workspace root and its replica store.
68pub 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    /// Fires once after the next path a scan reconciles. Lets a test change the
76    /// filesystem in the middle of a scan.
77    #[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
102/// Move a verified staged file into place, falling back to copy + rename when the
103/// staging directory is on another volume.
104fn 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    // The file is in place; the staged copy is a cache. Failing to remove it must not
123    // turn a completed placement into an error and leave the state uncommitted.
124    let _ = fs::remove_file(staged);
125    Ok(())
126}
127
128/// Lookups over the stored path states that would otherwise be a full store read per
129/// path: `case_twin` on a case-insensitive filesystem runs for every path of a first
130/// scan, and `read_content` runs for every file materialized from a peer. Built once
131/// per `scan`/`apply`/`fetch_missing` and kept current as states are committed, so it
132/// never goes stale within a call.
133#[derive(Debug, Default)]
134struct StoreIndex {
135    /// `disk.hash` of each stored path that has a file on disk.
136    hash_of: HashMap<String, ContentHash>,
137    /// `disk.hash` -> the stored paths whose file on disk has that hash, in path order.
138    by_hash: HashMap<ContentHash, BTreeSet<String>>,
139    /// Lowercased path -> the stored paths with that lowercase form. Left empty on a
140    /// case-sensitive filesystem, where no path can have a case twin.
141    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    /// Note that `rel` now has `hash` on disk, replacing whatever it had before.
154    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    /// Another stored path, differing from `rel` only in case, whose file is on disk.
180    /// Always `None` on a case-sensitive filesystem.
181    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    /// Stored paths whose file on disk has `hash`, in path order.
190    fn paths_with(&self, hash: &ContentHash) -> impl Iterator<Item = &String> {
191        self.by_hash.get(hash).into_iter().flatten()
192    }
193}
194
195/// Turn a per-path `Error::Io` into a `Skipped` entry and let the caller continue;
196/// propagate every other error so callers still abort on store/format/pause failures.
197fn 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    /// Why this replica must not scan or apply right now. A root or marker that is
261    /// missing while files were written would otherwise read as "everything deleted".
262    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    /// Record every unrecorded edit under the root and finish pending writes.
306    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                // One unreadable directory or Windows-locked file must not abort the
325                // whole scan: report it like any other per-path I/O error and keep
326                // walking, which `walkdir` does for the entry's siblings.
327                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    /// Path states the peer with version vector `peer` has not merged.
371    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    /// Join updates received from a peer, fetching content from `source`.
386    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            // A counter of 0 is not a dot any replica assigns (`next_entry` increments
396            // first), and it has no predecessor for `disk_seen` to pin a loser at.
397            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            // Report a per-path I/O error and still integrate the update: the
411            // on-disk check in `settle` is what keeps this from overwriting an
412            // unrecorded local edit, and every delivered update must still be
413            // joined into the store for `commit_session` to be correct.
414            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    /// Declare that every update of a session with `peer` has been applied.
431    pub fn commit_session(&mut self, peer: &VersionVector) -> Result<()> {
432        self.meta.vv.merge(peer);
433        self.store.commit(&self.meta, &[])
434    }
435
436    /// Retry writes that were waiting for content.
437    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    /// Bytes with `hash` from a file on disk or the staging directory.
453    pub fn read_content(&self, hash: &ContentHash) -> Result<Option<Vec<u8>>> {
454        self.content_with(hash, &StoreIndex::build(&self.store.all()?))
455    }
456
457    /// [`Self::read_content`] against an index the caller already holds.
458    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    /// Compare one path's file with its state: record an edit, or settle the state.
493    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        // On a case-insensitive filesystem, `a.txt` would find the file of `A.txt`.
508        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        // A root that disappears *during* a scan looks exactly like a bulk delete: the
570        // entry guard already passed, so every remaining path would be tombstoned and
571        // the next push would delete them everywhere. Re-check before recording a
572        // delete for a file this replica had materialized. `Error::Paused` is not
573        // `Error::Io`, so `tolerate_io` propagates it and the scan stops here.
574        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    /// Join `update` into the stored state, commit, then settle the file.
600    /// `local_disk` is set when the update is a local write already on disk.
601    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        // Copies first: settling may overwrite the loser's bytes on disk.
632        self.ensure_conflict_copies(&rel, &state, filter, source, index, report)?;
633        self.settle(&rel, state, filter, source, index, report)
634    }
635
636    /// Write a conflict copy for every loser that needs one and has never had one.
637    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(&copy_rel, false) {
662                report.skipped.push(Skipped {
663                    path: copy_rel,
664                    reason: SkipReason::Ignored,
665                });
666                continue;
667            }
668            if !paths::representable(&copy_rel) {
669                report.skipped.push(Skipped {
670                    path: copy_rel,
671                    reason: SkipReason::Unrepresentable,
672                });
673                continue;
674            }
675            if self.store.get(&copy_rel)?.is_some() {
676                continue;
677            }
678            let copy_abs = paths::to_native(&self.config.root, &copy_rel);
679            // Only the loser's own bytes count as the copy already being there. Anything
680            // else at the copy path is an unrelated file, and recording it as the copy
681            // (which the reconcile below does) would lose the loser silently.
682            let holds_the_loser = match fs::symlink_metadata(&copy_abs) {
683                Ok(m) if m.is_file() && ContentHash::of_file(&copy_abs)? == hash => true,
684                // A regular file with different bytes, a directory, or a symlink: not
685                // the loser's bytes, and not something to write over. The loser stays
686                // out of `disk.seen`, so it is not lost.
687                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                // The loser may be exactly what is on disk at `rel` right now. A read
699                // error here must propagate rather than be swallowed as unavailable
700                // content: `settle` would otherwise overwrite the only local copy of
701                // the loser's bytes.
702                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, &copy_abs)?;
727                report.conflicts.push(Conflict {
728                    path: rel.to_owned(),
729                    copy_path: copy_rel.clone(),
730                });
731            }
732            // Record the copy (or a file someone already put there) as a local write.
733            self.reconcile_path(&copy_rel, filter, source, index, report)?;
734        }
735        Ok(())
736    }
737
738    /// Make the file on disk hold the winner, then record what it reflects.
739    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    /// Commit one path state and keep `index` in step with it.
807    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    /// Versions the file on disk reflects once the winner is written: everything
815    /// merged, except losers still waiting for a conflict copy — a local edit must
816    /// not supersede a version whose bytes were never preserved. A loser whose copy
817    /// path the filter rejects, or that the local OS cannot represent, is never
818    /// excluded this way: no copy will ever be attempted for it, so pinning it here
819    /// would keep it out of `seen` forever.
820    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(&copy_rel, false) || !paths::representable(&copy_rel) {
830                continue;
831            }
832            if self.store.get(&copy_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    /// Whether the path on disk at `rel` holds something this replica has not
870    /// recorded: a symlink, a directory, a file over the size cap, or a file whose
871    /// content differs from `disk.hash`. A missing file, or a file matching
872    /// `disk.hash`, is not occupied.
873    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            // A symlink or a directory.
882            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    /// A verified copy of `hash` in the staging directory, if one can be had.
897    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    /// Open with a fixed replica id (used only when the store is created).
932    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    /// Fire `point` the next time it is reached, then clear it.
941    pub fn inject_fault(&mut self, point: crate::testing::FaultPoint) {
942        self.fault = Some(point);
943    }
944
945    /// Run `hook` once after the next path a scan reconciles, then clear it.
946    pub fn inject_after_reconcile(&mut self, hook: Box<dyn FnOnce()>) {
947        self.reconcile_hook = Some(hook);
948    }
949
950    /// Everything that must agree across converged replicas, without timestamps of
951    /// the local filesystem.
952    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        // A deleted file has no bytes on disk any more.
1016        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}