1use anyhow::{Result, ensure};
17use serde::{Deserialize, Serialize};
18
19use super::exchange::{self, Link};
20use super::keys::ProtectedLocalKeyStore;
21use crate::db::Database;
22use crate::sync::shared_state::validated::{ProofCache, ValidatedSeed};
23use crate::sync::{
24 base64_bytes, bootstrap_staging as staging,
25 seed_claim::{ClaimAuthentication, ClaimResult, Genesis, PublicationOutcome, Secret},
26};
27
28pub(crate) struct StageTimer {
30 stage: &'static str,
31 started: std::time::Instant,
32}
33
34impl StageTimer {
35 pub(crate) fn start(stage: &'static str) -> Self {
36 Self {
37 stage,
38 started: std::time::Instant::now(),
39 }
40 }
41}
42
43impl Drop for StageTimer {
44 fn drop(&mut self) {
45 tracing::debug!(
46 stage = self.stage,
47 elapsed_ms = self.started.elapsed().as_secs_f64() * 1000.0,
48 "setup stage finished"
49 );
50 }
51}
52
53pub const PATH: &str = "/e2ee/bootstrap/v1";
54pub const REQUEST_LIMIT: usize = base64_bytes::encoded_len(staging::MAX_REQUEST_BYTES) + 4096;
55pub const RESPONSE_LIMIT: usize = 1_048_576;
56
57#[derive(Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct Envelope {
60 pub vault: [u8; 32],
61 pub genesis: [u8; 32],
62 pub operation: Operation,
63}
64
65#[derive(Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub enum Operation {
68 ClaimSetup {
69 #[serde(with = "crate::sync::base64_bytes")]
70 bytes: Vec<u8>,
71 },
72 ClaimBearer {
73 #[serde(with = "crate::sync::base64_bytes")]
74 bytes: Vec<u8>,
75 },
76 Declare {
77 #[serde(with = "crate::sync::base64_bytes")]
78 descriptor: Vec<u8>,
79 budget: staging::Budget,
80 },
81 Status {
82 bootstrap: [u8; 32],
83 },
84 Ensure {
85 bootstrap: [u8; 32],
86 commitment: [u8; 32],
87 },
88 Cancel {
89 bootstrap: [u8; 32],
90 },
91 Publish {
92 bootstrap: [u8; 32],
93 commitment: [u8; 32],
94 #[serde(with = "crate::sync::base64_bytes")]
95 record: Vec<u8>,
96 },
97}
98
99#[derive(Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub enum Reply {
102 Claimed {
103 vault: [u8; 32],
104 claim: [u8; 32],
105 genesis: [u8; 32],
106 },
107 Missing,
108 Canceled,
109 Staging(staging::StagingStatus),
110 Stored,
111 Published(#[serde(with = "crate::sync::base64_bytes")] Vec<u8>),
112}
113
114impl From<staging::Status> for Reply {
115 fn from(value: staging::Status) -> Self {
116 match value {
117 staging::Status::Missing => Self::Missing,
118 staging::Status::Canceled => Self::Canceled,
119 staging::Status::Staging(status) => Self::Staging(status),
120 staging::Status::Published(outcome) => {
121 Self::Published(outcome.publication().record().to_vec())
122 }
123 }
124 }
125}
126
127pub struct Client {
129 link: Link,
130 endpoint: url::Url,
131}
132
133impl Client {
134 pub fn new(origin: &str, link: Link) -> Result<Self> {
135 Ok(Self {
136 link,
137 endpoint: super::origin::endpoint(origin, PATH)?,
138 })
139 }
140
141 pub async fn exchange(
142 &self,
143 genesis: &Genesis,
144 secret: &Secret,
145 operation: Operation,
146 ) -> Result<Reply> {
147 let claim = matches!(
148 &operation,
149 Operation::ClaimSetup { .. } | Operation::ClaimBearer { .. }
150 );
151 let bytes = serde_json::to_vec(&Envelope {
152 vault: genesis.context().vault_id,
153 genesis: genesis.commitment(),
154 operation,
155 })
156 .map_err(|_| anyhow::anyhow!("error bootstrap-request"))?;
157 ensure!(
158 bytes.len() <= REQUEST_LIMIT,
159 "error bootstrap-request-limit"
160 );
161 self.post(secret, exchange::json_content(), bytes, claim)
162 .await
163 }
164
165 pub async fn put_batch(
168 &self,
169 secret: &Secret,
170 header: &staging::batch::Header,
171 records: &[&[u8]],
172 ) -> Result<Reply> {
173 let bytes = staging::batch::encode(header, records)?;
174 let content_type = exchange::HttpHeader {
175 name: "content-type".into(),
176 value: staging::batch::CONTENT_TYPE.into(),
177 };
178 self.post(secret, content_type, bytes, false).await
179 }
180
181 async fn post(
182 &self,
183 secret: &Secret,
184 content_type: exchange::HttpHeader,
185 bytes: Vec<u8>,
186 claim: bool,
187 ) -> Result<Reply> {
188 let bytes = exchange::post(
189 &self.link,
190 &self.endpoint,
191 Some(secret),
192 content_type,
193 bytes,
194 RESPONSE_LIMIT,
195 )
196 .await
197 .map_err(|failure| match failure {
198 exchange::Failure::Network => {
199 anyhow::anyhow!("error bootstrap-network outcome-unknown")
200 }
201 exchange::Failure::SecureTransport => {
202 anyhow::anyhow!("error bootstrap-tls outcome-unknown")
203 }
204 exchange::Failure::Malformed => anyhow::anyhow!("error bootstrap-response"),
205 exchange::Failure::TooLarge => anyhow::anyhow!("error bootstrap-response-limit"),
206 exchange::Failure::RequestBodyLimit => {
207 anyhow::anyhow!("error sync-request-body-limit")
208 }
209 exchange::Failure::Refused { code, .. } => match code.as_deref() {
210 Some("bootstrap-storage-already-claimed") => {
211 anyhow::anyhow!("error bootstrap-storage-already-claimed")
212 }
213 Some("bootstrap-setup-invitation-rejected") if claim => {
214 anyhow::anyhow!("error bootstrap-setup-invitation-rejected")
215 }
216 Some("bootstrap-setup-invitation-expired") if claim => {
217 anyhow::anyhow!("error bootstrap-setup-invitation-expired")
218 }
219 Some("attachment-quota-exceeded") => {
220 anyhow::anyhow!("error attachment-quota-exceeded")
221 }
222 _ => anyhow::anyhow!("error bootstrap-refused outcome-unknown"),
223 },
224 })?;
225 serde_json::from_slice(&bytes).map_err(|_| anyhow::anyhow!("error bootstrap-response"))
226 }
227
228 pub async fn claim(
232 &self,
233 genesis: &Genesis,
234 authentication: ClaimAuthentication<'_>,
235 ) -> Result<()> {
236 let (secret, operation) = match authentication {
237 ClaimAuthentication::SetupSecret(secret) => (
238 secret,
239 Operation::ClaimSetup {
240 bytes: genesis.claim_bytes(),
241 },
242 ),
243 ClaimAuthentication::SeedBearer(secret) => (
244 secret,
245 Operation::ClaimBearer {
246 bytes: genesis.claim_bytes(),
247 },
248 ),
249 };
250 match self.exchange(genesis, secret, operation).await? {
251 Reply::Claimed {
252 vault,
253 claim,
254 genesis: commitment,
255 } => ClaimResult {
256 vault_id: vault,
257 claim_id: claim,
258 genesis_commitment: commitment,
259 }
260 .validate_pinned(genesis),
261 _ => anyhow::bail!("error bootstrap-response"),
262 }
263 }
264
265 pub async fn resume(
269 &self,
270 store: &ProtectedLocalKeyStore,
271 database: &Database,
272 ) -> Result<bool> {
273 self.resume_reporting(store, database, &|_, _| {}).await
274 }
275
276 pub async fn resume_reporting(
280 &self,
281 store: &ProtectedLocalKeyStore,
282 database: &Database,
283 uploaded: &(dyn Fn(u64, u64) + Sync),
284 ) -> Result<bool> {
285 self.resume_validated(store, database, None, uploaded).await
286 }
287
288 pub(crate) async fn resume_validated(
291 &self,
292 store: &ProtectedLocalKeyStore,
293 database: &Database,
294 proof: Option<ValidatedSeed>,
295 uploaded: &(dyn Fn(u64, u64) + Sync),
296 ) -> Result<bool> {
297 let mut proofs = ProofCache::new(proof);
298 let (seed, intent) = store.seed_resume_intent(database, &mut proofs).await?;
299 let publication = intent.publication(seed.genesis())?;
300 let binding = publication.binding();
301 let status = self
302 .exchange(
303 seed.genesis(),
304 seed.bearer(),
305 Operation::Status {
306 bootstrap: binding.bootstrap_id,
307 },
308 )
309 .await?;
310 let record = match status {
311 Reply::Published(record) => record,
312 Reply::Missing | Reply::Staging(_) => {
313 let upload = store
314 .seed_upload(database, &intent, &mut proofs)
315 .await?
316 .ok_or_else(|| anyhow::anyhow!("error bootstrap-outcome-missing"))?;
317 let _timer = StageTimer::start("upload");
318 let budget = upload.budget();
319 if let Reply::Staging(ref current) = status {
320 validate_staging(current, upload.slots(), binding, budget)?;
321 }
322 let staging = match status {
323 Reply::Missing => {
324 self.exchange(
325 seed.genesis(),
326 seed.bearer(),
327 Operation::Declare {
328 descriptor: upload.descriptor().to_vec(),
329 budget,
330 },
331 )
332 .await?
333 }
334 Reply::Staging(_) => {
335 self.exchange(
336 seed.genesis(),
337 seed.bearer(),
338 Operation::Ensure {
339 bootstrap: binding.bootstrap_id,
340 commitment: binding.descriptor_commitment,
341 },
342 )
343 .await?
344 }
345 _ => unreachable!(),
346 };
347 let Reply::Staging(staging) = staging else {
348 anyhow::bail!("error bootstrap-response");
349 };
350 validate_staging(&staging, upload.slots(), binding, budget)?;
351 let header = |records| staging::batch::Header {
352 vault: seed.genesis().context().vault_id,
353 genesis: seed.genesis().commitment(),
354 bootstrap: binding.bootstrap_id,
355 commitment: binding.descriptor_commitment,
356 records,
357 };
358 uploaded(0, budget.bytes);
359 let mut sent = 0_u64;
360 for batch in pack(upload.slots(), header) {
363 let records = upload.read(database, &batch).await?;
364 let refs: Vec<&[u8]> = records.iter().map(Vec::as_slice).collect();
365 let reply = self.put_batch(seed.bearer(), &header(batch), &refs).await?;
366 ensure!(
367 matches!(reply, Reply::Stored),
368 "error bootstrap-upload-refused"
369 );
370 sent += records
371 .iter()
372 .map(|record| record.len() as u64)
373 .sum::<u64>();
374 uploaded(sent, budget.bytes);
375 }
376 tracing::debug!(sent, total = budget.bytes, "uploaded frozen staged bytes");
377 match self
378 .exchange(
379 seed.genesis(),
380 seed.bearer(),
381 Operation::Publish {
382 bootstrap: binding.bootstrap_id,
383 commitment: binding.descriptor_commitment,
384 record: publication.record().to_vec(),
385 },
386 )
387 .await?
388 {
389 Reply::Published(record) => record,
390 _ => anyhow::bail!("error bootstrap-response"),
391 }
392 }
393 _ => anyhow::bail!("error bootstrap-candidate-unavailable"),
394 };
395 let outcome =
396 PublicationOutcome::from_response(seed.genesis(), intent.descriptor(), &record)?;
397 ensure!(
398 outcome.publication() == &publication,
399 "error bootstrap-outcome-mismatch"
400 );
401 let _timer = StageTimer::start("adopt");
402 store
403 .adopt_seed_publication_with(database, &outcome, &mut proofs)
404 .await
405 }
406}
407
408fn validate_staging(
411 status: &staging::StagingStatus,
412 slots: &[staging::batch::Slot],
413 binding: &crate::sync::seed_claim::PublicationBinding,
414 budget: staging::Budget,
415) -> Result<()> {
416 ensure!(
417 status.descriptor_commitment == binding.descriptor_commitment
418 && status.stream_id == binding.stream_id
419 && status.budget == budget,
420 "error bootstrap-status-mismatch"
421 );
422
423 let mut seen = Vec::new();
424 for listed in &status.components {
425 let expected = slots
426 .iter()
427 .filter(|slot| slot.component == listed.component)
428 .count();
429 ensure!(
430 expected > 0 && listed.chunks.len() == expected && !seen.contains(&listed.component),
431 "error bootstrap-status-mismatch"
432 );
433 seen.push(listed.component);
434 }
435
436 for component in [
439 staging::Component::DataCatalog,
440 staging::Component::PrefixCatalog,
441 staging::Component::ImageCatalog,
442 staging::Component::Manifest,
443 ] {
444 ensure!(seen.contains(&component), "error bootstrap-status-mismatch");
445 }
446
447 let catalog_complete = |component| {
448 status
449 .components
450 .iter()
451 .find(|listed| listed.component == component)
452 .is_some_and(|listed| {
453 listed
454 .chunks
455 .iter()
456 .all(|presence| *presence == staging::Presence::Verified)
457 })
458 };
459 let data_complete = catalog_complete(staging::Component::DataCatalog);
460 let images_complete = catalog_complete(staging::Component::ImageCatalog);
461 let listed = |component| seen.contains(&component);
462 ensure!(
463 listed(staging::Component::State) == data_complete,
464 "error bootstrap-status-mismatch"
465 );
466 let mut image_components = Vec::new();
467 for slot in slots {
468 if let staging::Component::Image(_) = slot.component
469 && !image_components.contains(&slot.component)
470 {
471 image_components.push(slot.component);
472 }
473 }
474 ensure!(
475 image_components
476 .iter()
477 .all(|component| listed(*component) == images_complete),
478 "error bootstrap-status-mismatch"
479 );
480 Ok(())
481}
482
483const UPLOAD_BATCH_PAYLOAD: u64 = staging::MAX_REQUEST_BYTES as u64;
486
487fn pack(
490 slots: &[staging::batch::Slot],
491 header: impl Fn(Vec<staging::batch::Slot>) -> staging::batch::Header,
492) -> Vec<Vec<staging::batch::Slot>> {
493 let mut batches: Vec<Vec<staging::batch::Slot>> = Vec::new();
494 let mut current: Vec<staging::batch::Slot> = Vec::new();
495 let mut payload = 0_u64;
496 for slot in slots {
497 let fits = !current.is_empty()
498 && current.len() < staging::batch::MAX_RECORDS
499 && payload + slot.len <= UPLOAD_BATCH_PAYLOAD
500 && current[0].component.is_catalog() == slot.component.is_catalog()
501 && staging::batch::header_len(&header(
502 current.iter().copied().chain([*slot]).collect(),
503 ))
504 .is_some();
505 if !fits && !current.is_empty() {
506 batches.push(std::mem::take(&mut current));
507 payload = 0;
508 }
509 current.push(*slot);
510 payload += slot.len;
511 }
512 if !current.is_empty() {
513 batches.push(current);
514 }
515 batches
516}
517
518#[cfg(any(test, feature = "test-support"))]
520pub fn components(
521 package: &crate::sync::bootstrap_format::Package,
522) -> Vec<(staging::Component, Vec<&[u8]>)> {
523 let mut out = Vec::new();
524 for (index, component) in [
525 staging::Component::DataCatalog,
526 staging::Component::PrefixCatalog,
527 staging::Component::ImageCatalog,
528 ]
529 .into_iter()
530 .enumerate()
531 {
532 out.push((
533 component,
534 package.catalogs[index].chunks(1_048_576).collect(),
535 ));
536 }
537 out.push((
538 staging::Component::Manifest,
539 package.manifest.iter().map(Vec::as_slice).collect(),
540 ));
541 out.push((
542 staging::Component::State,
543 package.state.iter().map(Vec::as_slice).collect(),
544 ));
545 for image in &package.images {
546 out.push((
547 staging::Component::Image(image.object_id),
548 image.records.iter().map(Vec::as_slice).collect(),
549 ));
550 }
551 out
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 fn header(records: Vec<staging::batch::Slot>) -> staging::batch::Header {
559 staging::batch::Header {
560 vault: [1; 32],
561 genesis: [2; 32],
562 bootstrap: [3; 32],
563 commitment: [4; 32],
564 records,
565 }
566 }
567
568 #[test]
569 fn upload_batches_acknowledge_each_full_size_record() {
570 let slots: Vec<_> = (0..4)
571 .map(|index| staging::batch::Slot {
572 component: staging::Component::State,
573 index,
574 len: staging::MAX_REQUEST_BYTES as u64,
575 })
576 .collect();
577 let batches = pack(&slots, header);
578 assert_eq!(batches.len(), slots.len());
579 assert!(batches.iter().all(|batch| batch.len() == 1));
580 assert_eq!(batches.concat(), slots);
581 }
582
583 #[test]
584 fn upload_batches_group_small_records_without_mixing_catalogs() {
585 let slots: Vec<_> = (0..10)
586 .map(|index| staging::batch::Slot {
587 component: if index < 2 {
588 staging::Component::DataCatalog
589 } else {
590 staging::Component::State
591 },
592 index,
593 len: UPLOAD_BATCH_PAYLOAD / 4,
594 })
595 .collect();
596 let batches = pack(&slots, header);
597 assert_eq!(batches.iter().map(Vec::len).collect::<Vec<_>>(), [2, 4, 4]);
598 assert_eq!(batches.concat(), slots);
599 for batch in batches {
600 let records: Vec<_> = batch
601 .iter()
602 .map(|slot| vec![0; slot.len as usize])
603 .collect();
604 let refs: Vec<_> = records.iter().map(Vec::as_slice).collect();
605 assert!(staging::batch::encode(&header(batch), &refs).is_ok());
606 }
607 }
608}