1use super::super::{Accepted, Authority, Downloads, codec, domain::Projection, hash, valid};
2use super::codec::{Descriptor, DescriptorAuthority};
3use crate::{
4 db::{self, Database, begin_immediate},
5 sync::{bootstrap_format, wire::ChangeWire},
6};
7use anyhow::{Context as _, Result, ensure};
8use sqlx::SqliteConnection;
9use std::path::Path;
10#[cfg(any(test, feature = "test-support"))]
11use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
12#[cfg(any(test, feature = "test-support"))]
13use std::time::Instant;
14
15#[cfg(any(test, feature = "test-support"))]
16static INTEGRITY_SCAN_CALLS: AtomicU64 = AtomicU64::new(0);
17#[cfg(any(test, feature = "test-support"))]
18static INTEGRITY_SCAN_NANOS: AtomicU64 = AtomicU64::new(0);
19
20#[cfg(any(test, feature = "test-support"))]
21pub(crate) fn integrity_scan_metrics() -> (u64, u64) {
22 (
23 INTEGRITY_SCAN_CALLS.load(Relaxed),
24 INTEGRITY_SCAN_NANOS.load(Relaxed),
25 )
26}
27
28#[cfg(any(test, feature = "test-support"))]
29fn record_integrity_scan(start: Instant) {
30 INTEGRITY_SCAN_CALLS.fetch_add(1, Relaxed);
31 INTEGRITY_SCAN_NANOS.fetch_add(
32 u64::try_from(start.elapsed().as_nanos()).unwrap_or(u64::MAX),
33 Relaxed,
34 );
35}
36
37const DOWNLOAD_CURSOR: &str = "e2ee_image_download_after";
38const DOWNLOAD_CANDIDATES: &str = "
39 FROM local_e2ee_image_references r
40 JOIN local_e2ee_image_objects o ON o.object=r.object
41 JOIN task_attachments a ON a.workspace_id=r.workspace AND a.attachment_id=r.reference
42 JOIN tasks t ON t.workspace_id=a.workspace_id AND t.id=a.task_id
43 WHERE a.deleted=0 AND t.deleted=0
44 AND (o.verified=0 OR NOT EXISTS(
45 SELECT 1 FROM blob_inventory b WHERE b.sha256=o.sha256 AND b.available=1))";
46const DOWNLOAD_ACTIVE_REFERENCE: &str = "
47 FROM local_e2ee_image_references r
48 JOIN task_attachments a ON a.workspace_id=r.workspace AND a.attachment_id=r.reference
49 JOIN tasks t ON t.workspace_id=a.workspace_id AND t.id=a.task_id
50 WHERE r.object=o.object AND a.deleted=0 AND t.deleted=0";
51
52async fn select_download_candidate(
53 conn: &mut SqliteConnection,
54 after: Option<&[u8]>,
55) -> Result<Option<(String, Vec<u8>, Vec<u8>, String)>> {
56 let lower_bound = if after.is_some() {
57 "AND o.object>?"
58 } else {
59 ""
60 };
61 let mut query = sqlx::query_as(sqlx::AssertSqlSafe(format!(
62 "SELECT (SELECT r.workspace {DOWNLOAD_ACTIVE_REFERENCE} LIMIT 1),
63 o.object,o.descriptor,o.sha256
64 FROM local_e2ee_image_objects o
65 WHERE (o.verified=0 OR NOT EXISTS(
66 SELECT 1 FROM blob_inventory b WHERE b.sha256=o.sha256 AND b.available=1))
67 AND EXISTS(SELECT 1 {DOWNLOAD_ACTIVE_REFERENCE})
68 {lower_bound}
69 ORDER BY o.object LIMIT 1"
70 )));
71 if let Some(after) = after {
72 query = query.bind(after);
73 }
74 query.fetch_optional(conn).await.map_err(Into::into)
75}
76
77#[cfg(any(test, feature = "test-support"))]
78pub(crate) async fn initialize(
79 conn: &mut SqliteConnection,
80 association: &str,
81 generation: i64,
82 prefix: i64,
83 package: &bootstrap_format::Package,
84 key: &crate::sync::LocalSharedStatePackageKey,
85) -> Result<()> {
86 let index = bootstrap_format::attachment_index(package, key)?;
87 initialize_index(
88 conn,
89 association,
90 generation,
91 prefix,
92 &hash(&package.descriptor),
93 index,
94 true,
95 )
96 .await
97}
98
99pub(crate) async fn initialize_index(
100 conn: &mut SqliteConnection,
101 association: &str,
102 generation: i64,
103 prefix: i64,
104 descriptor: &[u8; 32],
105 index: bootstrap_format::AttachmentIndex,
106 verified: bool,
107) -> Result<()> {
108 db::set_meta(conn, DOWNLOAD_CURSOR, "").await?;
109 db::set_meta(
110 conn,
111 super::super::client::INITIAL_IMAGE_WATERMARK,
112 if verified { "ready" } else { "pending" },
113 )
114 .await?;
115 sqlx::query("INSERT INTO local_e2ee_image_initialization(singleton,association,sync_generation,prefix_count,descriptor) VALUES(1,?,?,?,?)").bind(association).bind(generation).bind(prefix).bind(descriptor.as_slice()).execute(&mut *conn).await?;
116 for (image, sha) in index.objects {
117 let d = Descriptor {
118 vault: index.context.vault_id,
119 stream: index.stream,
120 generation: index.context.generation_id,
121 object: image.id,
122 artifact: image.artifact,
123 };
124 sqlx::query("INSERT INTO local_e2ee_image_objects(object,descriptor,sha256,origin,verified) VALUES(?,?,?,'bootstrap',?)").bind(d.object.as_slice()).bind(d.encode()?).bind(sha).bind(verified).execute(&mut *conn).await?;
125 }
126 for r in index.references {
127 sqlx::query("INSERT INTO local_e2ee_image_references(workspace,reference,parent,object,origin,deleted) VALUES(?,?,?,?,'bootstrap',?)").bind(r.workspace).bind(r.reference).bind(r.task).bind(r.object.map(|id|id.to_vec())).bind(r.deleted).execute(&mut *conn).await?;
128 }
129 Ok(())
130}
131pub(crate) async fn validate(
132 conn: &mut SqliteConnection,
133 association: &str,
134 prefix: i64,
135 descriptor: &[u8; 32],
136) -> Result<()> {
137 let ok:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM local_e2ee_image_initialization WHERE singleton=1 AND association=? AND prefix_count=? AND descriptor=? AND association=(SELECT value FROM meta WHERE key='e2ee_association') AND sync_generation=CAST((SELECT value FROM meta WHERE key='sync_generation') AS INTEGER))").bind(association).bind(prefix).bind(descriptor.as_slice()).fetch_one(&mut *conn).await?;
138 ensure!(ok, "error encrypted-image-reinitialization-required");
139 super::super::client::initial_image_watermark(conn).await?;
140 Ok(())
141}
142
143pub(crate) async fn validate_integrity(conn: &mut SqliteConnection) -> Result<()> {
144 #[cfg(any(test, feature = "test-support"))]
145 let scan_start = Instant::now();
146 let missing:bool=sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM task_attachments a WHERE NOT EXISTS(SELECT 1 FROM local_e2ee_image_references r WHERE r.workspace=a.workspace_id AND r.reference=a.attachment_id) AND NOT EXISTS(SELECT 1 FROM changes c WHERE c.change_id=a.created_by_change_id AND c.server_seq IS NULL))").fetch_one(conn).await?;
147 #[cfg(any(test, feature = "test-support"))]
148 record_integrity_scan(scan_start);
149 ensure!(!missing, "error encrypted-image-reinitialization-required");
150 Ok(())
151}
152pub(crate) async fn accept(
153 conn: &mut SqliteConnection,
154 accepted: &Accepted,
155 change: &ChangeWire,
156) -> Result<()> {
157 let p = codec::parse(&accepted.record)?.projection;
158 match p {
159 Projection::Ref {
160 workspace,
161 task,
162 reference,
163 descriptor,
164 ..
165 } => {
166 let workspace: String =
167 sqlx::query_scalar("SELECT workspace_id FROM tasks WHERE id = ?")
168 .bind(&task)
169 .fetch_optional(&mut *conn)
170 .await?
171 .unwrap_or(workspace);
172 let d = Descriptor::decode(&descriptor)?;
173 let sha = change.payload["sha256"]
174 .as_str()
175 .context("error encrypted-image-hash")?;
176 let old: Option<(Vec<u8>, String)> = sqlx::query_as(
177 "SELECT descriptor,sha256 FROM local_e2ee_image_objects WHERE object=?",
178 )
179 .bind(d.object.as_slice())
180 .fetch_optional(&mut *conn)
181 .await?;
182 valid(
183 old.as_ref()
184 .is_none_or(|(bytes, h)| *bytes == descriptor && h == sha),
185 )?;
186 sqlx::query("INSERT OR IGNORE INTO local_e2ee_image_objects(object,descriptor,sha256,origin) VALUES(?,?,?,?)").bind(d.object.as_slice()).bind(&descriptor).bind(sha).bind(&change.change_id).execute(&mut *conn).await?;
187 let old:Option<(String,Option<Vec<u8>>,String)>=sqlx::query_as("SELECT parent,object,origin FROM local_e2ee_image_references WHERE workspace=? AND reference=?").bind(&workspace).bind(&reference).fetch_optional(&mut *conn).await?;
188 valid(old.as_ref().is_none_or(|(p, o, id)| {
189 p == &task && o.as_deref() == Some(d.object.as_slice()) && id == &change.change_id
190 }))?;
191 if old.is_none() {
192 sqlx::query("UPDATE local_e2ee_image_objects SET verified=0 WHERE object=?")
193 .bind(d.object.as_slice())
194 .execute(&mut *conn)
195 .await?;
196 }
197 sqlx::query("INSERT OR IGNORE INTO local_e2ee_image_references(workspace,reference,parent,object,origin,deleted) VALUES(?,?,?,?,?,0)").bind(workspace).bind(reference).bind(task).bind(d.object.as_slice()).bind(&change.change_id).execute(&mut *conn).await?;
198 }
199 Projection::Unref {
200 workspace,
201 task,
202 reference,
203 } => {
204 let workspace: String =
205 sqlx::query_scalar("SELECT workspace_id FROM tasks WHERE id = ?")
206 .bind(&task)
207 .fetch_optional(&mut *conn)
208 .await?
209 .unwrap_or(workspace);
210 let n=sqlx::query("UPDATE local_e2ee_image_references SET deleted=1 WHERE workspace=? AND reference=? AND parent=?").bind(workspace).bind(reference).bind(task).execute(&mut *conn).await?.rows_affected();
211 valid(n == 1)?;
212 }
213 _ => {}
214 }
215 sqlx::query("DELETE FROM local_e2ee_image_preparation WHERE operation_id=?")
216 .bind(&change.change_id)
217 .execute(conn)
218 .await?;
219 Ok(())
220}
221
222pub struct Upload {
224 pub workspace: String,
225 pub descriptor: Vec<u8>,
226 pub object: [u8; 32],
227 pub commitment: [u8; 32],
228 pub records: Vec<Vec<u8>>,
229}
230impl Upload {
231 fn new(change: &ChangeWire, d: &Descriptor, records: Vec<Vec<u8>>) -> Result<Self> {
232 let descriptor = d.encode()?;
233 Ok(Self {
234 workspace: change.payload["workspace_id"]
235 .as_str()
236 .context("error encrypted-image-workspace")?
237 .into(),
238 commitment: hash(&descriptor),
239 descriptor,
240 object: d.object,
241 records,
242 })
243 }
244}
245
246pub(in crate::sync::encrypted_tail) async fn stage(
248 conn: &mut SqliteConnection,
249 a: &Authority,
250 c: &ChangeWire,
251 blob_dir: &Path,
252) -> Result<(Projection, Upload)> {
253 let sha = c.payload["sha256"]
254 .as_str()
255 .context("error encrypted-image-source")?;
256 let source = read_source(
257 blob_dir,
258 sha,
259 c.payload["byte_size"]
260 .as_u64()
261 .context("error encrypted-image-size")?,
262 )
263 .await?;
264 let image = crate::attachments::decode::validate_image(
265 source,
266 Some(
267 c.payload["media_type"]
268 .as_str()
269 .context("error encrypted-image-media")?
270 .into(),
271 ),
272 )
273 .await?;
274 valid(
275 c.payload["width"].as_i64() == Some(image.facts.width)
276 && c.payload["height"].as_i64() == Some(image.facts.height),
277 )?;
278 let reusable:Option<Vec<u8>>=sqlx::query_scalar("SELECT o.descriptor FROM local_e2ee_image_objects o JOIN local_e2ee_image_references r ON r.object=o.object WHERE o.sha256=? AND r.workspace=? ORDER BY o.object LIMIT 1").bind(sha).bind(c.payload["workspace_id"].as_str()).fetch_optional(&mut *conn).await?;
279 let (d, records) = if let Some(bytes) = reusable {
280 let d = Descriptor::decode(&bytes)?;
281 let records = d.reconstruct(a, &image.bytes, sha)?;
282 (d, records)
283 } else {
284 Descriptor::seal(a, &image.bytes)?
285 };
286 let descriptor = d.encode()?;
287 sqlx::query("INSERT INTO local_e2ee_image_preparation(singleton,operation_id,descriptor,sha256) VALUES(1,?,?,?)").bind(&c.change_id).bind(&descriptor).bind(sha).execute(&mut *conn).await?;
288 for (i, record) in records.iter().enumerate() {
289 sqlx::query(
290 "INSERT INTO local_e2ee_image_staging(operation_id,chunk_index,bytes) VALUES(?,?,?)",
291 )
292 .bind(&c.change_id)
293 .bind(i as i64)
294 .bind(record)
295 .execute(&mut *conn)
296 .await?;
297 }
298 let (deleted,version):(bool,Option<String>)=sqlx::query_as("SELECT t.deleted,(SELECT version FROM field_versions f WHERE f.workspace_id=t.workspace_id AND f.entity_type='task' AND f.entity_id=t.id AND f.field='deleted') FROM tasks t WHERE t.id=?").bind(&c.entity_id).fetch_one(&mut *conn).await?;
299 let projection = Projection::Ref {
300 workspace: c.payload["workspace_id"]
301 .as_str()
302 .context("error encrypted-image-workspace")?
303 .into(),
304 task: c.entity_id.clone(),
305 reference: c.payload["attachment_id"]
306 .as_str()
307 .context("error encrypted-image-reference")?
308 .into(),
309 descriptor,
310 deleted,
311 version,
312 };
313 Ok((projection, Upload::new(c, &d, records)?))
314}
315
316pub(in crate::sync::encrypted_tail) async fn frozen_upload(
319 conn: &mut SqliteConnection,
320 a: &Authority,
321 c: &ChangeWire,
322 record: &[u8],
323 blob_dir: &Path,
324) -> Result<Upload> {
325 let (bytes, saved_sha): (Vec<u8>, String) = sqlx::query_as(
326 "SELECT descriptor,sha256 FROM local_e2ee_image_preparation WHERE operation_id=?",
327 )
328 .bind(&c.change_id)
329 .fetch_optional(&mut *conn)
330 .await?
331 .context("error encrypted-image-preparation-required")?;
332 let Projection::Ref { descriptor, .. } = codec::parse(record)?.projection else {
333 anyhow::bail!("error encrypted-image-preparation")
334 };
335 valid(descriptor == bytes && c.payload["sha256"].as_str() == Some(&saved_sha))?;
336 let d = Descriptor::decode(&bytes)?;
337 let rows: Vec<(i64, Vec<u8>)> = sqlx::query_as("SELECT chunk_index,bytes FROM local_e2ee_image_staging WHERE operation_id=? ORDER BY chunk_index")
338 .bind(&c.change_id).fetch_all(&mut *conn).await?;
339 for (index, chunk) in &rows {
340 d.verify_chunk(usize::try_from(*index)?, chunk)?;
341 }
342 let records = if rows.len() == d.artifact.chunks.len() {
343 let records: Vec<Vec<u8>> = rows.into_iter().map(|(_, b)| b).collect();
344 d.verify(&records)?;
345 records
346 } else {
347 let source = read_source(blob_dir, &saved_sha, d.artifact.total).await?;
348 let records = d.reconstruct(a, &source, &saved_sha)?;
349 for (i, record) in records.iter().enumerate() {
350 sqlx::query("INSERT OR IGNORE INTO local_e2ee_image_staging(operation_id,chunk_index,bytes) VALUES(?,?,?)").bind(&c.change_id).bind(i as i64).bind(record).execute(&mut *conn).await?;
351 }
352 records
353 };
354 Upload::new(c, &d, records)
355}
356
357pub(in crate::sync::encrypted_tail) async fn supersede(
358 conn: &mut SqliteConnection,
359 a: &Authority,
360 change: &ChangeWire,
361 mut projection: Projection,
362 blob_dir: &Path,
363) -> Result<Projection> {
364 let Projection::Ref { descriptor, .. } = &mut projection else {
365 anyhow::bail!("error encrypted-image-preparation");
366 };
367 let (stored, sha): (Vec<u8>, String) = sqlx::query_as(
368 "SELECT descriptor,sha256 FROM local_e2ee_image_preparation WHERE operation_id=?",
369 )
370 .bind(&change.change_id)
371 .fetch_one(&mut *conn)
372 .await?;
373 valid(stored == *descriptor && change.payload["sha256"].as_str() == Some(&sha))?;
374 let old = Descriptor::decode(&stored)?;
375 let rows: Vec<(i64, Vec<u8>)> = sqlx::query_as("SELECT chunk_index,bytes FROM local_e2ee_image_staging WHERE operation_id=? ORDER BY chunk_index")
376 .bind(&change.change_id).fetch_all(&mut *conn).await?;
377 for (index, bytes) in &rows {
378 old.verify_chunk(usize::try_from(*index)?, bytes)?;
379 }
380 let source = if rows.len() == old.artifact.chunks.len() {
381 old.open(a, &rows.into_iter().map(|(_, b)| b).collect::<Vec<_>>())?
382 .to_vec()
383 } else {
384 let source = read_source(blob_dir, &sha, old.artifact.total).await?;
385 old.reconstruct(a, &source, &sha)?;
386 source
387 };
388 valid(hex::encode(hash(&source)) == sha)?;
389 let (next, records) = Descriptor::seal(a, &source)?;
390 sqlx::query("DELETE FROM local_e2ee_image_preparation WHERE operation_id=?")
392 .bind(&change.change_id)
393 .execute(&mut *conn)
394 .await?;
395 *descriptor = next.encode()?;
396 sqlx::query("INSERT INTO local_e2ee_image_preparation(singleton,operation_id,descriptor,sha256) VALUES(1,?,?,?)")
397 .bind(&change.change_id).bind(&*descriptor).bind(sha).execute(&mut *conn).await?;
398 for (index, record) in records.iter().enumerate() {
399 sqlx::query(
400 "INSERT INTO local_e2ee_image_staging(operation_id,chunk_index,bytes) VALUES(?,?,?)",
401 )
402 .bind(&change.change_id)
403 .bind(index as i64)
404 .bind(record)
405 .execute(&mut *conn)
406 .await?;
407 }
408 Ok(projection)
409}
410#[derive(Debug)]
412pub struct ImageSourceUnavailable;
413impl std::fmt::Display for ImageSourceUnavailable {
414 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 f.write_str("error encrypted-image-source-unavailable")
416 }
417}
418async fn read_source(blob_dir: &Path, sha: &str, total: u64) -> Result<Vec<u8>> {
419 use tokio::io::AsyncReadExt;
420 async {
421 let file =
422 tokio::fs::File::open(crate::attachments::storage::object_path(blob_dir, sha)?).await?;
423 let mut bytes = Vec::new();
424 file.take(super::codec::IMAGE_BYTES as u64 + 1)
425 .read_to_end(&mut bytes)
426 .await?;
427 valid(
428 bytes.len() as u64 == total
429 && bytes.len() <= super::codec::IMAGE_BYTES
430 && hex::encode(hash(&bytes)) == sha,
431 )?;
432 anyhow::Ok(bytes)
433 }
434 .await
435 .context(ImageSourceUnavailable)
436}
437
438pub(in crate::sync::encrypted_tail) async fn downloads(
440 conn: &mut SqliteConnection,
441) -> Result<Downloads> {
442 let (pending, unavailable): (bool, bool) = sqlx::query_as(sqlx::AssertSqlSafe(format!(
443 "SELECT EXISTS(SELECT 1 {DOWNLOAD_CANDIDATES}),
444 EXISTS(SELECT 1 FROM local_e2ee_image_references r
445 JOIN task_attachments a ON a.workspace_id=r.workspace AND a.attachment_id=r.reference
446 JOIN tasks t ON t.workspace_id=a.workspace_id AND t.id=a.task_id
447 WHERE r.object IS NULL AND a.deleted=0 AND t.deleted=0)"
448 )))
449 .fetch_one(conn)
450 .await?;
451 Ok(Downloads {
452 pending,
453 unavailable,
454 })
455}
456
457pub struct Download {
458 pub workspace: String,
459 pub object: [u8; 32],
460 pub descriptor_commitment: [u8; 32],
461 pub chunk_count: usize,
462 descriptor: Vec<u8>,
463 sha256: String,
464}
465impl Database {
466 pub async fn encrypted_image_downloads_remaining(&self) -> Result<u64> {
469 let mut conn = self.acquire_reader().await?;
470 let count: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
471 "SELECT COUNT(DISTINCT o.object) {DOWNLOAD_CANDIDATES}"
472 )))
473 .fetch_one(&mut *conn)
474 .await?;
475 Ok(u64::try_from(count)?)
476 }
477 pub async fn prepare_encrypted_image_download(
480 &self,
481 a: &Authority,
482 ) -> Result<Option<Download>> {
483 let mut conn = self.acquire_writer().await?;
484 let mut tx = begin_immediate(&mut conn).await?;
485 super::super::client::validate_binding_and_cursor(&mut tx, a).await?;
486 ensure!(
487 super::super::client::initial_image_watermark(&mut tx).await? == "ready",
488 "error encrypted-image-initial-catch-up"
489 );
490 let after = hex::decode(
491 db::get_meta(&mut tx, DOWNLOAD_CURSOR)
492 .await?
493 .unwrap_or_default(),
494 )
495 .context("error encrypted-image-download-cursor")?;
496 valid(after.is_empty() || after.len() == 32)?;
497 let row = match select_download_candidate(&mut tx, Some(&after)).await? {
498 Some(row) => Some(row),
499 None => select_download_candidate(&mut tx, None).await?,
500 };
501 let result = row
502 .map(|(workspace, object, bytes, sha256)| -> Result<_> {
503 let d = Descriptor::decode(&bytes)?;
504 valid(object.as_slice() == d.object)?;
505 Ok(Download {
506 workspace,
507 object: d.object,
508 descriptor_commitment: hash(&bytes),
509 chunk_count: d.artifact.chunks.len(),
510 descriptor: bytes,
511 sha256,
512 })
513 })
514 .transpose()?;
515 if let Some(download) = &result {
516 db::set_meta(&mut tx, DOWNLOAD_CURSOR, &hex::encode(download.object)).await?;
517 }
518 tx.commit().await?;
519 Ok(result)
520 }
521 pub async fn install_encrypted_image(
522 &self,
523 a: &Authority,
524 blob_dir: &Path,
525 download: &Download,
526 records: &[Vec<u8>],
527 policy: crate::attachments::LifecyclePolicy,
528 ) -> Result<()> {
529 let d = Descriptor::decode(&download.descriptor)?;
530 let plain = d.open(a, records)?;
531 valid(hex::encode(hash(&plain)) == download.sha256)?;
532 self.install_image_plaintext(a, blob_dir, download, plain.to_vec(), policy)
533 .await
534 }
535 pub async fn complete_encrypted_image_from_local(
536 &self,
537 a: &Authority,
538 blob_dir: &Path,
539 download: &Download,
540 policy: crate::attachments::LifecyclePolicy,
541 ) -> Result<bool> {
542 let d = Descriptor::decode(&download.descriptor)?;
543 d.authority(a)?;
544 match read_source(blob_dir, &download.sha256, d.artifact.total).await {
545 Ok(bytes) => {
546 self.install_image_plaintext(a, blob_dir, download, bytes, policy)
547 .await?;
548 Ok(true)
549 }
550 Err(_) => Ok(false),
551 }
552 }
553 async fn install_image_plaintext(
554 &self,
555 a: &Authority,
556 blob_dir: &Path,
557 download: &Download,
558 plain: Vec<u8>,
559 policy: crate::attachments::LifecyclePolicy,
560 ) -> Result<()> {
561 let d = Descriptor::decode(&download.descriptor)?;
562 let image = crate::attachments::decode::validate_image(plain, None)
563 .await
564 .map_err(|_| anyhow::anyhow!("error encrypted-image-invalid"))?;
565 let mut conn = self.acquire_writer().await?;
566 super::super::client::validate_binding_and_cursor(&mut conn, a).await?;
567 let reservation = crate::attachments::lifecycle::ensure_local_capacity(
568 &mut conn,
569 blob_dir,
570 &download.sha256,
571 image.bytes.len() as i64,
572 policy,
573 &crate::attachments::lifecycle::SystemClock,
574 )
575 .await?;
576 let result = async {
577 let mut tx = begin_immediate(&mut conn).await?;
578 super::super::client::validate_binding_and_cursor(&mut tx, a).await?;
579 let descriptor: Vec<u8> = sqlx::query_scalar(
580 "SELECT descriptor FROM local_e2ee_image_objects WHERE object=? AND sha256=?",
581 )
582 .bind(d.object.as_slice())
583 .bind(&download.sha256)
584 .fetch_one(&mut *tx)
585 .await?;
586 valid(descriptor == download.descriptor)?;
587 let facts: Vec<(i64, String, Option<i64>, Option<i64>)> = sqlx::query_as(
588 "SELECT byte_size,media_type,width,height FROM task_attachments WHERE sha256=?",
589 )
590 .bind(&download.sha256)
591 .fetch_all(&mut *tx)
592 .await?;
593 for (size, media, width, height) in facts {
594 valid(
595 size == image.bytes.len() as i64
596 && media == image.facts.media_type
597 && width == Some(image.facts.width)
598 && height == Some(image.facts.height),
599 )?;
600 }
601 let stored =
602 crate::attachments::storage::stage_blob(blob_dir, &download.sha256, &image.bytes)
603 .await?;
604 crate::attachments::storage::upsert_inventory_available(
605 &mut tx,
606 &download.sha256,
607 stored.byte_size,
608 &image.facts.media_type,
609 )
610 .await?;
611 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
612 &mut tx,
613 std::slice::from_ref(&download.sha256),
614 &crate::attachments::lifecycle::SystemClock,
615 )
616 .await?;
617 sqlx::query("UPDATE local_e2ee_image_objects SET verified=1 WHERE object=?")
618 .bind(d.object.as_slice())
619 .execute(&mut *tx)
620 .await?;
621 tx.commit().await?;
622 Ok(())
623 }
624 .await;
625 if let Some(reservation) = reservation {
626 crate::attachments::lifecycle::release_reservation(&mut conn, &reservation).await?;
627 }
628 result
629 }
630}
631
632impl Database {
633 pub async fn encrypted_tail_frozen_record(
634 &self,
635 a: &Authority,
636 ) -> Result<Option<(String, Vec<u8>)>> {
637 let mut conn = self.acquire_reader().await?;
638 super::super::client::validate_binding_and_cursor(&mut conn, a).await?;
639 let record: Option<Vec<u8>> =
640 sqlx::query_scalar("SELECT record FROM local_e2ee_outbox ORDER BY position LIMIT 1")
641 .fetch_optional(&mut *conn)
642 .await?;
643 record
644 .map(|r| Ok((codec::open(a, &r)?.change_id, r)))
645 .transpose()
646 }
647}
648
649impl Database {
650 pub async fn repair_encrypted_image(
652 &self,
653 a: &Authority,
654 blob_dir: &Path,
655 workspace: &str,
656 reference: &str,
657 ) -> Result<Upload> {
658 let mut conn = self.acquire_writer().await?;
659 let mut tx = begin_immediate(&mut conn).await?;
660 super::super::client::validate_binding_and_cursor(&mut tx, a).await?;
661 ensure!(!a.rotation_pending(), "error membership-rotation-pending");
662 let (descriptor,sha):(Vec<u8>,String)=sqlx::query_as("SELECT o.descriptor,o.sha256 FROM local_e2ee_image_references r JOIN local_e2ee_image_objects o ON o.object=r.object WHERE r.workspace=? AND r.reference=?").bind(workspace).bind(reference).fetch_optional(&mut *tx).await?.context("error encrypted-image-unavailable")?;
663 let d = Descriptor::decode(&descriptor)?;
664 let source = read_source(blob_dir, &sha, d.artifact.total).await?;
665 let records = d.reconstruct(a, &source, &sha)?;
666 tx.commit().await?;
667 Ok(Upload {
668 workspace: workspace.into(),
669 object: d.object,
670 commitment: hash(&descriptor),
671 descriptor,
672 records,
673 })
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use sqlx::Connection;
681
682 fn object(index: u32) -> Vec<u8> {
683 let mut object = vec![0; 32];
684 object[28..].copy_from_slice(&index.to_be_bytes());
685 object
686 }
687
688 #[tokio::test]
689 async fn download_selection_uses_keyset_order_and_wraps() {
690 let mut conn = SqliteConnection::connect(":memory:").await.unwrap();
691 sqlx::raw_sql(
692 "CREATE TABLE local_e2ee_image_objects(
693 object BLOB PRIMARY KEY, descriptor BLOB, sha256 TEXT,
694 verified INTEGER, origin TEXT);
695 CREATE TABLE local_e2ee_image_references(
696 workspace TEXT, reference TEXT, parent TEXT, object BLOB,
697 origin TEXT, deleted INTEGER, PRIMARY KEY(workspace,reference));
698 CREATE INDEX local_e2ee_image_references_object
699 ON local_e2ee_image_references(object);
700 CREATE TABLE task_attachments(
701 workspace_id TEXT, attachment_id TEXT, task_id TEXT, deleted INTEGER,
702 PRIMARY KEY(workspace_id,attachment_id));
703 CREATE TABLE tasks(
704 workspace_id TEXT, id TEXT, deleted INTEGER,
705 PRIMARY KEY(workspace_id,id));
706 CREATE TABLE blob_inventory(sha256 TEXT PRIMARY KEY, available INTEGER);
707 INSERT INTO tasks VALUES('workspace','task',0);",
708 )
709 .execute(&mut conn)
710 .await
711 .unwrap();
712 let mut tx = conn.begin().await.unwrap();
713 for index in 0..4096_u32 {
714 let object = object(index);
715 let reference = format!("reference-{index:04}");
716 sqlx::query("INSERT INTO local_e2ee_image_objects VALUES(?,X'00',?,0,'test')")
717 .bind(&object)
718 .bind(format!("sha-{index}"))
719 .execute(&mut *tx)
720 .await
721 .unwrap();
722 sqlx::query(
723 "INSERT INTO local_e2ee_image_references VALUES('workspace',?,'task',?,'test',0)",
724 )
725 .bind(&reference)
726 .bind(&object)
727 .execute(&mut *tx)
728 .await
729 .unwrap();
730 sqlx::query("INSERT INTO task_attachments VALUES('workspace',?,'task',0)")
731 .bind(reference)
732 .execute(&mut *tx)
733 .await
734 .unwrap();
735 }
736 tx.commit().await.unwrap();
737
738 let selected = select_download_candidate(&mut conn, Some(&object(2047)))
739 .await
740 .unwrap()
741 .unwrap();
742 assert_eq!(selected.1, object(2048));
743 assert!(
744 select_download_candidate(&mut conn, Some(&object(4095)))
745 .await
746 .unwrap()
747 .is_none()
748 );
749 let wrapped = select_download_candidate(&mut conn, None)
750 .await
751 .unwrap()
752 .unwrap();
753 assert_eq!(wrapped.1, object(0));
754
755 let plan: Vec<(i64, i64, i64, String)> = sqlx::query_as(
756 "EXPLAIN QUERY PLAN SELECT o.object
757 FROM local_e2ee_image_objects o
758 WHERE o.object>? AND EXISTS(
759 SELECT 1 FROM local_e2ee_image_references r
760 WHERE r.object=o.object)
761 ORDER BY o.object LIMIT 1",
762 )
763 .bind(object(2047))
764 .fetch_all(&mut conn)
765 .await
766 .unwrap();
767 assert!(
768 plan.iter().any(|row| row.3.contains("object>?")),
769 "{plan:?}"
770 );
771 assert!(
772 !plan.iter().any(|row| row.3.contains("TEMP B-TREE")),
773 "{plan:?}"
774 );
775 }
776
777 #[tokio::test]
778 async fn valid_image_aead_does_not_make_invalid_plaintext_available() {
779 let root = tempfile::tempdir().unwrap();
780 let db = Database::open(&root.path().join("test.sqlite"))
781 .await
782 .unwrap();
783 let a = crate::sync::encrypted_tail::tests::authority();
784 let plain = b"not an image";
785 let (d, records) = Descriptor::seal(&a, plain).unwrap();
786 assert_eq!(d.open(&a, &records).unwrap().as_slice(), plain);
787 let bytes = d.encode().unwrap();
788 let download = Download {
789 workspace: "0000000000000000".into(),
790 object: d.object,
791 descriptor_commitment: hash(&bytes),
792 chunk_count: records.len(),
793 descriptor: bytes,
794 sha256: hex::encode(hash(plain)),
795 };
796 let error = db
797 .install_encrypted_image(&a, root.path(), &download, &records, Default::default())
798 .await
799 .unwrap_err();
800 assert_eq!(error.to_string(), "error encrypted-image-invalid");
801 assert!(!root.path().join("objects").exists());
802 }
803}