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_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                            version_id: None,
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            // D13: under a gated citation this reader resolves the
248            // CITED version, never the current one — the same rule
249            // checkout follows, for the same reason. An inbox-overlaid
250            // path has no cited version yet and takes the etag path.
251            let pinned_version = if theirs.pinned_reads {
252                theirs
253                    .entries
254                    .get(path)
255                    .filter(|e| &e.etag == etag)
256                    .and_then(|e| e.version_id.clone())
257            } else {
258                None
259            };
260            let fetched = match &pinned_version {
261                Some(vid) => self.store.get_version(&key, vid).await,
262                None => self.store.get_whole(&key, Some(etag)).await,
263            };
264            let (meta, body) = match fetched {
265                Ok(ok) => ok,
266                Err(StoreError::PreconditionFailed(_)) | Err(StoreError::NotFound(_)) => {
267                    continue; // superseded mid-sync; the next sync sees the newer truth
268                }
269                Err(e) => return Err(e.into()),
270            };
271            let mode = PosixStamps::from_meta(&meta.meta).map(|p| p.mode);
272            // VERIFIED before it is written, exactly as checkout's fresh
273            // fetch is: against the writer's CRC for this etag (the
274            // manifest's for a cited entry, the gateway's for an inbox
275            // overlay), else the backend's attestation when it offers
276            // one. What the baseline records is OURS, over the bytes
277            // written; the next citation repair cites that.
278            let got = crc64_to_b64(crc64_nvme(&body));
279            let want =
280                remote_crc.get(path).cloned().flatten().or_else(|| meta.crc64_b64.clone());
281            if let Some(want) = want {
282                if want != got {
283                    return Err(LeanError::State(format!(
284                        "sync: {path} (etag {etag}) is cited with CRC-64 {want}, but the bytes \
285                         fetched under that etag hash to {got} — the object is corrupt or the \
286                         store returned the wrong bytes; refusing to apply it (nothing written)"
287                    )));
288                }
289            }
290            if let Err(e) = write_file_atomic_in(&self.cfg.root, path, &body, mode) {
291                // Containment refusal: surfaced, never a wedge.
292                self.state.append_conflict(&ConflictRecord {
293                    path: path.clone(),
294                    foreign_etag: etag.clone(),
295                    preserved_key: None,
296                    kind: format!("sync-refused-containment: {e}"),
297                    at_unix: now_unix(),
298                })?;
299                report.conflicts.push(path.clone());
300                continue;
301            }
302            let local = self.cfg.root.join(path);
303            let st = std::fs::metadata(&local)?;
304            let stamps = GenerationStamps::from_meta(&meta.meta);
305            baseline.entries.insert(
306                path.clone(),
307                BaselineEntry {
308                    etag: meta.etag.clone(),
309                    generation: stamps.map(|s| s.generation).unwrap_or(0),
310                    size: st.len(),
311                    mtime_unix: mtime_of(&st),
312                    version_id: None,
313                    crc64_b64: Some(got),
314                },
315            );
316            advanced.0.insert(path.clone());
317            report.applied.push(path.clone());
318        }
319
320        // 4. Remote deletions: in our merge base, gone from the
321        //    manifest, and not overlaid by an inbox entry — apply only
322        //    on locally-clean paths.
323        let base_paths: Vec<String> = baseline.inst_base.keys().cloned().collect();
324        for path in base_paths {
325            if remote.contains_key(&path) {
326                continue;
327            }
328            if !in_scope(&path) {
329                report.out_of_scope_foreign += 1;
330                continue;
331            }
332            let local = self.cfg.root.join(&path);
333            if !local.exists() {
334                baseline.entries.remove(&path);
335                advanced.0.insert(path.clone());
336                continue;
337            }
338            if locally_dirty(&path) {
339                self.state.append_conflict(&ConflictRecord {
340                    path: path.clone(),
341                    foreign_etag: String::new(),
342                    preserved_key: None,
343                    kind: "sync-remote-delete-vs-dirty".into(),
344                    at_unix: now_unix(),
345                })?;
346                report.conflicts.push(path.clone());
347                continue;
348            }
349            std::fs::remove_file(&local)?;
350            baseline.entries.remove(&path);
351            advanced.0.insert(path.clone());
352            report.deleted.push(path.clone());
353        }
354
355        // 5. Advance the merge base.
356        //
357        //    Whole-tree: to the manifest we synced against, exactly as
358        //    shipped. Scoped (D4): ONLY for the paths this sync applied
359        //    or verified, and seq/manifest_etag stay put — otherwise
360        //    every out-of-scope foreign change reads as
361        //    already-integrated at the next merge and is lost.
362        let theirs_base: BTreeMap<String, String> =
363            theirs.entries.iter().map(|(p, e)| (p.clone(), e.etag.clone())).collect();
364        match &scope {
365            None => {
366                baseline.seq = theirs.seq;
367                baseline.inst_base = theirs_base;
368                report.seq = theirs.seq;
369            }
370            Some(_) => {
371                for path in &advanced.0 {
372                    match theirs_base.get(path) {
373                        Some(etag) => {
374                            baseline.inst_base.insert(path.clone(), etag.clone());
375                        }
376                        None => {
377                            baseline.inst_base.remove(path);
378                        }
379                    }
380                }
381                report.seq = baseline.seq;
382            }
383        }
384        let rescan = scan::scan(&self.cfg.root)?;
385        baseline.prev_scan = rescan.keys().cloned().collect();
386        // Materialised files before the baseline that vouches for them.
387        self.state.sync_tree()?;
388        self.state.save_baseline(&baseline)?;
389        Ok(report)
390    }
391}