Skip to main content

flint_lean/
sync.rs

1//! The sync verb (v1, HITL — decided §9 Q4), plus the scoped form the
2//! `.flint/sync` sentinel invokes (boundary-verbs plan §2.2, D4).
3//! Harness- or sentinel-invoked, never background.
4//!
5//! Sync BEGINS with a full scan: "locally dirty" means dirty per THAT
6//! scan against the baseline — never per the last barrier's snapshot
7//! (otherwise sync honors a remote delete over the agent's un-scanned
8//! latest work: the review's steady-state destruction finding). Policy:
9//! locally-dirty wins; remote deletions apply only to locally-clean
10//! paths; adds/changes fetch through the store; every skipped apply is
11//! a surfaced conflict, never silent.
12//!
13//! **The scope rule (D4) is a correctness rule, not an optimization.**
14//! A scoped sync advances `inst_base` only for the paths it actually
15//! applied or verified in scope, and leaves `baseline.seq` /
16//! `baseline.manifest_etag` UNTOUCHED. `inst_base` is the three-way
17//! merge base; if a scoped sync advanced the whole merge base to
18//! bucket-current, every out-of-scope foreign change would look
19//! already-integrated to the next merge and would be silently lost from
20//! the inbox flow forever. With D4 those changes remain "foreign" and
21//! flow through the normal merge → inbox → consume path at the next
22//! barrier, untouched from today.
23
24use std::collections::BTreeMap;
25
26use serde::Serialize;
27
28use flint_store::{crc64_nvme, crc64_to_b64, GenerationStamps, PosixStamps, StoreError};
29
30use super::barrier::{mtime_nanos_of, mtime_of, write_file_atomic_in};
31use super::state::{BaselineEntry, ConflictRecord};
32use super::{inbox, manifest, now_unix, scan, LeanError, LeanResult, Syncer};
33
34#[derive(Debug, Default, Serialize)]
35pub struct SyncReport {
36    pub applied: Vec<String>,
37    pub deleted: Vec<String>,
38    pub conflicts: Vec<String>,
39    pub seq: u64,
40    /// Remote changes seen but deferred to the inbox flow because they
41    /// fell outside the requested scope (D4). Zero for a whole-tree
42    /// sync.
43    #[serde(default)]
44    pub out_of_scope_foreign: usize,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub scope: Option<Vec<String>>,
47}
48
49/// A normalized scope: path prefixes and exact paths, matched on
50/// COMPONENT boundaries (`"in"` never matches `internal/`).
51#[derive(Debug, Clone)]
52pub struct Scope {
53    entries: Vec<String>,
54}
55
56impl Scope {
57    pub fn new(raw: &[String]) -> Scope {
58        let mut entries = vec![];
59        for e in raw.iter().take(super::sentinel::MAX_SCOPE_ENTRIES) {
60            if e.len() > super::sentinel::MAX_SCOPE_ENTRY_LEN {
61                continue;
62            }
63            let norm = e.trim_matches('/').replace('\\', "/");
64            if norm.is_empty() || norm.split('/').any(|c| c == ".." || c == ".") {
65                continue;
66            }
67            entries.push(norm);
68        }
69        Scope { entries }
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.entries.is_empty()
74    }
75
76    pub fn entries(&self) -> &[String] {
77        &self.entries
78    }
79
80    /// Component-boundary match: an entry covers itself exactly and
81    /// everything beneath it, never a sibling sharing a name prefix.
82    pub fn covers(&self, path: &str) -> bool {
83        self.entries.iter().any(|e| {
84            path == e.as_str()
85                || path.strip_prefix(e.as_str()).map(|r| r.starts_with('/')).unwrap_or(false)
86        })
87    }
88}
89
90impl Syncer {
91    /// Whole-tree sync — exactly as shipped, including advancing
92    /// `seq`/`manifest_etag`.
93    pub async fn sync(&mut self) -> LeanResult<SyncReport> {
94        self.sync_scoped(None).await
95    }
96
97    pub async fn sync_scoped(&mut self, scope: Option<Vec<String>>) -> LeanResult<SyncReport> {
98        // An all-rejected scope is REFUSED, never widened. `Scope::new`
99        // silently drops entries that are too long, past
100        // MAX_SCOPE_ENTRIES, or contain `.`/`..` — so a caller naming
101        // three malformed paths used to get `None`, and `in_scope`'s
102        // `.unwrap_or(true)` turned that into the WHOLE TREE. A
103        // whole-tree sync re-derives against the remote manifest and
104        // DELETES local files for remotely-deleted paths, so the failure
105        // mode of a typo was maximum privilege. An error must not
106        // return a legal value.
107        let scope = match scope {
108            None => None,
109            Some(raw) => {
110                let s = Scope::new(&raw);
111                if s.is_empty() {
112                    return Err(LeanError::State(format!(
113                        "sync scope named {} entr{} and NONE survived validation — \
114                         refusing, because an empty scope widens to the WHOLE TREE",
115                        raw.len(),
116                        if raw.len() == 1 { "y" } else { "ies" }
117                    )));
118                }
119                Some(s)
120            }
121        };
122        let in_scope = |path: &str| scope.as_ref().map(|s| s.covers(path)).unwrap_or(true);
123        let mut report = SyncReport::default();
124        report.scope = scope.as_ref().map(|s| s.entries().to_vec());
125        let mut baseline = self.state.load_baseline()?;
126
127        // 1. The scan comes FIRST; dirt is judged against it alone.
128        let scanned = scan::scan(&self.cfg.root)?;
129        let classified = scan::classify(&scanned, &baseline);
130        let locally_dirty = |path: &str| {
131            classified.uploads.contains(path)
132                || classified.deletes.contains(path)
133                || classified.first_absence.contains(path)
134        };
135
136        // 2. Remote truth: manifest + inbox overlay (an inbox entry is
137        //    a write the manifest has not re-cited yet).
138        let loaded = manifest::load(self.store.as_ref(), &self.cfg).await?;
139        let (theirs, _metag) = match loaded {
140            Some(l) => (l.manifest, Some(l.etag)),
141            None => (Default::default(), None),
142        };
143        let ib = inbox::load(self.store.as_ref(), &self.cfg).await?;
144        let mut remote: BTreeMap<String, String> =
145            theirs.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
146        // The writer's CRC for each remote etag, overlaid in the same
147        // order as the etags so the two maps always describe the same
148        // object: the manifest's for a cited entry, the inbox entry's
149        // for an overlay (`None` if that writer had none to give).
150        let mut remote_crc: BTreeMap<String, Option<String>> = theirs
151            .entries
152            .iter()
153            .map(|(p, e)| (p.clone(), Some(e.crc64_b64.clone())))
154            .collect();
155        for e in &ib.doc.entries {
156            remote.insert(e.path.clone(), e.etag.clone());
157            remote_crc.insert(e.path.clone(), e.crc64_b64.clone());
158        }
159
160        /// Paths whose `inst_base` this sync is entitled to advance:
161        /// under D4, exactly those it applied or verified in scope.
162        struct Advanced(std::collections::BTreeSet<String>);
163        let mut advanced = Advanced(Default::default());
164
165        // 3. Apply adds/changes (remote differs from OUR merge base).
166        for (path, etag) in &remote {
167            let base = baseline.inst_base.get(path);
168            let unchanged_remotely = base.map(|b| b == etag).unwrap_or(false);
169            if unchanged_remotely {
170                advanced.0.insert(path.clone());
171                continue;
172            }
173            if !in_scope(path) {
174                // D4: NOT integrated, NOT advanced — it stays foreign
175                // and reaches this workspace through the next barrier's
176                // merge → inbox → consume path.
177                report.out_of_scope_foreign += 1;
178                continue;
179            }
180            if baseline.entries.get(path).map(|b| &b.etag == etag).unwrap_or(false) {
181                advanced.0.insert(path.clone()); // already integrated (e.g. our own publish)
182                continue;
183            }
184            if locally_dirty(path) {
185                // The phantom-conflict rule (§2.2): `sync` saves the
186                // baseline only at the end, so a crash mid-apply
187                // followed by a re-honor makes already-applied paths
188                // scan dirty against the stale baseline. Declaring a
189                // conflict there would report a conflict for a path
190                // whose local bytes ARE the remote bytes, and the path
191                // would then re-publish as a spurious generation bump.
192                // Compare content identity first.
193                // The remote entry's crc must come from the object we
194                // would actually apply, not from the manifest: when
195                // remote truth is an INBOX overlay, the manifest entry
196                // is a generation behind and its crc would never match.
197                // One HEAD, only on the dirty-vs-remote path.
198                let remote_meta = match self.store.head(&self.cfg.file_key(path)).await {
199                    Ok(m) if m.etag == *etag => Some(m),
200                    _ => None,
201                };
202                // The remote's CRC: the backend's attestation when it
203                // offers one, else the writer's for this etag — client-
204                // computed, so it exists on a backend that attests none
205                // (Ozone). Without either the bytes cannot be judged
206                // identical, and the path is a conflict as before.
207                let want: Option<String> = remote_meta.as_ref().and_then(|m| {
208                    m.crc64_b64.clone().or_else(|| remote_crc.get(path).cloned().flatten())
209                });
210                let local_path = self.cfg.root.join(path);
211                let local_crc =
212                    std::fs::read(&local_path).ok().map(|b| crc64_to_b64(crc64_nvme(&b)));
213                let identical = want.is_some() && want == local_crc;
214                if identical {
215                    let st = std::fs::metadata(&local_path)?;
216                    let stamps =
217                        remote_meta.as_ref().and_then(|m| GenerationStamps::from_meta(&m.meta));
218                    baseline.entries.insert(
219                        path.clone(),
220                        BaselineEntry {
221                            etag: etag.clone(),
222                            generation: stamps
223                                .map(|s| s.generation)
224                                .or_else(|| theirs.entries.get(path).map(|e| e.generation))
225                                .unwrap_or(0),
226                            size: st.len(),
227                            mtime_unix: mtime_of(&st),
228                            mtime_nanos: Some(mtime_nanos_of(&st)),
229                            crc64_b64: local_crc,
230                        },
231                    );
232                    advanced.0.insert(path.clone());
233                    report.applied.push(path.clone());
234                    continue;
235                }
236                self.state.append_conflict(&ConflictRecord {
237                    path: path.clone(),
238                    foreign_etag: etag.clone(),
239                    preserved_key: None, // remote version stays in the bucket
240                    kind: "sync-dirty".into(),
241                    at_unix: now_unix(),
242                })?;
243                report.conflicts.push(path.clone());
244                continue;
245            }
246            let key = self.cfg.file_key(path);
247            let fetched = self.store.get_whole(&key, Some(etag)).await;
248            let (meta, body) = match fetched {
249                Ok(ok) => ok,
250                Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
251                    continue; // superseded mid-sync; the next sync sees the newer truth
252                }
253                Err(e) => return Err(e.into()),
254            };
255            let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
256            // VERIFIED before it is written, exactly as checkout's fresh
257            // fetch is: against the writer's CRC for this etag (the
258            // manifest's for a cited entry, the gateway's for an inbox
259            // overlay), else the backend's attestation when it offers
260            // one. What the baseline records is OURS, over the bytes
261            // written; the next citation repair cites that.
262            let got = crc64_to_b64(crc64_nvme(&body));
263            let want =
264                remote_crc.get(path).cloned().flatten().or_else(|| meta.crc64_b64.clone());
265            if let Some(want) = want {
266                if want != got {
267                    return Err(LeanError::State(format!(
268                        "sync: {path} (etag {etag}) is cited with CRC-64 {want}, but the bytes \
269                         fetched under that etag hash to {got} — the object is corrupt or the \
270                         store returned the wrong bytes; refusing to apply it (nothing written)"
271                    )));
272                }
273            }
274            if let Err(e) = write_file_atomic_in(&self.cfg.root, path, &body, mode) {
275                // Containment refusal: surfaced, never a wedge.
276                self.state.append_conflict(&ConflictRecord {
277                    path: path.clone(),
278                    foreign_etag: etag.clone(),
279                    preserved_key: None,
280                    kind: format!("sync-refused-containment: {e}"),
281                    at_unix: now_unix(),
282                })?;
283                report.conflicts.push(path.clone());
284                continue;
285            }
286            let local = self.cfg.root.join(path);
287            let st = std::fs::metadata(&local)?;
288            let stamps = GenerationStamps::from_meta(&meta.meta);
289            baseline.entries.insert(
290                path.clone(),
291                BaselineEntry {
292                    etag: meta.etag.clone(),
293                    generation: stamps.map(|s| s.generation).unwrap_or(0),
294                    size: st.len(),
295                    mtime_unix: mtime_of(&st),
296                    mtime_nanos: Some(mtime_nanos_of(&st)),
297                    crc64_b64: Some(got),
298                },
299            );
300            advanced.0.insert(path.clone());
301            report.applied.push(path.clone());
302        }
303
304        // 4. Remote deletions: in our merge base, gone from the
305        //    manifest, and not overlaid by an inbox entry — apply only
306        //    on locally-clean paths.
307        let base_paths: Vec<String> = baseline.inst_base.keys().cloned().collect();
308        for path in base_paths {
309            if remote.contains_key(&path) {
310                continue;
311            }
312            if !in_scope(&path) {
313                report.out_of_scope_foreign += 1;
314                continue;
315            }
316            let local = self.cfg.root.join(&path);
317            if !local.exists() {
318                baseline.entries.remove(&path);
319                advanced.0.insert(path.clone());
320                continue;
321            }
322            if locally_dirty(&path) {
323                self.state.append_conflict(&ConflictRecord {
324                    path: path.clone(),
325                    foreign_etag: String::new(),
326                    preserved_key: None,
327                    kind: "sync-remote-delete-vs-dirty".into(),
328                    at_unix: now_unix(),
329                })?;
330                report.conflicts.push(path.clone());
331                continue;
332            }
333            std::fs::remove_file(&local)?;
334            baseline.entries.remove(&path);
335            advanced.0.insert(path.clone());
336            report.deleted.push(path.clone());
337        }
338
339        // 5. Advance the merge base.
340        //
341        //    Whole-tree: to the manifest we synced against, exactly as
342        //    shipped. Scoped (D4): ONLY for the paths this sync applied
343        //    or verified, and seq/manifest_etag stay put — otherwise
344        //    every out-of-scope foreign change reads as
345        //    already-integrated at the next merge and is lost.
346        let theirs_base: BTreeMap<String, String> =
347            theirs.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
348        //    Except where an inbox OVERLAY hid the manifest's version. This
349        //    sync judged those paths against the overlay's etag, never the
350        //    manifest's, so the base must not claim the manifest's: an
351        //    entry can be OLDER than the manifest (the commit that cited
352        //    past it drops it only after its CAS), and advancing there
353        //    skipped a change the tree never received — for good, since
354        //    nothing later compares against it again (the model's
355        //    LeanBarrierLeaseSyncOverlayStale). Left where it was, the next
356        //    barrier's merge sees the manifest's change as foreign and
357        //    brings it in.
358        let hidden: std::collections::BTreeSet<&String> = ib
359            .doc
360            .entries
361            .iter()
362            .map(|e| &e.path)
363            .filter(|p| theirs_base.get(*p) != remote.get(*p))
364            .collect();
365        match &scope {
366            None => {
367                let before = std::mem::take(&mut baseline.inst_base);
368                baseline.inst_base = theirs_base.clone();
369                for path in &hidden {
370                    match before.get(*path) {
371                        Some(etag) => {
372                            baseline.inst_base.insert((*path).clone(), etag.clone());
373                        }
374                        None => {
375                            baseline.inst_base.remove(*path);
376                        }
377                    }
378                }
379                baseline.seq = theirs.seq;
380                report.seq = theirs.seq;
381            }
382            Some(_) => {
383                for path in &advanced.0 {
384                    if hidden.contains(path) {
385                        continue;
386                    }
387                    match theirs_base.get(path) {
388                        Some(etag) => {
389                            baseline.inst_base.insert(path.clone(), etag.clone());
390                        }
391                        None => {
392                            baseline.inst_base.remove(path);
393                        }
394                    }
395                }
396                report.seq = baseline.seq;
397            }
398        }
399        let rescan = scan::scan(&self.cfg.root)?;
400        baseline.prev_scan = rescan.keys().cloned().collect();
401        // Materialised files before the baseline that vouches for them.
402        self.state.sync_tree()?;
403        self.state.save_baseline(&baseline)?;
404        self.trace("sync", serde_json::json!({"scoped": scope.is_some(), "applied": report.applied.len(),
405            "deleted": report.deleted.len(), "conflicts": report.conflicts.len(), "seq": report.seq,
406            "hidden": hidden.len()}));
407        Ok(report)
408    }
409}