remote_settings/
client.rs

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