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 pub 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 expected_root_hash = if inner.api_client.is_prod_server()? {
447 ROOT_CERT_SHA256_HASH_PROD
448 } else {
449 ROOT_CERT_SHA256_HASH_NONPROD
450 };
451 let mut result = Err(Error::IncompleteSignatureDataError(
454 "No valid signatures found".into(),
455 ));
456 for signature in &metadata.signatures {
457 let cert_chain_bytes = inner.api_client.fetch_cert(&signature.x5u)?;
458
459 let expected_leaf_cname = format!(
464 "{}.content-signature.mozilla.org",
465 if metadata.bucket.contains("security-state") {
466 "onecrl"
467 } else {
468 "remote-settings"
469 }
470 );
471
472 result = signatures::verify_signature(
473 timestamp,
474 records,
475 signature.signature.as_bytes(),
476 &cert_chain_bytes,
477 epoch_seconds(),
478 expected_root_hash,
479 &expected_leaf_cname,
480 )
481 .inspect_err(|err| {
482 debug!(
483 "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'",
484 self.collection_name, err, &signature.x5u, expected_leaf_cname
485 );
486 });
487 if result.is_ok() {
489 trace!("{0}: signature verification success.", self.collection_name);
490 return Ok(());
491 }
492 }
493 result
495 }
496 _ => {
497 let missing_field = if timestamp.is_none() {
498 "timestamp"
499 } else if records.is_none() {
500 "records"
501 } else {
502 "metadata"
503 };
504 Err(Error::IncompleteSignatureDataError(missing_field.into()))
505 }
506 }
507 }
508
509 pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> Result<Vec<u8>> {
512 let metadata = record
513 .attachment
514 .as_ref()
515 .ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
516
517 let mut inner = self.lock_inner()?;
518 let collection_url = inner.api_client.collection_url();
519
520 if let Some(data) = inner
522 .storage
523 .get_attachment(&collection_url, metadata.clone())?
524 {
525 return Ok(data);
526 }
527
528 if inner.api_client.is_prod_server()? {
530 if let Some((data, manifest)) = self.load_packaged_attachment(&record.id) {
531 if let Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) {
532 if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
533 && metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
534 {
535 inner
537 .storage
538 .set_attachment(&collection_url, &metadata.location, data)?;
539 return Ok(data.to_vec());
540 }
541 }
542 }
543 }
544
545 let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
547
548 if attachment.len() as u64 != metadata.size {
550 return Err(Error::RecordAttachmentMismatchError(
551 "Downloaded attachment size mismatch".into(),
552 ));
553 }
554 let hash = format!("{:x}", Sha256::digest(&attachment));
555 if hash != metadata.hash {
556 return Err(Error::RecordAttachmentMismatchError(
557 "Downloaded attachment hash mismatch".into(),
558 ));
559 }
560
561 inner
563 .storage
564 .set_attachment(&collection_url, &metadata.location, &attachment)?;
565 Ok(attachment)
566 }
567
568 pub fn update_config(
569 &self,
570 server_url: BaseUrl,
571 bucket_name: String,
572 context: Option<RemoteSettingsContext>,
573 ) {
574 let mut pending_config = self.pending_config.lock();
575 *pending_config = Some(RemoteSettingsClientConfig {
576 server_url,
577 bucket_name,
578 context,
579 })
580 }
581}
582
583impl RemoteSettingsClient<ViaductApiClient> {
584 pub fn new(
585 server_url: BaseUrl,
586 bucket_name: String,
587 collection_name: String,
588 context: Option<RemoteSettingsContext>,
589 storage: Storage,
590 ) -> Self {
591 let api_client = ViaductApiClient::new(server_url, &bucket_name, &collection_name);
592 let jexl_filter = JexlFilter::new(context);
593
594 Self::new_from_parts(collection_name, storage, jexl_filter, api_client)
595 }
596}
597
598#[cfg_attr(test, mockall::automock)]
599pub trait ApiClient {
600 fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self;
602
603 fn collection_url(&self) -> String;
611
612 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse>;
614
615 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>>;
617
618 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>>;
620
621 fn is_prod_server(&self) -> Result<bool>;
623}
624
625pub struct ViaductApiClient {
627 endpoints: RemoteSettingsEndpoints,
628 remote_state: RemoteState,
629}
630
631impl ViaductApiClient {
632 fn new(base_url: BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
633 Self {
634 endpoints: RemoteSettingsEndpoints::new(&base_url, bucket_name, collection_name),
635 remote_state: RemoteState::default(),
636 }
637 }
638
639 fn make_request(&mut self, url: Url) -> Result<Response> {
640 trace!("make_request: {url}");
641 self.remote_state.ensure_no_backoff()?;
642
643 let req = Request::get(url);
644 let resp = req.send()?;
645
646 self.remote_state.handle_backoff_hint(&resp)?;
647
648 if resp.is_success() {
649 Ok(resp)
650 } else {
651 Err(Error::response_error(
652 &resp.url,
653 format!("status code: {}", resp.status),
654 ))
655 }
656 }
657}
658
659impl ApiClient for ViaductApiClient {
660 fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self {
661 Self::new(server_url, &bucket_name, collection_name)
662 }
663
664 fn collection_url(&self) -> String {
665 self.endpoints.collection_url.to_string()
666 }
667
668 fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse> {
669 let mut url = self.endpoints.changeset_url.clone();
670 url.query_pairs_mut().append_pair("_expected", "0");
676 if let Some(timestamp) = timestamp {
677 url.query_pairs_mut()
678 .append_pair("_since", &format!("\"{}\"", timestamp));
679 }
680
681 let resp = self.make_request(url)?;
682
683 if resp.is_success() {
684 Ok(resp.json::<ChangesetResponse>()?)
685 } else {
686 Err(Error::response_error(
687 &resp.url,
688 format!("status code: {}", resp.status),
689 ))
690 }
691 }
692
693 fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>> {
694 let attachments_base_url = match &self.remote_state.attachments_base_url {
695 Some(attachments_base_url) => attachments_base_url.to_owned(),
696 None => {
697 let server_info = self
698 .make_request(self.endpoints.root_url.clone())?
699 .json::<ServerInfo>()?;
700 let attachments_base_url = match server_info.capabilities.attachments {
701 Some(capability) => Url::parse(&capability.base_url)?,
702 None => Err(Error::AttachmentsUnsupportedError)?,
703 };
704 self.remote_state.attachments_base_url = Some(attachments_base_url.clone());
705 attachments_base_url
706 }
707 };
708
709 let resp = self.make_request(attachments_base_url.join(attachment_location)?)?;
710 Ok(resp.body)
711 }
712
713 fn is_prod_server(&self) -> Result<bool> {
714 Ok(self
715 .endpoints
716 .root_url
717 .as_str()
718 .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str()))
719 }
720
721 fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>> {
722 let resp = self.make_request(Url::parse(x5u)?)?;
723 Ok(resp.body)
724 }
725}
726
727pub struct Client {
731 endpoints: RemoteSettingsEndpoints,
732 pub(crate) remote_state: Mutex<RemoteState>,
733}
734
735impl Client {
736 pub fn new(config: RemoteSettingsConfig) -> Result<Self> {
738 let server = match (config.server, config.server_url) {
739 (Some(server), None) => server,
740 (None, Some(server_url)) => RemoteSettingsServer::Custom { url: server_url },
741 (None, None) => RemoteSettingsServer::Prod,
742 (Some(_), Some(_)) => Err(Error::ConfigError(
743 "`RemoteSettingsConfig` takes either `server` or `server_url`, not both".into(),
744 ))?,
745 };
746
747 let bucket_name = config.bucket_name.unwrap_or_else(|| String::from("main"));
748 let endpoints = RemoteSettingsEndpoints::new(
749 &server.get_base_url()?,
750 &bucket_name,
751 &config.collection_name,
752 );
753
754 Ok(Self {
755 endpoints,
756 remote_state: Default::default(),
757 })
758 }
759
760 pub fn get_records(&self) -> Result<RemoteSettingsResponse> {
764 self.get_records_with_options(&GetItemsOptions::new())
765 }
766
767 pub fn get_records_raw(&self) -> Result<Response> {
771 self.get_records_raw_with_options(&GetItemsOptions::new())
772 }
773
774 pub fn get_records_since(&self, timestamp: u64) -> Result<RemoteSettingsResponse> {
778 self.get_records_with_options(
779 GetItemsOptions::new().filter_gt("last_modified", timestamp.to_string()),
780 )
781 }
782
783 pub fn get_records_with_options(
785 &self,
786 options: &GetItemsOptions,
787 ) -> Result<RemoteSettingsResponse> {
788 let resp = self.get_records_raw_with_options(options)?;
789 let records = resp.json::<RecordsResponse>()?.data;
790 let etag = resp
791 .headers
792 .get(HEADER_ETAG)
793 .ok_or_else(|| Error::response_error(&resp.url, "no etag header"))?;
794 let last_modified = etag.trim_matches('"').parse().map_err(|_| {
798 Error::response_error(
799 &resp.url,
800 format!("expected quoted integer in etag header; got `{}`", etag),
801 )
802 })?;
803 Ok(RemoteSettingsResponse {
804 records,
805 last_modified,
806 })
807 }
808
809 pub fn get_records_raw_with_options(&self, options: &GetItemsOptions) -> Result<Response> {
812 let mut url = self.endpoints.records_url.clone();
813 for (name, value) in options.iter_query_pairs() {
814 url.query_pairs_mut().append_pair(&name, &value);
815 }
816 self.make_request(url)
817 }
818
819 pub fn get_attachment(&self, attachment_location: &str) -> Result<Vec<u8>> {
823 Ok(self.get_attachment_raw(attachment_location)?.body)
824 }
825
826 pub fn get_attachment_raw(&self, attachment_location: &str) -> Result<Response> {
828 let maybe_attachments_base_url = self.remote_state.lock().attachments_base_url.clone();
833
834 let attachments_base_url = match maybe_attachments_base_url {
835 Some(attachments_base_url) => attachments_base_url,
836 None => {
837 let server_info = self
838 .make_request(self.endpoints.root_url.clone())?
839 .json::<ServerInfo>()?;
840 let attachments_base_url = match server_info.capabilities.attachments {
841 Some(capability) => Url::parse(&capability.base_url)?,
842 None => Err(Error::AttachmentsUnsupportedError)?,
843 };
844 self.remote_state.lock().attachments_base_url = Some(attachments_base_url.clone());
845 attachments_base_url
846 }
847 };
848
849 self.make_request(attachments_base_url.join(attachment_location)?)
850 }
851
852 fn make_request(&self, url: Url) -> Result<Response> {
853 let mut current_remote_state = self.remote_state.lock();
854 current_remote_state.ensure_no_backoff()?;
855 drop(current_remote_state);
856
857 let req = Request::get(url);
858 let resp = req.send()?;
859
860 let mut current_remote_state = self.remote_state.lock();
861 current_remote_state.handle_backoff_hint(&resp)?;
862
863 if resp.is_success() {
864 Ok(resp)
865 } else {
866 Err(Error::response_error(
867 &resp.url,
868 format!("status code: {}", resp.status),
869 ))
870 }
871 }
872}
873
874struct RemoteSettingsEndpoints {
878 root_url: Url,
882 collection_url: Url,
892 changeset_url: Url,
899 records_url: Url,
906}
907
908impl RemoteSettingsEndpoints {
909 fn new(base_url: &BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
913 let mut root_url = base_url.clone();
914 root_url.path_segments_mut().push("");
916
917 let mut collection_url = base_url.clone();
918 collection_url
919 .path_segments_mut()
920 .push("buckets")
921 .push(bucket_name)
922 .push("collections")
923 .push(collection_name);
924
925 let mut records_url = collection_url.clone();
926 records_url.path_segments_mut().push("records");
927
928 let mut changeset_url = collection_url.clone();
929 changeset_url.path_segments_mut().push("changeset");
930
931 Self {
932 root_url: root_url.into_inner(),
933 collection_url: collection_url.into_inner(),
934 records_url: records_url.into_inner(),
935 changeset_url: changeset_url.into_inner(),
936 }
937 }
938}
939
940#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, uniffi::Record)]
943pub struct RemoteSettingsResponse {
944 pub records: Vec<RemoteSettingsRecord>,
945 pub last_modified: u64,
946}
947
948#[derive(Deserialize, Serialize)]
949struct RecordsResponse {
950 data: Vec<RemoteSettingsRecord>,
951}
952
953#[derive(Clone, Deserialize, Serialize)]
954pub struct ChangesetResponse {
955 changes: Vec<RemoteSettingsRecord>,
956 timestamp: u64,
957 metadata: CollectionMetadata,
958}
959
960#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
961pub struct CollectionMetadata {
962 pub bucket: String,
963 pub signatures: Vec<CollectionSignature>,
964}
965
966#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
967pub struct CollectionSignature {
968 pub signature: String,
969 pub x5u: String,
971}
972
973#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
976pub struct RemoteSettingsRecord {
977 pub id: String,
978 pub last_modified: u64,
979 #[serde(default)]
981 pub deleted: bool,
982 pub attachment: Option<Attachment>,
983 #[serde(flatten)]
984 pub fields: RsJsonObject,
985}
986
987#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
990pub struct Attachment {
991 pub filename: String,
992 pub mimetype: String,
993 pub location: String,
994 pub hash: String,
995 pub size: u64,
996}
997
998pub type RsJsonObject = serde_json::Map<String, serde_json::Value>;
1004uniffi::custom_type!(RsJsonObject, String, {
1005 remote,
1006 try_lift: |val| {
1007 let json: serde_json::Value = serde_json::from_str(&val)?;
1008
1009 match json {
1010 serde_json::Value::Object(obj) => Ok(obj),
1011 _ => Err(uniffi::deps::anyhow::anyhow!(
1012 "Unexpected JSON-non-object in the bagging area"
1013 )),
1014 }
1015 },
1016 lower: |obj| serde_json::Value::Object(obj).to_string(),
1017});
1018
1019#[derive(Clone, Debug)]
1020pub(crate) struct RemoteState {
1021 attachments_base_url: Option<Url>,
1022 backoff: BackoffState,
1023}
1024
1025impl Default for RemoteState {
1026 fn default() -> Self {
1027 Self {
1028 attachments_base_url: None,
1029 backoff: BackoffState::Ok,
1030 }
1031 }
1032}
1033
1034impl RemoteState {
1035 pub fn handle_backoff_hint(&mut self, response: &Response) -> Result<()> {
1036 let extract_backoff_header = |header| -> Result<u64> {
1037 Ok(response
1038 .headers
1039 .get_as::<u64, _>(header)
1040 .transpose()
1041 .unwrap_or_default() .unwrap_or(0))
1043 };
1044 let backoff = extract_backoff_header(HEADER_BACKOFF)?;
1046 let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?;
1047 let max_backoff = backoff.max(retry_after);
1048
1049 if max_backoff > 0 {
1050 self.backoff = BackoffState::Backoff {
1051 observed_at: Instant::now(),
1052 duration: Duration::from_secs(max_backoff),
1053 };
1054 }
1055 Ok(())
1056 }
1057
1058 pub fn ensure_no_backoff(&mut self) -> Result<()> {
1059 if let BackoffState::Backoff {
1060 observed_at,
1061 duration,
1062 } = self.backoff
1063 {
1064 let elapsed_time = observed_at.elapsed();
1065 if elapsed_time >= duration {
1066 self.backoff = BackoffState::Ok;
1067 } else {
1068 let remaining = duration - elapsed_time;
1069 return Err(Error::BackoffError(remaining.as_secs()));
1070 }
1071 }
1072 Ok(())
1073 }
1074}
1075
1076#[derive(Clone, Copy, Debug)]
1078pub(crate) enum BackoffState {
1079 Ok,
1080 Backoff {
1081 observed_at: Instant,
1082 duration: Duration,
1083 },
1084}
1085
1086#[derive(Deserialize)]
1087struct ServerInfo {
1088 capabilities: Capabilities,
1089}
1090
1091#[derive(Deserialize)]
1092struct Capabilities {
1093 attachments: Option<AttachmentsCapability>,
1094}
1095
1096#[derive(Deserialize)]
1097struct AttachmentsCapability {
1098 base_url: String,
1099}
1100
1101#[derive(Clone, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)]
1103pub struct GetItemsOptions {
1104 filters: Vec<Filter>,
1105 sort: Vec<Sort>,
1106 fields: Vec<String>,
1107 limit: Option<u64>,
1108}
1109
1110impl GetItemsOptions {
1111 pub fn new() -> Self {
1113 Self::default()
1114 }
1115
1116 pub fn filter_eq(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1124 self.filters.push(Filter::Eq(field.into(), value.into()));
1125 self
1126 }
1127
1128 pub fn filter_not(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1131 self.filters.push(Filter::Not(field.into(), value.into()));
1132 self
1133 }
1134
1135 pub fn filter_contains(
1139 &mut self,
1140 field: impl Into<String>,
1141 value: impl Into<String>,
1142 ) -> &mut Self {
1143 self.filters
1144 .push(Filter::Contains(field.into(), value.into()));
1145 self
1146 }
1147
1148 pub fn filter_lt(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1151 self.filters.push(Filter::Lt(field.into(), value.into()));
1152 self
1153 }
1154
1155 pub fn filter_gt(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1158 self.filters.push(Filter::Gt(field.into(), value.into()));
1159 self
1160 }
1161
1162 pub fn filter_max(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1165 self.filters.push(Filter::Max(field.into(), value.into()));
1166 self
1167 }
1168
1169 pub fn filter_min(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1172 self.filters.push(Filter::Min(field.into(), value.into()));
1173 self
1174 }
1175
1176 pub fn filter_like(&mut self, field: impl Into<String>, value: impl Into<String>) -> &mut Self {
1179 self.filters.push(Filter::Like(field.into(), value.into()));
1180 self
1181 }
1182
1183 pub fn filter_has(&mut self, field: impl Into<String>) -> &mut Self {
1185 self.filters.push(Filter::Has(field.into()));
1186 self
1187 }
1188
1189 pub fn filter_has_not(&mut self, field: impl Into<String>) -> &mut Self {
1191 self.filters.push(Filter::HasNot(field.into()));
1192 self
1193 }
1194
1195 pub fn sort(&mut self, field: impl Into<String>, order: SortOrder) -> &mut Self {
1197 self.sort.push(Sort(field.into(), order));
1198 self
1199 }
1200
1201 pub fn field(&mut self, field: impl Into<String>) -> &mut Self {
1205 self.fields.push(field.into());
1206 self
1207 }
1208
1209 pub fn limit(&mut self, count: u64) -> &mut Self {
1211 self.limit = Some(count);
1212 self
1213 }
1214
1215 pub fn iter_query_pairs(&self) -> impl Iterator<Item = (Cow<'_, str>, Cow<'_, str>)> {
1217 self.filters
1218 .iter()
1219 .map(Filter::as_query_pair)
1220 .chain({
1221 (!self.sort.is_empty()).then(|| {
1226 (
1227 "_sort".into(),
1228 (self
1229 .sort
1230 .iter()
1231 .map(Sort::as_query_value)
1232 .collect::<Vec<_>>()
1233 .join(","))
1234 .into(),
1235 )
1236 })
1237 })
1238 .chain({
1239 (!self.fields.is_empty()).then(|| ("_fields".into(), self.fields.join(",").into()))
1242 })
1243 .chain({
1244 self.limit
1247 .map(|count| ("_limit".into(), count.to_string().into()))
1248 })
1249 }
1250}
1251
1252#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
1254pub enum SortOrder {
1255 Ascending,
1257 Descending,
1259}
1260
1261#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
1262enum Filter {
1263 Eq(String, String),
1264 Not(String, String),
1265 Contains(String, String),
1266 Lt(String, String),
1267 Gt(String, String),
1268 Max(String, String),
1269 Min(String, String),
1270 Like(String, String),
1271 Has(String),
1272 HasNot(String),
1273}
1274
1275impl Filter {
1276 fn as_query_pair(&self) -> (Cow<'_, str>, Cow<'_, str>) {
1277 match self {
1280 Filter::Eq(field, value) => (field.into(), value.into()),
1281 Filter::Not(field, value) => (format!("not_{field}").into(), value.into()),
1282 Filter::Contains(field, value) => (format!("contains_{field}").into(), value.into()),
1283 Filter::Lt(field, value) => (format!("lt_{field}").into(), value.into()),
1284 Filter::Gt(field, value) => (format!("gt_{field}").into(), value.into()),
1285 Filter::Max(field, value) => (format!("max_{field}").into(), value.into()),
1286 Filter::Min(field, value) => (format!("min_{field}").into(), value.into()),
1287 Filter::Like(field, value) => (format!("like_{field}").into(), value.into()),
1288 Filter::Has(field) => (format!("has_{field}").into(), "true".into()),
1289 Filter::HasNot(field) => (format!("has_{field}").into(), "false".into()),
1290 }
1291 }
1292}
1293
1294#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
1295struct Sort(String, SortOrder);
1296
1297impl Sort {
1298 fn as_query_value(&self) -> Cow<'_, str> {
1299 match self.1 {
1300 SortOrder::Ascending => self.0.as_str().into(),
1301 SortOrder::Descending => format!("-{}", self.0).into(),
1302 }
1303 }
1304}
1305
1306#[cfg(test)]
1307mod test {
1308 use super::*;
1309 use expect_test::expect;
1310 use mockito::{mock, Matcher};
1311 #[test]
1312 fn test_defaults() {
1313 let config = RemoteSettingsConfig {
1314 server: None,
1315 server_url: None,
1316 bucket_name: None,
1317 collection_name: String::from("the-collection"),
1318 };
1319 let client = Client::new(config).unwrap();
1320 assert_eq!(
1321 Url::parse("https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/the-collection").unwrap(),
1322 client.endpoints.collection_url
1323 );
1324 }
1325
1326 #[test]
1327 fn test_deprecated_server_url() {
1328 let config = RemoteSettingsConfig {
1329 server: None,
1330 server_url: Some("https://example.com".into()),
1331 bucket_name: None,
1332 collection_name: String::from("the-collection"),
1333 };
1334 let client = Client::new(config).unwrap();
1335 assert_eq!(
1336 Url::parse("https://example.com/v1/buckets/main/collections/the-collection").unwrap(),
1337 client.endpoints.collection_url
1338 );
1339 }
1340
1341 #[test]
1342 fn test_invalid_config() {
1343 let config = RemoteSettingsConfig {
1344 server: Some(RemoteSettingsServer::Prod),
1345 server_url: Some("https://example.com".into()),
1346 bucket_name: None,
1347 collection_name: String::from("the-collection"),
1348 };
1349 match Client::new(config) {
1350 Ok(_) => panic!("Wanted config error; got client"),
1351 Err(Error::ConfigError(_)) => {}
1352 Err(err) => panic!("Wanted config error; got {}", err),
1353 }
1354 }
1355
1356 #[test]
1357 fn test_attachment_can_be_downloaded() {
1358 viaduct_dev::init_backend_dev();
1359 let server_info_m = mock("GET", "/v1/")
1360 .with_body(attachment_metadata(mockito::server_url()))
1361 .with_status(200)
1362 .with_header("content-type", "application/json")
1363 .create();
1364
1365 let attachment_location = "123.jpg";
1366 let attachment_bytes: Vec<u8> = "I'm a JPG, I swear".into();
1367 let attachment_m = mock(
1368 "GET",
1369 format!("/attachments/{}", attachment_location).as_str(),
1370 )
1371 .with_body(attachment_bytes.clone())
1372 .with_status(200)
1373 .with_header("content-type", "application/json")
1374 .create();
1375
1376 let config = RemoteSettingsConfig {
1377 server: Some(RemoteSettingsServer::Custom {
1378 url: mockito::server_url(),
1379 }),
1380 server_url: None,
1381 collection_name: String::from("the-collection"),
1382 bucket_name: None,
1383 };
1384
1385 let client = Client::new(config).unwrap();
1386 let first_resp = client.get_attachment(attachment_location).unwrap();
1387 let second_resp = client.get_attachment(attachment_location).unwrap();
1388
1389 server_info_m.expect(1).assert();
1390 attachment_m.expect(2).assert();
1391 assert_eq!(first_resp, attachment_bytes);
1392 assert_eq!(second_resp, attachment_bytes);
1393 }
1394
1395 #[test]
1396 fn test_attachment_errors_if_server_not_configured_for_attachments() {
1397 viaduct_dev::init_backend_dev();
1398 let server_info_m = mock("GET", "/v1/")
1399 .with_body(NO_ATTACHMENTS_METADATA)
1400 .with_status(200)
1401 .with_header("content-type", "application/json")
1402 .create();
1403
1404 let attachment_location = "123.jpg";
1405 let attachment_bytes: Vec<u8> = "I'm a JPG, I swear".into();
1406 let attachment_m = mock(
1407 "GET",
1408 format!("/attachments/{}", attachment_location).as_str(),
1409 )
1410 .with_body(attachment_bytes)
1411 .with_status(200)
1412 .with_header("content-type", "application/json")
1413 .create();
1414
1415 let config = RemoteSettingsConfig {
1416 server: Some(RemoteSettingsServer::Custom {
1417 url: mockito::server_url(),
1418 }),
1419 server_url: None,
1420 collection_name: String::from("the-collection"),
1421 bucket_name: None,
1422 };
1423
1424 let client = Client::new(config).unwrap();
1425 let resp = client.get_attachment(attachment_location);
1426 server_info_m.expect(1).assert();
1427 attachment_m.expect(0).assert();
1428 assert!(matches!(resp, Err(Error::AttachmentsUnsupportedError)))
1429 }
1430
1431 #[test]
1432 fn test_backoff() {
1433 viaduct_dev::init_backend_dev();
1434 let m = mock(
1435 "GET",
1436 "/v1/buckets/the-bucket/collections/the-collection/records",
1437 )
1438 .with_body(response_body())
1439 .with_status(200)
1440 .with_header("content-type", "application/json")
1441 .with_header("Backoff", "60")
1442 .with_header("etag", "\"1000\"")
1443 .create();
1444 let config = RemoteSettingsConfig {
1445 server: Some(RemoteSettingsServer::Custom {
1446 url: mockito::server_url(),
1447 }),
1448 server_url: None,
1449 collection_name: String::from("the-collection"),
1450 bucket_name: Some(String::from("the-bucket")),
1451 };
1452 let http_client = Client::new(config).unwrap();
1453
1454 assert!(http_client.get_records().is_ok());
1455 let second_resp = http_client.get_records();
1456 assert!(matches!(second_resp, Err(Error::BackoffError(_))));
1457 m.expect(1).assert();
1458 }
1459
1460 #[test]
1461 fn test_500_retry_after() {
1462 viaduct_dev::init_backend_dev();
1463 let m = mock(
1464 "GET",
1465 "/v1/buckets/the-bucket/collections/the-collection/records",
1466 )
1467 .with_body("Boom!")
1468 .with_status(500)
1469 .with_header("Retry-After", "60")
1470 .create();
1471 let config = RemoteSettingsConfig {
1472 server: Some(RemoteSettingsServer::Custom {
1473 url: mockito::server_url(),
1474 }),
1475 server_url: None,
1476 collection_name: String::from("the-collection"),
1477 bucket_name: Some(String::from("the-bucket")),
1478 };
1479 let http_client = Client::new(config).unwrap();
1480 assert!(http_client.get_records().is_err());
1481 let second_request = http_client.get_records();
1482 assert!(matches!(second_request, Err(Error::BackoffError(_))));
1483 m.expect(1).assert();
1484 }
1485
1486 #[test]
1487 fn test_options() {
1488 viaduct_dev::init_backend_dev();
1489 let m = mock(
1490 "GET",
1491 "/v1/buckets/the-bucket/collections/the-collection/records",
1492 )
1493 .match_query(Matcher::AllOf(vec![
1494 Matcher::UrlEncoded("a".into(), "b".into()),
1495 Matcher::UrlEncoded("lt_c.d".into(), "5".into()),
1496 Matcher::UrlEncoded("gt_e".into(), "15".into()),
1497 Matcher::UrlEncoded("max_f".into(), "20".into()),
1498 Matcher::UrlEncoded("min_g".into(), "10".into()),
1499 Matcher::UrlEncoded("not_h".into(), "i".into()),
1500 Matcher::UrlEncoded("like_j".into(), "*k*".into()),
1501 Matcher::UrlEncoded("has_l".into(), "true".into()),
1502 Matcher::UrlEncoded("has_m".into(), "false".into()),
1503 Matcher::UrlEncoded("contains_n".into(), "o".into()),
1504 Matcher::UrlEncoded("_sort".into(), "-b,a".into()),
1505 Matcher::UrlEncoded("_fields".into(), "a,c,b".into()),
1506 Matcher::UrlEncoded("_limit".into(), "3".into()),
1507 ]))
1508 .with_body(response_body())
1509 .with_status(200)
1510 .with_header("content-type", "application/json")
1511 .with_header("etag", "\"1000\"")
1512 .create();
1513 let config = RemoteSettingsConfig {
1514 server: Some(RemoteSettingsServer::Custom {
1515 url: mockito::server_url(),
1516 }),
1517 server_url: None,
1518 collection_name: String::from("the-collection"),
1519 bucket_name: Some(String::from("the-bucket")),
1520 };
1521 let http_client = Client::new(config).unwrap();
1522 let mut options = GetItemsOptions::new();
1523 options
1524 .field("a")
1525 .field("c")
1526 .field("b")
1527 .filter_eq("a", "b")
1528 .filter_lt("c.d", "5")
1529 .filter_gt("e", "15")
1530 .filter_max("f", "20")
1531 .filter_min("g", "10")
1532 .filter_not("h", "i")
1533 .filter_like("j", "*k*")
1534 .filter_has("l")
1535 .filter_has_not("m")
1536 .filter_contains("n", "o")
1537 .sort("b", SortOrder::Descending)
1538 .sort("a", SortOrder::Ascending)
1539 .limit(3);
1540
1541 assert!(http_client.get_records_raw_with_options(&options).is_ok());
1542 expect![[r#"
1543 RemoteSettingsResponse {
1544 records: [
1545 RemoteSettingsRecord {
1546 id: "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
1547 last_modified: 1677694949407,
1548 deleted: false,
1549 attachment: Some(
1550 Attachment {
1551 filename: "jgp-attachment.jpg",
1552 mimetype: "image/jpeg",
1553 location: "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
1554 hash: "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
1555 size: 1374325,
1556 },
1557 ),
1558 fields: {
1559 "title": String("jpg-attachment"),
1560 "content": String("content"),
1561 "schema": Number(1677694447771),
1562 },
1563 },
1564 RemoteSettingsRecord {
1565 id: "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
1566 last_modified: 1677694470354,
1567 deleted: false,
1568 attachment: Some(
1569 Attachment {
1570 filename: "pdf-attachment.pdf",
1571 mimetype: "application/pdf",
1572 location: "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
1573 hash: "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
1574 size: 157,
1575 },
1576 ),
1577 fields: {
1578 "title": String("with-attachment"),
1579 "content": String("content"),
1580 "schema": Number(1677694447771),
1581 },
1582 },
1583 RemoteSettingsRecord {
1584 id: "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
1585 last_modified: 1677694455368,
1586 deleted: false,
1587 attachment: None,
1588 fields: {
1589 "title": String("no-attachment"),
1590 "content": String("content"),
1591 "schema": Number(1677694447771),
1592 },
1593 },
1594 RemoteSettingsRecord {
1595 id: "9320f53c-0a39-4997-9120-62ff597ffb26",
1596 last_modified: 1690921847416,
1597 deleted: true,
1598 attachment: None,
1599 fields: {},
1600 },
1601 ],
1602 last_modified: 1000,
1603 }
1604 "#]].assert_debug_eq(&http_client
1605 .get_records_with_options(&options)
1606 .unwrap());
1607 m.expect(2).assert();
1608 }
1609
1610 #[test]
1611 fn test_backoff_recovery() {
1612 viaduct_dev::init_backend_dev();
1613 let m = mock(
1614 "GET",
1615 "/v1/buckets/the-bucket/collections/the-collection/records",
1616 )
1617 .with_body(response_body())
1618 .with_status(200)
1619 .with_header("content-type", "application/json")
1620 .with_header("etag", "\"1000\"")
1621 .create();
1622 let config = RemoteSettingsConfig {
1623 server: Some(RemoteSettingsServer::Custom {
1624 url: mockito::server_url(),
1625 }),
1626 server_url: None,
1627 collection_name: String::from("the-collection"),
1628 bucket_name: Some(String::from("the-bucket")),
1629 };
1630 let http_client = Client::new(config).unwrap();
1631 let mut current_remote_state = http_client.remote_state.lock();
1633 current_remote_state.backoff = BackoffState::Backoff {
1634 observed_at: Instant::now(),
1635 duration: Duration::from_secs(30),
1636 };
1637 drop(current_remote_state);
1638 assert!(matches!(
1639 http_client.get_records(),
1640 Err(Error::BackoffError(_))
1641 ));
1642 let mut current_remote_state = http_client.remote_state.lock();
1644 current_remote_state.backoff = BackoffState::Backoff {
1645 observed_at: Instant::now() - Duration::from_secs(31),
1646 duration: Duration::from_secs(30),
1647 };
1648 drop(current_remote_state);
1649 assert!(http_client.get_records().is_ok());
1650 m.expect(1).assert();
1651 }
1652
1653 #[test]
1654 fn test_record_fields() {
1655 viaduct_dev::init_backend_dev();
1656 let m = mock(
1657 "GET",
1658 "/v1/buckets/the-bucket/collections/the-collection/records",
1659 )
1660 .with_body(response_body())
1661 .with_status(200)
1662 .with_header("content-type", "application/json")
1663 .with_header("etag", "\"1000\"")
1664 .create();
1665 let config = RemoteSettingsConfig {
1666 server: Some(RemoteSettingsServer::Custom {
1667 url: mockito::server_url(),
1668 }),
1669 server_url: None,
1670 collection_name: String::from("the-collection"),
1671 bucket_name: Some(String::from("the-bucket")),
1672 };
1673 let http_client = Client::new(config).unwrap();
1674 let response = http_client.get_records().unwrap();
1675 expect![[r#"
1676 RemoteSettingsResponse {
1677 records: [
1678 RemoteSettingsRecord {
1679 id: "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
1680 last_modified: 1677694949407,
1681 deleted: false,
1682 attachment: Some(
1683 Attachment {
1684 filename: "jgp-attachment.jpg",
1685 mimetype: "image/jpeg",
1686 location: "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
1687 hash: "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
1688 size: 1374325,
1689 },
1690 ),
1691 fields: {
1692 "title": String("jpg-attachment"),
1693 "content": String("content"),
1694 "schema": Number(1677694447771),
1695 },
1696 },
1697 RemoteSettingsRecord {
1698 id: "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
1699 last_modified: 1677694470354,
1700 deleted: false,
1701 attachment: Some(
1702 Attachment {
1703 filename: "pdf-attachment.pdf",
1704 mimetype: "application/pdf",
1705 location: "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
1706 hash: "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
1707 size: 157,
1708 },
1709 ),
1710 fields: {
1711 "title": String("with-attachment"),
1712 "content": String("content"),
1713 "schema": Number(1677694447771),
1714 },
1715 },
1716 RemoteSettingsRecord {
1717 id: "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
1718 last_modified: 1677694455368,
1719 deleted: false,
1720 attachment: None,
1721 fields: {
1722 "title": String("no-attachment"),
1723 "content": String("content"),
1724 "schema": Number(1677694447771),
1725 },
1726 },
1727 RemoteSettingsRecord {
1728 id: "9320f53c-0a39-4997-9120-62ff597ffb26",
1729 last_modified: 1690921847416,
1730 deleted: true,
1731 attachment: None,
1732 fields: {},
1733 },
1734 ],
1735 last_modified: 1000,
1736 }
1737 "#]].assert_debug_eq(&response);
1738 m.expect(1).assert();
1739 }
1740
1741 #[test]
1742 fn test_missing_etag() {
1743 viaduct_dev::init_backend_dev();
1744 let m = mock(
1745 "GET",
1746 "/v1/buckets/the-bucket/collections/the-collection/records",
1747 )
1748 .with_body(response_body())
1749 .with_status(200)
1750 .with_header("content-type", "application/json")
1751 .create();
1752
1753 let config = RemoteSettingsConfig {
1754 server: Some(RemoteSettingsServer::Custom {
1755 url: mockito::server_url(),
1756 }),
1757 server_url: None,
1758 bucket_name: Some(String::from("the-bucket")),
1759 collection_name: String::from("the-collection"),
1760 };
1761 let client = Client::new(config).unwrap();
1762
1763 let err = client.get_records().unwrap_err();
1764 assert!(
1765 matches!(err, Error::ResponseError { .. }),
1766 "Want response error for missing `ETag`; got {}",
1767 err
1768 );
1769 m.expect(1).assert();
1770 }
1771
1772 #[test]
1773 fn test_invalid_etag() {
1774 viaduct_dev::init_backend_dev();
1775 let m = mock(
1776 "GET",
1777 "/v1/buckets/the-bucket/collections/the-collection/records",
1778 )
1779 .with_body(response_body())
1780 .with_status(200)
1781 .with_header("content-type", "application/json")
1782 .with_header("etag", "bad!")
1783 .create();
1784
1785 let config = RemoteSettingsConfig {
1786 server: Some(RemoteSettingsServer::Custom {
1787 url: mockito::server_url(),
1788 }),
1789 server_url: None,
1790 bucket_name: Some(String::from("the-bucket")),
1791 collection_name: String::from("the-collection"),
1792 };
1793 let client = Client::new(config).unwrap();
1794
1795 let err = client.get_records().unwrap_err();
1796 assert!(
1797 matches!(err, Error::ResponseError { .. }),
1798 "Want response error for invalid `ETag`; got {}",
1799 err
1800 );
1801 m.expect(1).assert();
1802 }
1803
1804 fn attachment_metadata(base_url: String) -> String {
1805 format!(
1806 r#"
1807 {{
1808 "capabilities": {{
1809 "admin": {{
1810 "description": "Serves the admin console.",
1811 "url": "https://github.com/Kinto/kinto-admin/",
1812 "version": "2.0.0"
1813 }},
1814 "attachments": {{
1815 "description": "Add file attachments to records",
1816 "url": "https://github.com/Kinto/kinto-attachment/",
1817 "version": "6.3.1",
1818 "base_url": "{}/attachments/"
1819 }}
1820 }}
1821 }}
1822 "#,
1823 base_url
1824 )
1825 }
1826
1827 const NO_ATTACHMENTS_METADATA: &str = r#"
1828 {
1829 "capabilities": {
1830 "admin": {
1831 "description": "Serves the admin console.",
1832 "url": "https://github.com/Kinto/kinto-admin/",
1833 "version": "2.0.0"
1834 }
1835 }
1836 }
1837 "#;
1838
1839 fn response_body() -> String {
1840 format!(
1841 r#"
1842 {{
1843 "data": [
1844 {},
1845 {},
1846 {},
1847 {}
1848 ]
1849 }}"#,
1850 JPG_ATTACHMENT, PDF_ATTACHMENT, NO_ATTACHMENT, TOMBSTONE
1851 )
1852 }
1853
1854 const JPG_ATTACHMENT: &str = r#"
1855 {
1856 "title": "jpg-attachment",
1857 "content": "content",
1858 "attachment": {
1859 "filename": "jgp-attachment.jpg",
1860 "location": "the-bucket/the-collection/d3a5eccc-f0ca-42c3-b0bb-c0d4408c21c9.jpg",
1861 "hash": "2cbd593f3fd5f1585f92265433a6696a863bc98726f03e7222135ff0d8e83543",
1862 "mimetype": "image/jpeg",
1863 "size": 1374325
1864 },
1865 "id": "c5dcd1da-7126-4abb-846b-ec85b0d4d0d7",
1866 "schema": 1677694447771,
1867 "last_modified": 1677694949407
1868 }
1869 "#;
1870
1871 const PDF_ATTACHMENT: &str = r#"
1872 {
1873 "title": "with-attachment",
1874 "content": "content",
1875 "attachment": {
1876 "filename": "pdf-attachment.pdf",
1877 "location": "the-bucket/the-collection/5f7347c2-af92-411d-a65b-f794f9b5084c.pdf",
1878 "hash": "de1cde3571ef3faa77ea0493276de9231acaa6f6651602e93aa1036f51181e9b",
1879 "mimetype": "application/pdf",
1880 "size": 157
1881 },
1882 "id": "ff301910-6bf5-4cfe-bc4c-5c80308661a5",
1883 "schema": 1677694447771,
1884 "last_modified": 1677694470354
1885 }
1886 "#;
1887
1888 const NO_ATTACHMENT: &str = r#"
1889 {
1890 "title": "no-attachment",
1891 "content": "content",
1892 "schema": 1677694447771,
1893 "id": "7403c6f9-79be-4e0c-a37a-8f2b5bd7ad58",
1894 "last_modified": 1677694455368
1895 }
1896 "#;
1897
1898 const TOMBSTONE: &str = r#"
1899 {
1900 "id": "9320f53c-0a39-4997-9120-62ff597ffb26",
1901 "last_modified": 1690921847416,
1902 "deleted": true
1903 }
1904 "#;
1905}
1906
1907#[cfg(test)]
1908mod test_new_client {
1909 use super::*;
1910
1911 #[test]
1912 fn test_endpoints() {
1913 let endpoints = RemoteSettingsEndpoints::new(
1914 &BaseUrl::parse("http://rs.example.com/v1").unwrap(),
1915 "main",
1916 "test-collection",
1917 );
1918 assert_eq!(endpoints.root_url.to_string(), "http://rs.example.com/v1/");
1919 assert_eq!(
1920 endpoints.collection_url.to_string(),
1921 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1922 );
1923 assert_eq!(
1924 endpoints.records_url.to_string(),
1925 "http://rs.example.com/v1/buckets/main/collections/test-collection/records",
1926 );
1927 assert_eq!(
1928 endpoints.changeset_url.to_string(),
1929 "http://rs.example.com/v1/buckets/main/collections/test-collection/changeset",
1930 );
1931 }
1932}
1933
1934#[cfg(test)]
1935mod jexl_tests {
1936 use super::*;
1937 use std::sync::{Arc, Weak};
1938
1939 #[test]
1940 fn test_get_records_filtered_app_version_pass() {
1941 let mut api_client = MockApiClient::new();
1942 let records = vec![RemoteSettingsRecord {
1943 id: "record-0001".into(),
1944 last_modified: 100,
1945 deleted: false,
1946 attachment: None,
1947 fields: serde_json::json!({
1948 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1949 })
1950 .as_object()
1951 .unwrap()
1952 .clone(),
1953 }];
1954 let changeset = ChangesetResponse {
1955 changes: records.clone(),
1956 timestamp: 42,
1957 metadata: CollectionMetadata::default(),
1958 };
1959 api_client.expect_collection_url().returning(|| {
1960 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1961 });
1962 api_client.expect_fetch_changeset().returning({
1963 let changeset = changeset.clone();
1964 move |timestamp| {
1965 assert_eq!(timestamp, None);
1966 Ok(changeset.clone())
1967 }
1968 });
1969 api_client.expect_is_prod_server().returning(|| Ok(false));
1970
1971 let context = RemoteSettingsContext {
1972 app_version: Some("129.0.0".to_string()),
1973 ..Default::default()
1974 };
1975
1976 let mut storage = Storage::new(":memory:".into());
1977 let _ = storage.insert_collection_content(
1978 "http://rs.example.com/v1/buckets/main/collections/test-collection",
1979 &records,
1980 42,
1981 CollectionMetadata::default(),
1982 );
1983
1984 let rs_client = RemoteSettingsClient::new_from_parts(
1985 "test-collection".into(),
1986 storage,
1987 JexlFilter::new(Some(context)),
1988 api_client,
1989 );
1990
1991 assert_eq!(
1992 rs_client.get_records(false).expect("Error getting records"),
1993 Some(records)
1994 );
1995 }
1996
1997 #[test]
1998 fn test_get_records_filtered_app_version_too_low() {
1999 let mut api_client = MockApiClient::new();
2000 let records = vec![RemoteSettingsRecord {
2001 id: "record-0001".into(),
2002 last_modified: 100,
2003 deleted: false,
2004 attachment: None,
2005 fields: serde_json::json!({
2006 "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
2007 })
2008 .as_object()
2009 .unwrap()
2010 .clone(),
2011 }];
2012 let changeset = ChangesetResponse {
2013 changes: records.clone(),
2014 timestamp: 42,
2015 metadata: CollectionMetadata::default(),
2016 };
2017 api_client.expect_collection_url().returning(|| {
2018 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
2019 });
2020 api_client.expect_fetch_changeset().returning({
2021 let changeset = changeset.clone();
2022 move |timestamp| {
2023 assert_eq!(timestamp, None);
2024 Ok(changeset.clone())
2025 }
2026 });
2027 api_client.expect_is_prod_server().returning(|| Ok(false));
2028
2029 let context = RemoteSettingsContext {
2030 app_version: Some("127.0.0.".to_string()),
2031 ..Default::default()
2032 };
2033
2034 let mut storage = Storage::new(":memory:".into());
2035 let _ = storage.insert_collection_content(
2036 "http://rs.example.com/v1/buckets/main/collections/test-collection",
2037 &records,
2038 42,
2039 CollectionMetadata::default(),
2040 );
2041
2042 let rs_client = RemoteSettingsClient::new_from_parts(
2043 "test-collection".into(),
2044 storage,
2045 JexlFilter::new(Some(context)),
2046 api_client,
2047 );
2048
2049 assert_eq!(
2050 rs_client.get_records(false).expect("Error getting records"),
2051 Some(vec![])
2052 );
2053 }
2054
2055 #[test]
2056 fn test_update_jexl_context() {
2057 let mut api_client = MockApiClient::new();
2058 let records = vec![RemoteSettingsRecord {
2059 id: "record-0001".into(),
2060 last_modified: 100,
2061 deleted: false,
2062 attachment: None,
2063 fields: serde_json::json!({
2064 "filter_expression": "env.country == \"US\""
2065 })
2066 .as_object()
2067 .unwrap()
2068 .clone(),
2069 }];
2070 let changeset = ChangesetResponse {
2071 changes: records.clone(),
2072 timestamp: 42,
2073 metadata: CollectionMetadata::default(),
2074 };
2075 api_client.expect_collection_url().returning(|| {
2076 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
2077 });
2078 api_client.expect_fetch_changeset().returning({
2079 let changeset = changeset.clone();
2080 move |timestamp| {
2081 assert_eq!(timestamp, None);
2082 Ok(changeset.clone())
2083 }
2084 });
2085 api_client.expect_is_prod_server().returning(|| Ok(false));
2086
2087 let context = RemoteSettingsContext {
2088 country: Some("US".to_string()),
2089 ..Default::default()
2090 };
2091
2092 let mut storage = Storage::new(":memory:".into());
2093 let _ = storage.insert_collection_content(
2094 "http://rs.example.com/v1/buckets/main/collections/test-collection",
2095 &records,
2096 42,
2097 CollectionMetadata::default(),
2098 );
2099
2100 let rs_client = RemoteSettingsClient::new_from_parts(
2101 "test-collection".into(),
2102 storage,
2103 JexlFilter::new(Some(context)),
2104 api_client,
2105 );
2106
2107 assert_eq!(
2108 rs_client.get_records(false).expect("Error getting records"),
2109 Some(records)
2110 );
2111
2112 rs_client.inner.lock().jexl_filter = JexlFilter::new(Some(RemoteSettingsContext {
2115 country: Some("UK".to_string()),
2116 ..Default::default()
2117 }));
2118
2119 assert_eq!(
2120 rs_client.get_records(false).expect("Error getting records"),
2121 Some(vec![])
2122 );
2123 }
2124
2125 #[test]
2128 fn test_update_config_deadlock() {
2129 let mut api_client = MockApiClient::new();
2130 let rs_client_ref: Arc<Mutex<Weak<RemoteSettingsClient<MockApiClient>>>> =
2131 Arc::new(Mutex::new(Weak::new()));
2132 let rs_client_ref2 = rs_client_ref.clone();
2133
2134 api_client.expect_collection_url().returning(move || {
2135 rs_client_ref2
2146 .lock()
2147 .upgrade()
2148 .expect("rs_client_ref not set")
2149 .update_config(
2150 BaseUrl::parse("https://example.com/").unwrap(),
2151 "test-collection".to_string(),
2152 None,
2153 );
2154 "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
2155 });
2156 api_client.expect_is_prod_server().returning(|| Ok(false));
2157
2158 let context = RemoteSettingsContext {
2159 app_version: Some("129.0.0".to_string()),
2160 ..Default::default()
2161 };
2162 let storage = Storage::new(":memory:".into());
2163
2164 let rs_client = Arc::new(RemoteSettingsClient::new_from_parts(
2165 "test-collection".into(),
2166 storage,
2167 JexlFilter::new(Some(context)),
2168 api_client,
2169 ));
2170 *rs_client_ref.lock() = Arc::downgrade(&rs_client);
2171
2172 assert_eq!(
2173 rs_client.get_records(false).expect("Error getting records"),
2174 None,
2175 );
2176 }
2177}
2178
2179#[cfg(feature = "signatures")]
2180#[cfg(test)]
2181mod test_signatures {
2182 use core::assert_eq;
2183
2184 use crate::RemoteSettingsContext;
2185
2186 use super::*;
2187 use nss::ensure_initialized;
2188
2189 const VALID_CERTIFICATE: &str = "\
2190-----BEGIN CERTIFICATE-----
2191MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
2192AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
2193bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
2194dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
2195emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ
2196BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
2197biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
2198bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt
2199c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb
2200HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt
2201zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD
2202MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
2203GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0
2204dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
2205aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ
22069FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc
2207fS8//SQGTlCqKQ==
2208-----END CERTIFICATE-----
2209-----BEGIN CERTIFICATE-----
2210MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC
2211VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
2212ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
2213aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
2214bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB
2215ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt
2216BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw
2217QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz
2218cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE
2219xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz
2220vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6
2221MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK
2222BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud
2223IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT
2224AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE
2225ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln
2226bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk
2227b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk
2228b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250
2229ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t
2230b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx
2231qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ
22329qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH
2233TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr
2234v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O
2235ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea
2236UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg
2237VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0
2238xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d
2239mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi
2240ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz
2241LC4GzWQGSCGDyD+JCVw=
2242-----END CERTIFICATE-----
2243-----BEGIN CERTIFICATE-----
2244MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC
2245VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
2246ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
2247aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
2248bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB
2249qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW
2250aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0
2251LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz
2252dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC
2253DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8
2254rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5
2255eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k
225651uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj
2257khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD
2258OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf
22594sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM
2260NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+
2261uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh
22627v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y
2263JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB
2264o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/
2265BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk
2266IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls
2267bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd
2268MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw
2269CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx
2270HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk
2271b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl
2272cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA
2273nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap
2274Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD
2275DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+
2276AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF
22772iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d
2278PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA
2279dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8
22805xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy
2281iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO
22826pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg
2283IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE=
2284-----END CERTIFICATE-----";
2285 const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#;
2286 const VALID_CERT_EPOCH_SECONDS: u64 = 1615559719;
2287
2288 fn run_client_sync(
2289 diff_records: &[RemoteSettingsRecord],
2290 full_records: &[RemoteSettingsRecord],
2291 certificate: &str,
2292 signatures: &[CollectionSignature],
2293 epoch_secs: u64,
2294 bucket: &str,
2295 ) -> Result<()> {
2296 let collection_name = "pioneer-study-addons";
2297
2298 MOCK_TIME.with(|cell| cell.set(Some(epoch_secs)));
2299
2300 let some_metadata = CollectionMetadata {
2301 bucket: bucket.into(),
2302 signatures: signatures.to_vec(),
2303 };
2304 let diff_changeset = ChangesetResponse {
2306 changes: diff_records.to_vec(),
2307 timestamp: 1603992731957,
2308 metadata: some_metadata.clone(),
2309 };
2310 let full_changeset = ChangesetResponse {
2312 changes: full_records.to_vec(),
2313 timestamp: 1603992731957,
2314 metadata: some_metadata.clone(),
2315 };
2316
2317 let mut api_client = MockApiClient::new();
2318 api_client
2319 .expect_collection_url()
2320 .returning(move || format!("http://server/{}", collection_name));
2321 api_client.expect_is_prod_server().returning(|| Ok(false));
2322 api_client.expect_fetch_changeset().returning(move |since| {
2323 Ok(if since.is_some() {
2324 diff_changeset.clone()
2325 } else {
2326 full_changeset.clone()
2327 })
2328 });
2329
2330 let certificate = certificate.to_string();
2331 api_client
2332 .expect_fetch_cert()
2333 .returning(move |_| Ok(certificate.clone().into_bytes()));
2334
2335 let storage = Storage::new(":memory:".into());
2336 let jexl_filter = JexlFilter::new(Some(RemoteSettingsContext::default()));
2337 let rs_client = RemoteSettingsClient::new_from_parts(
2338 collection_name.to_string(),
2339 storage,
2340 jexl_filter,
2341 api_client,
2342 );
2343
2344 rs_client.sync()
2345 }
2346
2347 #[test]
2348 fn test_valid_signature() -> Result<()> {
2349 ensure_initialized();
2350 run_client_sync(
2351 &[],
2352 &[],
2353 VALID_CERTIFICATE,
2354 &[CollectionSignature {
2355 signature: VALID_SIGNATURE.to_string(),
2356 x5u: "http://mocked".into(),
2357 }],
2358 VALID_CERT_EPOCH_SECONDS,
2359 "main",
2360 )
2361 .expect("Valid signature");
2362 Ok(())
2363 }
2364
2365 #[test]
2366 fn test_second_signature_is_valid() -> Result<()> {
2367 ensure_initialized();
2368 run_client_sync(
2369 &[],
2370 &[],
2371 VALID_CERTIFICATE,
2372 &[
2373 CollectionSignature {
2374 signature: "invalid signature".to_string(),
2375 x5u: "http://mocked".into(),
2376 },
2377 CollectionSignature {
2378 signature: VALID_SIGNATURE.to_string(),
2379 x5u: "http://mocked".into(),
2380 },
2381 ],
2382 VALID_CERT_EPOCH_SECONDS,
2383 "main",
2384 )
2385 .expect("Valid signature");
2386 Ok(())
2387 }
2388
2389 #[test]
2390 fn test_valid_signature_after_retry() -> Result<()> {
2391 ensure_initialized();
2392 run_client_sync(
2393 &[RemoteSettingsRecord {
2394 id: "bad-record".to_string(),
2395 last_modified: 9999,
2396 deleted: true,
2397 attachment: None,
2398 fields: serde_json::Map::new(),
2399 }],
2400 &[],
2401 VALID_CERTIFICATE,
2402 &[CollectionSignature {
2403 signature: VALID_SIGNATURE.to_string(),
2404 x5u: "http://mocked".into(),
2405 }],
2406 VALID_CERT_EPOCH_SECONDS,
2407 "main",
2408 )
2409 .expect("Valid signature");
2410 Ok(())
2411 }
2412
2413 #[test]
2414 fn test_invalid_signature_value() -> Result<()> {
2415 ensure_initialized();
2416 let err = run_client_sync(
2417 &[],
2418 &[],
2419 VALID_CERTIFICATE,
2420 &[CollectionSignature {
2421 signature: "invalid signature".to_string(),
2422 x5u: "http://mocked".into(),
2423 }],
2424 VALID_CERT_EPOCH_SECONDS,
2425 "main",
2426 )
2427 .unwrap_err();
2428 assert!(matches!(err, Error::SignatureError(_)));
2429 assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
2430
2431 Ok(())
2432 }
2433
2434 #[test]
2435 fn test_invalid_certificate_value() -> Result<()> {
2436 ensure_initialized();
2437 let err = run_client_sync(
2438 &[],
2439 &[],
2440 "some bad PEM content",
2441 &[CollectionSignature {
2442 signature: VALID_SIGNATURE.to_string(),
2443 x5u: "http://mocked".into(),
2444 }],
2445 VALID_CERT_EPOCH_SECONDS,
2446 "main",
2447 )
2448 .unwrap_err();
2449
2450 assert!(matches!(err, Error::SignatureError(_)));
2451 assert_eq!(
2452 format!("{}", err),
2453 "Signature could not be verified: PEM content format error: Missing PEM data"
2454 );
2455
2456 Ok(())
2457 }
2458
2459 #[test]
2460 fn test_invalid_signature_expired_cert() -> Result<()> {
2461 ensure_initialized();
2462 let december_20_2024 = 1734651582;
2463
2464 let err = run_client_sync(
2465 &[],
2466 &[],
2467 VALID_CERTIFICATE,
2468 &[CollectionSignature {
2469 signature: VALID_SIGNATURE.to_string(),
2470 x5u: "http://mocked".into(),
2471 }],
2472 december_20_2024,
2473 "main",
2474 )
2475 .unwrap_err();
2476
2477 assert!(matches!(err, Error::SignatureError(_)));
2478 assert_eq!(
2479 format!("{}", err),
2480 "Signature could not be verified: Certificate not yet valid or expired"
2481 );
2482
2483 Ok(())
2484 }
2485
2486 #[test]
2487 fn test_invalid_signature_invalid_data() -> Result<()> {
2488 ensure_initialized();
2489 let records = vec![RemoteSettingsRecord {
2491 id: "unexpected-data".to_string(),
2492 last_modified: 42,
2493 deleted: false,
2494 attachment: None,
2495 fields: serde_json::Map::new(),
2496 }];
2497 let err = run_client_sync(
2498 &records,
2499 &records,
2500 VALID_CERTIFICATE,
2501 &[CollectionSignature {
2502 signature: VALID_SIGNATURE.to_string(),
2503 x5u: "http://mocked".into(),
2504 }],
2505 VALID_CERT_EPOCH_SECONDS,
2506 "main",
2507 )
2508 .unwrap_err();
2509
2510 assert!(matches!(err, Error::SignatureError(_)));
2511 assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
2512
2513 Ok(())
2514 }
2515
2516 #[test]
2517 fn test_invalid_signature_invalid_signer_name() -> Result<()> {
2518 ensure_initialized();
2519 let err = run_client_sync(
2520 &[],
2521 &[],
2522 VALID_CERTIFICATE,
2523 &[CollectionSignature {
2524 signature: VALID_SIGNATURE.to_string(),
2525 x5u: "http://mocked".into(),
2526 }],
2527 VALID_CERT_EPOCH_SECONDS,
2528 "security-state",
2529 )
2530 .unwrap_err();
2531 assert!(matches!(err, Error::SignatureError(_)));
2532 assert_eq!(
2533 format!("{}", err),
2534 "Signature could not be verified: Certificate subject mismatch"
2535 );
2536
2537 Ok(())
2538 }
2539}
2540
2541#[cfg(test)]
2542mod test_reset_storage {
2543 use super::*;
2544
2545 #[test]
2546 fn test_reset_storage_deletes_records_and_attachments() {
2547 let collection_url = "http://rs.example.com/v1/buckets/main/collections/test-collection";
2548
2549 let mut api_client = MockApiClient::new();
2550 api_client
2551 .expect_collection_url()
2552 .returning(|| collection_url.into());
2553 api_client.expect_is_prod_server().returning(|| Ok(false));
2554
2555 let records = vec![RemoteSettingsRecord {
2556 id: "record-0001".into(),
2557 last_modified: 100,
2558 deleted: false,
2559 attachment: Some(Attachment {
2560 filename: "test-file.bin".into(),
2561 mimetype: "application/octet-stream".into(),
2562 location: "attachments/test-file.bin".into(),
2563 hash: "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7".into(),
2564 size: 4,
2565 }),
2566 fields: serde_json::Map::new(),
2567 }];
2568
2569 let mut storage = Storage::new(":memory:".into());
2570 storage
2571 .insert_collection_content(collection_url, &records, 100, CollectionMetadata::default())
2572 .expect("Failed to insert records");
2573
2574 storage
2575 .set_attachment(collection_url, "attachments/test-file.bin", b"data")
2576 .expect("Failed to insert attachment");
2577
2578 assert!(storage.get_records(collection_url).unwrap().is_some());
2580 assert!(storage
2581 .get_attachment(collection_url, records[0].attachment.clone().unwrap())
2582 .unwrap()
2583 .is_some());
2584
2585 let rs_client = RemoteSettingsClient::new_from_parts(
2586 "test-collection".into(),
2587 storage,
2588 JexlFilter::new(None),
2589 api_client,
2590 );
2591
2592 rs_client.reset_storage().expect("Failed to reset storage");
2593
2594 let mut inner = rs_client.inner.lock();
2596 assert_eq!(
2597 inner.storage.get_records(collection_url).unwrap(),
2598 None,
2599 "Records should be deleted after reset_storage"
2600 );
2601 assert_eq!(
2602 inner
2603 .storage
2604 .get_attachment(collection_url, records[0].attachment.clone().unwrap(),)
2605 .unwrap(),
2606 None,
2607 "Attachments should be deleted after reset_storage"
2608 );
2609 }
2610
2611 #[test]
2612 fn test_reset_storage_reverts_to_packaged_data() {
2613 let collection_url = "http://rs.example.com/v1/buckets/main/collections/regions";
2614
2615 let mut api_client = MockApiClient::new();
2616 api_client
2617 .expect_collection_url()
2618 .returning(|| collection_url.into());
2619 api_client.expect_is_prod_server().returning(|| Ok(true));
2621
2622 let synced_records = vec![RemoteSettingsRecord {
2623 id: "custom-synced-record".into(),
2624 last_modified: 99999,
2625 deleted: false,
2626 attachment: None,
2627 fields: serde_json::json!({"key": "synced-value"})
2628 .as_object()
2629 .unwrap()
2630 .clone(),
2631 }];
2632
2633 let mut storage = Storage::new(":memory:".into());
2634 storage
2635 .insert_collection_content(
2636 collection_url,
2637 &synced_records,
2638 99999,
2639 CollectionMetadata::default(),
2640 )
2641 .expect("Failed to insert synced records");
2642
2643 let records_before = storage.get_records(collection_url).unwrap().unwrap();
2645 assert_eq!(records_before[0].id, "custom-synced-record");
2646
2647 let rs_client = RemoteSettingsClient::new_from_parts(
2648 "regions".into(),
2649 storage,
2650 JexlFilter::new(None),
2651 api_client,
2652 );
2653
2654 rs_client.reset_storage().expect("Failed to reset storage");
2655
2656 let mut inner = rs_client.inner.lock();
2657 let records = inner.storage.get_records(collection_url).unwrap();
2658 assert!(
2659 records.is_some(),
2660 "Packaged data should be restored after reset_storage on prod"
2661 );
2662 let records = records.unwrap();
2663 assert!(
2664 !records.is_empty(),
2665 "Packaged regions data should not be empty"
2666 );
2667 assert!(
2668 !records.iter().any(|r| r.id == "custom-synced-record"),
2669 "Synced data should be replaced by packaged data after reset"
2670 );
2671 }
2672}