1use super::*;
2use crate::sync::encrypted_tail::attachments::{
3 self as images, Operation as ImageOperation, Reply as ImageReply, Ticket,
4};
5use std::{
6 path::Path,
7 time::{Duration, Instant},
8};
9pub const IMAGES_PATH: &str = "/e2ee/images/v1";
10
11const PUSH_LIMIT: usize = 2048;
14
15struct ImageRoundLimits {
16 objects: usize,
17 elapsed: Duration,
18 metadata_pull_skips: usize,
19}
20
21const IMAGE_ROUND_LIMITS: ImageRoundLimits = ImageRoundLimits {
27 objects: 16,
28 elapsed: Duration::from_secs(2),
29 metadata_pull_skips: 3,
30};
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum ImageTransfer {
35 Complete,
36 Pending,
37 Failed,
38 Unavailable,
39}
40
41impl std::fmt::Display for ImageTransfer {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.write_str(match self {
44 Self::Complete => "complete",
45 Self::Pending => "pending",
46 Self::Failed => "failed",
47 Self::Unavailable => "unavailable",
48 })
49 }
50}
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Round {
53 pub metadata_caught_up: bool,
56 pub images: ImageTransfer,
57 pub sent_changes: usize,
59 pub received_changes: usize,
61 pub publishing_blocked: bool,
64}
65
66pub struct DrainSnapshot {
67 tail: TailSnapshot,
68 pull: PullFreshness,
69 batch_count: usize,
70 withdrawal: Option<anyhow::Error>,
72}
73
74#[derive(Default)]
75struct PullFreshness {
76 complete: bool,
77 skipped_rounds: usize,
78}
79impl DrainSnapshot {
80 pub fn publishing_blocked_error(&mut self) -> anyhow::Error {
82 match self.withdrawal.take() {
83 Some(error) => error.context(PublishingBlocked),
84 None => PublishingBlocked.into(),
85 }
86 }
87}
88
89async fn validated_tail_inputs(
90 store: &ProtectedLocalKeyStore,
91 db: &Database,
92 locator: &str,
93) -> Result<TailSnapshot> {
94 let inputs = store.tail_inputs(db, locator).await?;
95 db.validate_encrypted_attachment_integrity(&inputs.authority)
96 .await?;
97 Ok(inputs)
98}
99
100struct RoundProgress {
101 pushes: usize,
102 batch_collisions: usize,
103 preflight: Option<tail::Preflight>,
104 push_complete: bool,
105 publishing_blocked: bool,
106 page_complete: Option<bool>,
107 pulled: bool,
108 image_state: Option<ImageTransfer>,
109 selected: bool,
110 download: Option<images::Download>,
111 image_started: Instant,
112 image_transfers: usize,
113}
114
115impl Default for RoundProgress {
116 fn default() -> Self {
117 Self {
118 pushes: 0,
119 batch_collisions: 0,
120 preflight: None,
121 push_complete: false,
122 publishing_blocked: false,
123 page_complete: None,
124 pulled: false,
125 image_state: None,
126 selected: false,
127 download: None,
128 image_started: Instant::now(),
129 image_transfers: 0,
130 }
131 }
132}
133
134impl RoundProgress {
135 fn image_budget_available(&self) -> bool {
136 self.image_transfers == 0
137 || (self.image_transfers < IMAGE_ROUND_LIMITS.objects
138 && self.image_started.elapsed() < IMAGE_ROUND_LIMITS.elapsed)
139 }
140
141 fn transferred_image(&mut self) {
142 self.image_transfers += 1;
143 }
144}
145impl Client {
146 pub async fn image_exchange(
147 &self,
148 context: &Context,
149 bearer: &Secret,
150 operation: ImageOperation,
151 ) -> Result<ImageReply> {
152 let request_limit = if matches!(operation, ImageOperation::Put { .. }) {
153 images::HTTP_LIMIT
154 } else {
155 tail::CONTROL_LIMIT
156 };
157 let response_limit = if matches!(operation, ImageOperation::Read { .. }) {
158 images::HTTP_LIMIT
159 } else {
160 tail::CONTROL_LIMIT
161 };
162 self.exchange_to(
163 IMAGES_PATH,
164 context,
165 bearer,
166 operation,
167 request_limit,
168 response_limit,
169 )
170 .await
171 }
172 pub async fn start_drain(
173 &self,
174 store: &ProtectedLocalKeyStore,
175 db: &Database,
176 ) -> Result<DrainSnapshot> {
177 let enrollment = self.enrollment()?;
178 enrollment.finish_pending_removal(store, db).await?;
179 let withdrawal = Box::pin(enrollment.withdraw_expired_disclosure(store, db))
181 .await
182 .err();
183 Ok(DrainSnapshot {
184 tail: validated_tail_inputs(store, db, &self.locator).await?,
185 pull: PullFreshness::default(),
186 withdrawal,
187 batch_count: 0,
188 })
189 }
190 #[cfg(any(test, feature = "test-support"))]
194 pub async fn round(
195 &self,
196 store: &ProtectedLocalKeyStore,
197 db: &Database,
198 blob_dir: &Path,
199 lifecycle_policy: crate::attachments::LifecyclePolicy,
200 ) -> Result<Round> {
201 let mut drain = Box::pin(self.start_drain(store, db)).await?;
202 Box::pin(self.round_in_drain(store, db, blob_dir, lifecycle_policy, &mut drain)).await
203 }
204 pub async fn round_in_drain(
205 &self,
206 store: &ProtectedLocalKeyStore,
207 db: &Database,
208 blob_dir: &Path,
209 lifecycle_policy: crate::attachments::LifecyclePolicy,
210 drain: &mut DrainSnapshot,
211 ) -> Result<Round> {
212 if !drain.tail.is_current(db).await? {
213 drain.tail = validated_tail_inputs(store, db, &self.locator).await?;
214 }
215 let mut progress = RoundProgress::default();
216 retry_stale!(
217 self.round_once(
218 &drain.tail,
219 &mut drain.pull,
220 db,
221 blob_dir,
222 lifecycle_policy,
223 &mut progress,
224 &mut drain.batch_count
225 )
226 .await,
227 async {
228 self.enrollment()?
229 .refresh_and_finish_pending_removal(store, db)
230 .await?;
231 drain.tail = validated_tail_inputs(store, db, &self.locator).await?;
232 drain.pull = PullFreshness::default();
233 progress.preflight = None;
234 progress.page_complete = None;
235 progress.pulled = false;
236 anyhow::Ok(())
237 }
238 .await,
239 )
240 }
241 #[allow(clippy::too_many_arguments)]
242 async fn round_once(
243 &self,
244 inputs: &TailSnapshot,
245 pull: &mut PullFreshness,
246 db: &Database,
247 blob_dir: &Path,
248 lifecycle_policy: crate::attachments::LifecyclePolicy,
249 progress: &mut RoundProgress,
250 batch_count: &mut usize,
251 ) -> Result<Round> {
252 let a = &inputs.authority;
253 while !progress.push_complete && progress.pushes < PUSH_LIMIT {
254 let (step, preflight) = match self
255 .push_in_run(
256 inputs,
257 db,
258 blob_dir,
259 progress.preflight.clone(),
260 batch_count,
261 PUSH_LIMIT - progress.pushes,
262 )
263 .await
264 {
265 Err(error)
266 if super::super::errors::has_code(&error, "encrypted-tail-batch-known")
267 && progress.batch_collisions < 3 =>
268 {
269 progress.batch_collisions += 1;
270 continue;
271 }
272 Err(error) if error.is::<PublishingBlocked>() => {
273 progress.publishing_blocked = true;
274 break;
275 }
276 result => result?,
277 };
278 progress.preflight = preflight;
279 match step {
280 PushStep::Appended => progress.pushes += 1,
281 PushStep::BatchAppended(count) => progress.pushes += count,
282 PushStep::Image(Some(state)) => {
283 progress.image_state = Some(state);
284 progress.push_complete = true;
285 }
286 PushStep::Image(None) => {
287 progress.transferred_image();
288 if !progress.image_budget_available() {
289 progress.push_complete = true;
290 }
291 }
292 PushStep::Empty => progress.push_complete = true,
293 }
294 }
295 progress.push_complete = true;
298 let state_before_pull = db.encrypted_round_state(a).await?;
299 let cursor_before_pull = state_before_pull.cursor;
300 let image_work_waiting = state_before_pull.upload_pending
301 || state_before_pull
302 .downloads
303 .is_some_and(|downloads| downloads.pending);
304 let may_skip_pull = pull.complete
305 && progress.pushes == 0
306 && image_work_waiting
307 && pull.skipped_rounds < IMAGE_ROUND_LIMITS.metadata_pull_skips;
308 if progress.page_complete.is_none() && !may_skip_pull {
309 progress.page_complete = Some(self.pull(a, &inputs.bearer, db).await?);
310 progress.pulled = true;
311 pull.complete = progress.page_complete == Some(true);
312 pull.skipped_rounds = 0;
313 } else if progress.page_complete.is_none() {
314 progress.page_complete = Some(true);
315 pull.skipped_rounds += 1;
316 }
317 let state = db.encrypted_round_state(a).await?;
318 let mut downloaded = None;
319 if progress.image_state.is_none() && state.downloads.is_some() {
320 while progress.image_budget_available() {
321 if !progress.selected {
322 progress.download = db.prepare_encrypted_image_download(a).await?;
323 progress.selected = true;
324 }
325 if progress.download.is_none() {
326 break;
327 }
328 match self
329 .download_image(
330 a,
331 &inputs.bearer,
332 db,
333 blob_dir,
334 progress.download.as_ref(),
335 lifecycle_policy,
336 )
337 .await
338 {
339 Ok(true) => {
340 progress.transferred_image();
341 progress.selected = false;
342 progress.download = None;
343 }
344 Ok(false) => {
345 downloaded = Some(ImageTransfer::Unavailable);
346 break;
347 }
348 Err(error) if is_stale(&error) => return Err(error),
349 Err(_) => {
350 downloaded = Some(ImageTransfer::Failed);
351 break;
352 }
353 }
354 }
355 }
356 let final_state = db.encrypted_round_state(a).await?;
357 let images =
358 downloaded.unwrap_or_else(|| settled(&final_state, progress.publishing_blocked));
359 Ok(Round {
360 metadata_caught_up: progress.pulled
361 && progress.page_complete == Some(true)
362 && (final_state.idle || progress.publishing_blocked),
363 images: progress.image_state.unwrap_or(images),
365 sent_changes: progress.pushes,
366 received_changes: final_state.cursor.saturating_sub(cursor_before_pull) as usize,
367 publishing_blocked: progress.publishing_blocked,
368 })
369 }
370 pub async fn upload_prepared_image(
371 &self,
372 inputs: &TailSnapshot,
373 upload: images::Upload,
374 ) -> Result<Ticket> {
375 let (a, bearer) = (&inputs.authority, &inputs.bearer);
376 inputs.require_publishing_ready()?;
377 let ImageReply::Status(status) = self
378 .image_exchange(
379 &a.context,
380 bearer,
381 ImageOperation::Declare {
382 workspace: upload.workspace.clone(),
383 descriptor: upload.descriptor.clone(),
384 },
385 )
386 .await?
387 else {
388 anyhow::bail!("error encrypted-image-reply")
389 };
390 let mut indices = std::collections::HashSet::new();
391 ensure!(
392 status.missing.len() <= upload.records.len()
393 && (!status.complete || status.missing.is_empty())
394 && status.expires_at.is_some(),
395 "error encrypted-image-status"
396 );
397 ensure!(
398 status
399 .missing
400 .iter()
401 .all(|i| *i < upload.records.len() && indices.insert(*i)),
402 "error encrypted-image-status"
403 );
404 let ticket = Ticket {
405 reservation: status
406 .reservation
407 .ok_or_else(|| anyhow::anyhow!("error encrypted-image-ticket"))?,
408 };
409 for index in status.missing {
410 inputs.require_publishing_ready()?;
411 let record = upload
412 .records
413 .get(index)
414 .ok_or_else(|| anyhow::anyhow!("error encrypted-image-index"))?
415 .clone();
416 ensure!(
417 matches!(
418 self.image_exchange(
419 &a.context,
420 bearer,
421 ImageOperation::Put {
422 workspace: upload.workspace.clone(),
423 object: upload.object,
424 descriptor_commitment: upload.commitment,
425 reservation: ticket.reservation,
426 index,
427 record
428 }
429 )
430 .await?,
431 ImageReply::Done
432 ),
433 "error encrypted-image-reply"
434 );
435 }
436 crate::sync::crash::Crash::Tail.at("image-put");
437 inputs.require_publishing_ready()?;
438 ensure!(
439 matches!(
440 self.image_exchange(
441 &a.context,
442 bearer,
443 ImageOperation::Complete {
444 workspace: upload.workspace,
445 object: upload.object,
446 descriptor_commitment: upload.commitment,
447 reservation: ticket.reservation
448 }
449 )
450 .await?,
451 ImageReply::Done
452 ),
453 "error encrypted-image-reply"
454 );
455 Ok(ticket)
456 }
457 pub async fn repair_attachment(
459 &self,
460 store: &ProtectedLocalKeyStore,
461 db: &Database,
462 blob_dir: &Path,
463 workspace: &str,
464 reference: &str,
465 ) -> Result<()> {
466 let enrollment = self.enrollment()?;
467 enrollment.refresh(store, db).await?;
468 retry_stale!(
469 self.repair_attachment_once(store, db, blob_dir, workspace, reference)
470 .await,
471 enrollment.refresh(store, db).await,
472 )
473 }
474 async fn repair_attachment_once(
475 &self,
476 store: &ProtectedLocalKeyStore,
477 db: &Database,
478 blob_dir: &Path,
479 workspace: &str,
480 reference: &str,
481 ) -> Result<()> {
482 let inputs = validated_tail_inputs(store, db, &self.locator).await?;
483 let upload = db
484 .repair_encrypted_image(&inputs.authority, blob_dir, workspace, reference)
485 .await?;
486 let object = upload.object;
487 let commitment = upload.commitment;
488 let ticket = self.upload_prepared_image(&inputs, upload).await?;
489 ensure!(
490 matches!(
491 self.image_exchange(
492 &inputs.authority.context,
493 &inputs.bearer,
494 ImageOperation::Release {
495 workspace: workspace.into(),
496 object,
497 descriptor_commitment: commitment,
498 reservation: ticket.reservation
499 }
500 )
501 .await?,
502 ImageReply::Done
503 ),
504 "error encrypted-image-release"
505 );
506 Ok(())
507 }
508 async fn download_image(
510 &self,
511 a: &tail::Authority,
512 bearer: &Secret,
513 db: &Database,
514 blob_dir: &Path,
515 selected: Option<&images::Download>,
516 lifecycle_policy: crate::attachments::LifecyclePolicy,
517 ) -> Result<bool> {
518 if let Some(download) = selected
519 && !db
520 .complete_encrypted_image_from_local(a, blob_dir, download, lifecycle_policy)
521 .await?
522 {
523 let mut records = Vec::new();
524 let mut total = 0;
525 for index in 0..download.chunk_count {
526 match self
527 .image_exchange(
528 &a.context,
529 bearer,
530 ImageOperation::Read {
531 workspace: download.workspace.clone(),
532 object: download.object,
533 descriptor_commitment: download.descriptor_commitment,
534 index,
535 },
536 )
537 .await?
538 {
539 ImageReply::Chunk(record) => {
540 total += record.len();
541 ensure!(
542 total <= images::TRANSFER_BYTES,
543 "error encrypted-image-limit"
544 );
545 records.push(record);
546 }
547 ImageReply::Unavailable => return Ok(false),
548 _ => anyhow::bail!("error encrypted-image-reply"),
549 }
550 }
551 db.install_encrypted_image(a, blob_dir, download, &records, lifecycle_policy)
552 .await?;
553 }
554 Ok(true)
555 }
556}
557fn settled(state: &tail::RoundState, publishing_blocked: bool) -> ImageTransfer {
560 match state.downloads {
561 Some(d) if d.pending => ImageTransfer::Pending,
562 Some(d) if d.unavailable => ImageTransfer::Unavailable,
563 Some(_) if !state.upload_pending || publishing_blocked => ImageTransfer::Complete,
564 _ => ImageTransfer::Pending,
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn exhausted_round_time_still_allows_the_first_image_transfer() {
574 let mut progress = RoundProgress {
575 image_started: Instant::now() - IMAGE_ROUND_LIMITS.elapsed,
576 ..RoundProgress::default()
577 };
578 assert!(progress.image_budget_available());
579 progress.transferred_image();
580 assert!(!progress.image_budget_available());
581 }
582}