1use crate::config::{BaseUrl, RemoteSettingsConfig};
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;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::{
17 borrow::Cow,
18 time::{Duration, Instant},
19};
20use url::Url;
21use viaduct::{Request, Response};
22
23#[cfg(feature = "signatures")]
24#[cfg(not(test))]
25use std::time::{SystemTime, UNIX_EPOCH};
26
27#[cfg(feature = "signatures")]
28#[cfg(not(test))]
29fn epoch_seconds() -> u64 {
30 SystemTime::now()
31 .duration_since(UNIX_EPOCH)
32 .unwrap() .as_secs()
34}
35
36#[cfg(feature = "signatures")]
37#[cfg(test)]
38thread_local! {
39 static MOCK_TIME: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) }
40}
41
42#[cfg(feature = "signatures")]
43#[cfg(test)]
44fn epoch_seconds() -> u64 {
45 MOCK_TIME.with(|mock_time| mock_time.get().unwrap_or(0))
46}
47
48const HEADER_BACKOFF: &str = "Backoff";
49const HEADER_ETAG: &str = "ETag";
50const HEADER_RETRY_AFTER: &str = "Retry-After";
51
52#[cfg(feature = "signatures")]
56const 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";
57#[cfg(feature = "signatures")]
58const 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";
59
60#[derive(Debug, Clone, Deserialize)]
61struct CollectionData {
62 data: Vec<RemoteSettingsRecord>,
63 timestamp: u64,
64}
65
66pub struct RemoteSettingsClient<C = ViaductApiClient> {
71 collection_name: String,
73 inner: Mutex<RemoteSettingsClientInner<C>>,
74}
75
76struct RemoteSettingsClientInner<C> {
77 storage: Storage,
78 api_client: C,
79 jexl_filter: JexlFilter,
80}
81
82impl<C: ApiClient> RemoteSettingsClient<C> {
89 packaged_collections! {
91 ("main", "regions"),
92 ("main", "search-config-icons"),
93 ("main", "search-config-v2"),
94 ("main", "search-telemetry-v2"),
95 ("main", "summarizer-models-config"),
96 ("main", "translations-models"),
97 ("main", "translations-wasm"),
98 }
99
100 packaged_attachments! {
111 ("main", "regions") => [
112 "world",
113 "world-buffered",
114 ],
115 ("main", "search-config-icons") => [
116 "001500a9-1a6c-3f5a-ba15-a5f5a075d256",
117 "06cf7432-efd7-f244-927b-5e423005e1ea",
118 "0a57b0cf-34f0-4d09-96e4-dbd6e3355410",
119 "0d7668a8-c3f4-cfee-cbc8-536511528937",
120 "0eec5640-6fde-d6fe-322a-c72c6d5bd5a2",
121 "101ce01d-2691-b729-7f16-9d389803384b",
122 "177aba42-9bed-4078-e36b-580e8794cd7f",
123 "25de0352-aabb-d31f-15f7-bf9299fb004c",
124 "2bbe48f4-d3b8-c9e0-86e3-a54c37ec3335",
125 "2e835b0e-9709-d1bb-9725-87f59f3445ca",
126 "2ecca3f8-c1ef-43cc-b053-886d1ae46c36",
127 "32d26d19-aeb0-5c01-32e8-f8970be9246f",
128 "39d0b17d-c020-4890-932f-83c0f6ed130b",
129 "41135a88-093d-4077-873b-9de1ae133427",
130 "41f0d805-3775-4988-8d8c-5ad8ccd86d1c",
131 "47da97b5-600f-c450-fd15-a52bb2169c11",
132 "48c72361-cd67-412e-bd7f-f81a43c10791",
133 "4e271681-3e0f-91ac-9750-03f665efc171",
134 "50f6171f-8e7a-b41b-862e-f97397038fb2",
135 "5203dd03-2c55-4b53-9c60-58258d587be1",
136 "5914932e-66ba-4126-8be5-d37beadd9532",
137 "5ded611d-44b2-dc46-fd67-fb116888d75d",
138 "5e03d6f4-6ee9-8bc8-cf22-7a5f2cf55c41",
139 "6644f26f-28ea-4222-929d-5d43a02dae05",
140 "6d10d702-7bd6-1452-90a5-3df665a38f66",
141 "6e36a151-e4f4-4117-9067-1ca82c47d01a",
142 "6f4da442-d31e-28f8-03af-797d16bbdd27",
143 "7072564d-a573-4750-bf33-f0a07631c9eb",
144 "70fdd651-6c50-b7bb-09ec-7e85da259173",
145 "71f41a0c-5b70-4116-b30f-e62089083522",
146 "74793ce1-a918-a5eb-d3c0-2aadaff3c88c",
147 "74f94dc2-caf6-4b90-b3d2-f3e2f7714d88",
148 "764e3b14-fe16-4feb-8384-124c516a5afa",
149 "7bf4ca37-e2b8-4d31-a1c3-979bc0e85131",
150 "7c81cf98-7c11-4afd-8279-db89118a6dfb",
151 "7cb4d88a-d4df-45b2-87e4-f896eaf1bbdb",
152 "7edaf4fe-a8a0-432b-86d2-bf75ebe80851",
153 "7efbed51-813c-581d-d8d3-f8758434e451",
154 "84bb4962-e571-227a-9ef6-2ac5f2aac361",
155 "87ac4cde-f581-398b-1e32-eb4079183b36",
156 "8831ce10-b1e4-6eb4-4975-83c67457288e",
157 "890de5c4-0941-a116-473a-5d240e79497a",
158 "8abb10a7-212f-46b5-a7b4-244f414e3810",
159 "91a9672d-e945-8e1e-0996-aefdb0190716",
160 "94a84724-c30f-4767-ba42-01cc37fc31a4",
161 "96327a73-c433-5eb4-a16d-b090cadfb80b",
162 "9802e63d-05ec-48ba-93f9-746e0981ad98",
163 "9d96547d-7575-49ca-8908-1e046b8ea90e",
164 "a06db97d-1210-ea2e-5474-0e2f7d295bfd",
165 "a06dc3fd-4bdb-41f3-2ebc-4cbed06a9bd3",
166 "a2c7d4e9-f770-51e1-0963-3c2c8401631d",
167 "a83f24e4-602c-47bd-930c-ad0947ee1adf",
168 "b64f09fd-52d1-c48e-af23-4ce918e7bf3b",
169 "b882b24d-1776-4ef9-9016-0bdbd935eda3",
170 "b8ca5a94-8fff-27ad-6e00-96e244a32e21",
171 "b9424309-f601-4a69-98ca-ca68e65633e6",
172 "c411adc1-9661-4fb5-a4c1-8cfe74911943",
173 "cbf9e891-d079-2b28-5617-283450d463dd",
174 "d87f251c-3e12-a8bf-e2d0-afd43d36c5f9",
175 "db0e1627-ae89-4c25-8944-a9481d8512d9",
176 "e02f23df-8d48-2b1b-3b5c-6dd27302c61c",
177 "e718e983-09aa-e8f6-b25f-cd4b395d4785",
178 "e7547f62-187b-b641-d462-e54a3f813d9a",
179 "eb62e768-151b-45d1-9fe5-9e1d2a5991c5",
180 "f312610a-ebfb-a106-ea92-fd643c5d3636",
181 "f943d7bc-872e-4a81-810f-94d26465da69",
182 "fa0fc42c-d91d-fca7-34eb-806ff46062dc",
183 "fca3e3ee-56cd-f474-dc31-307fd24a891d",
184 "fe75ce3f-1545-400c-b28c-ad771054e69f",
185 "fed4f021-ff3e-942a-010e-afa43fda2136",
186 ],
187 ("main", "translations-wasm") => [
188 "4fd32605-9889-4dd9-9fc7-577ad1136746",
189 ]
190 }
191}
192
193impl<C: ApiClient> RemoteSettingsClient<C> {
194 pub fn new_from_parts(
195 collection_name: String,
196 storage: Storage,
197 jexl_filter: JexlFilter,
198 api_client: C,
199 ) -> Self {
200 Self {
201 collection_name,
202 inner: Mutex::new(RemoteSettingsClientInner {
203 storage,
204 api_client,
205 jexl_filter,
206 }),
207 }
208 }
209
210 pub fn collection_name(&self) -> &str {
211 &self.collection_name
212 }
213
214 fn load_packaged_timestamp(&self) -> Option<u64> {
215 Self::get_packaged_timestamp(&self.collection_name)
217 }
218
219 fn load_packaged_data(&self) -> Option<CollectionData> {
220 let str_data = Self::get_packaged_data(&self.collection_name)?;
222 let data: CollectionData = serde_json::from_str(str_data).ok()?;
223 debug_assert_eq!(data.timestamp, self.load_packaged_timestamp().unwrap());
224 Some(data)
225 }
226
227 fn load_packaged_attachment(&self, filename: &str) -> Option<(&'static [u8], &'static str)> {
228 Self::get_packaged_attachment(&self.collection_name, filename)
230 }
231
232 fn filter_records(
234 &self,
235 records: Vec<RemoteSettingsRecord>,
236 inner: &RemoteSettingsClientInner<C>,
237 ) -> Vec<RemoteSettingsRecord> {
238 records
239 .into_iter()
240 .filter(|record| match record.fields.get("filter_expression") {
241 Some(serde_json::Value::String(filter_expr)) => {
242 inner.jexl_filter.evaluate(filter_expr).unwrap_or(false)
243 }
244 _ => true, })
246 .collect()
247 }
248
249 fn get_packaged_data_if_newer(
252 &self,
253 storage: &mut Storage,
254 collection_url: &str,
255 ) -> Result<Option<CollectionData>> {
256 let packaged_ts = self.load_packaged_timestamp();
257 let storage_ts = storage.get_last_modified_timestamp(collection_url)?;
258 let packaged_is_newer = match (packaged_ts, storage_ts) {
259 (Some(packaged_ts), Some(storage_ts)) => packaged_ts > storage_ts,
260 (Some(_), None) => true, (None, _) => false, };
263
264 if packaged_is_newer {
265 Ok(self.load_packaged_data())
266 } else {
267 Ok(None)
268 }
269 }
270
271 pub fn get_records(&self, sync_if_empty: bool) -> Result<Option<Vec<RemoteSettingsRecord>>> {
276 let mut inner = self.inner.lock();
277 let collection_url = inner.api_client.collection_url();
278
279 if inner.api_client.is_prod_server()? {
284 if let Some(packaged_data) =
285 self.get_packaged_data_if_newer(&mut inner.storage, &collection_url)?
286 {
287 inner.storage.empty()?;
289 inner.storage.insert_collection_content(
291 &collection_url,
292 &packaged_data.data,
293 packaged_data.timestamp,
294 CollectionMetadata::default(),
295 )?;
296 return Ok(Some(self.filter_records(packaged_data.data, &inner)));
297 }
298 }
299
300 let cached_records = inner.storage.get_records(&collection_url)?;
301
302 Ok(match (cached_records, sync_if_empty) {
303 (Some(cached_records), _) => Some(self.filter_records(cached_records, &inner)),
308 (None, true) => {
310 let changeset = inner.api_client.fetch_changeset(None)?;
311 inner.storage.insert_collection_content(
312 &collection_url,
313 &changeset.changes,
314 changeset.timestamp,
315 changeset.metadata,
316 )?;
317 Some(self.filter_records(changeset.changes, &inner))
318 }
319 (None, false) => None,
321 })
322 }
323
324 pub fn get_last_modified_timestamp(&self) -> Result<Option<u64>> {
325 let mut inner = self.inner.lock();
326 let collection_url = inner.api_client.collection_url();
327 inner.storage.get_last_modified_timestamp(&collection_url)
328 }
329
330 fn perform_sync_operation(&self) -> Result<()> {
335 let mut inner = self.inner.lock();
336 let collection_url = inner.api_client.collection_url();
337 let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
338 let changeset = inner.api_client.fetch_changeset(timestamp)?;
339 debug!(
340 "{0}: apply {1} change(s) locally.",
341 self.collection_name,
342 changeset.changes.len()
343 );
344 inner.storage.insert_collection_content(
345 &collection_url,
346 &changeset.changes,
347 changeset.timestamp,
348 changeset.metadata,
349 )
350 }
351
352 pub fn sync(&self) -> Result<()> {
353 self.perform_sync_operation()?;
355 if self.verify_signature().is_err() {
357 debug!(
358 "{0}: signature verification failed. Reset and retry.",
359 self.collection_name
360 );
361 self.reset_storage()?;
363 self.perform_sync_operation()?;
364 self.verify_signature().inspect_err(|_| {
366 self.reset_storage()
368 .expect("Failed to reset storage after verification failure");
369 })?;
370 }
371 trace!("{0}: sync done.", self.collection_name);
372 Ok(())
373 }
374
375 fn reset_storage(&self) -> Result<()> {
376 trace!("{0}: reset local storage.", self.collection_name);
377 let mut inner = self.inner.lock();
378 let collection_url = inner.api_client.collection_url();
379 inner.storage.empty()?;
381 if inner.api_client.is_prod_server()? {
383 if let Some(packaged_data) = self.load_packaged_data() {
384 trace!("{0}: restore packaged dump.", self.collection_name);
385 inner.storage.insert_collection_content(
386 &collection_url,
387 &packaged_data.data,
388 packaged_data.timestamp,
389 CollectionMetadata::default(),
390 )?;
391 }
392 }
393 Ok(())
394 }
395
396 pub fn shutdown(&self) {
397 self.inner.lock().storage.close();
398 }
399
400 #[cfg(not(feature = "signatures"))]
401 fn verify_signature(&self) -> Result<()> {
402 debug!("{0}: signature verification skipped.", self.collection_name);
403 Ok(())
404 }
405
406 #[cfg(feature = "signatures")]
407 fn verify_signature(&self) -> Result<()> {
408 let mut inner = self.inner.lock();
409 let collection_url = inner.api_client.collection_url();
410 let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
411 let records = inner.storage.get_records(&collection_url)?;
412 let metadata = inner.storage.get_collection_metadata(&collection_url)?;
413 match (timestamp, &records, metadata) {
414 (Some(timestamp), Some(records), Some(metadata)) => {
415 let cert_chain_bytes = inner.api_client.fetch_cert(&metadata.signature.x5u)?;
416 let expected_root_hash = if inner.api_client.is_prod_server()? {
418 ROOT_CERT_SHA256_HASH_PROD
419 } else {
420 ROOT_CERT_SHA256_HASH_NONPROD
421 };
422
423 let expected_leaf_cname = format!(
428 "{}.content-signature.mozilla.org",
429 if metadata.bucket.contains("security-state") {
430 "onecrl"
431 } else {
432 "remote-settings"
433 }
434 );
435 signatures::verify_signature(
436 timestamp,
437 records,
438 metadata.signature.signature.as_bytes(),
439 &cert_chain_bytes,
440 epoch_seconds(),
441 expected_root_hash,
442 &expected_leaf_cname,
443 )
444 .inspect_err(|err| {
445 debug!(
446 "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'",
447 self.collection_name, err, &metadata.signature.x5u, expected_leaf_cname
448 );
449 })?;
450 trace!("{0}: signature verification success.", self.collection_name);
451 Ok(())
452 }
453 _ => {
454 let missing_field = if timestamp.is_none() {
455 "timestamp"
456 } else if records.is_none() {
457 "records"
458 } else {
459 "metadata"
460 };
461 Err(Error::IncompleteSignatureDataError(missing_field.into()))
462 }
463 }
464 }
465
466 pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> Result<Vec<u8>> {
469 let metadata = record
470 .attachment
471 .as_ref()
472 .ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
473
474 let mut inner = self.inner.lock();
475 let collection_url = inner.api_client.collection_url();
476
477 if let Some(data) = inner
479 .storage
480 .get_attachment(&collection_url, metadata.clone())?
481 {
482 return Ok(data);
483 }
484
485 if inner.api_client.is_prod_server()? {
487 if let Some((data, manifest)) = self.load_packaged_attachment(&record.id) {
488 if let Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) {
489 if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
490 && metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
491 {
492 inner
494 .storage
495 .set_attachment(&collection_url, &metadata.location, data)?;
496 return Ok(data.to_vec());
497 }
498 }
499 }
500 }
501
502 let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
504
505 if attachment.len() as u64 != metadata.size {
507 return Err(Error::RecordAttachmentMismatchError(
508 "Downloaded attachment size mismatch".into(),
509 ));
510 }
511 let hash = format!("{:x}", Sha256::digest(&attachment));
512 if hash != metadata.hash {
513 return Err(Error::RecordAttachmentMismatchError(
514 "Downloaded attachment hash mismatch".into(),
515 ));
516 }
517
518 inner
520 .storage
521 .set_attachment(&collection_url, &metadata.location, &attachment)?;
522 Ok(attachment)
523 }
524}
525
526impl RemoteSettingsClient<ViaductApiClient> {
527 pub fn new(
528 server_url: BaseUrl,
529 bucket_name: String,
530 collection_name: String,
531 context: Option<RemoteSettingsContext>,
532 storage: Storage,
533 ) -> Self {
534 let api_client = ViaductApiClient::new(server_url, &bucket_name, &collection_name);
535 let jexl_filter = JexlFilter::new(context);
536
537 Self::new_from_parts(collection_name, storage, jexl_filter, api_client)
538 }
539
540 pub fn update_config(
541 &self,
542 server_url: BaseUrl,
543 bucket_name: String,
544 context: Option<RemoteSettingsContext>,
545 ) -> Result<()> {
546 let mut inner = self.inner.lock();
547 inner.api_client = ViaductApiClient::new(server_url, &bucket_name, &self.collection_name);
548 inner.jexl_filter = JexlFilter::new(context);
549 inner.storage.empty()
550 }
551}
552
553#[cfg_attr(test, mockall::automock)]
554pub trait ApiClient {
555 fn collection_url(&self) -> String;
563
564 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse>;
566
567 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>>;
569
570 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>>;
572
573 fn is_prod_server(&self) -> Result<bool>;
575}
576
577pub struct ViaductApiClient {
579 endpoints: RemoteSettingsEndpoints,
580 remote_state: RemoteState,
581}
582
583impl ViaductApiClient {
584 fn new(base_url: BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
585 Self {
586 endpoints: RemoteSettingsEndpoints::new(&base_url, bucket_name, collection_name),
587 remote_state: RemoteState::default(),
588 }
589 }
590
591 fn make_request(&mut self, url: Url) -> Result<Response> {
592 trace!("make_request: {url}");
593 self.remote_state.ensure_no_backoff()?;
594
595 let req = Request::get(url);
596 let resp = req.send()?;
597
598 self.remote_state.handle_backoff_hint(&resp)?;
599
600 if resp.is_success() {
601 Ok(resp)
602 } else {
603 Err(Error::ResponseError(format!(
604 "status code: {}",
605 resp.status
606 )))
607 }
608 }
609}
610
611impl ApiClient for ViaductApiClient {
612 fn collection_url(&self) -> String {
613 self.endpoints.collection_url.to_string()
614 }
615
616 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse> {
617 let mut url = self.endpoints.changeset_url.clone();
618 url.query_pairs_mut().append_pair("_expected", "0");
624 if let Some(timestamp) = timestamp {
625 url.query_pairs_mut()
626 .append_pair("_since", &format!("\"{}\"", timestamp));
627 }
628
629 let resp = self.make_request(url)?;
630
631 if resp.is_success() {
632 Ok(resp.json::<ChangesetResponse>()?)
633 } else {
634 Err(Error::ResponseError(format!(
635 "status code: {}",
636 resp.status
637 )))
638 }
639 }
640
641 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>> {
642 let attachments_base_url = match &self.remote_state.attachments_base_url {
643 Some(attachments_base_url) => attachments_base_url.to_owned(),
644 None => {
645 let server_info = self
646 .make_request(self.endpoints.root_url.clone())?
647 .json::<ServerInfo>()?;
648 let attachments_base_url = match server_info.capabilities.attachments {
649 Some(capability) => Url::parse(&capability.base_url)?,
650 None => Err(Error::AttachmentsUnsupportedError)?,
651 };
652 self.remote_state.attachments_base_url = Some(attachments_base_url.clone());
653 attachments_base_url
654 }
655 };
656
657 let resp = self.make_request(attachments_base_url.join(attachment_location)?)?;
658 Ok(resp.body)
659 }
660
661 fn is_prod_server(&self) -> Result<bool> {
662 Ok(self
663 .endpoints
664 .root_url
665 .as_str()
666 .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str()))
667 }
668
669 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>> {
670 let resp = self.make_request(Url::parse(x5u)?)?;
671 Ok(resp.body)
672 }
673}
674
675pub struct Client {
679 endpoints: RemoteSettingsEndpoints,
680 pub(crate) remote_state: Mutex<RemoteState>,
681}
682
683impl Client {
684 pub fn new(config: RemoteSettingsConfig) -> Result<Self> {
686 let server = match (config.server, config.server_url) {
687 (Some(server), None) => server,
688 (None, Some(server_url)) => RemoteSettingsServer::Custom { url: server_url },
689 (None, None) => RemoteSettingsServer::Prod,
690 (Some(_), Some(_)) => Err(Error::ConfigError(
691 "`RemoteSettingsConfig` takes either `server` or `server_url`, not both".into(),
692 ))?,
693 };
694
695 let bucket_name = config.bucket_name.unwrap_or_else(|| String::from("main"));
696 let endpoints = RemoteSettingsEndpoints::new(
697 &server.get_base_url()?,
698 &bucket_name,
699 &config.collection_name,
700 );
701
702 Ok(Self {
703 endpoints,
704 remote_state: Default::default(),
705 })
706 }
707
708 pub fn get_records(&self) -> Result<RemoteSettingsResponse> {
712 self.get_records_with_options(&GetItemsOptions::new())
713 }
714
715 pub fn get_records_raw(&self) -> Result<Response> {
719 self.get_records_raw_with_options(&GetItemsOptions::new())
720 }
721
722 pub fn get_records_since(&self, timestamp: u64) -> Result<RemoteSettingsResponse> {
726 self.get_records_with_options(
727 GetItemsOptions::new().filter_gt("last_modified", timestamp.to_string()),
728 )
729 }
730
731 pub fn get_records_with_options(
733 &self,
734 options: &GetItemsOptions,
735 ) -> Result<RemoteSettingsResponse> {
736 let resp = self.get_records_raw_with_options(options)?;
737 let records = resp.json::<RecordsResponse>()?.data;
738 let etag = resp
739 .headers
740 .get(HEADER_ETAG)
741 .ok_or_else(|| Error::ResponseError("no etag header".into()))?;
742 let last_modified = etag.trim_matches('"').parse().map_err(|_| {
746 Error::ResponseError(format!(
747 "expected quoted integer in etag header; got `{}`",
748 etag
749 ))
750 })?;
751 Ok(RemoteSettingsResponse {
752 records,
753 last_modified,
754 })
755 }
756
757 pub fn get_records_raw_with_options(&self, options: &GetItemsOptions) -> Result<Response> {
760 let mut url = self.endpoints.records_url.clone();
761 for (name, value) in options.iter_query_pairs() {
762 url.query_pairs_mut().append_pair(&name, &value);
763 }
764 self.make_request(url)
765 }
766
767 pub fn get_attachment(&self, attachment_location: &str) -> Result<Vec<u8>> {
771 Ok(self.get_attachment_raw(attachment_location)?.body)
772 }
773
774 pub fn get_attachment_raw(&self, attachment_location: &str) -> Result<Response> {
776 let maybe_attachments_base_url = self.remote_state.lock().attachments_base_url.clone();
781
782 let attachments_base_url = match maybe_attachments_base_url {
783 Some(attachments_base_url) => attachments_base_url,
784 None => {
785 let server_info = self
786 .make_request(self.endpoints.root_url.clone())?
787 .json::<ServerInfo>()?;
788 let attachments_base_url = match server_info.capabilities.attachments {
789 Some(capability) => Url::parse(&capability.base_url)?,
790 None => Err(Error::AttachmentsUnsupportedError)?,
791 };
792 self.remote_state.lock().attachments_base_url = Some(attachments_base_url.clone());
793 attachments_base_url
794 }
795 };
796
797 self.make_request(attachments_base_url.join(attachment_location)?)
798 }
799
800 fn make_request(&self, url: Url) -> Result<Response> {
801 let mut current_remote_state = self.remote_state.lock();
802 current_remote_state.ensure_no_backoff()?;
803 drop(current_remote_state);
804
805 let req = Request::get(url);
806 let resp = req.send()?;
807
808 let mut current_remote_state = self.remote_state.lock();
809 current_remote_state.handle_backoff_hint(&resp)?;
810
811 if resp.is_success() {
812 Ok(resp)
813 } else {
814 Err(Error::ResponseError(format!(
815 "status code: {}",
816 resp.status
817 )))
818 }
819 }
820}
821
822struct RemoteSettingsEndpoints {
826 root_url: Url,
830 collection_url: Url,
840 changeset_url: Url,
847 records_url: Url,
854}
855
856impl RemoteSettingsEndpoints {
857 fn new(base_url: &BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
861 let mut root_url = base_url.clone();
862 root_url.path_segments_mut().push("");
864
865 let mut collection_url = base_url.clone();
866 collection_url
867 .path_segments_mut()
868 .push("buckets")
869 .push(bucket_name)
870 .push("collections")
871 .push(collection_name);
872
873 let mut records_url = collection_url.clone();
874 records_url.path_segments_mut().push("records");
875
876 let mut changeset_url = collection_url.clone();
877 changeset_url.path_segments_mut().push("changeset");
878
879 Self {
880 root_url: root_url.into_inner(),
881 collection_url: collection_url.into_inner(),
882 records_url: records_url.into_inner(),
883 changeset_url: changeset_url.into_inner(),
884 }
885 }
886}
887
888#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, uniffi::Record)]
891pub struct RemoteSettingsResponse {
892 pub records: Vec<RemoteSettingsRecord>,
893 pub last_modified: u64,
894}
895
896#[derive(Deserialize, Serialize)]
897struct RecordsResponse {
898 data: Vec<RemoteSettingsRecord>,
899}
900
901#[derive(Clone, Deserialize, Serialize)]
902pub struct ChangesetResponse {
903 changes: Vec<RemoteSettingsRecord>,
904 timestamp: u64,
905 metadata: CollectionMetadata,
906}
907
908#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
909pub struct CollectionMetadata {
910 pub bucket: String,
911 pub signature: CollectionSignature,
912}
913
914#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
915pub struct CollectionSignature {
916 pub signature: String,
917 pub x5u: String,
919}
920
921#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
924pub struct RemoteSettingsRecord {
925 pub id: String,
926 pub last_modified: u64,
927 #[serde(default)]
929 pub deleted: bool,
930 pub attachment: Option<Attachment>,
931 #[serde(flatten)]
932 pub fields: RsJsonObject,
933}
934
935#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
938pub struct Attachment {
939 pub filename: String,
940 pub mimetype: String,
941 pub location: String,
942 pub hash: String,
943 pub size: u64,
944}
945
946pub type RsJsonObject = serde_json::Map<String, serde_json::Value>;
952uniffi::custom_type!(RsJsonObject, String, {
953 remote,
954 try_lift: |val| {
955 let json: serde_json::Value = serde_json::from_str(&val)?;
956
957 match json {
958 serde_json::Value::Object(obj) => Ok(obj),
959 _ => Err(uniffi::deps::anyhow::anyhow!(
960 "Unexpected JSON-non-object in the bagging area"
961 )),
962 }
963 },
964 lower: |obj| serde_json::Value::Object(obj).to_string(),
965});
966
967#[derive(Clone, Debug)]
968pub(crate) struct RemoteState {
969 attachments_base_url: Option<Url>,
970 backoff: BackoffState,
971}
972
973impl Default for RemoteState {
974 fn default() -> Self {
975 Self {
976 attachments_base_url: None,
977 backoff: BackoffState::Ok,
978 }
979 }
980}
981
982impl RemoteState {
983 pub fn handle_backoff_hint(&mut self, response: &Response) -> Result<()> {
984 let extract_backoff_header = |header| -> Result<u64> {
985 Ok(response
986 .headers
987 .get_as::<u64, _>(header)
988 .transpose()
989 .unwrap_or_default() .unwrap_or(0))
991 };
992 let backoff = extract_backoff_header(HEADER_BACKOFF)?;
994 let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?;
995 let max_backoff = backoff.max(retry_after);
996
997 if max_backoff > 0 {
998 self.backoff = BackoffState::Backoff {
999 observed_at: Instant::now(),
1000 duration: Duration::from_secs(max_backoff),
1001 };
1002 }
1003 Ok(())
1004 }
1005
1006 pub fn ensure_no_backoff(&mut self) -> Result<()> {
1007 if let BackoffState::Backoff {
1008 observed_at,
1009 duration,
1010 } = self.backoff
1011 {
1012 let elapsed_time = observed_at.elapsed();
1013 if elapsed_time >= duration {
1014 self.backoff = BackoffState::Ok;
1015 } else {
1016 let remaining = duration - elapsed_time;
1017 return Err(Error::BackoffError(remaining.as_secs()));
1018 }
1019 }
1020 Ok(())
1021 }
1022}
1023
1024#[derive(Clone, Copy, Debug)]
1026pub(crate) enum BackoffState {
1027 Ok,
1028 Backoff {
1029 observed_at: Instant,
1030 duration: Duration,
1031 },
1032}
1033
1034#[derive(Deserialize)]
1035struct ServerInfo {
1036 capabilities: Capabilities,
1037}
1038
1039#[derive(Deserialize)]
1040struct Capabilities {
1041 attachments: Option<AttachmentsCapability>,
1042}
1043
1044#[derive(Deserialize)]
1045struct AttachmentsCapability {
1046 base_url: String,
1047}
1048
1049#[derive(Clone, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)]
1051pub struct GetItemsOptions {
1052 filters: Vec<Filter>,
1053 sort: Vec<Sort>,
1054 fields: Vec<String>,
1055 limit: Option<u64>,
1056}
1057
1058impl GetItemsOptions {
1059 pub fn new() -> Self {
1061 Self::default()
1062 }
1063
1064 pub fn filter_eq(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1072 self.filters.push(Filter::Eq(field.into(), value.into()));
1073 self
1074 }
1075
1076 pub fn filter_not(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1079 self.filters.push(Filter::Not(field.into(), value.into()));
1080 self
1081 }
1082
1083 pub fn filter_contains(
1087 &mut self,
1088 field: impl Into<String>,
1089 value: impl Into<String>,
1090 ) -> &mut Self {
1091 self.filters
1092 .push(Filter::Contains(field.into(), value.into()));
1093 self
1094 }
1095
1096 pub fn filter_lt(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1099 self.filters.push(Filter::Lt(field.into(), value.into()));
1100 self
1101 }
1102
1103 pub fn filter_gt(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1106 self.filters.push(Filter::Gt(field.into(), value.into()));
1107 self
1108 }
1109
1110 pub fn filter_max(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1113 self.filters.push(Filter::Max(field.into(), value.into()));
1114 self
1115 }
1116
1117 pub fn filter_min(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1120 self.filters.push(Filter::Min(field.into(), value.into()));
1121 self
1122 }
1123
1124 pub fn filter_like(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1127 self.filters.push(Filter::Like(field.into(), value.into()));
1128 self
1129 }
1130
1131 pub fn filter_has(&mut self, field: impl Into<String>) -> &mut Self {
1133 self.filters.push(Filter::Has(field.into()));
1134 self
1135 }
1136
1137 pub fn filter_has_not(&mut self, field: impl Into<String>) -> &mut Self {
1139 self.filters.push(Filter::HasNot(field.into()));
1140 self
1141 }
1142
1143 pub fn sort(&mut self, field: impl Into<String>, order: SortOrder) -> &mut Self {
1145 self.sort.push(Sort(field.into(), order));
1146 self
1147 }
1148
1149 pub fn field(&mut self, field: impl Into<String>) -> &mut Self {
1153 self.fields.push(field.into());
1154 self
1155 }
1156
1157 pub fn limit(&mut self, count: u64) -> &mut Self {
1159 self.limit = Some(count);
1160 self
1161 }
1162
1163 pub fn iter_query_pairs(&self) -> impl Iterator<Item = (Cow<'_, str>, Cow<'_, str>)> {
1165 self.filters
1166 .iter()
1167 .map(Filter::as_query_pair)
1168 .chain({
1169 (!self.sort.is_empty()).then(|| {
1174 (
1175 "_sort".into(),
1176 (self
1177 .sort
1178 .iter()
1179 .map(Sort::as_query_value)
1180 .collect::<Vec<_>>()
1181 .join(","))
1182 .into(),
1183 )
1184 })
1185 })
1186 .chain({
1187 (!self.fields.is_empty()).then(|| ("_fields".into(), self.fields.join(",").into()))
1190 })
1191 .chain({
1192 self.limit
1195 .map(|count| ("_limit".into(), count.to_string().into()))
1196 })
1197 }
1198}
1199
1200#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
1202pub enum SortOrder {
1203 Ascending,
1205 Descending,
1207}
1208
1209#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
1210enum Filter {
1211 Eq(String, String),
1212 Not(String, String),
1213 Contains(String, String),
1214 Lt(String, String),
1215 Gt(String, String),
1216 Max(String, String),
1217 Min(String, String),
1218 Like(String, String),
1219 Has(String),
1220 HasNot(String),
1221}
1222
1223impl Filter {
1224 fn as_query_pair(&self) -> (Cow<'_, str>, Cow<'_, str>) {
1225 match self {
1228 Filter::Eq(field, value) => (field.into(), value.into()),
1229 Filter::Not(field, value) => (format!("not_{field}").into(), value.into()),
1230 Filter::Contains(field, value) => (format!("contains_{field}").into(), value.into()),
1231 Filter::Lt(field, value) => (format!("lt_{field}").into(), value.into()),
1232 Filter::Gt(field, value) => (format!("gt_{field}").into(), value.into()),
1233 Filter::Max(field, value) => (format!("max_{field}").into(), value.into()),
1234 Filter::Min(field, value) => (format!("min_{field}").into(), value.into()),
1235 Filter::Like(field, value) => (format!("like_{field}").into(), value.into()),
1236 Filter::Has(field) => (format!("has_{field}").into(), "true".into()),
1237 Filter::HasNot(field) => (format!("has_{field}").into(), "false".into()),
1238 }
1239 }
1240}
1241
1242#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
1243struct Sort(String, SortOrder);
1244
1245impl Sort {
1246 fn as_query_value(&self) -> Cow<'_, str> {
1247 match self.1 {
1248 SortOrder::Ascending => self.0.as_str().into(),
1249 SortOrder::Descending => format!("-{}", self.0).into(),
1250 }
1251 }
1252}
1253
1254#[cfg(test)]
1255mod test {
1256 use super::*;
1257 use expect_test::expect;
1258 use mockito::{mock, Matcher};
1259 #[test]
1260 fn test_defaults() {
1261 let config = RemoteSettingsConfig {
1262 server: None,
1263 server_url: None,
1264 bucket_name: None,
1265 collection_name: String::from("the-collection"),
1266 };
1267 let client = Client::new(config).unwrap();
1268 assert_eq!(
1269 Url::parse("https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/the-collection").unwrap(),
1270 client.endpoints.collection_url
1271 );
1272 }
1273
1274 #[test]
1275 fn test_deprecated_server_url() {
1276 let config = RemoteSettingsConfig {
1277 server: None,
1278 server_url: Some("https://example.com".into()),
1279 bucket_name: None,
1280 collection_name: String::from("the-collection"),
1281 };
1282 let client = Client::new(config).unwrap();
1283 assert_eq!(
1284 Url::parse("https://example.com/v1/buckets/main/collections/the-collection").unwrap(),
1285 client.endpoints.collection_url
1286 );
1287 }
1288
1289 #[test]
1290 fn test_invalid_config() {
1291 let config = RemoteSettingsConfig {
1292 server: Some(RemoteSettingsServer::Prod),
1293 server_url: Some("https://example.com".into()),
1294 bucket_name: None,
1295 collection_name: String::from("the-collection"),
1296 };
1297 match Client::new(config) {
1298 Ok(_) => panic!("Wanted config error; got client"),
1299 Err(Error::ConfigError(_)) => {}
1300 Err(err) => panic!("Wanted config error; got {}", err),
1301 }
1302 }
1303
1304 #[test]
1305 fn test_attachment_can_be_downloaded() {
1306 viaduct_dev::init_backend_dev();
1307 let server_info_m = mock("GET", "/v1/")
1308 .with_body(attachment_metadata(mockito::server_url()))
1309 .with_status(200)
1310 .with_header("content-type", "application/json")
1311 .create();
1312
1313 let attachment_location = "123.jpg";
1314 let attachment_bytes: Vec<u8> = "I'm a JPG, I swear".into();
1315 let attachment_m = mock(
1316 "GET",
1317 format!("/attachments/{}", attachment_location).as_str(),
1318 )
1319 .with_body(attachment_bytes.clone())
1320 .with_status(200)
1321 .with_header("content-type", "application/json")
1322 .create();
1323
1324 let config = RemoteSettingsConfig {
1325 server: Some(RemoteSettingsServer::Custom {
1326 url: mockito::server_url(),
1327 }),
1328 server_url: None,
1329 collection_name: String::from("the-collection"),
1330 bucket_name: None,
1331 };
1332
1333 let client = Client::new(config).unwrap();
1334 let first_resp = client.get_attachment(attachment_location).unwrap();
1335 let second_resp = client.get_attachment(attachment_location).unwrap();
1336
1337 server_info_m.expect(1).assert();
1338 attachment_m.expect(2).assert();
1339 assert_eq!(first_resp, attachment_bytes);
1340 assert_eq!(second_resp, attachment_bytes);
1341 }
1342
1343 #[test]
1344 fn test_attachment_errors_if_server_not_configured_for_attachments() {
1345 viaduct_dev::init_backend_dev();
1346 let server_info_m = mock("GET", "/v1/")
1347 .with_body(NO_ATTACHMENTS_METADATA)
1348 .with_status(200)
1349 .with_header("content-type", "application/json")
1350 .create();
1351
1352 let attachment_location = "123.jpg";
1353 let attachment_bytes: Vec<u8> = "I'm a JPG, I swear".into();
1354 let attachment_m = mock(
1355 "GET",
1356 format!("/attachments/{}", attachment_location).as_str(),
1357 )
1358 .with_body(attachment_bytes)
1359 .with_status(200)
1360 .with_header("content-type", "application/json")
1361 .create();
1362
1363 let config = RemoteSettingsConfig {
1364 server: Some(RemoteSettingsServer::Custom {
1365 url: mockito::server_url(),
1366 }),
1367 server_url: None,
1368 collection_name: String::from("the-collection"),
1369 bucket_name: None,
1370 };
1371
1372 let client = Client::new(config).unwrap();
1373 let resp = client.get_attachment(attachment_location);
1374 server_info_m.expect(1).assert();
1375 attachment_m.expect(0).assert();
1376 assert!(matches!(resp, Err(Error::AttachmentsUnsupportedError)))
1377 }
1378
1379 #[test]
1380 fn test_backoff() {
1381 viaduct_dev::init_backend_dev();
1382 let m = mock(
1383 "GET",
1384 "/v1/buckets/the-bucket/collections/the-collection/records",
1385 )
1386 .with_body(response_body())
1387 .with_status(200)
1388 .with_header("content-type", "application/json")
1389 .with_header("Backoff", "60")
1390 .with_header("etag", "\"1000\"")
1391 .create();
1392 let config = RemoteSettingsConfig {
1393 server: Some(RemoteSettingsServer::Custom {
1394 url: mockito::server_url(),
1395 }),
1396 server_url: None,
1397 collection_name: String::from("the-collection"),
1398 bucket_name: Some(String::from("the-bucket")),
1399 };
1400 let http_client = Client::new(config).unwrap();
1401
1402 assert!(http_client.get_records().is_ok());
1403 let second_resp = http_client.get_records();
1404 assert!(matches!(second_resp, Err(Error::BackoffError(_))));
1405 m.expect(1).assert();
1406 }
1407
1408 #[test]
1409 fn test_500_retry_after() {
1410 viaduct_dev::init_backend_dev();
1411 let m = mock(
1412 "GET",
1413 "/v1/buckets/the-bucket/collections/the-collection/records",
1414 )
1415 .with_body("Boom!")
1416 .with_status(500)
1417 .with_header("Retry-After", "60")
1418 .create();
1419 let config = RemoteSettingsConfig {
1420 server: Some(RemoteSettingsServer::Custom {
1421 url: mockito::server_url(),
1422 }),
1423 server_url: None,
1424 collection_name: String::from("the-collection"),
1425 bucket_name: Some(String::from("the-bucket")),
1426 };
1427 let http_client = Client::new(config).unwrap();
1428 assert!(http_client.get_records().is_err());
1429 let second_request = http_client.get_records();
1430 assert!(matches!(second_request, Err(Error::BackoffError(_))));
1431 m.expect(1).assert();
1432 }
1433
1434 #[test]
1435 fn test_options() {
1436 viaduct_dev::init_backend_dev();
1437 let m = mock(
1438 "GET",
1439 "/v1/buckets/the-bucket/collections/the-collection/records",
1440 )
1441 .match_query(Matcher::AllOf(vec![
1442 Matcher::UrlEncoded("a".into(), "b".into()),
1443 Matcher::UrlEncoded("lt_c.d".into(), "5".into()),
1444 Matcher::UrlEncoded("gt_e".into(), "15".into()),
1445 Matcher::UrlEncoded("max_f".into(), "20".into()),
1446 Matcher::UrlEncoded("min_g".into(), "10".into()),
1447 Matcher::UrlEncoded("not_h".into(), "i".into()),
1448 Matcher::UrlEncoded("like_j".into(), "*k*".into()),
1449 Matcher::UrlEncoded("has_l".into(), "true".into()),
1450 Matcher::UrlEncoded("has_m".into(), "false".into()),
1451 Matcher::UrlEncoded("contains_n".into(), "o".into()),
1452 Matcher::UrlEncoded("_sort".into(), "-b,a".into()),
1453 Matcher::UrlEncoded("_fields".into(), "a,c,b".into()),
1454 Matcher::UrlEncoded("_limit".into(), "3".into()),
1455 ]))
1456 .with_body(response_body())
1457 .with_status(200)
1458 .with_header("content-type", "application/json")
1459 .with_header("etag", "\"1000\"")
1460 .create();
1461 let config = RemoteSettingsConfig {
1462 server: Some(RemoteSettingsServer::Custom {
1463 url: mockito::server_url(),
1464 }),
1465 server_url: None,
1466 collection_name: String::from("the-collection"),
1467 bucket_name: Some(String::from("the-bucket")),
1468 };
1469 let http_client = Client::new(config).unwrap();
1470 let mut options = GetItemsOptions::new();
1471 options
1472 .field("a")
1473 .field("c")
1474 .field("b")
1475 .filter_eq("a", "b")
1476 .filter_lt("c.d", "5")
1477 .filter_gt("e", "15")
1478 .filter_max("f", "20")
1479 .filter_min("g", "10")
1480 .filter_not("h", "i")
1481 .filter_like("j", "*k*")
1482 .filter_has("l")
1483 .filter_has_not("m")
1484 .filter_contains("n", "o")
1485 .sort("b", SortOrder::Descending)
1486 .sort("a", SortOrder::Ascending)
1487 .limit(3);
1488
1489 assert!(http_client.get_records_raw_with_options(&options).is_ok());
1490 expect![[r#"
1491 RemoteSettingsResponse {
1492 records: [
1493 RemoteSettingsRecord {
1494 id: "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
1495 last_modified: 1677694949407,
1496 deleted: false,
1497 attachment: Some(
1498 Attachment {
1499 filename: "jgp-attachment.jpg",
1500 mimetype: "image/jpeg",
1501 location: "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
1502 hash: "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
1503 size: 1374325,
1504 },
1505 ),
1506 fields: {
1507 "title": String("jpg-attachment"),
1508 "content": String("content"),
1509 "schema": Number(1677694447771),
1510 },
1511 },
1512 RemoteSettingsRecord {
1513 id: "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
1514 last_modified: 1677694470354,
1515 deleted: false,
1516 attachment: Some(
1517 Attachment {
1518 filename: "pdf-attachment.pdf",
1519 mimetype: "application/pdf",
1520 location: "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
1521 hash: "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
1522 size: 157,
1523 },
1524 ),
1525 fields: {
1526 "title": String("with-attachment"),
1527 "content": String("content"),
1528 "schema": Number(1677694447771),
1529 },
1530 },
1531 RemoteSettingsRecord {
1532 id: "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
1533 last_modified: 1677694455368,
1534 deleted: false,
1535 attachment: None,
1536 fields: {
1537 "title": String("no-attachment"),
1538 "content": String("content"),
1539 "schema": Number(1677694447771),
1540 },
1541 },
1542 RemoteSettingsRecord {
1543 id: "9320f53c-0a39-4997-9120-62ff597ffb26",
1544 last_modified: 1690921847416,
1545 deleted: true,
1546 attachment: None,
1547 fields: {},
1548 },
1549 ],
1550 last_modified: 1000,
1551 }
1552 "#]].assert_debug_eq(&http_client
1553 .get_records_with_options(&options)
1554 .unwrap());
1555 m.expect(2).assert();
1556 }
1557
1558 #[test]
1559 fn test_backoff_recovery() {
1560 viaduct_dev::init_backend_dev();
1561 let m = mock(
1562 "GET",
1563 "/v1/buckets/the-bucket/collections/the-collection/records",
1564 )
1565 .with_body(response_body())
1566 .with_status(200)
1567 .with_header("content-type", "application/json")
1568 .with_header("etag", "\"1000\"")
1569 .create();
1570 let config = RemoteSettingsConfig {
1571 server: Some(RemoteSettingsServer::Custom {
1572 url: mockito::server_url(),
1573 }),
1574 server_url: None,
1575 collection_name: String::from("the-collection"),
1576 bucket_name: Some(String::from("the-bucket")),
1577 };
1578 let http_client = Client::new(config).unwrap();
1579 let mut current_remote_state = http_client.remote_state.lock();
1581 current_remote_state.backoff = BackoffState::Backoff {
1582 observed_at: Instant::now(),
1583 duration: Duration::from_secs(30),
1584 };
1585 drop(current_remote_state);
1586 assert!(matches!(
1587 http_client.get_records(),
1588 Err(Error::BackoffError(_))
1589 ));
1590 let mut current_remote_state = http_client.remote_state.lock();
1592 current_remote_state.backoff = BackoffState::Backoff {
1593 observed_at: Instant::now() - Duration::from_secs(31),
1594 duration: Duration::from_secs(30),
1595 };
1596 drop(current_remote_state);
1597 assert!(http_client.get_records().is_ok());
1598 m.expect(1).assert();
1599 }
1600
1601 #[test]
1602 fn test_record_fields() {
1603 viaduct_dev::init_backend_dev();
1604 let m = mock(
1605 "GET",
1606 "/v1/buckets/the-bucket/collections/the-collection/records",
1607 )
1608 .with_body(response_body())
1609 .with_status(200)
1610 .with_header("content-type", "application/json")
1611 .with_header("etag", "\"1000\"")
1612 .create();
1613 let config = RemoteSettingsConfig {
1614 server: Some(RemoteSettingsServer::Custom {
1615 url: mockito::server_url(),
1616 }),
1617 server_url: None,
1618 collection_name: String::from("the-collection"),
1619 bucket_name: Some(String::from("the-bucket")),
1620 };
1621 let http_client = Client::new(config).unwrap();
1622 let response = http_client.get_records().unwrap();
1623 expect![[r#"
1624 RemoteSettingsResponse {
1625 records: [
1626 RemoteSettingsRecord {
1627 id: "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
1628 last_modified: 1677694949407,
1629 deleted: false,
1630 attachment: Some(
1631 Attachment {
1632 filename: "jgp-attachment.jpg",
1633 mimetype: "image/jpeg",
1634 location: "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
1635 hash: "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
1636 size: 1374325,
1637 },
1638 ),
1639 fields: {
1640 "title": String("jpg-attachment"),
1641 "content": String("content"),
1642 "schema": Number(1677694447771),
1643 },
1644 },
1645 RemoteSettingsRecord {
1646 id: "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
1647 last_modified: 1677694470354,
1648 deleted: false,
1649 attachment: Some(
1650 Attachment {
1651 filename: "pdf-attachment.pdf",
1652 mimetype: "application/pdf",
1653 location: "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
1654 hash: "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
1655 size: 157,
1656 },
1657 ),
1658 fields: {
1659 "title": String("with-attachment"),
1660 "content": String("content"),
1661 "schema": Number(1677694447771),
1662 },
1663 },
1664 RemoteSettingsRecord {
1665 id: "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
1666 last_modified: 1677694455368,
1667 deleted: false,
1668 attachment: None,
1669 fields: {
1670 "title": String("no-attachment"),
1671 "content": String("content"),
1672 "schema": Number(1677694447771),
1673 },
1674 },
1675 RemoteSettingsRecord {
1676 id: "9320f53c-0a39-4997-9120-62ff597ffb26",
1677 last_modified: 1690921847416,
1678 deleted: true,
1679 attachment: None,
1680 fields: {},
1681 },
1682 ],
1683 last_modified: 1000,
1684 }
1685 "#]].assert_debug_eq(&response);
1686 m.expect(1).assert();
1687 }
1688
1689 #[test]
1690 fn test_missing_etag() {
1691 viaduct_dev::init_backend_dev();
1692 let m = mock(
1693 "GET",
1694 "/v1/buckets/the-bucket/collections/the-collection/records",
1695 )
1696 .with_body(response_body())
1697 .with_status(200)
1698 .with_header("content-type", "application/json")
1699 .create();
1700
1701 let config = RemoteSettingsConfig {
1702 server: Some(RemoteSettingsServer::Custom {
1703 url: mockito::server_url(),
1704 }),
1705 server_url: None,
1706 bucket_name: Some(String::from("the-bucket")),
1707 collection_name: String::from("the-collection"),
1708 };
1709 let client = Client::new(config).unwrap();
1710
1711 let err = client.get_records().unwrap_err();
1712 assert!(
1713 matches!(err, Error::ResponseError(_)),
1714 "Want response error for missing `ETag`; got {}",
1715 err
1716 );
1717 m.expect(1).assert();
1718 }
1719
1720 #[test]
1721 fn test_invalid_etag() {
1722 viaduct_dev::init_backend_dev();
1723 let m = mock(
1724 "GET",
1725 "/v1/buckets/the-bucket/collections/the-collection/records",
1726 )
1727 .with_body(response_body())
1728 .with_status(200)
1729 .with_header("content-type", "application/json")
1730 .with_header("etag", "bad!")
1731 .create();
1732
1733 let config = RemoteSettingsConfig {
1734 server: Some(RemoteSettingsServer::Custom {
1735 url: mockito::server_url(),
1736 }),
1737 server_url: None,
1738 bucket_name: Some(String::from("the-bucket")),
1739 collection_name: String::from("the-collection"),
1740 };
1741 let client = Client::new(config).unwrap();
1742
1743 let err = client.get_records().unwrap_err();
1744 assert!(
1745 matches!(err, Error::ResponseError(_)),
1746 "Want response error for invalid `ETag`; got {}",
1747 err
1748 );
1749 m.expect(1).assert();
1750 }
1751
1752 fn attachment_metadata(base_url: String) -> String {
1753 format!(
1754 r#"
1755 {{
1756 "capabilities": {{
1757 "admin": {{
1758 "description": "Serves the admin console.",
1759 "url": "https://github.com/Kinto/kinto-admin/",
1760 "version": "2.0.0"
1761 }},
1762 "attachments": {{
1763 "description": "Add file attachments to records",
1764 "url": "https://github.com/Kinto/kinto-attachment/",
1765 "version": "6.3.1",
1766 "base_url": "{}/attachments/"
1767 }}
1768 }}
1769 }}
1770 "#,
1771 base_url
1772 )
1773 }
1774
1775 const NO_ATTACHMENTS_METADATA: &str = r#"
1776 {
1777 "capabilities": {
1778 "admin": {
1779 "description": "Serves the admin console.",
1780 "url": "https://github.com/Kinto/kinto-admin/",
1781 "version": "2.0.0"
1782 }
1783 }
1784 }
1785 "#;
1786
1787 fn response_body() -> String {
1788 format!(
1789 r#"
1790 {{
1791 "data": [
1792 {},
1793 {},
1794 {},
1795 {}
1796 ]
1797 }}"#,
1798 JPG_ATTACHMENT, PDF_ATTACHMENT, NO_ATTACHMENT, TOMBSTONE
1799 )
1800 }
1801
1802 const JPG_ATTACHMENT: &str = r#"
1803 {
1804 "title": "jpg-attachment",
1805 "content": "content",
1806 "attachment": {
1807 "filename": "jgp-attachment.jpg",
1808 "location": "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
1809 "hash": "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
1810 "mimetype": "image/jpeg",
1811 "size": 1374325
1812 },
1813 "id": "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
1814 "schema": 1677694447771,
1815 "last_modified": 1677694949407
1816 }
1817 "#;
1818
1819 const PDF_ATTACHMENT: &str = r#"
1820 {
1821 "title": "with-attachment",
1822 "content": "content",
1823 "attachment": {
1824 "filename": "pdf-attachment.pdf",
1825 "location": "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
1826 "hash": "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
1827 "mimetype": "application/pdf",
1828 "size": 157
1829 },
1830 "id": "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
1831 "schema": 1677694447771,
1832 "last_modified": 1677694470354
1833 }
1834 "#;
1835
1836 const NO_ATTACHMENT: &str = r#"
1837 {
1838 "title": "no-attachment",
1839 "content": "content",
1840 "schema": 1677694447771,
1841 "id": "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
1842 "last_modified": 1677694455368
1843 }
1844 "#;
1845
1846 const TOMBSTONE: &str = r#"
1847 {
1848 "id": "9320f53c-0a39-4997-9120-62ff597ffb26",
1849 "last_modified": 1690921847416,
1850 "deleted": true
1851 }
1852 "#;
1853}
1854
1855#[cfg(test)]
1856mod test_new_client {
1857 use super::*;
1858
1859 #[test]
1860 fn test_endpoints() {
1861 let endpoints = RemoteSettingsEndpoints::new(
1862 &BaseUrl::parse("http://rs.example.com/v1").unwrap(),
1863 "main",
1864 "test-collection",
1865 );
1866 assert_eq!(endpoints.root_url.to_string(), "http://rs.example.com/v1/");
1867 assert_eq!(
1868 endpoints.collection_url.to_string(),
1869 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1870 );
1871 assert_eq!(
1872 endpoints.records_url.to_string(),
1873 "http://rs.example.com/v1/buckets/main/collections/test-collection/records",
1874 );
1875 assert_eq!(
1876 endpoints.changeset_url.to_string(),
1877 "http://rs.example.com/v1/buckets/main/collections/test-collection/changeset",
1878 );
1879 }
1880}
1881
1882#[cfg(test)]
1883mod jexl_tests {
1884 use super::*;
1885
1886 #[test]
1887 fn test_get_records_filtered_app_version_pass() {
1888 let mut api_client = MockApiClient::new();
1889 let records = vec![RemoteSettingsRecord {
1890 id: "record-0001".into(),
1891 last_modified: 100,
1892 deleted: false,
1893 attachment: None,
1894 fields: serde_json::json!({
1895 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1896 })
1897 .as_object()
1898 .unwrap()
1899 .clone(),
1900 }];
1901 let changeset = ChangesetResponse {
1902 changes: records.clone(),
1903 timestamp: 42,
1904 metadata: CollectionMetadata::default(),
1905 };
1906 api_client.expect_collection_url().returning(|| {
1907 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1908 });
1909 api_client.expect_fetch_changeset().returning({
1910 let changeset = changeset.clone();
1911 move |timestamp| {
1912 assert_eq!(timestamp, None);
1913 Ok(changeset.clone())
1914 }
1915 });
1916 api_client.expect_is_prod_server().returning(|| Ok(false));
1917
1918 let context = RemoteSettingsContext {
1919 app_version: Some("129.0.0".to_string()),
1920 ..Default::default()
1921 };
1922
1923 let mut storage = Storage::new(":memory:".into());
1924 let _ = storage.insert_collection_content(
1925 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1926 &records,
1927 42,
1928 CollectionMetadata::default(),
1929 );
1930
1931 let rs_client = RemoteSettingsClient::new_from_parts(
1932 "test-collection".into(),
1933 storage,
1934 JexlFilter::new(Some(context)),
1935 api_client,
1936 );
1937
1938 assert_eq!(
1939 rs_client.get_records(false).expect("Error getting records"),
1940 Some(records)
1941 );
1942 }
1943
1944 #[test]
1945 fn test_get_records_filtered_app_version_too_low() {
1946 let mut api_client = MockApiClient::new();
1947 let records = vec![RemoteSettingsRecord {
1948 id: "record-0001".into(),
1949 last_modified: 100,
1950 deleted: false,
1951 attachment: None,
1952 fields: serde_json::json!({
1953 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1954 })
1955 .as_object()
1956 .unwrap()
1957 .clone(),
1958 }];
1959 let changeset = ChangesetResponse {
1960 changes: records.clone(),
1961 timestamp: 42,
1962 metadata: CollectionMetadata::default(),
1963 };
1964 api_client.expect_collection_url().returning(|| {
1965 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1966 });
1967 api_client.expect_fetch_changeset().returning({
1968 let changeset = changeset.clone();
1969 move |timestamp| {
1970 assert_eq!(timestamp, None);
1971 Ok(changeset.clone())
1972 }
1973 });
1974 api_client.expect_is_prod_server().returning(|| Ok(false));
1975
1976 let context = RemoteSettingsContext {
1977 app_version: Some("127.0.0.".to_string()),
1978 ..Default::default()
1979 };
1980
1981 let mut storage = Storage::new(":memory:".into());
1982 let _ = storage.insert_collection_content(
1983 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1984 &records,
1985 42,
1986 CollectionMetadata::default(),
1987 );
1988
1989 let rs_client = RemoteSettingsClient::new_from_parts(
1990 "test-collection".into(),
1991 storage,
1992 JexlFilter::new(Some(context)),
1993 api_client,
1994 );
1995
1996 assert_eq!(
1997 rs_client.get_records(false).expect("Error getting records"),
1998 Some(vec![])
1999 );
2000 }
2001
2002 #[test]
2003 fn test_update_jexl_context() {
2004 let mut api_client = MockApiClient::new();
2005 let records = vec![RemoteSettingsRecord {
2006 id: "record-0001".into(),
2007 last_modified: 100,
2008 deleted: false,
2009 attachment: None,
2010 fields: serde_json::json!({
2011 "filter_expression": "env.country == \"US\""
2012 })
2013 .as_object()
2014 .unwrap()
2015 .clone(),
2016 }];
2017 let changeset = ChangesetResponse {
2018 changes: records.clone(),
2019 timestamp: 42,
2020 metadata: CollectionMetadata::default(),
2021 };
2022 api_client.expect_collection_url().returning(|| {
2023 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
2024 });
2025 api_client.expect_fetch_changeset().returning({
2026 let changeset = changeset.clone();
2027 move |timestamp| {
2028 assert_eq!(timestamp, None);
2029 Ok(changeset.clone())
2030 }
2031 });
2032 api_client.expect_is_prod_server().returning(|| Ok(false));
2033
2034 let context = RemoteSettingsContext {
2035 country: Some("US".to_string()),
2036 ..Default::default()
2037 };
2038
2039 let mut storage = Storage::new(":memory:".into());
2040 let _ = storage.insert_collection_content(
2041 "http://rs.example.com/v1/buckets/main/collections/test-collection",
2042 &records,
2043 42,
2044 CollectionMetadata::default(),
2045 );
2046
2047 let rs_client = RemoteSettingsClient::new_from_parts(
2048 "test-collection".into(),
2049 storage,
2050 JexlFilter::new(Some(context)),
2051 api_client,
2052 );
2053
2054 assert_eq!(
2055 rs_client.get_records(false).expect("Error getting records"),
2056 Some(records)
2057 );
2058
2059 rs_client.inner.lock().jexl_filter = JexlFilter::new(Some(RemoteSettingsContext {
2062 country: Some("UK".to_string()),
2063 ..Default::default()
2064 }));
2065
2066 assert_eq!(
2067 rs_client.get_records(false).expect("Error getting records"),
2068 Some(vec![])
2069 );
2070 }
2071}
2072
2073#[cfg(feature = "signatures")]
2074#[cfg(test)]
2075mod test_signatures {
2076 use core::assert_eq;
2077
2078 use crate::RemoteSettingsContext;
2079
2080 use super::*;
2081 use nss::ensure_initialized;
2082
2083 const VALID_CERTIFICATE: &str = "\
2084-----BEGIN CERTIFICATE-----
2085MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
2086AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
2087bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
2088dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
2089emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ
2090BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
2091biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
2092bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt
2093c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb
2094HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt
2095zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD
2096MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
2097GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0
2098dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
2099aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ
21009FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc
2101fS8//SQGTlCqKQ==
2102-----END CERTIFICATE-----
2103-----BEGIN CERTIFICATE-----
2104MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC
2105VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
2106ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
2107aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
2108bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB
2109ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt
2110BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw
2111QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz
2112cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE
2113xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz
2114vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6
2115MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK
2116BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud
2117IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT
2118AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE
2119ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln
2120bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk
2121b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk
2122b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250
2123ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t
2124b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx
2125qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ
21269qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH
2127TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr
2128v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O
2129ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea
2130UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg
2131VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0
2132xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d
2133mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi
2134ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz
2135LC4GzWQGSCGDyD+JCVw=
2136-----END CERTIFICATE-----
2137-----BEGIN CERTIFICATE-----
2138MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC
2139VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
2140ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
2141aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
2142bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB
2143qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW
2144aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0
2145LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz
2146dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC
2147DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8
2148rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5
2149eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k
215051uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj
2151khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD
2152OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf
21534sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM
2154NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+
2155uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh
21567v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y
2157JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB
2158o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/
2159BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk
2160IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls
2161bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd
2162MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw
2163CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx
2164HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk
2165b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl
2166cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA
2167nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap
2168Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD
2169DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+
2170AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF
21712iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d
2172PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA
2173dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8
21745xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy
2175iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO
21766pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg
2177IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE=
2178-----END CERTIFICATE-----";
2179 const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#;
2180 const VALID_CERT_EPOCH_SECONDS: u64 = 1615559719;
2181
2182 fn run_client_sync(
2183 diff_records: &[RemoteSettingsRecord],
2184 full_records: &[RemoteSettingsRecord],
2185 certificate: &str,
2186 signature: &str,
2187 epoch_secs: u64,
2188 bucket: &str,
2189 ) -> Result<()> {
2190 let collection_name = "pioneer-study-addons";
2191
2192 MOCK_TIME.with(|cell| cell.set(Some(epoch_secs)));
2193
2194 let some_metadata = CollectionMetadata {
2195 bucket: bucket.into(),
2196 signature: CollectionSignature {
2197 signature: signature.to_string(),
2198 x5u: "http://mocked".into(),
2199 },
2200 };
2201 let diff_changeset = ChangesetResponse {
2203 changes: diff_records.to_vec(),
2204 timestamp: 1603992731957,
2205 metadata: some_metadata.clone(),
2206 };
2207 let full_changeset = ChangesetResponse {
2209 changes: full_records.to_vec(),
2210 timestamp: 1603992731957,
2211 metadata: some_metadata.clone(),
2212 };
2213
2214 let mut api_client = MockApiClient::new();
2215 api_client
2216 .expect_collection_url()
2217 .returning(move || format!("http://server/{}", collection_name));
2218 api_client.expect_is_prod_server().returning(|| Ok(false));
2219 api_client.expect_fetch_changeset().returning(move |since| {
2220 Ok(if since.is_some() {
2221 diff_changeset.clone()
2222 } else {
2223 full_changeset.clone()
2224 })
2225 });
2226
2227 let certificate = certificate.to_string();
2228 api_client
2229 .expect_fetch_cert()
2230 .returning(move |_| Ok(certificate.clone().into_bytes()));
2231
2232 let storage = Storage::new(":memory:".into());
2233 let jexl_filter = JexlFilter::new(Some(RemoteSettingsContext::default()));
2234 let rs_client = RemoteSettingsClient::new_from_parts(
2235 collection_name.to_string(),
2236 storage,
2237 jexl_filter,
2238 api_client,
2239 );
2240
2241 rs_client.sync()
2242 }
2243
2244 #[test]
2245 fn test_valid_signature() -> Result<()> {
2246 ensure_initialized();
2247 run_client_sync(
2248 &[],
2249 &[],
2250 VALID_CERTIFICATE,
2251 VALID_SIGNATURE,
2252 VALID_CERT_EPOCH_SECONDS,
2253 "main",
2254 )
2255 .expect("Valid signature");
2256 Ok(())
2257 }
2258
2259 #[test]
2260 fn test_valid_signature_after_retry() -> Result<()> {
2261 ensure_initialized();
2262 run_client_sync(
2263 &[RemoteSettingsRecord {
2264 id: "bad-record".to_string(),
2265 last_modified: 9999,
2266 deleted: true,
2267 attachment: None,
2268 fields: serde_json::Map::new(),
2269 }],
2270 &[],
2271 VALID_CERTIFICATE,
2272 VALID_SIGNATURE,
2273 VALID_CERT_EPOCH_SECONDS,
2274 "main",
2275 )
2276 .expect("Valid signature");
2277 Ok(())
2278 }
2279
2280 #[test]
2281 fn test_invalid_signature_value() -> Result<()> {
2282 ensure_initialized();
2283 let err = run_client_sync(
2284 &[],
2285 &[],
2286 VALID_CERTIFICATE,
2287 "invalid signature",
2288 VALID_CERT_EPOCH_SECONDS,
2289 "main",
2290 )
2291 .unwrap_err();
2292 assert!(matches!(err, Error::SignatureError(_)));
2293 assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
2294
2295 Ok(())
2296 }
2297
2298 #[test]
2299 fn test_invalid_certificate_value() -> Result<()> {
2300 ensure_initialized();
2301 let err = run_client_sync(
2302 &[],
2303 &[],
2304 "some bad PEM content",
2305 VALID_SIGNATURE,
2306 VALID_CERT_EPOCH_SECONDS,
2307 "main",
2308 )
2309 .unwrap_err();
2310
2311 assert!(matches!(err, Error::SignatureError(_)));
2312 assert_eq!(
2313 format!("{}", err),
2314 "Signature could not be verified: PEM content format error: Missing PEM data"
2315 );
2316
2317 Ok(())
2318 }
2319
2320 #[test]
2321 fn test_invalid_signature_expired_cert() -> Result<()> {
2322 ensure_initialized();
2323 let december_20_2024 = 1734651582;
2324
2325 let err = run_client_sync(
2326 &[],
2327 &[],
2328 VALID_CERTIFICATE,
2329 VALID_SIGNATURE,
2330 december_20_2024,
2331 "main",
2332 )
2333 .unwrap_err();
2334
2335 assert!(matches!(err, Error::SignatureError(_)));
2336 assert_eq!(
2337 format!("{}", err),
2338 "Signature could not be verified: Certificate not yet valid or expired"
2339 );
2340
2341 Ok(())
2342 }
2343
2344 #[test]
2345 fn test_invalid_signature_invalid_data() -> Result<()> {
2346 ensure_initialized();
2347 let records = vec![RemoteSettingsRecord {
2349 id: "unexpected-data".to_string(),
2350 last_modified: 42,
2351 deleted: false,
2352 attachment: None,
2353 fields: serde_json::Map::new(),
2354 }];
2355 let err = run_client_sync(
2356 &records,
2357 &records,
2358 VALID_CERTIFICATE,
2359 VALID_SIGNATURE,
2360 VALID_CERT_EPOCH_SECONDS,
2361 "main",
2362 )
2363 .unwrap_err();
2364
2365 assert!(matches!(err, Error::SignatureError(_)));
2366 assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
2367
2368 Ok(())
2369 }
2370
2371 #[test]
2372 fn test_invalid_signature_invalid_signer_name() -> Result<()> {
2373 ensure_initialized();
2374 let err = run_client_sync(
2375 &[],
2376 &[],
2377 VALID_CERTIFICATE,
2378 VALID_SIGNATURE,
2379 VALID_CERT_EPOCH_SECONDS,
2380 "security-state",
2381 )
2382 .unwrap_err();
2383 assert!(matches!(err, Error::SignatureError(_)));
2384 assert_eq!(
2385 format!("{}", err),
2386 "Signature could not be verified: Certificate subject mismatch"
2387 );
2388
2389 Ok(())
2390 }
2391}