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