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