remote_settings/
client.rs

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