remote_settings/
client.rs

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