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