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#[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 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#[derive(Debug)]
57pub struct SharedStateCapture {
58 snapshot: AvenExport,
59}
60
61#[derive(Debug)]
66pub struct NeverDispatchedLocalSharedCapture {
67 candidate_id: String,
68 stream_id: String,
69 capture: SharedStateCapture,
70 images: Vec<PersistedCaptureImage>,
71 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 #[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 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 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 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 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 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 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
646const ZSTD_MAGIC: [u8; 4] = [0x28, 0xb5, 0x2f, 0xfd];
649const DOCUMENT_LIMIT: u64 = 1 << 30;
651
652pub(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
677pub(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 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
754pub(super) type RankRow<'a> = (std::borrow::Cow<'a, str>, i64, Option<i64>, Option<i64>);
757
758pub(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
861fn 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;