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