remote_settings/
client.rs

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