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;
6use crate::error::{breadcrumb, 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::time::{Duration, Instant};
17use url::Url;
18use viaduct::{Request, Response};
19
20#[cfg(feature = "signatures")]
21#[cfg(not(test))]
22use std::time::{SystemTime, UNIX_EPOCH};
23
24#[cfg(feature = "signatures")]
25#[cfg(not(test))]
26fn epoch_seconds() -> u64 {
27    SystemTime::now()
28        .duration_since(UNIX_EPOCH)
29        .unwrap() // Time won't go backwards.
30        .as_secs()
31}
32
33#[cfg(feature = "signatures")]
34#[cfg(test)]
35thread_local! {
36    static MOCK_TIME: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) }
37}
38
39#[cfg(feature = "signatures")]
40#[cfg(test)]
41fn epoch_seconds() -> u64 {
42    MOCK_TIME.with(|mock_time| mock_time.get().unwrap_or(0))
43}
44
45const HEADER_BACKOFF: &str = "Backoff";
46const HEADER_RETRY_AFTER: &str = "Retry-After";
47
48/// Hard-coded SHA256 of our root certificates. This is used by rc_crypto/pkixc to verify that the
49/// certificates chains used in content signatures verification were produced from our root certificate.
50/// See https://bugzilla.mozilla.org/show_bug.cgi?id=1940903 to align with desktop implementation.
51#[cfg(feature = "signatures")]
52const 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";
53#[cfg(feature = "signatures")]
54const 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";
55
56#[derive(Debug, Clone, Deserialize)]
57struct CollectionData {
58    data: Vec<RemoteSettingsRecord>,
59    timestamp: u64,
60}
61
62/// Internal Remote settings client API
63///
64/// This stores an ApiClient implementation.  In the real-world, this is always ViaductApiClient,
65/// but the tests use a mock client.
66pub struct RemoteSettingsClient<C = ViaductApiClient> {
67    // This is immutable, so it can be outside the mutex
68    collection_name: String,
69    inner: Mutex<RemoteSettingsClientInner<C>>,
70    // Config that we got from `update_config`.  This should be applied to
71    // `RemoteSettingsClientInner` the next time it's used.
72    pending_config: Mutex<Option<RemoteSettingsClientConfig>>,
73}
74
75struct RemoteSettingsClientInner<C> {
76    storage: Storage,
77    api_client: C,
78    jexl_filter: JexlFilter,
79}
80
81struct RemoteSettingsClientConfig {
82    server_url: BaseUrl,
83    bucket_name: String,
84    context: Option<RemoteSettingsContext>,
85}
86
87// To initially download the dump (and attachments, if any), run:
88//   $ cargo remote-settings dump-get --bucket main --collection-name <collection name>
89//
90// Then add the entry here.
91//
92// For subsequent updates, run the command above again.
93impl<C: ApiClient> RemoteSettingsClient<C> {
94    // One line per bucket + collection
95    packaged_collections! {
96        ("main", "regions"),
97        ("main", "search-config-icons"),
98        ("main", "search-config-v2"),
99        ("main", "search-telemetry-v2"),
100        ("main", "summarizer-models-config"),
101        ("main", "translations-models"),
102        ("main", "translations-wasm"),
103    }
104
105    // You have to specify
106    // - bucket + collection_name: ("main", "regions")
107    // - One line per file you want to add (e.g. "world")
108    //
109    // This will automatically also include the NAME.meta.json file
110    // for internal validation against hash and size
111    //
112    // The entries line up with the `Attachment::filename` field,
113    // and check for the folder + name in
114    // `remote_settings/dumps/{bucket}/attachments/{collection}/{filename}
115    packaged_attachments! {
116        ("main", "regions") => [
117            "world",
118            "world-buffered",
119        ],
120        ("main", "search-config-icons") => [
121            "001500a9-1a6c-3f5a-ba15-a5f5a075d256",
122            "06cf7432-efd7-f244-927b-5e423005e1ea",
123            "0a57b0cf-34f0-4d09-96e4-dbd6e3355410",
124            "0d7668a8-c3f4-cfee-cbc8-536511528937",
125            "0eec5640-6fde-d6fe-322a-c72c6d5bd5a2",
126            "101ce01d-2691-b729-7f16-9d389803384b",
127            "177aba42-9bed-4078-e36b-580e8794cd7f",
128            "25de0352-aabb-d31f-15f7-bf9299fb004c",
129            "2bbe48f4-d3b8-c9e0-86e3-a54c37ec3335",
130            "2e835b0e-9709-d1bb-9725-87f59f3445ca",
131            "2ecca3f8-c1ef-43cc-b053-886d1ae46c36",
132            "32d26d19-aeb0-5c01-32e8-f8970be9246f",
133            "39d0b17d-c020-4890-932f-83c0f6ed130b",
134            "41135a88-093d-4077-873b-9de1ae133427",
135            "41f0d805-3775-4988-8d8c-5ad8ccd86d1c",
136            "47da97b5-600f-c450-fd15-a52bb2169c11",
137            "48c72361-cd67-412e-bd7f-f81a43c10791",
138            "4e271681-3e0f-91ac-9750-03f665efc171",
139            "50f6171f-8e7a-b41b-862e-f97397038fb2",
140            "5203dd03-2c55-4b53-9c60-58258d587be1",
141            "5914932e-66ba-4126-8be5-d37beadd9532",
142            "5ded611d-44b2-dc46-fd67-fb116888d75d",
143            "5e03d6f4-6ee9-8bc8-cf22-7a5f2cf55c41",
144            "6644f26f-28ea-4222-929d-5d43a02dae05",
145            "6d10d702-7bd6-1452-90a5-3df665a38f66",
146            "6e36a151-e4f4-4117-9067-1ca82c47d01a",
147            "6f4da442-d31e-28f8-03af-797d16bbdd27",
148            "7072564d-a573-4750-bf33-f0a07631c9eb",
149            "70fdd651-6c50-b7bb-09ec-7e85da259173",
150            "71f41a0c-5b70-4116-b30f-e62089083522",
151            "74793ce1-a918-a5eb-d3c0-2aadaff3c88c",
152            "74f94dc2-caf6-4b90-b3d2-f3e2f7714d88",
153            "764e3b14-fe16-4feb-8384-124c516a5afa",
154            "7bf4ca37-e2b8-4d31-a1c3-979bc0e85131",
155            "7c81cf98-7c11-4afd-8279-db89118a6dfb",
156            "7cb4d88a-d4df-45b2-87e4-f896eaf1bbdb",
157            "7edaf4fe-a8a0-432b-86d2-bf75ebe80851",
158            "7efbed51-813c-581d-d8d3-f8758434e451",
159            "84bb4962-e571-227a-9ef6-2ac5f2aac361",
160            "87ac4cde-f581-398b-1e32-eb4079183b36",
161            "8831ce10-b1e4-6eb4-4975-83c67457288e",
162            "890de5c4-0941-a116-473a-5d240e79497a",
163            "8abb10a7-212f-46b5-a7b4-244f414e3810",
164            "91a9672d-e945-8e1e-0996-aefdb0190716",
165            "94a84724-c30f-4767-ba42-01cc37fc31a4",
166            "96327a73-c433-5eb4-a16d-b090cadfb80b",
167            "9802e63d-05ec-48ba-93f9-746e0981ad98",
168            "9d96547d-7575-49ca-8908-1e046b8ea90e",
169            "a06db97d-1210-ea2e-5474-0e2f7d295bfd",
170            "a06dc3fd-4bdb-41f3-2ebc-4cbed06a9bd3",
171            "a2c7d4e9-f770-51e1-0963-3c2c8401631d",
172            "a83f24e4-602c-47bd-930c-ad0947ee1adf",
173            "b50c3e3d-7bd0-4118-856f-19b26b21d01f",
174            "b64f09fd-52d1-c48e-af23-4ce918e7bf3b",
175            "b882b24d-1776-4ef9-9016-0bdbd935eda3",
176            "b8ca5a94-8fff-27ad-6e00-96e244a32e21",
177            "b9424309-f601-4a69-98ca-ca68e65633e6",
178            "c411adc1-9661-4fb5-a4c1-8cfe74911943",
179            "cbf9e891-d079-2b28-5617-283450d463dd",
180            "d87f251c-3e12-a8bf-e2d0-afd43d36c5f9",
181            "db0e1627-ae89-4c25-8944-a9481d8512d9",
182            "e02f23df-8d48-2b1b-3b5c-6dd27302c61c",
183            "e718e983-09aa-e8f6-b25f-cd4b395d4785",
184            "e7547f62-187b-b641-d462-e54a3f813d9a",
185            "eb62e768-151b-45d1-9fe5-9e1d2a5991c5",
186            "f312610a-ebfb-a106-ea92-fd643c5d3636",
187            "f943d7bc-872e-4a81-810f-94d26465da69",
188            "fa0fc42c-d91d-fca7-34eb-806ff46062dc",
189            "fca3e3ee-56cd-f474-dc31-307fd24a891d",
190            "fe75ce3f-1545-400c-b28c-ad771054e69f",
191            "fed4f021-ff3e-942a-010e-afa43fda2136",
192        ],
193        ("main", "translations-wasm") => [
194            "4fd32605-9889-4dd9-9fc7-577ad1136746",
195        ]
196    }
197}
198
199impl<C: ApiClient> RemoteSettingsClient<C> {
200    pub fn new_from_parts(
201        collection_name: String,
202        storage: Storage,
203        jexl_filter: JexlFilter,
204        api_client: C,
205    ) -> Self {
206        Self {
207            collection_name,
208            inner: Mutex::new(RemoteSettingsClientInner {
209                storage,
210                api_client,
211                jexl_filter,
212            }),
213            pending_config: Mutex::new(None),
214        }
215    }
216
217    /// Lock the `RemoteSettingsClientInner` field
218    ///
219    /// This also applies the pending config if set.
220    fn lock_inner(&self) -> Result<MutexGuard<'_, RemoteSettingsClientInner<C>>> {
221        let pending_config = self.get_pending_config();
222        let mut inner = self.inner.lock();
223        if let Some(config) = pending_config {
224            inner.api_client =
225                C::create(config.server_url, config.bucket_name, &self.collection_name);
226            inner.jexl_filter = JexlFilter::new(config.context);
227            inner.storage.empty()?;
228        }
229        Ok(inner)
230    }
231
232    fn get_pending_config(&self) -> Option<RemoteSettingsClientConfig> {
233        self.pending_config.lock().take()
234    }
235
236    pub fn collection_name(&self) -> &str {
237        &self.collection_name
238    }
239
240    fn load_packaged_timestamp(&self) -> Option<u64> {
241        // Using the macro generated `get_packaged_timestamp` in macros.rs
242        Self::get_packaged_timestamp(&self.collection_name)
243    }
244
245    fn load_packaged_data(&self) -> Option<CollectionData> {
246        // Using the macro generated `get_packaged_data` in macros.rs
247        let str_data = Self::get_packaged_data(&self.collection_name)?;
248        let data: CollectionData = serde_json::from_str(str_data).ok()?;
249        debug_assert_eq!(data.timestamp, self.load_packaged_timestamp().unwrap());
250        Some(data)
251    }
252
253    fn load_packaged_attachment(&self, filename: &str) -> Option<(&'static [u8], &'static str)> {
254        // Using the macro generated `get_packaged_attachment` in macros.rs
255        Self::get_packaged_attachment(&self.collection_name, filename)
256    }
257
258    /// Filters records based on the presence and evaluation of `filter_expression`.
259    fn filter_records(
260        &self,
261        records: Vec<RemoteSettingsRecord>,
262        inner: &RemoteSettingsClientInner<C>,
263    ) -> Vec<RemoteSettingsRecord> {
264        records
265            .into_iter()
266            .filter(|record| match record.fields.get("filter_expression") {
267                Some(serde_json::Value::String(filter_expr)) => {
268                    inner.jexl_filter.evaluate(filter_expr).unwrap_or(false)
269                }
270                _ => true, // Include records without a valid filter expression by default
271            })
272            .collect()
273    }
274
275    /// Returns the parsed packaged data, but only if it's newer than the data we have
276    /// in storage. This avoids parsing the packaged data if we won't use it.
277    fn get_packaged_data_if_newer(
278        &self,
279        storage: &mut Storage,
280        collection_url: &str,
281    ) -> Result<Option<CollectionData>> {
282        let packaged_ts = self.load_packaged_timestamp();
283        let storage_ts = storage.get_last_modified_timestamp(collection_url)?;
284        let packaged_is_newer = match (packaged_ts, storage_ts) {
285            (Some(packaged_ts), Some(storage_ts)) => packaged_ts > storage_ts,
286            (Some(_), None) => true, // no storage data
287            (None, _) => false,      // no packaged data
288        };
289
290        if packaged_is_newer {
291            Ok(self.load_packaged_data())
292        } else {
293            Ok(None)
294        }
295    }
296
297    /// Get the current set of records.
298    ///
299    /// If records are not present in storage this will normally return None.  Use `sync_if_empty =
300    /// true` to change this behavior and perform a network request in this case.
301    pub fn get_records(&self, sync_if_empty: bool) -> Result<Option<Vec<RemoteSettingsRecord>>> {
302        let mut inner = self.lock_inner()?;
303        let collection_url = inner.api_client.collection_url();
304
305        // Case 1: The packaged data is more recent than the cache
306        //
307        // This happens when there's no cached data or when we get new packaged data because of a
308        // product update
309        if inner.api_client.is_prod_server()? {
310            if let Some(packaged_data) =
311                self.get_packaged_data_if_newer(&mut inner.storage, &collection_url)?
312            {
313                // Remove previously cached data (packaged data does not have tombstones like diff responses do).
314                inner.storage.empty()?;
315                // Insert new packaged data.
316                inner.storage.insert_collection_content(
317                    &collection_url,
318                    &packaged_data.data,
319                    packaged_data.timestamp,
320                    CollectionMetadata::default(),
321                )?;
322                return Ok(Some(self.filter_records(packaged_data.data, &inner)));
323            }
324        }
325
326        let cached_records = inner.storage.get_records(&collection_url)?;
327
328        match (cached_records, sync_if_empty) {
329            // Case 2: We have cached records
330            //
331            // Note: we should return these even if it's an empty list and `sync_if_empty=true`.
332            // The "if empty" part refers to the cache being empty, not the list.
333            (Some(cached_records), _) => Ok(Some(self.filter_records(cached_records, &inner))),
334            // Case 3: sync_if_empty=true
335            (None, true) => {
336                // `sync()` takes the lock, release it first.
337                drop(inner);
338                // Sync and verify content signatures.
339                self.sync()?;
340                // Return what was just stored.
341                let mut inner = self.lock_inner()?;
342                Ok(inner
343                    .storage
344                    .get_records(&collection_url)?
345                    .map(|records| self.filter_records(records, &inner)))
346            }
347            // Case 4: Nothing to return
348            (None, false) => Ok(None),
349        }
350    }
351
352    /// Returns the last modified timestamp for the collection.
353    pub fn get_last_modified_timestamp(&self) -> Result<Option<u64>> {
354        let mut inner = self.lock_inner()?;
355        let collection_url = inner.api_client.collection_url();
356        inner.storage.get_last_modified_timestamp(&collection_url)
357    }
358
359    /// Synchronizes the local collection with the remote server by performing the following steps:
360    /// 1. Fetches the last modified timestamp of the collection from local storage.
361    /// 2. Fetches the changeset from the remote server based on the last modified timestamp.
362    /// 3. Inserts the fetched changeset into local storage.
363    fn perform_sync_operation(&self) -> Result<()> {
364        let mut inner = self.lock_inner()?;
365        let collection_url = inner.api_client.collection_url();
366        let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
367        let changeset = inner.api_client.fetch_changeset(timestamp)?;
368        debug!(
369            "{0}: apply {1} change(s) locally.",
370            self.collection_name,
371            changeset.changes.len()
372        );
373        inner.storage.insert_collection_content(
374            &collection_url,
375            &changeset.changes,
376            changeset.timestamp,
377            changeset.metadata,
378        )
379    }
380
381    pub fn sync(&self) -> Result<()> {
382        // First attempt
383        self.perform_sync_operation()?;
384        // Verify that inserted data has valid signature
385        if self.verify_signature().is_err() {
386            debug!(
387                "{0}: signature verification failed. Reset and retry.",
388                self.collection_name
389            );
390            // Retry with packaged dataset as base
391            self.reset_storage()?;
392            self.perform_sync_operation()?;
393            // Verify signature again
394            self.verify_signature().inspect_err(|_| {
395                // And reset with packaged data if it fails again.
396                self.reset_storage()
397                    .expect("Failed to reset storage after verification failure");
398            })?;
399        }
400        trace!("{0}: sync done.", self.collection_name);
401        Ok(())
402    }
403
404    pub fn run_maintenance(&self) -> Result<()> {
405        let mut inner = self.lock_inner()?;
406        inner.storage.run_maintenance()
407    }
408
409    pub fn reset_storage(&self) -> Result<()> {
410        trace!("{0}: reset local storage.", self.collection_name);
411        let mut inner = self.lock_inner()?;
412        let collection_url = inner.api_client.collection_url();
413        // Clear existing storage
414        inner.storage.empty()?;
415        // Load packaged data only for production
416        if inner.api_client.is_prod_server()? {
417            if let Some(packaged_data) = self.load_packaged_data() {
418                trace!("{0}: restore packaged dump.", self.collection_name);
419                inner.storage.insert_collection_content(
420                    &collection_url,
421                    &packaged_data.data,
422                    packaged_data.timestamp,
423                    CollectionMetadata::default(),
424                )?;
425            }
426        }
427        Ok(())
428    }
429
430    pub fn shutdown(&self) {
431        self.inner.lock().storage.close();
432    }
433
434    #[cfg(not(feature = "signatures"))]
435    fn verify_signature(&self) -> Result<()> {
436        debug!("{0}: signature verification skipped.", self.collection_name);
437        Ok(())
438    }
439
440    #[cfg(feature = "signatures")]
441    fn verify_signature(&self) -> Result<()> {
442        let mut inner = self.lock_inner()?;
443        let collection_url = inner.api_client.collection_url();
444        let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
445        let records = inner.storage.get_records(&collection_url)?;
446        let metadata = inner.storage.get_collection_metadata(&collection_url)?;
447        match (timestamp, &records, metadata) {
448            (Some(timestamp), Some(records), Some(metadata)) => {
449                // rc_crypto verifies that the provided certificates chain leads to our root certificate.
450                let expected_root_hash = if inner.api_client.is_prod_server()? {
451                    ROOT_CERT_SHA256_HASH_PROD
452                } else {
453                    ROOT_CERT_SHA256_HASH_NONPROD
454                };
455                // Iterate through the list of signatures, and verify that at least one of them is valid.
456                // This allows for key rotation without breaking clients that have an old certificate chain cached.
457                let mut result = Err(Error::IncompleteSignatureDataError(
458                    "No valid signatures found".into(),
459                ));
460                for signature in &metadata.signatures {
461                    if signature.mode != "p384ecdsa" {
462                        // We currently only support ECDSA P384.
463                        // Change this once `rc_crypto` will support more types (eg. post-quantum algorithms).
464                        continue;
465                    }
466
467                    let cert_chain_bytes = inner.api_client.fetch_cert(&signature.x5u)?;
468
469                    // The signer name is hard-coded. This would have to be modified in the very (very)
470                    // unlikely situation where we would add a new collection signer.
471                    // And clients code would have to be modified to handle this new collection anyway.
472                    // https://searchfox.org/mozilla-central/rev/df850fa290fe962c2c5ae8b63d0943ce768e3cc4/services/settings/remote-settings.sys.mjs#40-48
473                    let expected_leaf_cname = format!(
474                        "{}.content-signature.mozilla.org",
475                        if metadata.bucket.contains("security-state") {
476                            "onecrl"
477                        } else {
478                            "remote-settings"
479                        }
480                    );
481
482                    result = signatures::verify_signature(
483                        timestamp,
484                        records,
485                        signature.signature.as_bytes(),
486                        &cert_chain_bytes,
487                        epoch_seconds(),
488                        expected_root_hash,
489                        &expected_leaf_cname,
490                    )
491                    .inspect_err(|err| {
492                        debug!(
493                            "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'",
494                            self.collection_name, err, &signature.x5u, expected_leaf_cname
495                        );
496                    });
497                    // If verification succeeds, then we exit!
498                    if result.is_ok() {
499                        trace!("{0}: signature verification success.", self.collection_name);
500                        return Ok(());
501                    }
502                }
503                // If we tried all signatures and none worked, then we return an error.
504                result
505            }
506            _ => {
507                let missing_field = if timestamp.is_none() {
508                    "timestamp"
509                } else if records.is_none() {
510                    "records"
511                } else {
512                    "metadata"
513                };
514                Err(Error::IncompleteSignatureDataError(missing_field.into()))
515            }
516        }
517    }
518
519    /// Downloads an attachment from [attachment_location]. NOTE: there are no guarantees about a
520    /// maximum size, so use care when fetching potentially large attachments.
521    pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> Result<Vec<u8>> {
522        let metadata = record
523            .attachment
524            .as_ref()
525            .ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
526
527        let mut inner = self.lock_inner()?;
528        let collection_url = inner.api_client.collection_url();
529
530        // First try storage - it will only return data that matches our metadata
531        if let Some(data) = inner
532            .storage
533            .get_attachment(&collection_url, metadata.clone())?
534        {
535            return Ok(data);
536        }
537
538        // Then try packaged data if we're in prod
539        if inner.api_client.is_prod_server()? {
540            if let Some((data, manifest)) = self.load_packaged_attachment(&record.id) {
541                if let Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) {
542                    if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
543                        && metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
544                    {
545                        // Store valid packaged data in storage because it was either empty or outdated
546                        inner
547                            .storage
548                            .set_attachment(&collection_url, &metadata.location, data)?;
549                        return Ok(data.to_vec());
550                    }
551                }
552            }
553        }
554
555        // Try to download the attachment because neither the storage nor the local data had it
556        let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
557
558        // Verify downloaded data
559        if attachment.len() as u64 != metadata.size {
560            return Err(Error::RecordAttachmentMismatchError(
561                "Downloaded attachment size mismatch".into(),
562            ));
563        }
564        let hash = format!("{:x}", Sha256::digest(&attachment));
565        if hash != metadata.hash {
566            return Err(Error::RecordAttachmentMismatchError(
567                "Downloaded attachment hash mismatch".into(),
568            ));
569        }
570
571        // Store verified download in storage
572        inner
573            .storage
574            .set_attachment(&collection_url, &metadata.location, &attachment)?;
575        Ok(attachment)
576    }
577
578    pub fn update_config(
579        &self,
580        server_url: BaseUrl,
581        bucket_name: String,
582        context: Option<RemoteSettingsContext>,
583    ) {
584        let mut pending_config = self.pending_config.lock();
585        *pending_config = Some(RemoteSettingsClientConfig {
586            server_url,
587            bucket_name,
588            context,
589        })
590    }
591}
592
593impl RemoteSettingsClient<ViaductApiClient> {
594    pub fn new(
595        server_url: BaseUrl,
596        bucket_name: String,
597        collection_name: String,
598        context: Option<RemoteSettingsContext>,
599        storage: Storage,
600    ) -> Self {
601        let api_client = ViaductApiClient::new(server_url, &bucket_name, &collection_name);
602        let jexl_filter = JexlFilter::new(context);
603
604        Self::new_from_parts(collection_name, storage, jexl_filter, api_client)
605    }
606}
607
608#[cfg_attr(test, mockall::automock)]
609pub trait ApiClient {
610    /// Create a new instance of the client
611    fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self;
612
613    /// Get the Bucket URL for this client.
614    ///
615    /// This is a URL that includes the server URL, bucket name, and collection name.  This is used
616    /// to check if the application has switched the remote settings config and therefore we should
617    /// throw away any cached data
618    ///
619    /// Returns it as a String, since that's what the storage expects
620    fn collection_url(&self) -> String;
621
622    /// Fetch records from the server
623    fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse>;
624
625    /// Fetch an attachment from the server
626    fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>>;
627
628    /// Fetch a server certificate
629    fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>>;
630
631    /// Check if this client is pointing to the production server
632    fn is_prod_server(&self) -> Result<bool>;
633}
634
635/// Client for Remote settings API requests
636pub struct ViaductApiClient {
637    endpoints: RemoteSettingsEndpoints,
638    remote_state: RemoteState,
639}
640
641impl ViaductApiClient {
642    fn new(base_url: BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
643        Self {
644            endpoints: RemoteSettingsEndpoints::new(&base_url, bucket_name, collection_name),
645            remote_state: RemoteState::default(),
646        }
647    }
648
649    fn make_request(&mut self, url: Url) -> Result<Response> {
650        self._make_request(url.clone())
651            .inspect_err(|e| breadcrumb!("Request error: {e} ({url})"))
652    }
653
654    fn _make_request(&mut self, url: Url) -> Result<Response> {
655        self.remote_state.ensure_no_backoff()?;
656
657        let req = Request::get(url);
658        let resp = req.send()?;
659
660        self.remote_state.handle_backoff_hint(&resp)?;
661
662        if resp.is_success() {
663            Ok(resp)
664        } else {
665            Err(Error::response_error(
666                &resp.url,
667                format!("status code: {}", resp.status),
668            ))
669        }
670    }
671}
672
673impl ApiClient for ViaductApiClient {
674    fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self {
675        Self::new(server_url, &bucket_name, collection_name)
676    }
677
678    fn collection_url(&self) -> String {
679        self.endpoints.collection_url.to_string()
680    }
681
682    fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse> {
683        let mut url = self.endpoints.changeset_url.clone();
684        // 0 is used as an arbitrary value for `_expected` because the current implementation does
685        // not leverage push timestamps or polling from the monitor/changes endpoint. More
686        // details:
687        //
688        // https://remote-settings.readthedocs.io/en/latest/client-specifications.html#cache-busting
689        url.query_pairs_mut().append_pair("_expected", "0");
690        if let Some(timestamp) = timestamp {
691            url.query_pairs_mut()
692                .append_pair("_since", &format!("{}", timestamp));
693        }
694
695        let resp = self.make_request(url)?;
696
697        if resp.is_success() {
698            Ok(resp.json::<ChangesetResponse>()?)
699        } else {
700            Err(Error::response_error(
701                &resp.url,
702                format!("status code: {}", resp.status),
703            ))
704        }
705    }
706
707    fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>> {
708        let attachments_base_url = match &self.remote_state.attachments_base_url {
709            Some(attachments_base_url) => attachments_base_url.to_owned(),
710            None => {
711                let server_info = self
712                    .make_request(self.endpoints.root_url.clone())?
713                    .json::<ServerInfo>()?;
714                let attachments_base_url = match server_info.capabilities.attachments {
715                    Some(capability) => Url::parse(&capability.base_url)?,
716                    None => Err(Error::AttachmentsUnsupportedError)?,
717                };
718                self.remote_state.attachments_base_url = Some(attachments_base_url.clone());
719                attachments_base_url
720            }
721        };
722
723        let resp = self.make_request(attachments_base_url.join(attachment_location)?)?;
724        Ok(resp.body)
725    }
726
727    fn is_prod_server(&self) -> Result<bool> {
728        Ok(self
729            .endpoints
730            .root_url
731            .as_str()
732            .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str()))
733    }
734
735    fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>> {
736        let resp = self.make_request(Url::parse(x5u)?)?;
737        Ok(resp.body)
738    }
739}
740
741/// Stores all the endpoints for a Remote Settings server
742///
743/// There's actually not to many of these, so we can just pack them all into a struct
744struct RemoteSettingsEndpoints {
745    /// Root URL for Remote Settings server
746    ///
747    /// This has the form `[base-url]/`. It's where we get the attachment base url from.
748    root_url: Url,
749    /// URL for the collections endpoint
750    ///
751    /// This has the form:
752    /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]`.
753    ///
754    /// It can be used to fetch some metadata about the collection, but the real reason we use it
755    /// is to get a URL that uniquely identifies the server + bucket name.  This is used by the
756    /// [Storage] component to know when to throw away cached records because the user has changed
757    /// one of these,
758    collection_url: Url,
759    /// URL for the changeset request
760    ///
761    /// This has the form:
762    /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]/changeset`.
763    ///
764    /// This is the URL for fetching records and changes to records
765    changeset_url: Url,
766}
767
768impl RemoteSettingsEndpoints {
769    /// Construct a new RemoteSettingsEndpoints
770    ///
771    /// `base_url` should have the form `https://[domain]/v2` (no trailing slash).
772    fn new(base_url: &BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
773        let mut root_url = base_url.clone();
774        // Push the empty string to add the trailing slash.
775        root_url.path_segments_mut().push("");
776
777        let mut collection_url = base_url.clone();
778        collection_url
779            .path_segments_mut()
780            .push("buckets")
781            .push(bucket_name)
782            .push("collections")
783            .push(collection_name);
784
785        let mut changeset_url = collection_url.clone();
786        changeset_url.path_segments_mut().push("changeset");
787
788        Self {
789            root_url: root_url.into_inner(),
790            collection_url: collection_url.into_inner(),
791            changeset_url: changeset_url.into_inner(),
792        }
793    }
794}
795
796#[derive(Clone, Deserialize, Serialize)]
797pub struct ChangesetResponse {
798    changes: Vec<RemoteSettingsRecord>,
799    timestamp: u64,
800    metadata: CollectionMetadata,
801}
802
803#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
804pub struct CollectionMetadata {
805    pub bucket: String,
806    pub signatures: Vec<CollectionSignature>,
807}
808
809#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
810pub struct CollectionSignature {
811    pub signature: String,
812    /// X.509 certificate chain Url (x5u)
813    pub x5u: String,
814    /// Signature type
815    pub mode: String,
816}
817
818/// A parsed Remote Settings record. Records can contain arbitrary fields, so clients
819/// are required to further extract expected values from the [fields] member.
820#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
821pub struct RemoteSettingsRecord {
822    pub id: String,
823    pub last_modified: u64,
824    /// Tombstone flag (see https://remote-settings.readthedocs.io/en/latest/client-specifications.html#local-state)
825    #[serde(default)]
826    pub deleted: bool,
827    pub attachment: Option<Attachment>,
828    #[serde(flatten)]
829    pub fields: RsJsonObject,
830}
831
832/// Attachment metadata that can be optionally attached to a [Record]. The [location] should
833/// included in calls to [Client::get_attachment].
834#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
835pub struct Attachment {
836    pub filename: String,
837    pub mimetype: String,
838    pub location: String,
839    pub hash: String,
840    pub size: u64,
841}
842
843// Define a UniFFI custom types to pass JSON objects across the FFI as a string
844//
845// This is named `RsJsonObject` because, UniFFI cannot currently rename iOS bindings and JsonObject
846// conflicted with the declaration in Nimbus. This shouldn't really impact Android, since the type
847// is converted into the platform JsonObject thanks to the UniFFI binding.
848pub type RsJsonObject = serde_json::Map<String, serde_json::Value>;
849uniffi::custom_type!(RsJsonObject, String, {
850    remote,
851    try_lift: |val| {
852        let json: serde_json::Value = serde_json::from_str(&val)?;
853
854        match json {
855            serde_json::Value::Object(obj) => Ok(obj),
856            _ => Err(uniffi::deps::anyhow::anyhow!(
857                "Unexpected JSON-non-object in the bagging area"
858            )),
859        }
860    },
861    lower: |obj| serde_json::Value::Object(obj).to_string(),
862});
863
864#[derive(Clone, Debug)]
865pub(crate) struct RemoteState {
866    attachments_base_url: Option<Url>,
867    backoff: BackoffState,
868}
869
870impl Default for RemoteState {
871    fn default() -> Self {
872        Self {
873            attachments_base_url: None,
874            backoff: BackoffState::Ok,
875        }
876    }
877}
878
879impl RemoteState {
880    pub fn handle_backoff_hint(&mut self, response: &Response) -> Result<()> {
881        let extract_backoff_header = |header| -> Result<u64> {
882            Ok(response
883                .headers
884                .get_as::<u64, _>(header)
885                .transpose()
886                .unwrap_or_default() // Ignore number parsing errors.
887                .unwrap_or(0))
888        };
889        // In practice these two headers are mutually exclusive.
890        let backoff = extract_backoff_header(HEADER_BACKOFF)?;
891        let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?;
892        let max_backoff = backoff.max(retry_after);
893
894        if max_backoff > 0 {
895            self.backoff = BackoffState::Backoff {
896                observed_at: Instant::now(),
897                duration: Duration::from_secs(max_backoff),
898            };
899        }
900        Ok(())
901    }
902
903    pub fn ensure_no_backoff(&mut self) -> Result<()> {
904        if let BackoffState::Backoff {
905            observed_at,
906            duration,
907        } = self.backoff
908        {
909            let elapsed_time = observed_at.elapsed();
910            if elapsed_time >= duration {
911                self.backoff = BackoffState::Ok;
912            } else {
913                let remaining = duration - elapsed_time;
914                return Err(Error::BackoffError(remaining.as_secs()));
915            }
916        }
917        Ok(())
918    }
919}
920
921/// Used in handling backoff responses from the Remote Settings server.
922#[derive(Clone, Copy, Debug)]
923pub(crate) enum BackoffState {
924    Ok,
925    Backoff {
926        observed_at: Instant,
927        duration: Duration,
928    },
929}
930
931#[derive(Deserialize)]
932struct ServerInfo {
933    capabilities: Capabilities,
934}
935
936#[derive(Deserialize)]
937struct Capabilities {
938    attachments: Option<AttachmentsCapability>,
939}
940
941#[derive(Deserialize)]
942struct AttachmentsCapability {
943    base_url: String,
944}
945
946#[cfg(test)]
947mod test_new_client {
948    use super::*;
949
950    #[test]
951    fn test_endpoints() {
952        let endpoints = RemoteSettingsEndpoints::new(
953            &BaseUrl::parse("http://rs.example.com/v2").unwrap(),
954            "main",
955            "test-collection",
956        );
957        assert_eq!(endpoints.root_url.to_string(), "http://rs.example.com/v2/");
958        assert_eq!(
959            endpoints.collection_url.to_string(),
960            "http://rs.example.com/v2/buckets/main/collections/test-collection",
961        );
962        assert_eq!(
963            endpoints.changeset_url.to_string(),
964            "http://rs.example.com/v2/buckets/main/collections/test-collection/changeset",
965        );
966    }
967}
968
969#[cfg(test)]
970mod viaduct_client_tests {
971    use super::*;
972
973    #[test]
974    fn test_fetch_uses_local_timestamp_as_unquoted_since() {
975        viaduct_dev::init_backend_dev();
976        let changeset = mockito::mock(
977            "GET",
978            "/v2/buckets/main/collections/test-collection/changeset",
979        )
980        // The mock only matches if `_since` is sent unquoted.
981        .match_query(mockito::Matcher::AllOf(vec![
982            mockito::Matcher::UrlEncoded("_expected".into(), "0".into()),
983            mockito::Matcher::UrlEncoded("_since".into(), "42".into()),
984        ]))
985        .with_status(200)
986        .with_header("content-type", "application/json")
987        .with_body(
988            r#"{"changes": [], "timestamp": 42, "metadata": {"bucket": "main", "signatures": []}}"#,
989        )
990        .create();
991
992        let mut api_client = ViaductApiClient::new(
993            BaseUrl::parse(&format!("{}/v2", mockito::server_url())).unwrap(),
994            "main",
995            "test-collection",
996        );
997        api_client.fetch_changeset(Some(42)).unwrap();
998
999        changeset.assert();
1000    }
1001}
1002
1003#[cfg(test)]
1004mod jexl_tests {
1005    use super::*;
1006    use std::sync::{Arc, Weak};
1007
1008    #[test]
1009    fn test_get_records_filtered_app_version_pass() {
1010        let mut api_client = MockApiClient::new();
1011        let records = vec![RemoteSettingsRecord {
1012            id: "record-0001".into(),
1013            last_modified: 100,
1014            deleted: false,
1015            attachment: None,
1016            fields: serde_json::json!({
1017                "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1018            })
1019            .as_object()
1020            .unwrap()
1021            .clone(),
1022        }];
1023        let changeset = ChangesetResponse {
1024            changes: records.clone(),
1025            timestamp: 42,
1026            metadata: CollectionMetadata::default(),
1027        };
1028        api_client.expect_collection_url().returning(|| {
1029            "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1030        });
1031        api_client.expect_fetch_changeset().returning({
1032            let changeset = changeset.clone();
1033            move |timestamp| {
1034                assert_eq!(timestamp, None);
1035                Ok(changeset.clone())
1036            }
1037        });
1038        api_client.expect_is_prod_server().returning(|| Ok(false));
1039
1040        let context = RemoteSettingsContext {
1041            app_version: Some("129.0.0".to_string()),
1042            ..Default::default()
1043        };
1044
1045        let mut storage = Storage::new(":memory:".into());
1046        let _ = storage.insert_collection_content(
1047            "http://rs.example.com/v2/buckets/main/collections/test-collection",
1048            &records,
1049            42,
1050            CollectionMetadata::default(),
1051        );
1052
1053        let rs_client = RemoteSettingsClient::new_from_parts(
1054            "test-collection".into(),
1055            storage,
1056            JexlFilter::new(Some(context)),
1057            api_client,
1058        );
1059
1060        assert_eq!(
1061            rs_client.get_records(false).expect("Error getting records"),
1062            Some(records)
1063        );
1064    }
1065
1066    #[test]
1067    fn test_get_records_filtered_app_version_too_low() {
1068        let mut api_client = MockApiClient::new();
1069        let records = vec![RemoteSettingsRecord {
1070            id: "record-0001".into(),
1071            last_modified: 100,
1072            deleted: false,
1073            attachment: None,
1074            fields: serde_json::json!({
1075                "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1076            })
1077            .as_object()
1078            .unwrap()
1079            .clone(),
1080        }];
1081        let changeset = ChangesetResponse {
1082            changes: records.clone(),
1083            timestamp: 42,
1084            metadata: CollectionMetadata::default(),
1085        };
1086        api_client.expect_collection_url().returning(|| {
1087            "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1088        });
1089        api_client.expect_fetch_changeset().returning({
1090            let changeset = changeset.clone();
1091            move |timestamp| {
1092                assert_eq!(timestamp, None);
1093                Ok(changeset.clone())
1094            }
1095        });
1096        api_client.expect_is_prod_server().returning(|| Ok(false));
1097
1098        let context = RemoteSettingsContext {
1099            app_version: Some("127.0.0.".to_string()),
1100            ..Default::default()
1101        };
1102
1103        let mut storage = Storage::new(":memory:".into());
1104        let _ = storage.insert_collection_content(
1105            "http://rs.example.com/v2/buckets/main/collections/test-collection",
1106            &records,
1107            42,
1108            CollectionMetadata::default(),
1109        );
1110
1111        let rs_client = RemoteSettingsClient::new_from_parts(
1112            "test-collection".into(),
1113            storage,
1114            JexlFilter::new(Some(context)),
1115            api_client,
1116        );
1117
1118        assert_eq!(
1119            rs_client.get_records(false).expect("Error getting records"),
1120            Some(vec![])
1121        );
1122    }
1123
1124    #[test]
1125    fn test_update_jexl_context() {
1126        let mut api_client = MockApiClient::new();
1127        let records = vec![RemoteSettingsRecord {
1128            id: "record-0001".into(),
1129            last_modified: 100,
1130            deleted: false,
1131            attachment: None,
1132            fields: serde_json::json!({
1133                "filter_expression": "env.country == \"US\""
1134            })
1135            .as_object()
1136            .unwrap()
1137            .clone(),
1138        }];
1139        let changeset = ChangesetResponse {
1140            changes: records.clone(),
1141            timestamp: 42,
1142            metadata: CollectionMetadata::default(),
1143        };
1144        api_client.expect_collection_url().returning(|| {
1145            "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1146        });
1147        api_client.expect_fetch_changeset().returning({
1148            let changeset = changeset.clone();
1149            move |timestamp| {
1150                assert_eq!(timestamp, None);
1151                Ok(changeset.clone())
1152            }
1153        });
1154        api_client.expect_is_prod_server().returning(|| Ok(false));
1155
1156        let context = RemoteSettingsContext {
1157            country: Some("US".to_string()),
1158            ..Default::default()
1159        };
1160
1161        let mut storage = Storage::new(":memory:".into());
1162        let _ = storage.insert_collection_content(
1163            "http://rs.example.com/v2/buckets/main/collections/test-collection",
1164            &records,
1165            42,
1166            CollectionMetadata::default(),
1167        );
1168
1169        let rs_client = RemoteSettingsClient::new_from_parts(
1170            "test-collection".into(),
1171            storage,
1172            JexlFilter::new(Some(context)),
1173            api_client,
1174        );
1175
1176        assert_eq!(
1177            rs_client.get_records(false).expect("Error getting records"),
1178            Some(records)
1179        );
1180
1181        // We can't call `update_config` directly, since that only works with a real API client.
1182        // Instead, just execute the code from that method that updates the JEXL filter.
1183        rs_client.inner.lock().jexl_filter = JexlFilter::new(Some(RemoteSettingsContext {
1184            country: Some("UK".to_string()),
1185            ..Default::default()
1186        }));
1187
1188        assert_eq!(
1189            rs_client.get_records(false).expect("Error getting records"),
1190            Some(vec![])
1191        );
1192    }
1193
1194    // Test that we can't hit the deadlock described in
1195    // https://bugzilla.mozilla.org/show_bug.cgi?id=2012955
1196    #[test]
1197    fn test_update_config_deadlock() {
1198        let mut api_client = MockApiClient::new();
1199        let rs_client_ref: Arc<Mutex<Weak<RemoteSettingsClient<MockApiClient>>>> =
1200            Arc::new(Mutex::new(Weak::new()));
1201        let rs_client_ref2 = rs_client_ref.clone();
1202
1203        api_client.expect_collection_url().returning(move || {
1204            // While we're in the middle of `get_records()` and have the `RemoteSettingsClientInner`
1205            // locked, call `update_config` to try to trigger the deadlock.
1206            //
1207            // Note: this code path is impossible in practice, since the client never calls
1208            // `update_config` in the middle of `get_records()`. What happens on desktop is that
1209            // `get_records()` needs to execute some Necko code in the main thread, while
1210            // `update_config` is also running in the main thread and blocked getting the lock.
1211            //
1212            // The two scenarios are different, but if this one doesn't deadlock then the real-life
1213            // Desktop scenario won't either.
1214            rs_client_ref2
1215                .lock()
1216                .upgrade()
1217                .expect("rs_client_ref not set")
1218                .update_config(
1219                    BaseUrl::parse("https://example.com/").unwrap(),
1220                    "test-collection".to_string(),
1221                    None,
1222                );
1223            "http://rs.example.com/v2/buckets/main/collections/test-collection".into()
1224        });
1225        api_client.expect_is_prod_server().returning(|| Ok(false));
1226
1227        let context = RemoteSettingsContext {
1228            app_version: Some("129.0.0".to_string()),
1229            ..Default::default()
1230        };
1231        let storage = Storage::new(":memory:".into());
1232
1233        let rs_client = Arc::new(RemoteSettingsClient::new_from_parts(
1234            "test-collection".into(),
1235            storage,
1236            JexlFilter::new(Some(context)),
1237            api_client,
1238        ));
1239        *rs_client_ref.lock() = Arc::downgrade(&rs_client);
1240
1241        assert_eq!(
1242            rs_client.get_records(false).expect("Error getting records"),
1243            None,
1244        );
1245    }
1246}
1247
1248#[cfg(feature = "signatures")]
1249#[cfg(test)]
1250mod test_signatures {
1251    use core::assert_eq;
1252
1253    use crate::RemoteSettingsContext;
1254
1255    use super::*;
1256    use nss_as::ensure_initialized;
1257
1258    const VALID_CERTIFICATE: &str = "\
1259-----BEGIN CERTIFICATE-----
1260MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
1261AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
1262bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
1263dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
1264emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ
1265BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
1266biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
1267bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt
1268c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb
1269HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt
1270zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD
1271MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
1272GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0
1273dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
1274aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ
12759FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc
1276fS8//SQGTlCqKQ==
1277-----END CERTIFICATE-----
1278-----BEGIN CERTIFICATE-----
1279MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC
1280VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1281ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1282aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1283bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB
1284ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt
1285BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw
1286QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz
1287cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE
1288xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz
1289vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6
1290MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK
1291BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud
1292IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT
1293AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE
1294ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln
1295bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk
1296b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk
1297b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250
1298ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t
1299b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx
1300qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ
13019qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH
1302TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr
1303v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O
1304ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea
1305UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg
1306VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0
1307xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d
1308mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi
1309ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz
1310LC4GzWQGSCGDyD+JCVw=
1311-----END CERTIFICATE-----
1312-----BEGIN CERTIFICATE-----
1313MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC
1314VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1315ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1316aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1317bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB
1318qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW
1319aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0
1320LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz
1321dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC
1322DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8
1323rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5
1324eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k
132551uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj
1326khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD
1327OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf
13284sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM
1329NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+
1330uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh
13317v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y
1332JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB
1333o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/
1334BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk
1335IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls
1336bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd
1337MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw
1338CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx
1339HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk
1340b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl
1341cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA
1342nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap
1343Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD
1344DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+
1345AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF
13462iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d
1347PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA
1348dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8
13495xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy
1350iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO
13516pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg
1352IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE=
1353-----END CERTIFICATE-----";
1354    const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#;
1355    const VALID_CERT_EPOCH_SECONDS: u64 = 1615559719;
1356
1357    fn build_client(
1358        diff_records: &[RemoteSettingsRecord],
1359        full_records: &[RemoteSettingsRecord],
1360        certificate: &str,
1361        signatures: &[CollectionSignature],
1362        epoch_secs: u64,
1363        bucket: &str,
1364    ) -> RemoteSettingsClient<MockApiClient> {
1365        let collection_name = "pioneer-study-addons";
1366
1367        MOCK_TIME.with(|cell| cell.set(Some(epoch_secs)));
1368
1369        let some_metadata = CollectionMetadata {
1370            bucket: bucket.into(),
1371            signatures: signatures.to_vec(),
1372        };
1373        // Changeset for when client fetches diff.
1374        let diff_changeset = ChangesetResponse {
1375            changes: diff_records.to_vec(),
1376            timestamp: 1603992731957,
1377            metadata: some_metadata.clone(),
1378        };
1379        // Changeset for when client retries from scratch.
1380        let full_changeset = ChangesetResponse {
1381            changes: full_records.to_vec(),
1382            timestamp: 1603992731957,
1383            metadata: some_metadata.clone(),
1384        };
1385
1386        let mut api_client = MockApiClient::new();
1387        api_client
1388            .expect_collection_url()
1389            .returning(move || format!("http://server/{}", collection_name));
1390        api_client.expect_is_prod_server().returning(|| Ok(false));
1391        api_client.expect_fetch_changeset().returning(move |since| {
1392            Ok(if since.is_some() {
1393                diff_changeset.clone()
1394            } else {
1395                full_changeset.clone()
1396            })
1397        });
1398
1399        let certificate = certificate.to_string();
1400        api_client
1401            .expect_fetch_cert()
1402            .returning(move |_| Ok(certificate.clone().into_bytes()));
1403
1404        let storage = Storage::new(":memory:".into());
1405        let jexl_filter = JexlFilter::new(Some(RemoteSettingsContext::default()));
1406        RemoteSettingsClient::new_from_parts(
1407            collection_name.to_string(),
1408            storage,
1409            jexl_filter,
1410            api_client,
1411        )
1412    }
1413
1414    fn run_client_sync(
1415        diff_records: &[RemoteSettingsRecord],
1416        full_records: &[RemoteSettingsRecord],
1417        certificate: &str,
1418        signatures: &[CollectionSignature],
1419        epoch_secs: u64,
1420        bucket: &str,
1421    ) -> Result<()> {
1422        build_client(
1423            diff_records,
1424            full_records,
1425            certificate,
1426            signatures,
1427            epoch_secs,
1428            bucket,
1429        )
1430        .sync()
1431    }
1432
1433    #[test]
1434    fn test_valid_signature() -> Result<()> {
1435        ensure_initialized();
1436        run_client_sync(
1437            &[],
1438            &[],
1439            VALID_CERTIFICATE,
1440            &[CollectionSignature {
1441                signature: VALID_SIGNATURE.to_string(),
1442                x5u: "http://mocked".into(),
1443                mode: "p384ecdsa".into(),
1444            }],
1445            VALID_CERT_EPOCH_SECONDS,
1446            "main",
1447        )
1448        .expect("Valid signature");
1449        Ok(())
1450    }
1451
1452    #[test]
1453    fn test_second_signature_is_valid() -> Result<()> {
1454        ensure_initialized();
1455        run_client_sync(
1456            &[],
1457            &[],
1458            VALID_CERTIFICATE,
1459            &[
1460                CollectionSignature {
1461                    signature: "invalid signature".to_string(),
1462                    x5u: "http://mocked".into(),
1463                    mode: "p384ecdsa".into(),
1464                },
1465                CollectionSignature {
1466                    signature: VALID_SIGNATURE.to_string(),
1467                    x5u: "http://mocked".into(),
1468                    mode: "p384ecdsa".into(),
1469                },
1470            ],
1471            VALID_CERT_EPOCH_SECONDS,
1472            "main",
1473        )
1474        .expect("Valid signature");
1475        Ok(())
1476    }
1477
1478    #[test]
1479    fn test_first_signature_has_unknown_type() -> Result<()> {
1480        ensure_initialized();
1481        run_client_sync(
1482            &[],
1483            &[],
1484            VALID_CERTIFICATE,
1485            &[
1486                CollectionSignature {
1487                    signature: "unkown signature".to_string(),
1488                    x5u: "http://mocked".into(),
1489                    // Unknown signature type.
1490                    mode: "mldsa".into(),
1491                },
1492                CollectionSignature {
1493                    signature: VALID_SIGNATURE.to_string(),
1494                    x5u: "http://mocked".into(),
1495                    mode: "p384ecdsa".into(),
1496                },
1497            ],
1498            VALID_CERT_EPOCH_SECONDS,
1499            "main",
1500        )
1501        .expect("Valid signature");
1502        Ok(())
1503    }
1504
1505    #[test]
1506    fn test_valid_signature_after_retry() -> Result<()> {
1507        ensure_initialized();
1508        run_client_sync(
1509            &[RemoteSettingsRecord {
1510                id: "bad-record".to_string(),
1511                last_modified: 9999,
1512                deleted: true,
1513                attachment: None,
1514                fields: serde_json::Map::new(),
1515            }],
1516            &[],
1517            VALID_CERTIFICATE,
1518            &[CollectionSignature {
1519                signature: VALID_SIGNATURE.to_string(),
1520                x5u: "http://mocked".into(),
1521                mode: "p384ecdsa".into(),
1522            }],
1523            VALID_CERT_EPOCH_SECONDS,
1524            "main",
1525        )
1526        .expect("Valid signature");
1527        Ok(())
1528    }
1529
1530    #[test]
1531    fn test_invalid_signature_value() -> Result<()> {
1532        ensure_initialized();
1533        let err = run_client_sync(
1534            &[],
1535            &[],
1536            VALID_CERTIFICATE,
1537            &[CollectionSignature {
1538                signature: "invalid signature".to_string(),
1539                x5u: "http://mocked".into(),
1540                mode: "p384ecdsa".into(),
1541            }],
1542            VALID_CERT_EPOCH_SECONDS,
1543            "main",
1544        )
1545        .unwrap_err();
1546        assert!(matches!(err, Error::SignatureError(_)));
1547        assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
1548
1549        Ok(())
1550    }
1551
1552    #[test]
1553    fn test_invalid_certificate_value() -> Result<()> {
1554        ensure_initialized();
1555        let err = run_client_sync(
1556            &[],
1557            &[],
1558            "some bad PEM content",
1559            &[CollectionSignature {
1560                signature: VALID_SIGNATURE.to_string(),
1561                x5u: "http://mocked".into(),
1562                mode: "p384ecdsa".into(),
1563            }],
1564            VALID_CERT_EPOCH_SECONDS,
1565            "main",
1566        )
1567        .unwrap_err();
1568
1569        assert!(matches!(err, Error::SignatureError(_)));
1570        assert_eq!(
1571            format!("{}", err),
1572            "Signature could not be verified: PEM content format error: Missing PEM data"
1573        );
1574
1575        Ok(())
1576    }
1577
1578    #[test]
1579    fn test_invalid_signature_expired_cert() -> Result<()> {
1580        ensure_initialized();
1581        let december_20_2024 = 1734651582;
1582
1583        let err = run_client_sync(
1584            &[],
1585            &[],
1586            VALID_CERTIFICATE,
1587            &[CollectionSignature {
1588                signature: VALID_SIGNATURE.to_string(),
1589                x5u: "http://mocked".into(),
1590                mode: "p384ecdsa".into(),
1591            }],
1592            december_20_2024,
1593            "main",
1594        )
1595        .unwrap_err();
1596
1597        assert!(matches!(err, Error::SignatureError(_)));
1598        assert_eq!(
1599            format!("{}", err),
1600            "Signature could not be verified: Certificate not yet valid or expired"
1601        );
1602
1603        Ok(())
1604    }
1605
1606    #[test]
1607    fn test_invalid_signature_invalid_data() -> Result<()> {
1608        ensure_initialized();
1609        // The signature is valid for an empty list of records.
1610        let records = vec![RemoteSettingsRecord {
1611            id: "unexpected-data".to_string(),
1612            last_modified: 42,
1613            deleted: false,
1614            attachment: None,
1615            fields: serde_json::Map::new(),
1616        }];
1617        let err = run_client_sync(
1618            &records,
1619            &records,
1620            VALID_CERTIFICATE,
1621            &[CollectionSignature {
1622                signature: VALID_SIGNATURE.to_string(),
1623                x5u: "http://mocked".into(),
1624                mode: "p384ecdsa".into(),
1625            }],
1626            VALID_CERT_EPOCH_SECONDS,
1627            "main",
1628        )
1629        .unwrap_err();
1630
1631        assert!(matches!(err, Error::SignatureError(_)));
1632        assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
1633
1634        Ok(())
1635    }
1636
1637    #[test]
1638    fn test_invalid_signature_invalid_signer_name() -> Result<()> {
1639        ensure_initialized();
1640        let err = run_client_sync(
1641            &[],
1642            &[],
1643            VALID_CERTIFICATE,
1644            &[CollectionSignature {
1645                signature: VALID_SIGNATURE.to_string(),
1646                x5u: "http://mocked".into(),
1647                mode: "p384ecdsa".into(),
1648            }],
1649            VALID_CERT_EPOCH_SECONDS,
1650            "security-state",
1651        )
1652        .unwrap_err();
1653        assert!(matches!(err, Error::SignatureError(_)));
1654        assert_eq!(
1655            format!("{}", err),
1656            "Signature could not be verified: Certificate subject mismatch"
1657        );
1658
1659        Ok(())
1660    }
1661
1662    #[test]
1663    fn test_get_records_sync_if_empty_verifies_signature() -> Result<()> {
1664        ensure_initialized();
1665        let rs_client = build_client(
1666            &[],
1667            &[],
1668            VALID_CERTIFICATE,
1669            &[CollectionSignature {
1670                signature: "invalid signature".to_string(),
1671                x5u: "http://mocked".into(),
1672                mode: "p384ecdsa".into(),
1673            }],
1674            VALID_CERT_EPOCH_SECONDS,
1675            "main",
1676        );
1677
1678        let err = rs_client.get_records(true).unwrap_err();
1679
1680        assert!(matches!(err, Error::SignatureError(_)));
1681        assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
1682
1683        // Unverified data was not kept in storage.
1684        let mut inner = rs_client.lock_inner()?;
1685        let collection_url = inner.api_client.collection_url();
1686        assert_eq!(inner.storage.get_records(&collection_url)?, None);
1687
1688        Ok(())
1689    }
1690
1691    #[test]
1692    fn test_get_records_sync_if_empty_with_valid_signature() -> Result<()> {
1693        ensure_initialized();
1694        let rs_client = build_client(
1695            &[],
1696            &[],
1697            VALID_CERTIFICATE,
1698            &[CollectionSignature {
1699                signature: VALID_SIGNATURE.to_string(),
1700                x5u: "http://mocked".into(),
1701                mode: "p384ecdsa".into(),
1702            }],
1703            VALID_CERT_EPOCH_SECONDS,
1704            "main",
1705        );
1706
1707        // The signature is only valid for an empty list of records.
1708        assert_eq!(rs_client.get_records(true)?, Some(vec![]));
1709
1710        Ok(())
1711    }
1712}
1713
1714#[cfg(test)]
1715mod test_reset_storage {
1716    use super::*;
1717
1718    #[test]
1719    fn test_reset_storage_deletes_records_and_attachments() {
1720        let collection_url = "http://rs.example.com/v2/buckets/main/collections/test-collection";
1721
1722        let mut api_client = MockApiClient::new();
1723        api_client
1724            .expect_collection_url()
1725            .returning(|| collection_url.into());
1726        api_client.expect_is_prod_server().returning(|| Ok(false));
1727
1728        let records = vec![RemoteSettingsRecord {
1729            id: "record-0001".into(),
1730            last_modified: 100,
1731            deleted: false,
1732            attachment: Some(Attachment {
1733                filename: "test-file.bin".into(),
1734                mimetype: "application/octet-stream".into(),
1735                location: "attachments/test-file.bin".into(),
1736                hash: "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7".into(),
1737                size: 4,
1738            }),
1739            fields: serde_json::Map::new(),
1740        }];
1741
1742        let mut storage = Storage::new(":memory:".into());
1743        storage
1744            .insert_collection_content(collection_url, &records, 100, CollectionMetadata::default())
1745            .expect("Failed to insert records");
1746
1747        storage
1748            .set_attachment(collection_url, "attachments/test-file.bin", b"data")
1749            .expect("Failed to insert attachment");
1750
1751        // Verify data is present before reset
1752        assert!(storage.get_records(collection_url).unwrap().is_some());
1753        assert!(storage
1754            .get_attachment(collection_url, records[0].attachment.clone().unwrap())
1755            .unwrap()
1756            .is_some());
1757
1758        let rs_client = RemoteSettingsClient::new_from_parts(
1759            "test-collection".into(),
1760            storage,
1761            JexlFilter::new(None),
1762            api_client,
1763        );
1764
1765        rs_client.reset_storage().expect("Failed to reset storage");
1766
1767        // After reset, both records and attachments should be gone
1768        let mut inner = rs_client.inner.lock();
1769        assert_eq!(
1770            inner.storage.get_records(collection_url).unwrap(),
1771            None,
1772            "Records should be deleted after reset_storage"
1773        );
1774        assert_eq!(
1775            inner
1776                .storage
1777                .get_attachment(collection_url, records[0].attachment.clone().unwrap(),)
1778                .unwrap(),
1779            None,
1780            "Attachments should be deleted after reset_storage"
1781        );
1782    }
1783
1784    #[test]
1785    fn test_reset_storage_reverts_to_packaged_data() {
1786        let collection_url = "http://rs.example.com/v2/buckets/main/collections/regions";
1787
1788        let mut api_client = MockApiClient::new();
1789        api_client
1790            .expect_collection_url()
1791            .returning(|| collection_url.into());
1792        // Must be prod for reset_storage to restore packaged data
1793        api_client.expect_is_prod_server().returning(|| Ok(true));
1794
1795        let synced_records = vec![RemoteSettingsRecord {
1796            id: "custom-synced-record".into(),
1797            last_modified: 99999,
1798            deleted: false,
1799            attachment: None,
1800            fields: serde_json::json!({"key": "synced-value"})
1801                .as_object()
1802                .unwrap()
1803                .clone(),
1804        }];
1805
1806        let mut storage = Storage::new(":memory:".into());
1807        storage
1808            .insert_collection_content(
1809                collection_url,
1810                &synced_records,
1811                99999,
1812                CollectionMetadata::default(),
1813            )
1814            .expect("Failed to insert synced records");
1815
1816        // Verify synced data is present
1817        let records_before = storage.get_records(collection_url).unwrap().unwrap();
1818        assert_eq!(records_before[0].id, "custom-synced-record");
1819
1820        let rs_client = RemoteSettingsClient::new_from_parts(
1821            "regions".into(),
1822            storage,
1823            JexlFilter::new(None),
1824            api_client,
1825        );
1826
1827        rs_client.reset_storage().expect("Failed to reset storage");
1828
1829        let mut inner = rs_client.inner.lock();
1830        let records = inner.storage.get_records(collection_url).unwrap();
1831        assert!(
1832            records.is_some(),
1833            "Packaged data should be restored after reset_storage on prod"
1834        );
1835        let records = records.unwrap();
1836        assert!(
1837            !records.is_empty(),
1838            "Packaged regions data should not be empty"
1839        );
1840        assert!(
1841            !records.iter().any(|r| r.id == "custom-synced-record"),
1842            "Synced data should be replaced by packaged data after reset"
1843        );
1844    }
1845}