remote_settings/
client.rs

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