1use crate::config::BaseUrl;
6use crate::error::{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 Ok(match (cached_records, sync_if_empty) {
329 (Some(cached_records), _) => Some(self.filter_records(cached_records, &inner)),
334 (None, true) => {
336 let changeset = inner.api_client.fetch_changeset(None)?;
337 inner.storage.insert_collection_content(
338 &collection_url,
339 &changeset.changes,
340 changeset.timestamp,
341 changeset.metadata,
342 )?;
343 Some(self.filter_records(changeset.changes, &inner))
344 }
345 (None, false) => None,
347 })
348 }
349
350 pub fn get_last_modified_timestamp(&self) -> Result<Option<u64>> {
352 let mut inner = self.lock_inner()?;
353 let collection_url = inner.api_client.collection_url();
354 inner.storage.get_last_modified_timestamp(&collection_url)
355 }
356
357 fn perform_sync_operation(&self) -> Result<()> {
362 let mut inner = self.lock_inner()?;
363 let collection_url = inner.api_client.collection_url();
364 let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
365 let changeset = inner.api_client.fetch_changeset(timestamp)?;
366 debug!(
367 "{0}: apply {1} change(s) locally.",
368 self.collection_name,
369 changeset.changes.len()
370 );
371 inner.storage.insert_collection_content(
372 &collection_url,
373 &changeset.changes,
374 changeset.timestamp,
375 changeset.metadata,
376 )
377 }
378
379 pub fn sync(&self) -> Result<()> {
380 self.perform_sync_operation()?;
382 if self.verify_signature().is_err() {
384 debug!(
385 "{0}: signature verification failed. Reset and retry.",
386 self.collection_name
387 );
388 self.reset_storage()?;
390 self.perform_sync_operation()?;
391 self.verify_signature().inspect_err(|_| {
393 self.reset_storage()
395 .expect("Failed to reset storage after verification failure");
396 })?;
397 }
398 trace!("{0}: sync done.", self.collection_name);
399 Ok(())
400 }
401
402 pub fn run_maintenance(&self) -> Result<()> {
403 let mut inner = self.lock_inner()?;
404 inner.storage.run_maintenance()
405 }
406
407 pub fn reset_storage(&self) -> Result<()> {
408 trace!("{0}: reset local storage.", self.collection_name);
409 let mut inner = self.lock_inner()?;
410 let collection_url = inner.api_client.collection_url();
411 inner.storage.empty()?;
413 if inner.api_client.is_prod_server()? {
415 if let Some(packaged_data) = self.load_packaged_data() {
416 trace!("{0}: restore packaged dump.", self.collection_name);
417 inner.storage.insert_collection_content(
418 &collection_url,
419 &packaged_data.data,
420 packaged_data.timestamp,
421 CollectionMetadata::default(),
422 )?;
423 }
424 }
425 Ok(())
426 }
427
428 pub fn shutdown(&self) {
429 self.inner.lock().storage.close();
430 }
431
432 #[cfg(not(feature = "signatures"))]
433 fn verify_signature(&self) -> Result<()> {
434 debug!("{0}: signature verification skipped.", self.collection_name);
435 Ok(())
436 }
437
438 #[cfg(feature = "signatures")]
439 fn verify_signature(&self) -> Result<()> {
440 let mut inner = self.lock_inner()?;
441 let collection_url = inner.api_client.collection_url();
442 let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
443 let records = inner.storage.get_records(&collection_url)?;
444 let metadata = inner.storage.get_collection_metadata(&collection_url)?;
445 match (timestamp, &records, metadata) {
446 (Some(timestamp), Some(records), Some(metadata)) => {
447 let expected_root_hash = if inner.api_client.is_prod_server()? {
449 ROOT_CERT_SHA256_HASH_PROD
450 } else {
451 ROOT_CERT_SHA256_HASH_NONPROD
452 };
453 let mut result = Err(Error::IncompleteSignatureDataError(
456 "No valid signatures found".into(),
457 ));
458 for signature in &metadata.signatures {
459 let cert_chain_bytes = inner.api_client.fetch_cert(&signature.x5u)?;
460
461 let expected_leaf_cname = format!(
466 "{}.content-signature.mozilla.org",
467 if metadata.bucket.contains("security-state") {
468 "onecrl"
469 } else {
470 "remote-settings"
471 }
472 );
473
474 result = signatures::verify_signature(
475 timestamp,
476 records,
477 signature.signature.as_bytes(),
478 &cert_chain_bytes,
479 epoch_seconds(),
480 expected_root_hash,
481 &expected_leaf_cname,
482 )
483 .inspect_err(|err| {
484 debug!(
485 "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'",
486 self.collection_name, err, &signature.x5u, expected_leaf_cname
487 );
488 });
489 if result.is_ok() {
491 trace!("{0}: signature verification success.", self.collection_name);
492 return Ok(());
493 }
494 }
495 result
497 }
498 _ => {
499 let missing_field = if timestamp.is_none() {
500 "timestamp"
501 } else if records.is_none() {
502 "records"
503 } else {
504 "metadata"
505 };
506 Err(Error::IncompleteSignatureDataError(missing_field.into()))
507 }
508 }
509 }
510
511 pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> Result<Vec<u8>> {
514 let metadata = record
515 .attachment
516 .as_ref()
517 .ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
518
519 let mut inner = self.lock_inner()?;
520 let collection_url = inner.api_client.collection_url();
521
522 if let Some(data) = inner
524 .storage
525 .get_attachment(&collection_url, metadata.clone())?
526 {
527 return Ok(data);
528 }
529
530 if inner.api_client.is_prod_server()? {
532 if let Some((data, manifest)) = self.load_packaged_attachment(&record.id) {
533 if let Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) {
534 if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
535 && metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
536 {
537 inner
539 .storage
540 .set_attachment(&collection_url, &metadata.location, data)?;
541 return Ok(data.to_vec());
542 }
543 }
544 }
545 }
546
547 let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
549
550 if attachment.len() as u64 != metadata.size {
552 return Err(Error::RecordAttachmentMismatchError(
553 "Downloaded attachment size mismatch".into(),
554 ));
555 }
556 let hash = format!("{:x}", Sha256::digest(&attachment));
557 if hash != metadata.hash {
558 return Err(Error::RecordAttachmentMismatchError(
559 "Downloaded attachment hash mismatch".into(),
560 ));
561 }
562
563 inner
565 .storage
566 .set_attachment(&collection_url, &metadata.location, &attachment)?;
567 Ok(attachment)
568 }
569
570 pub fn update_config(
571 &self,
572 server_url: BaseUrl,
573 bucket_name: String,
574 context: Option<RemoteSettingsContext>,
575 ) {
576 let mut pending_config = self.pending_config.lock();
577 *pending_config = Some(RemoteSettingsClientConfig {
578 server_url,
579 bucket_name,
580 context,
581 })
582 }
583}
584
585impl RemoteSettingsClient<ViaductApiClient> {
586 pub fn new(
587 server_url: BaseUrl,
588 bucket_name: String,
589 collection_name: String,
590 context: Option<RemoteSettingsContext>,
591 storage: Storage,
592 ) -> Self {
593 let api_client = ViaductApiClient::new(server_url, &bucket_name, &collection_name);
594 let jexl_filter = JexlFilter::new(context);
595
596 Self::new_from_parts(collection_name, storage, jexl_filter, api_client)
597 }
598}
599
600#[cfg_attr(test, mockall::automock)]
601pub trait ApiClient {
602 fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self;
604
605 fn collection_url(&self) -> String;
613
614 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse>;
616
617 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>>;
619
620 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>>;
622
623 fn is_prod_server(&self) -> Result<bool>;
625}
626
627pub struct ViaductApiClient {
629 endpoints: RemoteSettingsEndpoints,
630 remote_state: RemoteState,
631}
632
633impl ViaductApiClient {
634 fn new(base_url: BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
635 Self {
636 endpoints: RemoteSettingsEndpoints::new(&base_url, bucket_name, collection_name),
637 remote_state: RemoteState::default(),
638 }
639 }
640
641 fn make_request(&mut self, url: Url) -> Result<Response> {
642 trace!("make_request: {url}");
643 self.remote_state.ensure_no_backoff()?;
644
645 let req = Request::get(url);
646 let resp = req.send()?;
647
648 self.remote_state.handle_backoff_hint(&resp)?;
649
650 if resp.is_success() {
651 Ok(resp)
652 } else {
653 Err(Error::response_error(
654 &resp.url,
655 format!("status code: {}", resp.status),
656 ))
657 }
658 }
659}
660
661impl ApiClient for ViaductApiClient {
662 fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self {
663 Self::new(server_url, &bucket_name, collection_name)
664 }
665
666 fn collection_url(&self) -> String {
667 self.endpoints.collection_url.to_string()
668 }
669
670 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse> {
671 let mut url = self.endpoints.changeset_url.clone();
672 url.query_pairs_mut().append_pair("_expected", "0");
678 if let Some(timestamp) = timestamp {
679 url.query_pairs_mut()
680 .append_pair("_since", &format!("\"{}\"", timestamp));
681 }
682
683 let resp = self.make_request(url)?;
684
685 if resp.is_success() {
686 Ok(resp.json::<ChangesetResponse>()?)
687 } else {
688 Err(Error::response_error(
689 &resp.url,
690 format!("status code: {}", resp.status),
691 ))
692 }
693 }
694
695 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>> {
696 let attachments_base_url = match &self.remote_state.attachments_base_url {
697 Some(attachments_base_url) => attachments_base_url.to_owned(),
698 None => {
699 let server_info = self
700 .make_request(self.endpoints.root_url.clone())?
701 .json::<ServerInfo>()?;
702 let attachments_base_url = match server_info.capabilities.attachments {
703 Some(capability) => Url::parse(&capability.base_url)?,
704 None => Err(Error::AttachmentsUnsupportedError)?,
705 };
706 self.remote_state.attachments_base_url = Some(attachments_base_url.clone());
707 attachments_base_url
708 }
709 };
710
711 let resp = self.make_request(attachments_base_url.join(attachment_location)?)?;
712 Ok(resp.body)
713 }
714
715 fn is_prod_server(&self) -> Result<bool> {
716 Ok(self
717 .endpoints
718 .root_url
719 .as_str()
720 .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str()))
721 }
722
723 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>> {
724 let resp = self.make_request(Url::parse(x5u)?)?;
725 Ok(resp.body)
726 }
727}
728
729struct RemoteSettingsEndpoints {
733 root_url: Url,
737 collection_url: Url,
747 changeset_url: Url,
754}
755
756impl RemoteSettingsEndpoints {
757 fn new(base_url: &BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
761 let mut root_url = base_url.clone();
762 root_url.path_segments_mut().push("");
764
765 let mut collection_url = base_url.clone();
766 collection_url
767 .path_segments_mut()
768 .push("buckets")
769 .push(bucket_name)
770 .push("collections")
771 .push(collection_name);
772
773 let mut changeset_url = collection_url.clone();
774 changeset_url.path_segments_mut().push("changeset");
775
776 Self {
777 root_url: root_url.into_inner(),
778 collection_url: collection_url.into_inner(),
779 changeset_url: changeset_url.into_inner(),
780 }
781 }
782}
783
784#[derive(Clone, Deserialize, Serialize)]
785pub struct ChangesetResponse {
786 changes: Vec<RemoteSettingsRecord>,
787 timestamp: u64,
788 metadata: CollectionMetadata,
789}
790
791#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
792pub struct CollectionMetadata {
793 pub bucket: String,
794 pub signatures: Vec<CollectionSignature>,
795}
796
797#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
798pub struct CollectionSignature {
799 pub signature: String,
800 pub x5u: String,
802}
803
804#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
807pub struct RemoteSettingsRecord {
808 pub id: String,
809 pub last_modified: u64,
810 #[serde(default)]
812 pub deleted: bool,
813 pub attachment: Option<Attachment>,
814 #[serde(flatten)]
815 pub fields: RsJsonObject,
816}
817
818#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
821pub struct Attachment {
822 pub filename: String,
823 pub mimetype: String,
824 pub location: String,
825 pub hash: String,
826 pub size: u64,
827}
828
829pub type RsJsonObject = serde_json::Map<String, serde_json::Value>;
835uniffi::custom_type!(RsJsonObject, String, {
836 remote,
837 try_lift: |val| {
838 let json: serde_json::Value = serde_json::from_str(&val)?;
839
840 match json {
841 serde_json::Value::Object(obj) => Ok(obj),
842 _ => Err(uniffi::deps::anyhow::anyhow!(
843 "Unexpected JSON-non-object in the bagging area"
844 )),
845 }
846 },
847 lower: |obj| serde_json::Value::Object(obj).to_string(),
848});
849
850#[derive(Clone, Debug)]
851pub(crate) struct RemoteState {
852 attachments_base_url: Option<Url>,
853 backoff: BackoffState,
854}
855
856impl Default for RemoteState {
857 fn default() -> Self {
858 Self {
859 attachments_base_url: None,
860 backoff: BackoffState::Ok,
861 }
862 }
863}
864
865impl RemoteState {
866 pub fn handle_backoff_hint(&mut self, response: &Response) -> Result<()> {
867 let extract_backoff_header = |header| -> Result<u64> {
868 Ok(response
869 .headers
870 .get_as::<u64, _>(header)
871 .transpose()
872 .unwrap_or_default() .unwrap_or(0))
874 };
875 let backoff = extract_backoff_header(HEADER_BACKOFF)?;
877 let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?;
878 let max_backoff = backoff.max(retry_after);
879
880 if max_backoff > 0 {
881 self.backoff = BackoffState::Backoff {
882 observed_at: Instant::now(),
883 duration: Duration::from_secs(max_backoff),
884 };
885 }
886 Ok(())
887 }
888
889 pub fn ensure_no_backoff(&mut self) -> Result<()> {
890 if let BackoffState::Backoff {
891 observed_at,
892 duration,
893 } = self.backoff
894 {
895 let elapsed_time = observed_at.elapsed();
896 if elapsed_time >= duration {
897 self.backoff = BackoffState::Ok;
898 } else {
899 let remaining = duration - elapsed_time;
900 return Err(Error::BackoffError(remaining.as_secs()));
901 }
902 }
903 Ok(())
904 }
905}
906
907#[derive(Clone, Copy, Debug)]
909pub(crate) enum BackoffState {
910 Ok,
911 Backoff {
912 observed_at: Instant,
913 duration: Duration,
914 },
915}
916
917#[derive(Deserialize)]
918struct ServerInfo {
919 capabilities: Capabilities,
920}
921
922#[derive(Deserialize)]
923struct Capabilities {
924 attachments: Option<AttachmentsCapability>,
925}
926
927#[derive(Deserialize)]
928struct AttachmentsCapability {
929 base_url: String,
930}
931
932#[cfg(test)]
933mod test_new_client {
934 use super::*;
935
936 #[test]
937 fn test_endpoints() {
938 let endpoints = RemoteSettingsEndpoints::new(
939 &BaseUrl::parse("http://rs.example.com/v1").unwrap(),
940 "main",
941 "test-collection",
942 );
943 assert_eq!(endpoints.root_url.to_string(), "http://rs.example.com/v1/");
944 assert_eq!(
945 endpoints.collection_url.to_string(),
946 "http://rs.example.com/v1/buckets/main/collections/test-collection",
947 );
948 assert_eq!(
949 endpoints.changeset_url.to_string(),
950 "http://rs.example.com/v1/buckets/main/collections/test-collection/changeset",
951 );
952 }
953}
954
955#[cfg(test)]
956mod jexl_tests {
957 use super::*;
958 use std::sync::{Arc, Weak};
959
960 #[test]
961 fn test_get_records_filtered_app_version_pass() {
962 let mut api_client = MockApiClient::new();
963 let records = vec![RemoteSettingsRecord {
964 id: "record-0001".into(),
965 last_modified: 100,
966 deleted: false,
967 attachment: None,
968 fields: serde_json::json!({
969 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
970 })
971 .as_object()
972 .unwrap()
973 .clone(),
974 }];
975 let changeset = ChangesetResponse {
976 changes: records.clone(),
977 timestamp: 42,
978 metadata: CollectionMetadata::default(),
979 };
980 api_client.expect_collection_url().returning(|| {
981 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
982 });
983 api_client.expect_fetch_changeset().returning({
984 let changeset = changeset.clone();
985 move |timestamp| {
986 assert_eq!(timestamp, None);
987 Ok(changeset.clone())
988 }
989 });
990 api_client.expect_is_prod_server().returning(|| Ok(false));
991
992 let context = RemoteSettingsContext {
993 app_version: Some("129.0.0".to_string()),
994 ..Default::default()
995 };
996
997 let mut storage = Storage::new(":memory:".into());
998 let _ = storage.insert_collection_content(
999 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1000 &records,
1001 42,
1002 CollectionMetadata::default(),
1003 );
1004
1005 let rs_client = RemoteSettingsClient::new_from_parts(
1006 "test-collection".into(),
1007 storage,
1008 JexlFilter::new(Some(context)),
1009 api_client,
1010 );
1011
1012 assert_eq!(
1013 rs_client.get_records(false).expect("Error getting records"),
1014 Some(records)
1015 );
1016 }
1017
1018 #[test]
1019 fn test_get_records_filtered_app_version_too_low() {
1020 let mut api_client = MockApiClient::new();
1021 let records = vec![RemoteSettingsRecord {
1022 id: "record-0001".into(),
1023 last_modified: 100,
1024 deleted: false,
1025 attachment: None,
1026 fields: serde_json::json!({
1027 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1028 })
1029 .as_object()
1030 .unwrap()
1031 .clone(),
1032 }];
1033 let changeset = ChangesetResponse {
1034 changes: records.clone(),
1035 timestamp: 42,
1036 metadata: CollectionMetadata::default(),
1037 };
1038 api_client.expect_collection_url().returning(|| {
1039 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1040 });
1041 api_client.expect_fetch_changeset().returning({
1042 let changeset = changeset.clone();
1043 move |timestamp| {
1044 assert_eq!(timestamp, None);
1045 Ok(changeset.clone())
1046 }
1047 });
1048 api_client.expect_is_prod_server().returning(|| Ok(false));
1049
1050 let context = RemoteSettingsContext {
1051 app_version: Some("127.0.0.".to_string()),
1052 ..Default::default()
1053 };
1054
1055 let mut storage = Storage::new(":memory:".into());
1056 let _ = storage.insert_collection_content(
1057 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1058 &records,
1059 42,
1060 CollectionMetadata::default(),
1061 );
1062
1063 let rs_client = RemoteSettingsClient::new_from_parts(
1064 "test-collection".into(),
1065 storage,
1066 JexlFilter::new(Some(context)),
1067 api_client,
1068 );
1069
1070 assert_eq!(
1071 rs_client.get_records(false).expect("Error getting records"),
1072 Some(vec![])
1073 );
1074 }
1075
1076 #[test]
1077 fn test_update_jexl_context() {
1078 let mut api_client = MockApiClient::new();
1079 let records = vec![RemoteSettingsRecord {
1080 id: "record-0001".into(),
1081 last_modified: 100,
1082 deleted: false,
1083 attachment: None,
1084 fields: serde_json::json!({
1085 "filter_expression": "env.country == \"US\""
1086 })
1087 .as_object()
1088 .unwrap()
1089 .clone(),
1090 }];
1091 let changeset = ChangesetResponse {
1092 changes: records.clone(),
1093 timestamp: 42,
1094 metadata: CollectionMetadata::default(),
1095 };
1096 api_client.expect_collection_url().returning(|| {
1097 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1098 });
1099 api_client.expect_fetch_changeset().returning({
1100 let changeset = changeset.clone();
1101 move |timestamp| {
1102 assert_eq!(timestamp, None);
1103 Ok(changeset.clone())
1104 }
1105 });
1106 api_client.expect_is_prod_server().returning(|| Ok(false));
1107
1108 let context = RemoteSettingsContext {
1109 country: Some("US".to_string()),
1110 ..Default::default()
1111 };
1112
1113 let mut storage = Storage::new(":memory:".into());
1114 let _ = storage.insert_collection_content(
1115 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1116 &records,
1117 42,
1118 CollectionMetadata::default(),
1119 );
1120
1121 let rs_client = RemoteSettingsClient::new_from_parts(
1122 "test-collection".into(),
1123 storage,
1124 JexlFilter::new(Some(context)),
1125 api_client,
1126 );
1127
1128 assert_eq!(
1129 rs_client.get_records(false).expect("Error getting records"),
1130 Some(records)
1131 );
1132
1133 rs_client.inner.lock().jexl_filter = JexlFilter::new(Some(RemoteSettingsContext {
1136 country: Some("UK".to_string()),
1137 ..Default::default()
1138 }));
1139
1140 assert_eq!(
1141 rs_client.get_records(false).expect("Error getting records"),
1142 Some(vec![])
1143 );
1144 }
1145
1146 #[test]
1149 fn test_update_config_deadlock() {
1150 let mut api_client = MockApiClient::new();
1151 let rs_client_ref: Arc<Mutex<Weak<RemoteSettingsClient<MockApiClient>>>> =
1152 Arc::new(Mutex::new(Weak::new()));
1153 let rs_client_ref2 = rs_client_ref.clone();
1154
1155 api_client.expect_collection_url().returning(move || {
1156 rs_client_ref2
1167 .lock()
1168 .upgrade()
1169 .expect("rs_client_ref not set")
1170 .update_config(
1171 BaseUrl::parse("https://example.com/").unwrap(),
1172 "test-collection".to_string(),
1173 None,
1174 );
1175 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1176 });
1177 api_client.expect_is_prod_server().returning(|| Ok(false));
1178
1179 let context = RemoteSettingsContext {
1180 app_version: Some("129.0.0".to_string()),
1181 ..Default::default()
1182 };
1183 let storage = Storage::new(":memory:".into());
1184
1185 let rs_client = Arc::new(RemoteSettingsClient::new_from_parts(
1186 "test-collection".into(),
1187 storage,
1188 JexlFilter::new(Some(context)),
1189 api_client,
1190 ));
1191 *rs_client_ref.lock() = Arc::downgrade(&rs_client);
1192
1193 assert_eq!(
1194 rs_client.get_records(false).expect("Error getting records"),
1195 None,
1196 );
1197 }
1198}
1199
1200#[cfg(feature = "signatures")]
1201#[cfg(test)]
1202mod test_signatures {
1203 use core::assert_eq;
1204
1205 use crate::RemoteSettingsContext;
1206
1207 use super::*;
1208 use nss_as::ensure_initialized;
1209
1210 const VALID_CERTIFICATE: &str = "\
1211-----BEGIN CERTIFICATE-----
1212MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
1213AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
1214bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
1215dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
1216emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ
1217BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
1218biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
1219bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt
1220c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb
1221HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt
1222zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD
1223MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
1224GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0
1225dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
1226aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ
12279FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc
1228fS8//SQGTlCqKQ==
1229-----END CERTIFICATE-----
1230-----BEGIN CERTIFICATE-----
1231MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC
1232VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1233ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1234aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1235bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB
1236ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt
1237BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw
1238QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz
1239cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE
1240xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz
1241vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6
1242MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK
1243BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud
1244IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT
1245AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE
1246ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln
1247bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk
1248b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk
1249b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250
1250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t
1251b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx
1252qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ
12539qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH
1254TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr
1255v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O
1256ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea
1257UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg
1258VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0
1259xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d
1260mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi
1261ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz
1262LC4GzWQGSCGDyD+JCVw=
1263-----END CERTIFICATE-----
1264-----BEGIN CERTIFICATE-----
1265MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC
1266VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1267ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1268aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1269bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB
1270qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW
1271aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0
1272LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz
1273dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC
1274DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8
1275rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5
1276eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k
127751uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj
1278khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD
1279OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf
12804sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM
1281NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+
1282uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh
12837v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y
1284JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB
1285o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/
1286BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk
1287IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls
1288bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd
1289MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw
1290CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx
1291HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk
1292b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl
1293cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA
1294nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap
1295Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD
1296DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+
1297AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF
12982iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d
1299PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA
1300dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8
13015xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy
1302iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO
13036pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg
1304IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE=
1305-----END CERTIFICATE-----";
1306 const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#;
1307 const VALID_CERT_EPOCH_SECONDS: u64 = 1615559719;
1308
1309 fn run_client_sync(
1310 diff_records: &[RemoteSettingsRecord],
1311 full_records: &[RemoteSettingsRecord],
1312 certificate: &str,
1313 signatures: &[CollectionSignature],
1314 epoch_secs: u64,
1315 bucket: &str,
1316 ) -> Result<()> {
1317 let collection_name = "pioneer-study-addons";
1318
1319 MOCK_TIME.with(|cell| cell.set(Some(epoch_secs)));
1320
1321 let some_metadata = CollectionMetadata {
1322 bucket: bucket.into(),
1323 signatures: signatures.to_vec(),
1324 };
1325 let diff_changeset = ChangesetResponse {
1327 changes: diff_records.to_vec(),
1328 timestamp: 1603992731957,
1329 metadata: some_metadata.clone(),
1330 };
1331 let full_changeset = ChangesetResponse {
1333 changes: full_records.to_vec(),
1334 timestamp: 1603992731957,
1335 metadata: some_metadata.clone(),
1336 };
1337
1338 let mut api_client = MockApiClient::new();
1339 api_client
1340 .expect_collection_url()
1341 .returning(move || format!("http://server/{}", collection_name));
1342 api_client.expect_is_prod_server().returning(|| Ok(false));
1343 api_client.expect_fetch_changeset().returning(move |since| {
1344 Ok(if since.is_some() {
1345 diff_changeset.clone()
1346 } else {
1347 full_changeset.clone()
1348 })
1349 });
1350
1351 let certificate = certificate.to_string();
1352 api_client
1353 .expect_fetch_cert()
1354 .returning(move |_| Ok(certificate.clone().into_bytes()));
1355
1356 let storage = Storage::new(":memory:".into());
1357 let jexl_filter = JexlFilter::new(Some(RemoteSettingsContext::default()));
1358 let rs_client = RemoteSettingsClient::new_from_parts(
1359 collection_name.to_string(),
1360 storage,
1361 jexl_filter,
1362 api_client,
1363 );
1364
1365 rs_client.sync()
1366 }
1367
1368 #[test]
1369 fn test_valid_signature() -> Result<()> {
1370 ensure_initialized();
1371 run_client_sync(
1372 &[],
1373 &[],
1374 VALID_CERTIFICATE,
1375 &[CollectionSignature {
1376 signature: VALID_SIGNATURE.to_string(),
1377 x5u: "http://mocked".into(),
1378 }],
1379 VALID_CERT_EPOCH_SECONDS,
1380 "main",
1381 )
1382 .expect("Valid signature");
1383 Ok(())
1384 }
1385
1386 #[test]
1387 fn test_second_signature_is_valid() -> Result<()> {
1388 ensure_initialized();
1389 run_client_sync(
1390 &[],
1391 &[],
1392 VALID_CERTIFICATE,
1393 &[
1394 CollectionSignature {
1395 signature: "invalid signature".to_string(),
1396 x5u: "http://mocked".into(),
1397 },
1398 CollectionSignature {
1399 signature: VALID_SIGNATURE.to_string(),
1400 x5u: "http://mocked".into(),
1401 },
1402 ],
1403 VALID_CERT_EPOCH_SECONDS,
1404 "main",
1405 )
1406 .expect("Valid signature");
1407 Ok(())
1408 }
1409
1410 #[test]
1411 fn test_valid_signature_after_retry() -> Result<()> {
1412 ensure_initialized();
1413 run_client_sync(
1414 &[RemoteSettingsRecord {
1415 id: "bad-record".to_string(),
1416 last_modified: 9999,
1417 deleted: true,
1418 attachment: None,
1419 fields: serde_json::Map::new(),
1420 }],
1421 &[],
1422 VALID_CERTIFICATE,
1423 &[CollectionSignature {
1424 signature: VALID_SIGNATURE.to_string(),
1425 x5u: "http://mocked".into(),
1426 }],
1427 VALID_CERT_EPOCH_SECONDS,
1428 "main",
1429 )
1430 .expect("Valid signature");
1431 Ok(())
1432 }
1433
1434 #[test]
1435 fn test_invalid_signature_value() -> Result<()> {
1436 ensure_initialized();
1437 let err = run_client_sync(
1438 &[],
1439 &[],
1440 VALID_CERTIFICATE,
1441 &[CollectionSignature {
1442 signature: "invalid signature".to_string(),
1443 x5u: "http://mocked".into(),
1444 }],
1445 VALID_CERT_EPOCH_SECONDS,
1446 "main",
1447 )
1448 .unwrap_err();
1449 assert!(matches!(err, Error::SignatureError(_)));
1450 assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
1451
1452 Ok(())
1453 }
1454
1455 #[test]
1456 fn test_invalid_certificate_value() -> Result<()> {
1457 ensure_initialized();
1458 let err = run_client_sync(
1459 &[],
1460 &[],
1461 "some bad PEM content",
1462 &[CollectionSignature {
1463 signature: VALID_SIGNATURE.to_string(),
1464 x5u: "http://mocked".into(),
1465 }],
1466 VALID_CERT_EPOCH_SECONDS,
1467 "main",
1468 )
1469 .unwrap_err();
1470
1471 assert!(matches!(err, Error::SignatureError(_)));
1472 assert_eq!(
1473 format!("{}", err),
1474 "Signature could not be verified: PEM content format error: Missing PEM data"
1475 );
1476
1477 Ok(())
1478 }
1479
1480 #[test]
1481 fn test_invalid_signature_expired_cert() -> Result<()> {
1482 ensure_initialized();
1483 let december_20_2024 = 1734651582;
1484
1485 let err = run_client_sync(
1486 &[],
1487 &[],
1488 VALID_CERTIFICATE,
1489 &[CollectionSignature {
1490 signature: VALID_SIGNATURE.to_string(),
1491 x5u: "http://mocked".into(),
1492 }],
1493 december_20_2024,
1494 "main",
1495 )
1496 .unwrap_err();
1497
1498 assert!(matches!(err, Error::SignatureError(_)));
1499 assert_eq!(
1500 format!("{}", err),
1501 "Signature could not be verified: Certificate not yet valid or expired"
1502 );
1503
1504 Ok(())
1505 }
1506
1507 #[test]
1508 fn test_invalid_signature_invalid_data() -> Result<()> {
1509 ensure_initialized();
1510 let records = vec![RemoteSettingsRecord {
1512 id: "unexpected-data".to_string(),
1513 last_modified: 42,
1514 deleted: false,
1515 attachment: None,
1516 fields: serde_json::Map::new(),
1517 }];
1518 let err = run_client_sync(
1519 &records,
1520 &records,
1521 VALID_CERTIFICATE,
1522 &[CollectionSignature {
1523 signature: VALID_SIGNATURE.to_string(),
1524 x5u: "http://mocked".into(),
1525 }],
1526 VALID_CERT_EPOCH_SECONDS,
1527 "main",
1528 )
1529 .unwrap_err();
1530
1531 assert!(matches!(err, Error::SignatureError(_)));
1532 assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
1533
1534 Ok(())
1535 }
1536
1537 #[test]
1538 fn test_invalid_signature_invalid_signer_name() -> Result<()> {
1539 ensure_initialized();
1540 let err = run_client_sync(
1541 &[],
1542 &[],
1543 VALID_CERTIFICATE,
1544 &[CollectionSignature {
1545 signature: VALID_SIGNATURE.to_string(),
1546 x5u: "http://mocked".into(),
1547 }],
1548 VALID_CERT_EPOCH_SECONDS,
1549 "security-state",
1550 )
1551 .unwrap_err();
1552 assert!(matches!(err, Error::SignatureError(_)));
1553 assert_eq!(
1554 format!("{}", err),
1555 "Signature could not be verified: Certificate subject mismatch"
1556 );
1557
1558 Ok(())
1559 }
1560}
1561
1562#[cfg(test)]
1563mod test_reset_storage {
1564 use super::*;
1565
1566 #[test]
1567 fn test_reset_storage_deletes_records_and_attachments() {
1568 let collection_url = "http://rs.example.com/v1/buckets/main/collections/test-collection";
1569
1570 let mut api_client = MockApiClient::new();
1571 api_client
1572 .expect_collection_url()
1573 .returning(|| collection_url.into());
1574 api_client.expect_is_prod_server().returning(|| Ok(false));
1575
1576 let records = vec![RemoteSettingsRecord {
1577 id: "record-0001".into(),
1578 last_modified: 100,
1579 deleted: false,
1580 attachment: Some(Attachment {
1581 filename: "test-file.bin".into(),
1582 mimetype: "application/octet-stream".into(),
1583 location: "attachments/test-file.bin".into(),
1584 hash: "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7".into(),
1585 size: 4,
1586 }),
1587 fields: serde_json::Map::new(),
1588 }];
1589
1590 let mut storage = Storage::new(":memory:".into());
1591 storage
1592 .insert_collection_content(collection_url, &records, 100, CollectionMetadata::default())
1593 .expect("Failed to insert records");
1594
1595 storage
1596 .set_attachment(collection_url, "attachments/test-file.bin", b"data")
1597 .expect("Failed to insert attachment");
1598
1599 assert!(storage.get_records(collection_url).unwrap().is_some());
1601 assert!(storage
1602 .get_attachment(collection_url, records[0].attachment.clone().unwrap())
1603 .unwrap()
1604 .is_some());
1605
1606 let rs_client = RemoteSettingsClient::new_from_parts(
1607 "test-collection".into(),
1608 storage,
1609 JexlFilter::new(None),
1610 api_client,
1611 );
1612
1613 rs_client.reset_storage().expect("Failed to reset storage");
1614
1615 let mut inner = rs_client.inner.lock();
1617 assert_eq!(
1618 inner.storage.get_records(collection_url).unwrap(),
1619 None,
1620 "Records should be deleted after reset_storage"
1621 );
1622 assert_eq!(
1623 inner
1624 .storage
1625 .get_attachment(collection_url, records[0].attachment.clone().unwrap(),)
1626 .unwrap(),
1627 None,
1628 "Attachments should be deleted after reset_storage"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_reset_storage_reverts_to_packaged_data() {
1634 let collection_url = "http://rs.example.com/v1/buckets/main/collections/regions";
1635
1636 let mut api_client = MockApiClient::new();
1637 api_client
1638 .expect_collection_url()
1639 .returning(|| collection_url.into());
1640 api_client.expect_is_prod_server().returning(|| Ok(true));
1642
1643 let synced_records = vec![RemoteSettingsRecord {
1644 id: "custom-synced-record".into(),
1645 last_modified: 99999,
1646 deleted: false,
1647 attachment: None,
1648 fields: serde_json::json!({"key": "synced-value"})
1649 .as_object()
1650 .unwrap()
1651 .clone(),
1652 }];
1653
1654 let mut storage = Storage::new(":memory:".into());
1655 storage
1656 .insert_collection_content(
1657 collection_url,
1658 &synced_records,
1659 99999,
1660 CollectionMetadata::default(),
1661 )
1662 .expect("Failed to insert synced records");
1663
1664 let records_before = storage.get_records(collection_url).unwrap().unwrap();
1666 assert_eq!(records_before[0].id, "custom-synced-record");
1667
1668 let rs_client = RemoteSettingsClient::new_from_parts(
1669 "regions".into(),
1670 storage,
1671 JexlFilter::new(None),
1672 api_client,
1673 );
1674
1675 rs_client.reset_storage().expect("Failed to reset storage");
1676
1677 let mut inner = rs_client.inner.lock();
1678 let records = inner.storage.get_records(collection_url).unwrap();
1679 assert!(
1680 records.is_some(),
1681 "Packaged data should be restored after reset_storage on prod"
1682 );
1683 let records = records.unwrap();
1684 assert!(
1685 !records.is_empty(),
1686 "Packaged regions data should not be empty"
1687 );
1688 assert!(
1689 !records.iter().any(|r| r.id == "custom-synced-record"),
1690 "Synced data should be replaced by packaged data after reset"
1691 );
1692 }
1693}