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