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}