1use crate::config::BaseUrl;
6use crate::error::{breadcrumb, debug, trace, Error, Result};
7use crate::jexl_filter::JexlFilter;
8#[cfg(feature = "signatures")]
9use crate::signatures;
10use crate::storage::Storage;
11use crate::RemoteSettingsContext;
12use crate::{packaged_attachments, packaged_collections, RemoteSettingsServer};
13use parking_lot::{Mutex, MutexGuard};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::time::{Duration, Instant};
17use url::Url;
18use viaduct::{Request, Response};
19
20#[cfg(feature = "signatures")]
21#[cfg(not(test))]
22use std::time::{SystemTime, UNIX_EPOCH};
23
24#[cfg(feature = "signatures")]
25#[cfg(not(test))]
26fn epoch_seconds() -> u64 {
27 SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .unwrap() .as_secs()
31}
32
33#[cfg(feature = "signatures")]
34#[cfg(test)]
35thread_local! {
36 static MOCK_TIME: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) }
37}
38
39#[cfg(feature = "signatures")]
40#[cfg(test)]
41fn epoch_seconds() -> u64 {
42 MOCK_TIME.with(|mock_time| mock_time.get().unwrap_or(0))
43}
44
45const HEADER_BACKOFF: &str = "Backoff";
46const HEADER_RETRY_AFTER: &str = "Retry-After";
47
48#[cfg(feature = "signatures")]
52const ROOT_CERT_SHA256_HASH_PROD: &str = "C8:A8:0E:9A:FA:EF:4E:21:9B:6F:B5:D7:A7:1D:0F:10:12:23:BA:C5:00:1A:C2:8F:9B:0D:43:DC:59:A1:06:DB";
53#[cfg(feature = "signatures")]
54const ROOT_CERT_SHA256_HASH_NONPROD: &str = "3C:01:44:6A:BE:90:36:CE:A9:A0:9A:CA:A3:A5:20:AC:62:8F:20:A7:AE:32:CE:86:1C:B2:EF:B7:0F:A0:C7:45";
55
56#[derive(Debug, Clone, Deserialize)]
57struct CollectionData {
58 data: Vec<RemoteSettingsRecord>,
59 timestamp: u64,
60}
61
62pub struct RemoteSettingsClient<C = ViaductApiClient> {
67 collection_name: String,
69 inner: Mutex<RemoteSettingsClientInner<C>>,
70 pending_config: Mutex<Option<RemoteSettingsClientConfig>>,
73}
74
75struct RemoteSettingsClientInner<C> {
76 storage: Storage,
77 api_client: C,
78 jexl_filter: JexlFilter,
79}
80
81struct RemoteSettingsClientConfig {
82 server_url: BaseUrl,
83 bucket_name: String,
84 context: Option<RemoteSettingsContext>,
85}
86
87impl<C: ApiClient> RemoteSettingsClient<C> {
94 packaged_collections! {
96 ("main", "regions"),
97 ("main", "search-config-icons"),
98 ("main", "search-config-v2"),
99 ("main", "search-telemetry-v2"),
100 ("main", "summarizer-models-config"),
101 ("main", "translations-models"),
102 ("main", "translations-wasm"),
103 }
104
105 packaged_attachments! {
116 ("main", "regions") => [
117 "world",
118 "world-buffered",
119 ],
120 ("main", "search-config-icons") => [
121 "001500a9-1a6c-3f5a-ba15-a5f5a075d256",
122 "06cf7432-efd7-f244-927b-5e423005e1ea",
123 "0a57b0cf-34f0-4d09-96e4-dbd6e3355410",
124 "0d7668a8-c3f4-cfee-cbc8-536511528937",
125 "0eec5640-6fde-d6fe-322a-c72c6d5bd5a2",
126 "101ce01d-2691-b729-7f16-9d389803384b",
127 "177aba42-9bed-4078-e36b-580e8794cd7f",
128 "25de0352-aabb-d31f-15f7-bf9299fb004c",
129 "2bbe48f4-d3b8-c9e0-86e3-a54c37ec3335",
130 "2e835b0e-9709-d1bb-9725-87f59f3445ca",
131 "2ecca3f8-c1ef-43cc-b053-886d1ae46c36",
132 "32d26d19-aeb0-5c01-32e8-f8970be9246f",
133 "39d0b17d-c020-4890-932f-83c0f6ed130b",
134 "41135a88-093d-4077-873b-9de1ae133427",
135 "41f0d805-3775-4988-8d8c-5ad8ccd86d1c",
136 "47da97b5-600f-c450-fd15-a52bb2169c11",
137 "48c72361-cd67-412e-bd7f-f81a43c10791",
138 "4e271681-3e0f-91ac-9750-03f665efc171",
139 "50f6171f-8e7a-b41b-862e-f97397038fb2",
140 "5203dd03-2c55-4b53-9c60-58258d587be1",
141 "5914932e-66ba-4126-8be5-d37beadd9532",
142 "5ded611d-44b2-dc46-fd67-fb116888d75d",
143 "5e03d6f4-6ee9-8bc8-cf22-7a5f2cf55c41",
144 "6644f26f-28ea-4222-929d-5d43a02dae05",
145 "6d10d702-7bd6-1452-90a5-3df665a38f66",
146 "6e36a151-e4f4-4117-9067-1ca82c47d01a",
147 "6f4da442-d31e-28f8-03af-797d16bbdd27",
148 "7072564d-a573-4750-bf33-f0a07631c9eb",
149 "70fdd651-6c50-b7bb-09ec-7e85da259173",
150 "71f41a0c-5b70-4116-b30f-e62089083522",
151 "74793ce1-a918-a5eb-d3c0-2aadaff3c88c",
152 "74f94dc2-caf6-4b90-b3d2-f3e2f7714d88",
153 "764e3b14-fe16-4feb-8384-124c516a5afa",
154 "7bf4ca37-e2b8-4d31-a1c3-979bc0e85131",
155 "7c81cf98-7c11-4afd-8279-db89118a6dfb",
156 "7cb4d88a-d4df-45b2-87e4-f896eaf1bbdb",
157 "7edaf4fe-a8a0-432b-86d2-bf75ebe80851",
158 "7efbed51-813c-581d-d8d3-f8758434e451",
159 "84bb4962-e571-227a-9ef6-2ac5f2aac361",
160 "87ac4cde-f581-398b-1e32-eb4079183b36",
161 "8831ce10-b1e4-6eb4-4975-83c67457288e",
162 "890de5c4-0941-a116-473a-5d240e79497a",
163 "8abb10a7-212f-46b5-a7b4-244f414e3810",
164 "91a9672d-e945-8e1e-0996-aefdb0190716",
165 "94a84724-c30f-4767-ba42-01cc37fc31a4",
166 "96327a73-c433-5eb4-a16d-b090cadfb80b",
167 "9802e63d-05ec-48ba-93f9-746e0981ad98",
168 "9d96547d-7575-49ca-8908-1e046b8ea90e",
169 "a06db97d-1210-ea2e-5474-0e2f7d295bfd",
170 "a06dc3fd-4bdb-41f3-2ebc-4cbed06a9bd3",
171 "a2c7d4e9-f770-51e1-0963-3c2c8401631d",
172 "a83f24e4-602c-47bd-930c-ad0947ee1adf",
173 "b50c3e3d-7bd0-4118-856f-19b26b21d01f",
174 "b64f09fd-52d1-c48e-af23-4ce918e7bf3b",
175 "b882b24d-1776-4ef9-9016-0bdbd935eda3",
176 "b8ca5a94-8fff-27ad-6e00-96e244a32e21",
177 "b9424309-f601-4a69-98ca-ca68e65633e6",
178 "c411adc1-9661-4fb5-a4c1-8cfe74911943",
179 "cbf9e891-d079-2b28-5617-283450d463dd",
180 "d87f251c-3e12-a8bf-e2d0-afd43d36c5f9",
181 "db0e1627-ae89-4c25-8944-a9481d8512d9",
182 "e02f23df-8d48-2b1b-3b5c-6dd27302c61c",
183 "e718e983-09aa-e8f6-b25f-cd4b395d4785",
184 "e7547f62-187b-b641-d462-e54a3f813d9a",
185 "eb62e768-151b-45d1-9fe5-9e1d2a5991c5",
186 "f312610a-ebfb-a106-ea92-fd643c5d3636",
187 "f943d7bc-872e-4a81-810f-94d26465da69",
188 "fa0fc42c-d91d-fca7-34eb-806ff46062dc",
189 "fca3e3ee-56cd-f474-dc31-307fd24a891d",
190 "fe75ce3f-1545-400c-b28c-ad771054e69f",
191 "fed4f021-ff3e-942a-010e-afa43fda2136",
192 ],
193 ("main", "translations-wasm") => [
194 "4fd32605-9889-4dd9-9fc7-577ad1136746",
195 ]
196 }
197}
198
199impl<C: ApiClient> RemoteSettingsClient<C> {
200 pub fn new_from_parts(
201 collection_name: String,
202 storage: Storage,
203 jexl_filter: JexlFilter,
204 api_client: C,
205 ) -> Self {
206 Self {
207 collection_name,
208 inner: Mutex::new(RemoteSettingsClientInner {
209 storage,
210 api_client,
211 jexl_filter,
212 }),
213 pending_config: Mutex::new(None),
214 }
215 }
216
217 fn lock_inner(&self) -> Result<MutexGuard<'_, RemoteSettingsClientInner<C>>> {
221 let pending_config = self.get_pending_config();
222 let mut inner = self.inner.lock();
223 if let Some(config) = pending_config {
224 inner.api_client =
225 C::create(config.server_url, config.bucket_name, &self.collection_name);
226 inner.jexl_filter = JexlFilter::new(config.context);
227 inner.storage.empty()?;
228 }
229 Ok(inner)
230 }
231
232 fn get_pending_config(&self) -> Option<RemoteSettingsClientConfig> {
233 self.pending_config.lock().take()
234 }
235
236 pub fn collection_name(&self) -> &str {
237 &self.collection_name
238 }
239
240 fn load_packaged_timestamp(&self) -> Option<u64> {
241 Self::get_packaged_timestamp(&self.collection_name)
243 }
244
245 fn load_packaged_data(&self) -> Option<CollectionData> {
246 let str_data = Self::get_packaged_data(&self.collection_name)?;
248 let data: CollectionData = serde_json::from_str(str_data).ok()?;
249 debug_assert_eq!(data.timestamp, self.load_packaged_timestamp().unwrap());
250 Some(data)
251 }
252
253 fn load_packaged_attachment(&self, filename: &str) -> Option<(&'static [u8], &'static str)> {
254 Self::get_packaged_attachment(&self.collection_name, filename)
256 }
257
258 fn filter_records(
260 &self,
261 records: Vec<RemoteSettingsRecord>,
262 inner: &RemoteSettingsClientInner<C>,
263 ) -> Vec<RemoteSettingsRecord> {
264 records
265 .into_iter()
266 .filter(|record| match record.fields.get("filter_expression") {
267 Some(serde_json::Value::String(filter_expr)) => {
268 inner.jexl_filter.evaluate(filter_expr).unwrap_or(false)
269 }
270 _ => true, })
272 .collect()
273 }
274
275 fn get_packaged_data_if_newer(
278 &self,
279 storage: &mut Storage,
280 collection_url: &str,
281 ) -> Result<Option<CollectionData>> {
282 let packaged_ts = self.load_packaged_timestamp();
283 let storage_ts = storage.get_last_modified_timestamp(collection_url)?;
284 let packaged_is_newer = match (packaged_ts, storage_ts) {
285 (Some(packaged_ts), Some(storage_ts)) => packaged_ts > storage_ts,
286 (Some(_), None) => true, (None, _) => false, };
289
290 if packaged_is_newer {
291 Ok(self.load_packaged_data())
292 } else {
293 Ok(None)
294 }
295 }
296
297 pub fn get_records(&self, sync_if_empty: bool) -> Result<Option<Vec<RemoteSettingsRecord>>> {
302 let mut inner = self.lock_inner()?;
303 let collection_url = inner.api_client.collection_url();
304
305 if inner.api_client.is_prod_server()? {
310 if let Some(packaged_data) =
311 self.get_packaged_data_if_newer(&mut inner.storage, &collection_url)?
312 {
313 inner.storage.empty()?;
315 inner.storage.insert_collection_content(
317 &collection_url,
318 &packaged_data.data,
319 packaged_data.timestamp,
320 CollectionMetadata::default(),
321 )?;
322 return Ok(Some(self.filter_records(packaged_data.data, &inner)));
323 }
324 }
325
326 let cached_records = inner.storage.get_records(&collection_url)?;
327
328 match (cached_records, sync_if_empty) {
329 (Some(cached_records), _) => Ok(Some(self.filter_records(cached_records, &inner))),
334 (None, true) => {
336 drop(inner);
338 self.sync()?;
340 let mut inner = self.lock_inner()?;
342 Ok(inner
343 .storage
344 .get_records(&collection_url)?
345 .map(|records| self.filter_records(records, &inner)))
346 }
347 (None, false) => Ok(None),
349 }
350 }
351
352 pub fn get_last_modified_timestamp(&self) -> Result<Option<u64>> {
354 let mut inner = self.lock_inner()?;
355 let collection_url = inner.api_client.collection_url();
356 inner.storage.get_last_modified_timestamp(&collection_url)
357 }
358
359 fn perform_sync_operation(&self) -> Result<()> {
364 let mut inner = self.lock_inner()?;
365 let collection_url = inner.api_client.collection_url();
366 let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
367 let changeset = inner.api_client.fetch_changeset(timestamp)?;
368 debug!(
369 "{0}: apply {1} change(s) locally.",
370 self.collection_name,
371 changeset.changes.len()
372 );
373 inner.storage.insert_collection_content(
374 &collection_url,
375 &changeset.changes,
376 changeset.timestamp,
377 changeset.metadata,
378 )
379 }
380
381 pub fn sync(&self) -> Result<()> {
382 self.perform_sync_operation()?;
384 if self.verify_signature().is_err() {
386 debug!(
387 "{0}: signature verification failed. Reset and retry.",
388 self.collection_name
389 );
390 self.reset_storage()?;
392 self.perform_sync_operation()?;
393 self.verify_signature().inspect_err(|_| {
395 self.reset_storage()
397 .expect("Failed to reset storage after verification failure");
398 })?;
399 }
400 trace!("{0}: sync done.", self.collection_name);
401 Ok(())
402 }
403
404 pub fn run_maintenance(&self) -> Result<()> {
405 let mut inner = self.lock_inner()?;
406 inner.storage.run_maintenance()
407 }
408
409 pub fn reset_storage(&self) -> Result<()> {
410 trace!("{0}: reset local storage.", self.collection_name);
411 let mut inner = self.lock_inner()?;
412 let collection_url = inner.api_client.collection_url();
413 inner.storage.empty()?;
415 if inner.api_client.is_prod_server()? {
417 if let Some(packaged_data) = self.load_packaged_data() {
418 trace!("{0}: restore packaged dump.", self.collection_name);
419 inner.storage.insert_collection_content(
420 &collection_url,
421 &packaged_data.data,
422 packaged_data.timestamp,
423 CollectionMetadata::default(),
424 )?;
425 }
426 }
427 Ok(())
428 }
429
430 pub fn shutdown(&self) {
431 self.inner.lock().storage.close();
432 }
433
434 #[cfg(not(feature = "signatures"))]
435 fn verify_signature(&self) -> Result<()> {
436 debug!("{0}: signature verification skipped.", self.collection_name);
437 Ok(())
438 }
439
440 #[cfg(feature = "signatures")]
441 fn verify_signature(&self) -> Result<()> {
442 let mut inner = self.lock_inner()?;
443 let collection_url = inner.api_client.collection_url();
444 let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
445 let records = inner.storage.get_records(&collection_url)?;
446 let metadata = inner.storage.get_collection_metadata(&collection_url)?;
447 match (timestamp, &records, metadata) {
448 (Some(timestamp), Some(records), Some(metadata)) => {
449 let expected_root_hash = if inner.api_client.is_prod_server()? {
451 ROOT_CERT_SHA256_HASH_PROD
452 } else {
453 ROOT_CERT_SHA256_HASH_NONPROD
454 };
455 let mut result = Err(Error::IncompleteSignatureDataError(
458 "No valid signatures found".into(),
459 ));
460 for signature in &metadata.signatures {
461 if signature.mode != "p384ecdsa" {
462 continue;
465 }
466
467 let cert_chain_bytes = inner.api_client.fetch_cert(&signature.x5u)?;
468
469 let expected_leaf_cname = format!(
474 "{}.content-signature.mozilla.org",
475 if metadata.bucket.contains("security-state") {
476 "onecrl"
477 } else {
478 "remote-settings"
479 }
480 );
481
482 result = signatures::verify_signature(
483 timestamp,
484 records,
485 signature.signature.as_bytes(),
486 &cert_chain_bytes,
487 epoch_seconds(),
488 expected_root_hash,
489 &expected_leaf_cname,
490 )
491 .inspect_err(|err| {
492 debug!(
493 "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'",
494 self.collection_name, err, &signature.x5u, expected_leaf_cname
495 );
496 });
497 if result.is_ok() {
499 trace!("{0}: signature verification success.", self.collection_name);
500 return Ok(());
501 }
502 }
503 result
505 }
506 _ => {
507 let missing_field = if timestamp.is_none() {
508 "timestamp"
509 } else if records.is_none() {
510 "records"
511 } else {
512 "metadata"
513 };
514 Err(Error::IncompleteSignatureDataError(missing_field.into()))
515 }
516 }
517 }
518
519 pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> Result<Vec<u8>> {
522 let metadata = record
523 .attachment
524 .as_ref()
525 .ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
526
527 let mut inner = self.lock_inner()?;
528 let collection_url = inner.api_client.collection_url();
529
530 if let Some(data) = inner
532 .storage
533 .get_attachment(&collection_url, metadata.clone())?
534 {
535 return Ok(data);
536 }
537
538 if inner.api_client.is_prod_server()? {
540 if let Some((data, manifest)) = self.load_packaged_attachment(&record.id) {
541 if let Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) {
542 if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
543 && metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
544 {
545 inner
547 .storage
548 .set_attachment(&collection_url, &metadata.location, data)?;
549 return Ok(data.to_vec());
550 }
551 }
552 }
553 }
554
555 let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
557
558 if attachment.len() as u64 != metadata.size {
560 return Err(Error::RecordAttachmentMismatchError(
561 "Downloaded attachment size mismatch".into(),
562 ));
563 }
564 let hash = format!("{:x}", Sha256::digest(&attachment));
565 if hash != metadata.hash {
566 return Err(Error::RecordAttachmentMismatchError(
567 "Downloaded attachment hash mismatch".into(),
568 ));
569 }
570
571 inner
573 .storage
574 .set_attachment(&collection_url, &metadata.location, &attachment)?;
575 Ok(attachment)
576 }
577
578 pub fn update_config(
579 &self,
580 server_url: BaseUrl,
581 bucket_name: String,
582 context: Option<RemoteSettingsContext>,
583 ) {
584 let mut pending_config = self.pending_config.lock();
585 *pending_config = Some(RemoteSettingsClientConfig {
586 server_url,
587 bucket_name,
588 context,
589 })
590 }
591}
592
593impl RemoteSettingsClient<ViaductApiClient> {
594 pub fn new(
595 server_url: BaseUrl,
596 bucket_name: String,
597 collection_name: String,
598 context: Option<RemoteSettingsContext>,
599 storage: Storage,
600 ) -> Self {
601 let api_client = ViaductApiClient::new(server_url, &bucket_name, &collection_name);
602 let jexl_filter = JexlFilter::new(context);
603
604 Self::new_from_parts(collection_name, storage, jexl_filter, api_client)
605 }
606}
607
608#[cfg_attr(test, mockall::automock)]
609pub trait ApiClient {
610 fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self;
612
613 fn collection_url(&self) -> String;
621
622 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse>;
624
625 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>>;
627
628 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>>;
630
631 fn is_prod_server(&self) -> Result<bool>;
633}
634
635pub struct ViaductApiClient {
637 endpoints: RemoteSettingsEndpoints,
638 remote_state: RemoteState,
639}
640
641impl ViaductApiClient {
642 fn new(base_url: BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
643 Self {
644 endpoints: RemoteSettingsEndpoints::new(&base_url, bucket_name, collection_name),
645 remote_state: RemoteState::default(),
646 }
647 }
648
649 fn make_request(&mut self, url: Url) -> Result<Response> {
650 self._make_request(url.clone())
651 .inspect_err(|e| breadcrumb!("Request error: {e} ({url})"))
652 }
653
654 fn _make_request(&mut self, url: Url) -> Result<Response> {
655 self.remote_state.ensure_no_backoff()?;
656
657 let req = Request::get(url);
658 let resp = req.send()?;
659
660 self.remote_state.handle_backoff_hint(&resp)?;
661
662 if resp.is_success() {
663 Ok(resp)
664 } else {
665 Err(Error::response_error(
666 &resp.url,
667 format!("status code: {}", resp.status),
668 ))
669 }
670 }
671}
672
673impl ApiClient for ViaductApiClient {
674 fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self {
675 Self::new(server_url, &bucket_name, collection_name)
676 }
677
678 fn collection_url(&self) -> String {
679 self.endpoints.collection_url.to_string()
680 }
681
682 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse> {
683 let mut url = self.endpoints.changeset_url.clone();
684 url.query_pairs_mut().append_pair("_expected", "0");
690 if let Some(timestamp) = timestamp {
691 url.query_pairs_mut()
692 .append_pair("_since", &format!("{}", timestamp));
693 }
694
695 let resp = self.make_request(url)?;
696
697 if resp.is_success() {
698 Ok(resp.json::<ChangesetResponse>()?)
699 } else {
700 Err(Error::response_error(
701 &resp.url,
702 format!("status code: {}", resp.status),
703 ))
704 }
705 }
706
707 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>> {
708 let attachments_base_url = match &self.remote_state.attachments_base_url {
709 Some(attachments_base_url) => attachments_base_url.to_owned(),
710 None => {
711 let server_info = self
712 .make_request(self.endpoints.root_url.clone())?
713 .json::<ServerInfo>()?;
714 let attachments_base_url = match server_info.capabilities.attachments {
715 Some(capability) => Url::parse(&capability.base_url)?,
716 None => Err(Error::AttachmentsUnsupportedError)?,
717 };
718 self.remote_state.attachments_base_url = Some(attachments_base_url.clone());
719 attachments_base_url
720 }
721 };
722
723 let resp = self.make_request(attachments_base_url.join(attachment_location)?)?;
724 Ok(resp.body)
725 }
726
727 fn is_prod_server(&self) -> Result<bool> {
728 Ok(self
729 .endpoints
730 .root_url
731 .as_str()
732 .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str()))
733 }
734
735 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>> {
736 let resp = self.make_request(Url::parse(x5u)?)?;
737 Ok(resp.body)
738 }
739}
740
741struct RemoteSettingsEndpoints {
745 root_url: Url,
749 collection_url: Url,
759 changeset_url: Url,
766}
767
768impl RemoteSettingsEndpoints {
769 fn new(base_url: &BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
773 let mut root_url = base_url.clone();
774 root_url.path_segments_mut().push("");
776
777 let mut collection_url = base_url.clone();
778 collection_url
779 .path_segments_mut()
780 .push("buckets")
781 .push(bucket_name)
782 .push("collections")
783 .push(collection_name);
784
785 let mut changeset_url = collection_url.clone();
786 changeset_url.path_segments_mut().push("changeset");
787
788 Self {
789 root_url: root_url.into_inner(),
790 collection_url: collection_url.into_inner(),
791 changeset_url: changeset_url.into_inner(),
792 }
793 }
794}
795
796#[derive(Clone, Deserialize, Serialize)]
797pub struct ChangesetResponse {
798 changes: Vec<RemoteSettingsRecord>,
799 timestamp: u64,
800 metadata: CollectionMetadata,
801}
802
803#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
804pub struct CollectionMetadata {
805 pub bucket: String,
806 pub signatures: Vec<CollectionSignature>,
807}
808
809#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
810pub struct CollectionSignature {
811 pub signature: String,
812 pub x5u: String,
814 pub mode: String,
816}
817
818#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
821pub struct RemoteSettingsRecord {
822 pub id: String,
823 pub last_modified: u64,
824 #[serde(default)]
826 pub deleted: bool,
827 pub attachment: Option<Attachment>,
828 #[serde(flatten)]
829 pub fields: RsJsonObject,
830}
831
832#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
835pub struct Attachment {
836 pub filename: String,
837 pub mimetype: String,
838 pub location: String,
839 pub hash: String,
840 pub size: u64,
841}
842
843pub type RsJsonObject = serde_json::Map<String, serde_json::Value>;
849uniffi::custom_type!(RsJsonObject, String, {
850 remote,
851 try_lift: |val| {
852 let json: serde_json::Value = serde_json::from_str(&val)?;
853
854 match json {
855 serde_json::Value::Object(obj) => Ok(obj),
856 _ => Err(uniffi::deps::anyhow::anyhow!(
857 "Unexpected JSON-non-object in the bagging area"
858 )),
859 }
860 },
861 lower: |obj| serde_json::Value::Object(obj).to_string(),
862});
863
864#[derive(Clone, Debug)]
865pub(crate) struct RemoteState {
866 attachments_base_url: Option<Url>,
867 backoff: BackoffState,
868}
869
870impl Default for RemoteState {
871 fn default() -> Self {
872 Self {
873 attachments_base_url: None,
874 backoff: BackoffState::Ok,
875 }
876 }
877}
878
879impl RemoteState {
880 pub fn handle_backoff_hint(&mut self, response: &Response) -> Result<()> {
881 let extract_backoff_header = |header| -> Result<u64> {
882 Ok(response
883 .headers
884 .get_as::<u64, _>(header)
885 .transpose()
886 .unwrap_or_default() .unwrap_or(0))
888 };
889 let backoff = extract_backoff_header(HEADER_BACKOFF)?;
891 let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?;
892 let max_backoff = backoff.max(retry_after);
893
894 if max_backoff > 0 {
895 self.backoff = BackoffState::Backoff {
896 observed_at: Instant::now(),
897 duration: Duration::from_secs(max_backoff),
898 };
899 }
900 Ok(())
901 }
902
903 pub fn ensure_no_backoff(&mut self) -> Result<()> {
904 if let BackoffState::Backoff {
905 observed_at,
906 duration,
907 } = self.backoff
908 {
909 let elapsed_time = observed_at.elapsed();
910 if elapsed_time >= duration {
911 self.backoff = BackoffState::Ok;
912 } else {
913 let remaining = duration - elapsed_time;
914 return Err(Error::BackoffError(remaining.as_secs()));
915 }
916 }
917 Ok(())
918 }
919}
920
921#[derive(Clone, Copy, Debug)]
923pub(crate) enum BackoffState {
924 Ok,
925 Backoff {
926 observed_at: Instant,
927 duration: Duration,
928 },
929}
930
931#[derive(Deserialize)]
932struct ServerInfo {
933 capabilities: Capabilities,
934}
935
936#[derive(Deserialize)]
937struct Capabilities {
938 attachments: Option<AttachmentsCapability>,
939}
940
941#[derive(Deserialize)]
942struct AttachmentsCapability {
943 base_url: String,
944}
945
946#[cfg(test)]
947mod test_new_client {
948 use super::*;
949
950 #[test]
951 fn test_endpoints() {
952 let endpoints = RemoteSettingsEndpoints::new(
953 &BaseUrl::parse("http://rs.example.com/v2").unwrap(),
954 "main",
955 "test-collection",
956 );
957 assert_eq!(endpoints.root_url.to_string(), "http://rs.example.com/v2/");
958 assert_eq!(
959 endpoints.collection_url.to_string(),
960 "http://rs.example.com/v2/buckets/main/collections/test-collection",
961 );
962 assert_eq!(
963 endpoints.changeset_url.to_string(),
964 "http://rs.example.com/v2/buckets/main/collections/test-collection/changeset",
965 );
966 }
967}
968
969#[cfg(test)]
970mod viaduct_client_tests {
971 use super::*;
972
973 #[test]
974 fn test_fetch_uses_local_timestamp_as_unquoted_since() {
975 viaduct_dev::init_backend_dev();
976 let changeset = mockito::mock(
977 "GET",
978 "/v2/buckets/main/collections/test-collection/changeset",
979 )
980 .match_query(mockito::Matcher::AllOf(vec![
982 mockito::Matcher::UrlEncoded("_expected".into(), "0".into()),
983 mockito::Matcher::UrlEncoded("_since".into(), "42".into()),
984 ]))
985 .with_status(200)
986 .with_header("content-type", "application/json")
987 .with_body(
988 r#"{"changes": [], "timestamp": 42, "metadata": {"bucket": "main", "signatures": []}}"#,
989 )
990 .create();
991
992 let mut api_client = ViaductApiClient::new(
993 BaseUrl::parse(&format!("{}/v2", mockito::server_url())).unwrap(),
994 "main",
995 "test-collection",
996 );
997 api_client.fetch_changeset(Some(42)).unwrap();
998
999 changeset.assert();
1000 }
1001}
1002
1003#[cfg(test)]
1004mod jexl_tests {
1005 use super::*;
1006 use std::sync::{Arc, Weak};
1007
1008 #[test]
1009 fn test_get_records_filtered_app_version_pass() {
1010 let mut api_client = MockApiClient::new();
1011 let records = vec![RemoteSettingsRecord {
1012 id: "record-0001".into(),
1013 last_modified: 100,
1014 deleted: false,
1015 attachment: None,
1016 fields: serde_json::json!({
1017 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1018 })
1019 .as_object()
1020 .unwrap()
1021 .clone(),
1022 }];
1023 let changeset = ChangesetResponse {
1024 changes: records.clone(),
1025 timestamp: 42,
1026 metadata: CollectionMetadata::default(),
1027 };
1028 api_client.expect_collection_url().returning(|| {
1029 "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1030 });
1031 api_client.expect_fetch_changeset().returning({
1032 let changeset = changeset.clone();
1033 move |timestamp| {
1034 assert_eq!(timestamp, None);
1035 Ok(changeset.clone())
1036 }
1037 });
1038 api_client.expect_is_prod_server().returning(|| Ok(false));
1039
1040 let context = RemoteSettingsContext {
1041 app_version: Some("129.0.0".to_string()),
1042 ..Default::default()
1043 };
1044
1045 let mut storage = Storage::new(":memory:".into());
1046 let _ = storage.insert_collection_content(
1047 "http://rs.example.com/v2/buckets/main/collections/test-collection",
1048 &records,
1049 42,
1050 CollectionMetadata::default(),
1051 );
1052
1053 let rs_client = RemoteSettingsClient::new_from_parts(
1054 "test-collection".into(),
1055 storage,
1056 JexlFilter::new(Some(context)),
1057 api_client,
1058 );
1059
1060 assert_eq!(
1061 rs_client.get_records(false).expect("Error getting records"),
1062 Some(records)
1063 );
1064 }
1065
1066 #[test]
1067 fn test_get_records_filtered_app_version_too_low() {
1068 let mut api_client = MockApiClient::new();
1069 let records = vec![RemoteSettingsRecord {
1070 id: "record-0001".into(),
1071 last_modified: 100,
1072 deleted: false,
1073 attachment: None,
1074 fields: serde_json::json!({
1075 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1076 })
1077 .as_object()
1078 .unwrap()
1079 .clone(),
1080 }];
1081 let changeset = ChangesetResponse {
1082 changes: records.clone(),
1083 timestamp: 42,
1084 metadata: CollectionMetadata::default(),
1085 };
1086 api_client.expect_collection_url().returning(|| {
1087 "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1088 });
1089 api_client.expect_fetch_changeset().returning({
1090 let changeset = changeset.clone();
1091 move |timestamp| {
1092 assert_eq!(timestamp, None);
1093 Ok(changeset.clone())
1094 }
1095 });
1096 api_client.expect_is_prod_server().returning(|| Ok(false));
1097
1098 let context = RemoteSettingsContext {
1099 app_version: Some("127.0.0.".to_string()),
1100 ..Default::default()
1101 };
1102
1103 let mut storage = Storage::new(":memory:".into());
1104 let _ = storage.insert_collection_content(
1105 "http://rs.example.com/v2/buckets/main/collections/test-collection",
1106 &records,
1107 42,
1108 CollectionMetadata::default(),
1109 );
1110
1111 let rs_client = RemoteSettingsClient::new_from_parts(
1112 "test-collection".into(),
1113 storage,
1114 JexlFilter::new(Some(context)),
1115 api_client,
1116 );
1117
1118 assert_eq!(
1119 rs_client.get_records(false).expect("Error getting records"),
1120 Some(vec![])
1121 );
1122 }
1123
1124 #[test]
1125 fn test_update_jexl_context() {
1126 let mut api_client = MockApiClient::new();
1127 let records = vec![RemoteSettingsRecord {
1128 id: "record-0001".into(),
1129 last_modified: 100,
1130 deleted: false,
1131 attachment: None,
1132 fields: serde_json::json!({
1133 "filter_expression": "env.country == \"US\""
1134 })
1135 .as_object()
1136 .unwrap()
1137 .clone(),
1138 }];
1139 let changeset = ChangesetResponse {
1140 changes: records.clone(),
1141 timestamp: 42,
1142 metadata: CollectionMetadata::default(),
1143 };
1144 api_client.expect_collection_url().returning(|| {
1145 "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1146 });
1147 api_client.expect_fetch_changeset().returning({
1148 let changeset = changeset.clone();
1149 move |timestamp| {
1150 assert_eq!(timestamp, None);
1151 Ok(changeset.clone())
1152 }
1153 });
1154 api_client.expect_is_prod_server().returning(|| Ok(false));
1155
1156 let context = RemoteSettingsContext {
1157 country: Some("US".to_string()),
1158 ..Default::default()
1159 };
1160
1161 let mut storage = Storage::new(":memory:".into());
1162 let _ = storage.insert_collection_content(
1163 "http://rs.example.com/v2/buckets/main/collections/test-collection",
1164 &records,
1165 42,
1166 CollectionMetadata::default(),
1167 );
1168
1169 let rs_client = RemoteSettingsClient::new_from_parts(
1170 "test-collection".into(),
1171 storage,
1172 JexlFilter::new(Some(context)),
1173 api_client,
1174 );
1175
1176 assert_eq!(
1177 rs_client.get_records(false).expect("Error getting records"),
1178 Some(records)
1179 );
1180
1181 rs_client.inner.lock().jexl_filter = JexlFilter::new(Some(RemoteSettingsContext {
1184 country: Some("UK".to_string()),
1185 ..Default::default()
1186 }));
1187
1188 assert_eq!(
1189 rs_client.get_records(false).expect("Error getting records"),
1190 Some(vec![])
1191 );
1192 }
1193
1194 #[test]
1197 fn test_update_config_deadlock() {
1198 let mut api_client = MockApiClient::new();
1199 let rs_client_ref: Arc<Mutex<Weak<RemoteSettingsClient<MockApiClient>>>> =
1200 Arc::new(Mutex::new(Weak::new()));
1201 let rs_client_ref2 = rs_client_ref.clone();
1202
1203 api_client.expect_collection_url().returning(move || {
1204 rs_client_ref2
1215 .lock()
1216 .upgrade()
1217 .expect("rs_client_ref not set")
1218 .update_config(
1219 BaseUrl::parse("https://example.com/").unwrap(),
1220 "test-collection".to_string(),
1221 None,
1222 );
1223 "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1224 });
1225 api_client.expect_is_prod_server().returning(|| Ok(false));
1226
1227 let context = RemoteSettingsContext {
1228 app_version: Some("129.0.0".to_string()),
1229 ..Default::default()
1230 };
1231 let storage = Storage::new(":memory:".into());
1232
1233 let rs_client = Arc::new(RemoteSettingsClient::new_from_parts(
1234 "test-collection".into(),
1235 storage,
1236 JexlFilter::new(Some(context)),
1237 api_client,
1238 ));
1239 *rs_client_ref.lock() = Arc::downgrade(&rs_client);
1240
1241 assert_eq!(
1242 rs_client.get_records(false).expect("Error getting records"),
1243 None,
1244 );
1245 }
1246}
1247
1248#[cfg(feature = "signatures")]
1249#[cfg(test)]
1250mod test_signatures {
1251 use core::assert_eq;
1252
1253 use crate::RemoteSettingsContext;
1254
1255 use super::*;
1256 use nss_as::ensure_initialized;
1257
1258 const VALID_CERTIFICATE: &str = "\
1259-----BEGIN CERTIFICATE-----
1260MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
1261AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
1262bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
1263dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
1264emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ
1265BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
1266biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
1267bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt
1268c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb
1269HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt
1270zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD
1271MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
1272GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0
1273dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
1274aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ
12759FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc
1276fS8//SQGTlCqKQ==
1277-----END CERTIFICATE-----
1278-----BEGIN CERTIFICATE-----
1279MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC
1280VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1281ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1282aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1283bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB
1284ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt
1285BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw
1286QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz
1287cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE
1288xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz
1289vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6
1290MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK
1291BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud
1292IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT
1293AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE
1294ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln
1295bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk
1296b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk
1297b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250
1298ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t
1299b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx
1300qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ
13019qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH
1302TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr
1303v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O
1304ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea
1305UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg
1306VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0
1307xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d
1308mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi
1309ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz
1310LC4GzWQGSCGDyD+JCVw=
1311-----END CERTIFICATE-----
1312-----BEGIN CERTIFICATE-----
1313MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC
1314VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1315ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1316aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1317bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB
1318qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW
1319aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0
1320LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz
1321dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC
1322DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8
1323rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5
1324eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k
132551uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj
1326khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD
1327OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf
13284sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM
1329NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+
1330uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh
13317v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y
1332JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB
1333o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/
1334BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk
1335IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls
1336bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd
1337MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw
1338CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx
1339HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk
1340b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl
1341cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA
1342nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap
1343Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD
1344DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+
1345AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF
13462iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d
1347PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA
1348dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8
13495xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy
1350iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO
13516pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg
1352IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE=
1353-----END CERTIFICATE-----";
1354 const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#;
1355 const VALID_CERT_EPOCH_SECONDS: u64 = 1615559719;
1356
1357 fn build_client(
1358 diff_records: &[RemoteSettingsRecord],
1359 full_records: &[RemoteSettingsRecord],
1360 certificate: &str,
1361 signatures: &[CollectionSignature],
1362 epoch_secs: u64,
1363 bucket: &str,
1364 ) -> RemoteSettingsClient<MockApiClient> {
1365 let collection_name = "pioneer-study-addons";
1366
1367 MOCK_TIME.with(|cell| cell.set(Some(epoch_secs)));
1368
1369 let some_metadata = CollectionMetadata {
1370 bucket: bucket.into(),
1371 signatures: signatures.to_vec(),
1372 };
1373 let diff_changeset = ChangesetResponse {
1375 changes: diff_records.to_vec(),
1376 timestamp: 1603992731957,
1377 metadata: some_metadata.clone(),
1378 };
1379 let full_changeset = ChangesetResponse {
1381 changes: full_records.to_vec(),
1382 timestamp: 1603992731957,
1383 metadata: some_metadata.clone(),
1384 };
1385
1386 let mut api_client = MockApiClient::new();
1387 api_client
1388 .expect_collection_url()
1389 .returning(move || format!("http://server/{}", collection_name));
1390 api_client.expect_is_prod_server().returning(|| Ok(false));
1391 api_client.expect_fetch_changeset().returning(move |since| {
1392 Ok(if since.is_some() {
1393 diff_changeset.clone()
1394 } else {
1395 full_changeset.clone()
1396 })
1397 });
1398
1399 let certificate = certificate.to_string();
1400 api_client
1401 .expect_fetch_cert()
1402 .returning(move |_| Ok(certificate.clone().into_bytes()));
1403
1404 let storage = Storage::new(":memory:".into());
1405 let jexl_filter = JexlFilter::new(Some(RemoteSettingsContext::default()));
1406 RemoteSettingsClient::new_from_parts(
1407 collection_name.to_string(),
1408 storage,
1409 jexl_filter,
1410 api_client,
1411 )
1412 }
1413
1414 fn run_client_sync(
1415 diff_records: &[RemoteSettingsRecord],
1416 full_records: &[RemoteSettingsRecord],
1417 certificate: &str,
1418 signatures: &[CollectionSignature],
1419 epoch_secs: u64,
1420 bucket: &str,
1421 ) -> Result<()> {
1422 build_client(
1423 diff_records,
1424 full_records,
1425 certificate,
1426 signatures,
1427 epoch_secs,
1428 bucket,
1429 )
1430 .sync()
1431 }
1432
1433 #[test]
1434 fn test_valid_signature() -> Result<()> {
1435 ensure_initialized();
1436 run_client_sync(
1437 &[],
1438 &[],
1439 VALID_CERTIFICATE,
1440 &[CollectionSignature {
1441 signature: VALID_SIGNATURE.to_string(),
1442 x5u: "http://mocked".into(),
1443 mode: "p384ecdsa".into(),
1444 }],
1445 VALID_CERT_EPOCH_SECONDS,
1446 "main",
1447 )
1448 .expect("Valid signature");
1449 Ok(())
1450 }
1451
1452 #[test]
1453 fn test_second_signature_is_valid() -> Result<()> {
1454 ensure_initialized();
1455 run_client_sync(
1456 &[],
1457 &[],
1458 VALID_CERTIFICATE,
1459 &[
1460 CollectionSignature {
1461 signature: "invalid signature".to_string(),
1462 x5u: "http://mocked".into(),
1463 mode: "p384ecdsa".into(),
1464 },
1465 CollectionSignature {
1466 signature: VALID_SIGNATURE.to_string(),
1467 x5u: "http://mocked".into(),
1468 mode: "p384ecdsa".into(),
1469 },
1470 ],
1471 VALID_CERT_EPOCH_SECONDS,
1472 "main",
1473 )
1474 .expect("Valid signature");
1475 Ok(())
1476 }
1477
1478 #[test]
1479 fn test_first_signature_has_unknown_type() -> Result<()> {
1480 ensure_initialized();
1481 run_client_sync(
1482 &[],
1483 &[],
1484 VALID_CERTIFICATE,
1485 &[
1486 CollectionSignature {
1487 signature: "unkown signature".to_string(),
1488 x5u: "http://mocked".into(),
1489 mode: "mldsa".into(),
1491 },
1492 CollectionSignature {
1493 signature: VALID_SIGNATURE.to_string(),
1494 x5u: "http://mocked".into(),
1495 mode: "p384ecdsa".into(),
1496 },
1497 ],
1498 VALID_CERT_EPOCH_SECONDS,
1499 "main",
1500 )
1501 .expect("Valid signature");
1502 Ok(())
1503 }
1504
1505 #[test]
1506 fn test_valid_signature_after_retry() -> Result<()> {
1507 ensure_initialized();
1508 run_client_sync(
1509 &[RemoteSettingsRecord {
1510 id: "bad-record".to_string(),
1511 last_modified: 9999,
1512 deleted: true,
1513 attachment: None,
1514 fields: serde_json::Map::new(),
1515 }],
1516 &[],
1517 VALID_CERTIFICATE,
1518 &[CollectionSignature {
1519 signature: VALID_SIGNATURE.to_string(),
1520 x5u: "http://mocked".into(),
1521 mode: "p384ecdsa".into(),
1522 }],
1523 VALID_CERT_EPOCH_SECONDS,
1524 "main",
1525 )
1526 .expect("Valid signature");
1527 Ok(())
1528 }
1529
1530 #[test]
1531 fn test_invalid_signature_value() -> Result<()> {
1532 ensure_initialized();
1533 let err = run_client_sync(
1534 &[],
1535 &[],
1536 VALID_CERTIFICATE,
1537 &[CollectionSignature {
1538 signature: "invalid signature".to_string(),
1539 x5u: "http://mocked".into(),
1540 mode: "p384ecdsa".into(),
1541 }],
1542 VALID_CERT_EPOCH_SECONDS,
1543 "main",
1544 )
1545 .unwrap_err();
1546 assert!(matches!(err, Error::SignatureError(_)));
1547 assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
1548
1549 Ok(())
1550 }
1551
1552 #[test]
1553 fn test_invalid_certificate_value() -> Result<()> {
1554 ensure_initialized();
1555 let err = run_client_sync(
1556 &[],
1557 &[],
1558 "some bad PEM content",
1559 &[CollectionSignature {
1560 signature: VALID_SIGNATURE.to_string(),
1561 x5u: "http://mocked".into(),
1562 mode: "p384ecdsa".into(),
1563 }],
1564 VALID_CERT_EPOCH_SECONDS,
1565 "main",
1566 )
1567 .unwrap_err();
1568
1569 assert!(matches!(err, Error::SignatureError(_)));
1570 assert_eq!(
1571 format!("{}", err),
1572 "Signature could not be verified: PEM content format error: Missing PEM data"
1573 );
1574
1575 Ok(())
1576 }
1577
1578 #[test]
1579 fn test_invalid_signature_expired_cert() -> Result<()> {
1580 ensure_initialized();
1581 let december_20_2024 = 1734651582;
1582
1583 let err = run_client_sync(
1584 &[],
1585 &[],
1586 VALID_CERTIFICATE,
1587 &[CollectionSignature {
1588 signature: VALID_SIGNATURE.to_string(),
1589 x5u: "http://mocked".into(),
1590 mode: "p384ecdsa".into(),
1591 }],
1592 december_20_2024,
1593 "main",
1594 )
1595 .unwrap_err();
1596
1597 assert!(matches!(err, Error::SignatureError(_)));
1598 assert_eq!(
1599 format!("{}", err),
1600 "Signature could not be verified: Certificate not yet valid or expired"
1601 );
1602
1603 Ok(())
1604 }
1605
1606 #[test]
1607 fn test_invalid_signature_invalid_data() -> Result<()> {
1608 ensure_initialized();
1609 let records = vec![RemoteSettingsRecord {
1611 id: "unexpected-data".to_string(),
1612 last_modified: 42,
1613 deleted: false,
1614 attachment: None,
1615 fields: serde_json::Map::new(),
1616 }];
1617 let err = run_client_sync(
1618 &records,
1619 &records,
1620 VALID_CERTIFICATE,
1621 &[CollectionSignature {
1622 signature: VALID_SIGNATURE.to_string(),
1623 x5u: "http://mocked".into(),
1624 mode: "p384ecdsa".into(),
1625 }],
1626 VALID_CERT_EPOCH_SECONDS,
1627 "main",
1628 )
1629 .unwrap_err();
1630
1631 assert!(matches!(err, Error::SignatureError(_)));
1632 assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
1633
1634 Ok(())
1635 }
1636
1637 #[test]
1638 fn test_invalid_signature_invalid_signer_name() -> Result<()> {
1639 ensure_initialized();
1640 let err = run_client_sync(
1641 &[],
1642 &[],
1643 VALID_CERTIFICATE,
1644 &[CollectionSignature {
1645 signature: VALID_SIGNATURE.to_string(),
1646 x5u: "http://mocked".into(),
1647 mode: "p384ecdsa".into(),
1648 }],
1649 VALID_CERT_EPOCH_SECONDS,
1650 "security-state",
1651 )
1652 .unwrap_err();
1653 assert!(matches!(err, Error::SignatureError(_)));
1654 assert_eq!(
1655 format!("{}", err),
1656 "Signature could not be verified: Certificate subject mismatch"
1657 );
1658
1659 Ok(())
1660 }
1661
1662 #[test]
1663 fn test_get_records_sync_if_empty_verifies_signature() -> Result<()> {
1664 ensure_initialized();
1665 let rs_client = build_client(
1666 &[],
1667 &[],
1668 VALID_CERTIFICATE,
1669 &[CollectionSignature {
1670 signature: "invalid signature".to_string(),
1671 x5u: "http://mocked".into(),
1672 mode: "p384ecdsa".into(),
1673 }],
1674 VALID_CERT_EPOCH_SECONDS,
1675 "main",
1676 );
1677
1678 let err = rs_client.get_records(true).unwrap_err();
1679
1680 assert!(matches!(err, Error::SignatureError(_)));
1681 assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
1682
1683 let mut inner = rs_client.lock_inner()?;
1685 let collection_url = inner.api_client.collection_url();
1686 assert_eq!(inner.storage.get_records(&collection_url)?, None);
1687
1688 Ok(())
1689 }
1690
1691 #[test]
1692 fn test_get_records_sync_if_empty_with_valid_signature() -> Result<()> {
1693 ensure_initialized();
1694 let rs_client = build_client(
1695 &[],
1696 &[],
1697 VALID_CERTIFICATE,
1698 &[CollectionSignature {
1699 signature: VALID_SIGNATURE.to_string(),
1700 x5u: "http://mocked".into(),
1701 mode: "p384ecdsa".into(),
1702 }],
1703 VALID_CERT_EPOCH_SECONDS,
1704 "main",
1705 );
1706
1707 assert_eq!(rs_client.get_records(true)?, Some(vec![]));
1709
1710 Ok(())
1711 }
1712}
1713
1714#[cfg(test)]
1715mod test_reset_storage {
1716 use super::*;
1717
1718 #[test]
1719 fn test_reset_storage_deletes_records_and_attachments() {
1720 let collection_url = "http://rs.example.com/v2/buckets/main/collections/test-collection";
1721
1722 let mut api_client = MockApiClient::new();
1723 api_client
1724 .expect_collection_url()
1725 .returning(|| collection_url.into());
1726 api_client.expect_is_prod_server().returning(|| Ok(false));
1727
1728 let records = vec![RemoteSettingsRecord {
1729 id: "record-0001".into(),
1730 last_modified: 100,
1731 deleted: false,
1732 attachment: Some(Attachment {
1733 filename: "test-file.bin".into(),
1734 mimetype: "application/octet-stream".into(),
1735 location: "attachments/test-file.bin".into(),
1736 hash: "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7".into(),
1737 size: 4,
1738 }),
1739 fields: serde_json::Map::new(),
1740 }];
1741
1742 let mut storage = Storage::new(":memory:".into());
1743 storage
1744 .insert_collection_content(collection_url, &records, 100, CollectionMetadata::default())
1745 .expect("Failed to insert records");
1746
1747 storage
1748 .set_attachment(collection_url, "attachments/test-file.bin", b"data")
1749 .expect("Failed to insert attachment");
1750
1751 assert!(storage.get_records(collection_url).unwrap().is_some());
1753 assert!(storage
1754 .get_attachment(collection_url, records[0].attachment.clone().unwrap())
1755 .unwrap()
1756 .is_some());
1757
1758 let rs_client = RemoteSettingsClient::new_from_parts(
1759 "test-collection".into(),
1760 storage,
1761 JexlFilter::new(None),
1762 api_client,
1763 );
1764
1765 rs_client.reset_storage().expect("Failed to reset storage");
1766
1767 let mut inner = rs_client.inner.lock();
1769 assert_eq!(
1770 inner.storage.get_records(collection_url).unwrap(),
1771 None,
1772 "Records should be deleted after reset_storage"
1773 );
1774 assert_eq!(
1775 inner
1776 .storage
1777 .get_attachment(collection_url, records[0].attachment.clone().unwrap(),)
1778 .unwrap(),
1779 None,
1780 "Attachments should be deleted after reset_storage"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_reset_storage_reverts_to_packaged_data() {
1786 let collection_url = "http://rs.example.com/v2/buckets/main/collections/regions";
1787
1788 let mut api_client = MockApiClient::new();
1789 api_client
1790 .expect_collection_url()
1791 .returning(|| collection_url.into());
1792 api_client.expect_is_prod_server().returning(|| Ok(true));
1794
1795 let synced_records = vec![RemoteSettingsRecord {
1796 id: "custom-synced-record".into(),
1797 last_modified: 99999,
1798 deleted: false,
1799 attachment: None,
1800 fields: serde_json::json!({"key": "synced-value"})
1801 .as_object()
1802 .unwrap()
1803 .clone(),
1804 }];
1805
1806 let mut storage = Storage::new(":memory:".into());
1807 storage
1808 .insert_collection_content(
1809 collection_url,
1810 &synced_records,
1811 99999,
1812 CollectionMetadata::default(),
1813 )
1814 .expect("Failed to insert synced records");
1815
1816 let records_before = storage.get_records(collection_url).unwrap().unwrap();
1818 assert_eq!(records_before[0].id, "custom-synced-record");
1819
1820 let rs_client = RemoteSettingsClient::new_from_parts(
1821 "regions".into(),
1822 storage,
1823 JexlFilter::new(None),
1824 api_client,
1825 );
1826
1827 rs_client.reset_storage().expect("Failed to reset storage");
1828
1829 let mut inner = rs_client.inner.lock();
1830 let records = inner.storage.get_records(collection_url).unwrap();
1831 assert!(
1832 records.is_some(),
1833 "Packaged data should be restored after reset_storage on prod"
1834 );
1835 let records = records.unwrap();
1836 assert!(
1837 !records.is_empty(),
1838 "Packaged regions data should not be empty"
1839 );
1840 assert!(
1841 !records.iter().any(|r| r.id == "custom-synced-record"),
1842 "Synced data should be replaced by packaged data after reset"
1843 );
1844 }
1845}