Skip to main content

aven_core/sync/
shared_state.rs

1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use crate::data_safety::export_types::{
5    AvenExport, EXPORT_FORMAT, EXPORT_VERSION, RELATED_LINKS_EXPORT_VERSION,
6    SharedHistoryProvenanceRow,
7};
8use crate::data_safety::{self, tables, validation};
9use crate::db::{self, Database};
10use anyhow::{Context, Result, ensure};
11
12pub mod adoption;
13pub(crate) mod package;
14mod peer_install;
15pub(crate) mod validated;
16
17pub use package::publication as bootstrap_format;
18
19pub use package::{
20    EncryptedLocalSharedStatePackage, LocalSharedStatePackageContext, LocalSharedStatePackageKey,
21};
22
23const LOCAL_CAPTURE_VERSION: i64 = 1;
24
25/// Per-thread work counters, so parallel tests observe only their own passes.
26#[cfg(test)]
27pub(crate) mod counters {
28    use std::cell::Cell;
29
30    thread_local! {
31        static KEYED_PASSES: Cell<u32> = const { Cell::new(0) };
32        static SNAPSHOT_PARSES: Cell<u32> = const { Cell::new(0) };
33    }
34
35    pub(crate) fn keyed_pass() {
36        KEYED_PASSES.with(|c| c.set(c.get() + 1));
37    }
38
39    pub(crate) fn snapshot_parse() {
40        SNAPSHOT_PARSES.with(|c| c.set(c.get() + 1));
41    }
42
43    /// Returns and resets (keyed passes, snapshot parses).
44    pub(crate) fn take() -> (u32, u32) {
45        (
46            KEYED_PASSES.with(|c| c.replace(0)),
47            SNAPSHOT_PARSES.with(|c| c.replace(0)),
48        )
49    }
50}
51
52/// A consistent, installation-ready copy of shared domain state and retained history.
53///
54/// This value has no serialized wire representation; the encrypted package
55/// layer defines the protocol format.
56#[derive(Debug)]
57pub struct SharedStateCapture {
58    snapshot: AvenExport,
59}
60
61/// A durable local capture that has never been made available to a dispatcher.
62///
63/// Cancellation of this type is local-only. Publication code must use a separate
64/// state machine with a server-confirmed cancellation fence.
65#[derive(Debug)]
66pub struct NeverDispatchedLocalSharedCapture {
67    candidate_id: String,
68    stream_id: String,
69    capture: SharedStateCapture,
70    images: Vec<PersistedCaptureImage>,
71    /// SHA-256 of the exact stored snapshot document this value was decoded
72    /// from or encoded as.
73    snapshot_digest: [u8; 32],
74}
75
76impl NeverDispatchedLocalSharedCapture {
77    pub fn candidate_id(&self) -> &str {
78        &self.candidate_id
79    }
80
81    pub fn stream_id(&self) -> &str {
82        &self.stream_id
83    }
84
85    pub fn shared_state(&self) -> &SharedStateCapture {
86        &self.capture
87    }
88}
89
90#[derive(Debug, serde::Serialize, serde::Deserialize)]
91struct PersistedLocalCapture {
92    candidate_id: String,
93    stream_id: String,
94    images: Vec<PersistedCaptureImage>,
95    snapshot: AvenExport,
96}
97
98#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
99struct PersistedCaptureImage {
100    sha256: String,
101    classification: String,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct SharedStateInstallReport {
106    pub prefix_count: u64,
107    pub attachment_count: u64,
108}
109
110impl Database {
111    /// Captures materialized shared state and retained history in one SQLite read boundary.
112    ///
113    /// The source database is not acknowledged, renumbered, or associated with a
114    /// different server. Device-private metadata and attachment availability are
115    /// excluded from the captured value.
116    #[cfg(any(test, feature = "test-support"))]
117    pub async fn capture_shared_state(&self) -> Result<SharedStateCapture> {
118        let mut conn = self.acquire_writer().await?;
119        let mut tx = db::begin_immediate(&mut conn).await?;
120        let schema_version = db::current_schema_version(&mut tx).await?;
121        let tables = data_safety::scan_export_tables(&mut tx).await?;
122        let capture = SharedStateCapture::from_tables(schema_version, tables)?;
123        tx.commit().await?;
124        Ok(capture)
125    }
126
127    /// Creates or resumes the one durable, never-dispatched local capture.
128    ///
129    /// The snapshot, exact history map, stable candidate and stream identities,
130    /// image classification, ownership pins, counter floor, and sync fence commit
131    /// in one immediate transaction. An existing capture is resumed without
132    /// reading current domain rows or attachment bytes again.
133    pub async fn capture_local_shared_state_never_dispatched(
134        &self,
135    ) -> Result<NeverDispatchedLocalSharedCapture> {
136        self.capture_local_shared_state_never_dispatched_inner(None)
137            .await
138    }
139
140    /// Captures setup state while checking that inventory marked available is
141    /// still present on disk. Files missing at this boundary are published as
142    /// unavailable; other filesystem failures remain explicit errors.
143    pub async fn capture_local_shared_state_for_setup(
144        &self,
145        blob_dir: &Path,
146    ) -> Result<NeverDispatchedLocalSharedCapture> {
147        self.capture_local_shared_state_never_dispatched_inner(Some(blob_dir))
148            .await
149    }
150
151    async fn capture_local_shared_state_never_dispatched_inner(
152        &self,
153        blob_dir: Option<&Path>,
154    ) -> Result<NeverDispatchedLocalSharedCapture> {
155        let mut conn = self.acquire_writer().await?;
156        let mut tx = db::begin_immediate(&mut conn).await?;
157        adoption::ensure_no_intent(&mut tx).await?;
158        if let Some(capture) = load_persisted_local_capture(&mut tx).await? {
159            tx.commit().await?;
160            return Ok(capture);
161        }
162
163        let schema_version = db::current_schema_version(&mut tx).await?;
164        let tables = data_safety::scan_export_tables(&mut tx).await?;
165        let source_history = adoption::pack_history(&tables.changes)?;
166        let mut source_provenance = tables.shared_history_provenance.iter().collect::<Vec<_>>();
167        source_provenance.sort_by(|a, b| a.change_id.cmp(&b.change_id));
168        let source_provenance = pack_document(&source_provenance)?;
169        let image_classes = classify_images(&tables, blob_dir)?;
170        let capture = SharedStateCapture::from_tables(schema_version, tables)?;
171        let candidate_id = random_cryptographic_id()?;
172        let stream_id = random_cryptographic_id()?;
173        let created_at = crate::ids::now();
174        let local_seq_floor = capture
175            .snapshot
176            .tables
177            .changes
178            .iter()
179            .map(|row| row.local_seq)
180            .max()
181            .unwrap_or(0);
182        let existing_floor = db::get_meta(&mut tx, "local_seq")
183            .await?
184            .unwrap_or_else(|| "0".to_string())
185            .parse::<i64>()?;
186        db::set_meta(
187            &mut tx,
188            "local_seq",
189            &existing_floor.max(local_seq_floor).to_string(),
190        )
191        .await?;
192        let sync_generation = db::get_meta(&mut tx, "sync_generation")
193            .await?
194            .unwrap_or_else(|| "0".to_string())
195            .parse::<i64>()?
196            .checked_add(1)
197            .context("sync generation overflow")?;
198        db::set_meta(&mut tx, "sync_generation", &sync_generation.to_string()).await?;
199
200        let persisted = PersistedLocalCapture {
201            candidate_id: candidate_id.clone(),
202            stream_id: stream_id.clone(),
203            images: image_classes
204                .into_iter()
205                .map(|(sha256, classification)| PersistedCaptureImage {
206                    sha256,
207                    classification: classification.to_string(),
208                })
209                .collect(),
210            snapshot: capture.snapshot,
211        };
212        let snapshot = pack_document(&persisted)?;
213        let snapshot_digest = crate::sync::codec::hash(&snapshot);
214        sqlx::query(
215            "INSERT INTO local_shared_capture_journal(
216                 singleton, candidate_id, stream_id, internal_version,
217                 local_seq_floor, sync_generation, created_at, source_authority
218             ) VALUES (1, ?, ?, ?, ?, ?, ?,
219                       (SELECT authority FROM local_seed_source WHERE singleton = 1))",
220        )
221        .bind(&candidate_id)
222        .bind(&stream_id)
223        .bind(LOCAL_CAPTURE_VERSION)
224        .bind(local_seq_floor)
225        .bind(sync_generation)
226        .bind(&created_at)
227        .execute(&mut *tx)
228        .await?;
229        sqlx::query(
230            "INSERT INTO local_shared_capture_documents(
231                 candidate_id, snapshot, source_history, source_provenance
232             ) VALUES (?, ?, ?, ?)",
233        )
234        .bind(&candidate_id)
235        .bind(snapshot)
236        .bind(source_history)
237        .bind(source_provenance)
238        .execute(&mut *tx)
239        .await?;
240        let provenance_by_id = persisted
241            .snapshot
242            .tables
243            .shared_history_provenance
244            .iter()
245            .map(|row| (row.change_id.as_str(), row))
246            .collect::<HashMap<_, _>>();
247        let mut rank_rows = Vec::with_capacity(persisted.snapshot.tables.changes.len());
248        for change in &persisted.snapshot.tables.changes {
249            let provenance = provenance_by_id
250                .get(change.change_id.as_str())
251                .context("shared history provenance is missing")?;
252            rank_rows.push((
253                change.change_id.as_str(),
254                change
255                    .server_seq
256                    .context("shared history rank is missing")?,
257                provenance.source_server_seq,
258                provenance.source_pending_rank,
259            ));
260        }
261        // One statement over a JSON array; per-row inserts cost several
262        // times the write itself.
263        sqlx::query(
264            "INSERT INTO local_shared_capture_changes(
265                 candidate_id, change_id, prefix_rank, source_server_seq,
266                 source_pending_rank
267             )
268             SELECT ?, json_extract(value, '$[0]'), json_extract(value, '$[1]'),
269                    json_extract(value, '$[2]'), json_extract(value, '$[3]')
270             FROM json_each(?)",
271        )
272        .bind(&candidate_id)
273        .bind(serde_json::to_string(&rank_rows)?)
274        .execute(&mut *tx)
275        .await?;
276        for image in &persisted.images {
277            sqlx::query(
278                "INSERT INTO local_shared_capture_images(candidate_id, sha256, classification)
279                 VALUES (?, ?, ?)",
280            )
281            .bind(&candidate_id)
282            .bind(&image.sha256)
283            .bind(&image.classification)
284            .execute(&mut *tx)
285            .await?;
286            if image.classification != "unavailable" {
287                sqlx::query(
288                    "INSERT INTO local_shared_capture_pins(candidate_id, sha256) VALUES (?, ?)",
289                )
290                .bind(&candidate_id)
291                .bind(&image.sha256)
292                .execute(&mut *tx)
293                .await?;
294            }
295        }
296        validate_persisted_local_capture(
297            &mut tx,
298            &candidate_id,
299            &persisted.images,
300            &persisted.snapshot,
301        )
302        .await?;
303        tx.commit().await?;
304        Ok(NeverDispatchedLocalSharedCapture {
305            candidate_id,
306            stream_id,
307            capture: SharedStateCapture {
308                snapshot: persisted.snapshot,
309            },
310            images: persisted.images,
311            snapshot_digest,
312        })
313    }
314
315    /// Reopens the durable local capture without consulting newer domain rows.
316    pub async fn resume_local_shared_state_never_dispatched(
317        &self,
318    ) -> Result<Option<NeverDispatchedLocalSharedCapture>> {
319        let mut conn = self.acquire_writer().await?;
320        let mut tx = db::begin_immediate(&mut conn).await?;
321        adoption::ensure_no_intent(&mut tx).await?;
322        let capture = load_persisted_local_capture(&mut tx).await?;
323        tx.commit().await?;
324        Ok(capture)
325    }
326
327    /// Cancels only the given candidate's never-dispatched capture and releases its pins.
328    ///
329    /// Repeating cancellation after success is harmless. A different active
330    /// candidate fails closed. Domain rows and later edits are never removed.
331    pub async fn cancel_local_shared_state_never_dispatched(
332        &self,
333        candidate_id: &str,
334    ) -> Result<bool> {
335        let mut conn = self.acquire_writer().await?;
336        let mut tx = db::begin_immediate(&mut conn).await?;
337        adoption::ensure_no_intent(&mut tx).await?;
338        let active: Option<String> = sqlx::query_scalar(
339            "SELECT candidate_id FROM local_shared_capture_journal WHERE singleton = 1",
340        )
341        .fetch_optional(&mut *tx)
342        .await?;
343        let Some(active) = active else {
344            tx.commit().await?;
345            return Ok(false);
346        };
347        ensure!(
348            active == candidate_id,
349            "error local-shared-capture-candidate-mismatch"
350        );
351        let deleted = sqlx::query(
352            "DELETE FROM local_shared_capture_journal WHERE singleton = 1 AND candidate_id = ?",
353        )
354        .bind(candidate_id)
355        .execute(&mut *tx)
356        .await?
357        .rows_affected();
358        tx.commit().await?;
359        Ok(deleted == 1)
360    }
361
362    /// Atomically installs captured shared state into a fresh database.
363    ///
364    /// The target keeps its own client identity and local settings. Retained
365    /// history receives dense effective prefix ranks, while source ordering
366    /// provenance remains separate. Attachment metadata is installed without
367    /// claiming that image bytes are locally available.
368    pub async fn install_shared_state(
369        &self,
370        capture: &SharedStateCapture,
371    ) -> Result<SharedStateInstallReport> {
372        capture.validate()?;
373        let _installation = self.plaintext_installation_guard()?;
374        let mut conn = self.acquire_writer().await?;
375        let mut tx = db::begin_immediate(&mut conn).await?;
376        ensure_empty_target(&mut tx).await?;
377
378        let report = install_in_transaction(&mut tx, capture).await?;
379        tx.commit().await?;
380
381        Ok(report)
382    }
383}
384
385async fn install_in_transaction(
386    conn: &mut sqlx::SqliteConnection,
387    capture: &SharedStateCapture,
388) -> Result<SharedStateInstallReport> {
389    let identity = db::get_meta(conn, "client_id")
390        .await?
391        .context("missing target client identity")?;
392    ensure!(
393        !capture
394            .snapshot
395            .tables
396            .changes
397            .iter()
398            .any(|row| row.client_id == identity),
399        "error shared-state-install target identity is not distinct"
400    );
401
402    sqlx::query("DELETE FROM workspaces")
403        .execute(&mut *conn)
404        .await?;
405    let t = &capture.snapshot.tables;
406    tables::import_workspaces(conn, &t.workspaces).await?;
407    tables::import_projects(conn, &t.projects).await?;
408    tables::import_project_id_aliases(conn, &t.project_id_aliases).await?;
409    tables::import_labels(conn, &t.labels).await?;
410    tables::import_metadata_fields(conn, &t.metadata_fields).await?;
411    tables::import_metadata_field_id_aliases(conn, &t.metadata_field_id_aliases).await?;
412    tables::import_tasks(conn, &t.tasks).await?;
413    tables::import_task_metadata(conn, &t.task_metadata).await?;
414    tables::import_task_labels(conn, &t.task_labels).await?;
415    tables::import_notes(conn, &t.notes).await?;
416    tables::import_task_dependencies(conn, &t.task_dependencies).await?;
417    tables::import_task_epic_links(conn, &t.task_epic_links).await?;
418    tables::import_blob_inventory(conn, &t.blob_inventory).await?;
419    tables::import_task_attachments(conn, &t.task_attachments).await?;
420    tables::import_recurrence_series(conn, &t.recurrence_series).await?;
421    tables::import_recurrence_series_labels(conn, &t.recurrence_series_labels).await?;
422    tables::import_recurrence_series_metadata(conn, &t.recurrence_series_metadata).await?;
423    tables::import_recurrence_occurrences(conn, &t.recurrence_occurrences).await?;
424    tables::import_recurrence_pause_intervals(conn, &t.recurrence_pause_intervals).await?;
425    tables::import_changes(conn, &t.changes).await?;
426    tables::import_task_related_links(conn, &t.task_related_links).await?;
427    tables::import_field_versions(conn, &t.field_versions).await?;
428    tables::import_conflicts(conn, &t.conflicts).await?;
429    tables::import_shared_history_provenance(conn, &t.shared_history_provenance).await?;
430    for row in &t.meta {
431        db::set_meta(conn, &row.key, &row.value).await?;
432    }
433    let local_seq = t.changes.iter().map(|row| row.local_seq).max().unwrap_or(0);
434    db::set_meta(conn, "local_seq", &local_seq.to_string()).await?;
435    crate::sync::apply::rebuild_task_placements(conn).await?;
436    data_safety::ensure_integrity_ok(
437        &crate::data_safety::integrity_report_in_transaction(conn).await?,
438    )?;
439    Ok(SharedStateInstallReport {
440        prefix_count: u64::try_from(t.changes.len())?,
441        attachment_count: u64::try_from(t.task_attachments.len())?,
442    })
443}
444
445impl SharedStateCapture {
446    fn from_tables(
447        schema_version: i64,
448        mut tables: crate::data_safety::export_types::ExportTables,
449    ) -> Result<Self> {
450        let stored = std::mem::take(&mut tables.shared_history_provenance)
451            .into_iter()
452            .map(|row| (row.change_id.clone(), row))
453            .collect::<HashMap<_, _>>();
454
455        tables.project_paths.clear();
456        tables
457            .meta
458            .retain(|row| row.key.starts_with("epic_membership_baseline:"));
459        for blob in &mut tables.blob_inventory {
460            blob.available = 0;
461            blob.last_verified_at = None;
462        }
463
464        let mut accepted = tables
465            .changes
466            .iter()
467            .enumerate()
468            .filter_map(|(index, row)| row.server_seq.map(|seq| (seq, index)))
469            .collect::<Vec<_>>();
470        accepted.sort_by_key(|(seq, _)| *seq);
471        let mut pending = tables
472            .changes
473            .iter()
474            .enumerate()
475            .filter(|(_, row)| row.server_seq.is_none())
476            .map(|(index, row)| {
477                (
478                    row.local_seq,
479                    row.created_at.clone(),
480                    row.change_id.clone(),
481                    index,
482                )
483            })
484            .collect::<Vec<_>>();
485        pending.sort();
486
487        let pending_ranks = pending
488            .iter()
489            .enumerate()
490            .map(|(rank, (_, _, _, index))| (*index, rank + 1))
491            .collect::<HashMap<_, _>>();
492        let order = accepted
493            .iter()
494            .map(|(_, index)| *index)
495            .chain(pending.iter().map(|(_, _, _, index)| *index));
496        let mut provenance = Vec::with_capacity(tables.changes.len());
497        for (position, index) in order.enumerate() {
498            let row = &mut tables.changes[index];
499            let source = if let Some(stored) = stored.get(&row.change_id) {
500                SharedHistoryProvenanceRow {
501                    change_id: row.change_id.clone(),
502                    source_server_seq: stored.source_server_seq,
503                    source_pending_rank: stored.source_pending_rank,
504                }
505            } else if let Some(source_server_seq) = row.server_seq {
506                SharedHistoryProvenanceRow {
507                    change_id: row.change_id.clone(),
508                    source_server_seq: Some(source_server_seq),
509                    source_pending_rank: None,
510                }
511            } else {
512                let pending_rank = pending_ranks
513                    .get(&index)
514                    .context("pending history order is incomplete")?;
515                SharedHistoryProvenanceRow {
516                    change_id: row.change_id.clone(),
517                    source_server_seq: None,
518                    source_pending_rank: Some(i64::try_from(*pending_rank)?),
519                }
520            };
521            row.server_seq = Some(i64::try_from(position + 1)?);
522            provenance.push(source);
523        }
524
525        tables.shared_history_provenance = provenance;
526        let version = if !tables.shared_history_provenance.is_empty() {
527            EXPORT_VERSION
528        } else if tables.task_related_links.is_empty() {
529            2
530        } else {
531            RELATED_LINKS_EXPORT_VERSION
532        };
533        let capture = Self {
534            snapshot: AvenExport {
535                format: EXPORT_FORMAT.to_string(),
536                version,
537                exported_at: crate::ids::now(),
538                schema_version,
539                blobs_included: false,
540                tables,
541            },
542        };
543        capture.validate()?;
544        Ok(capture)
545    }
546
547    fn validate(&self) -> Result<()> {
548        validation::validate_shared_snapshot(&self.snapshot)?;
549        ensure!(
550            self.snapshot.tables.project_paths.is_empty(),
551            "error invalid-shared-state project paths are device-private"
552        );
553        ensure!(
554            self.snapshot
555                .tables
556                .meta
557                .iter()
558                .all(|row| row.key.starts_with("epic_membership_baseline:")),
559            "error invalid-shared-state private metadata is present"
560        );
561        let change_ids = self
562            .snapshot
563            .tables
564            .changes
565            .iter()
566            .map(|row| row.change_id.as_str())
567            .collect::<HashSet<_>>();
568        let provenance_ids = self
569            .snapshot
570            .tables
571            .shared_history_provenance
572            .iter()
573            .map(|row| row.change_id.as_str())
574            .collect::<HashSet<_>>();
575        ensure!(
576            change_ids.len() == self.snapshot.tables.changes.len()
577                && provenance_ids.len() == self.snapshot.tables.shared_history_provenance.len()
578                && change_ids == provenance_ids,
579            "error invalid-shared-state history provenance does not match retained history"
580        );
581        for row in &self.snapshot.tables.shared_history_provenance {
582            ensure!(
583                matches!(
584                    (row.source_server_seq, row.source_pending_rank),
585                    (Some(1..), None) | (None, Some(1..))
586                ),
587                "error invalid-shared-state history provenance is invalid"
588            );
589        }
590        let mut ranks = self
591            .snapshot
592            .tables
593            .changes
594            .iter()
595            .map(|row| row.server_seq.context("shared history rank is missing"))
596            .collect::<Result<Vec<_>>>()?;
597        ranks.sort_unstable();
598        let expected_ranks = (1..=i64::try_from(ranks.len())?).collect::<Vec<_>>();
599        ensure!(
600            ranks == expected_ranks,
601            "error invalid-shared-state history ranks are not dense"
602        );
603        Ok(())
604    }
605}
606
607pub(crate) async fn ensure_no_active_local_shared_capture(
608    conn: &mut sqlx::SqliteConnection,
609) -> Result<()> {
610    adoption::ensure_unbound(conn).await?;
611    let active: bool = sqlx::query_scalar(
612        "SELECT EXISTS(SELECT 1 FROM local_shared_capture_journal WHERE singleton = 1)",
613    )
614    .fetch_one(&mut *conn)
615    .await?;
616    ensure!(
617        !active,
618        "error local-shared-capture-active hint=cancel-never-dispatched-capture-first"
619    );
620    Ok(())
621}
622
623pub(crate) async fn ensure_changes_not_local_capture_protected(
624    conn: &mut sqlx::SqliteConnection,
625    change_ids: &[&str],
626) -> Result<()> {
627    if change_ids.is_empty() {
628        return Ok(());
629    }
630    let protected: bool = sqlx::query_scalar(
631        "SELECT EXISTS(
632             SELECT 1 FROM local_shared_capture_changes
633             WHERE change_id IN (SELECT value FROM json_each(?))
634         )",
635    )
636    .bind(serde_json::to_string(change_ids)?)
637    .fetch_one(&mut *conn)
638    .await?;
639    ensure!(
640        !protected,
641        "error local-shared-capture-protected-history hint=cancel-never-dispatched-capture-first"
642    );
643    Ok(())
644}
645
646/// Frame magic of the zstd documents a capture stores. Plain JSON text, which
647/// older captures stored, never starts with it.
648const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
649/// Upper bound on one decompressed capture document.
650const DOCUMENT_LIMIT: u64 = 1 << 30;
651
652/// Serializes a capture document straight into a zstd frame, without
653/// materializing its text. A counting pass first gives the frame its declared
654/// size, which lets readers allocate the text once.
655pub(super) fn pack_document<T: serde::Serialize + ?Sized>(value: &T) -> Result<Vec<u8>> {
656    struct Count(u64);
657    impl std::io::Write for Count {
658        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
659            self.0 += bytes.len() as u64;
660            Ok(bytes.len())
661        }
662        fn flush(&mut self) -> std::io::Result<()> {
663            Ok(())
664        }
665    }
666    let mut count = Count(0);
667    serde_json::to_writer(&mut count, value)?;
668    let mut encoder = zstd::stream::write::Encoder::new(Vec::new(), 1)?;
669    encoder.set_pledged_src_size(Some(count.0))?;
670    encoder.include_contentsize(true)?;
671    let mut writer = std::io::BufWriter::with_capacity(1 << 16, encoder);
672    serde_json::to_writer(&mut writer, value)?;
673    let encoder = writer.into_inner().map_err(|error| error.into_error())?;
674    Ok(encoder.finish()?)
675}
676
677/// The JSON text of a stored capture document, compressed or not.
678pub(super) fn unpack_document(stored: &[u8]) -> Result<String> {
679    if !stored.starts_with(&ZSTD_MAGIC) {
680        return Ok(std::str::from_utf8(stored)?.to_owned());
681    }
682    // Frames written by `pack_document` declare their size, so the text is
683    // allocated once. A larger actual size fails rather than growing.
684    if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(stored) {
685        ensure!(
686            size <= DOCUMENT_LIMIT,
687            "error local-shared-capture-document-too-large"
688        );
689        let text = zstd::bulk::decompress(stored, usize::try_from(size)?)?;
690        return Ok(String::from_utf8(text)?);
691    }
692    let mut text = Vec::new();
693    std::io::Read::read_to_end(
694        &mut std::io::Read::take(
695            zstd::stream::read::Decoder::new(stored)?,
696            DOCUMENT_LIMIT + 1,
697        ),
698        &mut text,
699    )?;
700    ensure!(
701        text.len() as u64 <= DOCUMENT_LIMIT,
702        "error local-shared-capture-document-too-large"
703    );
704    Ok(String::from_utf8(text)?)
705}
706
707async fn load_persisted_local_capture(
708    conn: &mut sqlx::SqliteConnection,
709) -> Result<Option<NeverDispatchedLocalSharedCapture>> {
710    let row: Option<(String, String, i64)> = sqlx::query_as(
711        "SELECT candidate_id, stream_id, internal_version
712         FROM local_shared_capture_journal WHERE singleton = 1",
713    )
714    .fetch_optional(&mut *conn)
715    .await?;
716    let Some((candidate_id, stream_id, internal_version)) = row else {
717        return Ok(None);
718    };
719    ensure!(
720        internal_version == LOCAL_CAPTURE_VERSION,
721        "error local-shared-capture-unsupported"
722    );
723    let snapshot: Vec<u8> = sqlx::query_scalar(
724        "SELECT snapshot FROM local_shared_capture_documents WHERE candidate_id = ?",
725    )
726    .bind(&candidate_id)
727    .fetch_one(&mut *conn)
728    .await?;
729    #[cfg(test)]
730    counters::snapshot_parse();
731    let persisted: PersistedLocalCapture = serde_json::from_str(
732        &unpack_document(&snapshot).context("error local-shared-capture-malformed")?,
733    )
734    .context("error local-shared-capture-malformed")?;
735    ensure!(
736        persisted.candidate_id == candidate_id && persisted.stream_id == stream_id,
737        "error local-shared-capture-encoding-mismatch"
738    );
739    let capture = SharedStateCapture {
740        snapshot: persisted.snapshot,
741    };
742    capture.validate()?;
743    validate_persisted_local_capture(conn, &candidate_id, &persisted.images, &capture.snapshot)
744        .await?;
745    Ok(Some(NeverDispatchedLocalSharedCapture {
746        candidate_id,
747        stream_id,
748        capture,
749        images: persisted.images,
750        snapshot_digest: crate::sync::codec::hash(&snapshot),
751    }))
752}
753
754/// One captured change: ID, prefix rank, source server sequence and source
755/// pending rank.
756pub(super) type RankRow<'a> = (std::borrow::Cow<'a, str>, i64, Option<i64>, Option<i64>);
757
758/// The capture's rank table as JSON text, parsed with [`RankRow`]. One array
759/// for the whole table: decoding thousands of rows one by one through the
760/// driver costs several times the query itself.
761pub(super) async fn rank_rows_json(
762    conn: &mut sqlx::SqliteConnection,
763    candidate_id: &str,
764) -> Result<String> {
765    Ok(sqlx::query_scalar(
766        "SELECT json_group_array(json_array(
767                    change_id, prefix_rank, source_server_seq, source_pending_rank))
768         FROM local_shared_capture_changes WHERE candidate_id = ?",
769    )
770    .bind(candidate_id)
771    .fetch_one(&mut *conn)
772    .await?)
773}
774
775async fn validate_persisted_local_capture(
776    conn: &mut sqlx::SqliteConnection,
777    candidate_id: &str,
778    persisted_images: &[PersistedCaptureImage],
779    snapshot: &AvenExport,
780) -> Result<()> {
781    let stored = rank_rows_json(conn, candidate_id).await?;
782    let mut stored: Vec<RankRow<'_>> = serde_json::from_str(&stored)?;
783    stored.sort_by_key(|row| row.1);
784    let provenance = snapshot
785        .tables
786        .shared_history_provenance
787        .iter()
788        .map(|row| (row.change_id.as_str(), row))
789        .collect::<HashMap<_, _>>();
790    let mut expected = snapshot
791        .tables
792        .changes
793        .iter()
794        .map(|change| {
795            let source = provenance
796                .get(change.change_id.as_str())
797                .context("shared history provenance is missing")?;
798            Ok((
799                std::borrow::Cow::Borrowed(change.change_id.as_str()),
800                change
801                    .server_seq
802                    .context("shared history rank is missing")?,
803                source.source_server_seq,
804                source.source_pending_rank,
805            ))
806        })
807        .collect::<Result<Vec<_>>>()?;
808    expected.sort_by_key(|row| row.1);
809    ensure!(
810        stored == expected,
811        "error local-shared-capture-history-mismatch"
812    );
813
814    let invalid_ownership: bool = sqlx::query_scalar(
815        "SELECT EXISTS(
816             SELECT 1 FROM local_shared_capture_images image
817             LEFT JOIN local_shared_capture_pins pin
818               ON pin.candidate_id = image.candidate_id AND pin.sha256 = image.sha256
819             WHERE image.candidate_id = ? AND (
820                 (image.classification = 'unavailable' AND pin.sha256 IS NOT NULL)
821                 OR (image.classification != 'unavailable' AND pin.sha256 IS NULL)
822             )
823         )",
824    )
825    .bind(candidate_id)
826    .fetch_one(&mut *conn)
827    .await?;
828    ensure!(
829        !invalid_ownership,
830        "error local-shared-capture-image-ownership-mismatch"
831    );
832    let stored_images: Vec<(String, String)> = sqlx::query_as(
833        "SELECT sha256, classification FROM local_shared_capture_images
834         WHERE candidate_id = ? ORDER BY sha256",
835    )
836    .bind(candidate_id)
837    .fetch_all(&mut *conn)
838    .await?;
839    let mut encoded_images = persisted_images
840        .iter()
841        .map(|image| (image.sha256.clone(), image.classification.clone()))
842        .collect::<Vec<_>>();
843    encoded_images.sort();
844    let inventory_hashes = snapshot
845        .tables
846        .blob_inventory
847        .iter()
848        .map(|row| row.sha256.as_str())
849        .collect::<HashSet<_>>();
850    ensure!(
851        stored_images == encoded_images
852            && encoded_images.len() == inventory_hashes.len()
853            && encoded_images
854                .iter()
855                .all(|(sha256, _)| inventory_hashes.contains(sha256.as_str())),
856        "error local-shared-capture-image-set-mismatch"
857    );
858    Ok(())
859}
860
861/// Classifies captured image metadata. Selected bytes are validated once, by
862/// packaging, before anything is frozen; pins protect them until then.
863fn classify_images(
864    tables: &crate::data_safety::export_types::ExportTables,
865    blob_dir: Option<&Path>,
866) -> Result<Vec<(String, &'static str)>> {
867    let deleted_tasks = tables
868        .tasks
869        .iter()
870        .map(|task| (task.id.as_str(), task.deleted != 0))
871        .collect::<HashMap<_, _>>();
872    let current_hashes = tables
873        .task_attachments
874        .iter()
875        .filter(|attachment| {
876            attachment.deleted == 0
877                && !deleted_tasks
878                    .get(attachment.task_id.as_str())
879                    .copied()
880                    .unwrap_or(true)
881        })
882        .map(|attachment| attachment.sha256.as_str())
883        .collect::<HashSet<_>>();
884    let mut result = Vec::with_capacity(tables.blob_inventory.len());
885    for inventory in &tables.blob_inventory {
886        let missing_on_disk = if let Some(blob_dir) = blob_dir {
887            let path = crate::attachments::storage::object_path(blob_dir, &inventory.sha256)?;
888            match std::fs::metadata(path) {
889                Ok(metadata) => !metadata.is_file(),
890                Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
891                Err(error) => {
892                    let attachment = tables
893                        .task_attachments
894                        .iter()
895                        .find(|attachment| attachment.sha256 == inventory.sha256);
896                    let task = attachment.and_then(|attachment| {
897                        tables.tasks.iter().find(|task| {
898                            task.id == attachment.task_id
899                                && task.workspace_id == attachment.workspace_id
900                        })
901                    });
902                    anyhow::bail!(
903                        "error local-shared-capture-image-file task={:?} attachment={:?}: {}",
904                        task.map(|task| task.title.as_str())
905                            .unwrap_or("unknown task"),
906                        attachment
907                            .and_then(|attachment| attachment.filename.as_deref())
908                            .unwrap_or("unknown attachment"),
909                        error
910                    );
911                }
912            }
913        } else {
914            false
915        };
916        ensure!(
917            matches!(inventory.available, 0 | 1),
918            "error local-shared-capture-image-availability-invalid"
919        );
920        let unavailable = if blob_dir.is_some() {
921            missing_on_disk
922        } else {
923            inventory.available == 0
924        };
925        if unavailable {
926            result.push((inventory.sha256.clone(), "unavailable"));
927            continue;
928        }
929        result.push((
930            inventory.sha256.clone(),
931            if current_hashes.contains(inventory.sha256.as_str()) {
932                "current_selected"
933            } else {
934                "extra_selected"
935            },
936        ));
937    }
938    Ok(result)
939}
940
941fn random_cryptographic_id() -> Result<String> {
942    let mut bytes = [0_u8; 32];
943    getrandom::fill(&mut bytes).context("error local-shared-capture-rng")?;
944    Ok(hex::encode(bytes))
945}
946
947pub(crate) async fn ensure_empty_target(conn: &mut sqlx::SqliteConnection) -> Result<()> {
948    adoption::ensure_unbound(conn).await?;
949    ensure_empty_domain(conn).await
950}
951
952pub(crate) async fn ensure_empty_domain(conn: &mut sqlx::SqliteConnection) -> Result<()> {
953    let occupied: i64 = sqlx::query_scalar(
954        "SELECT
955             (SELECT count(*) FROM workspaces WHERE id != '0000000000000000')
956           + (SELECT count(*) FROM projects)
957           + (SELECT count(*) FROM project_paths)
958           + (SELECT count(*) FROM project_id_aliases)
959           + (SELECT count(*) FROM labels)
960           + (SELECT count(*) FROM metadata_fields)
961           + (SELECT count(*) FROM metadata_field_id_aliases)
962           + (SELECT count(*) FROM tasks)
963           + (SELECT count(*) FROM task_metadata)
964           + (SELECT count(*) FROM task_labels)
965           + (SELECT count(*) FROM notes)
966           + (SELECT count(*) FROM task_dependencies)
967           + (SELECT count(*) FROM task_epic_links)
968           + (SELECT count(*) FROM task_related_links)
969           + (SELECT count(*) FROM task_attachments)
970           + (SELECT count(*) FROM blob_inventory)
971           + (SELECT count(*) FROM recurrence_series)
972           + (SELECT count(*) FROM recurrence_series_labels)
973           + (SELECT count(*) FROM recurrence_series_metadata)
974           + (SELECT count(*) FROM recurrence_occurrences)
975           + (SELECT count(*) FROM recurrence_pause_intervals)
976           + (SELECT count(*) FROM changes)
977           + (SELECT count(*) FROM field_versions)
978           + (SELECT count(*) FROM conflicts)
979           + (SELECT count(*) FROM shared_history_provenance)
980           + (SELECT count(*) FROM local_shared_capture_journal)
981           + (SELECT count(*) FROM local_e2ee_dependency_baseline)
982           + (SELECT count(*) FROM local_e2ee_dependency_edges)
983           + (SELECT count(*) FROM local_e2ee_epic_edges)",
984    )
985    .fetch_one(conn)
986    .await?;
987    ensure!(
988        occupied == 0,
989        "error shared-state-install target database is not empty"
990    );
991    Ok(())
992}
993
994#[cfg(test)]
995#[path = "shared_state/tests.rs"]
996mod tests;