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