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::{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        Ok(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), _) => Some(self.filter_records(cached_records, &inner)),
334            // Case 3: sync_if_empty=true
335            (None, true) => {
336                let changeset = inner.api_client.fetch_changeset(None)?;
337                inner.storage.insert_collection_content(
338                    &collection_url,
339                    &changeset.changes,
340                    changeset.timestamp,
341                    changeset.metadata,
342                )?;
343                Some(self.filter_records(changeset.changes, &inner))
344            }
345            // Case 4: Nothing to return
346            (None, false) => None,
347        })
348    }
349
350    /// Returns the last modified timestamp for the collection.
351    pub fn get_last_modified_timestamp(&self) -> Result<Option<u64>> {
352        let mut inner = self.lock_inner()?;
353        let collection_url = inner.api_client.collection_url();
354        inner.storage.get_last_modified_timestamp(&collection_url)
355    }
356
357    /// Synchronizes the local collection with the remote server by performing the following steps:
358    /// 1. Fetches the last modified timestamp of the collection from local storage.
359    /// 2. Fetches the changeset from the remote server based on the last modified timestamp.
360    /// 3. Inserts the fetched changeset into local storage.
361    fn perform_sync_operation(&self) -> Result<()> {
362        let mut inner = self.lock_inner()?;
363        let collection_url = inner.api_client.collection_url();
364        let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
365        let changeset = inner.api_client.fetch_changeset(timestamp)?;
366        debug!(
367            "{0}: apply {1} change(s) locally.",
368            self.collection_name,
369            changeset.changes.len()
370        );
371        inner.storage.insert_collection_content(
372            &collection_url,
373            &changeset.changes,
374            changeset.timestamp,
375            changeset.metadata,
376        )
377    }
378
379    pub fn sync(&self) -> Result<()> {
380        // First attempt
381        self.perform_sync_operation()?;
382        // Verify that inserted data has valid signature
383        if self.verify_signature().is_err() {
384            debug!(
385                "{0}: signature verification failed. Reset and retry.",
386                self.collection_name
387            );
388            // Retry with packaged dataset as base
389            self.reset_storage()?;
390            self.perform_sync_operation()?;
391            // Verify signature again
392            self.verify_signature().inspect_err(|_| {
393                // And reset with packaged data if it fails again.
394                self.reset_storage()
395                    .expect("Failed to reset storage after verification failure");
396            })?;
397        }
398        trace!("{0}: sync done.", self.collection_name);
399        Ok(())
400    }
401
402    pub fn reset_storage(&self) -> Result<()> {
403        trace!("{0}: reset local storage.", self.collection_name);
404        let mut inner = self.lock_inner()?;
405        let collection_url = inner.api_client.collection_url();
406        // Clear existing storage
407        inner.storage.empty()?;
408        // Load packaged data only for production
409        if inner.api_client.is_prod_server()? {
410            if let Some(packaged_data) = self.load_packaged_data() {
411                trace!("{0}: restore packaged dump.", self.collection_name);
412                inner.storage.insert_collection_content(
413                    &collection_url,
414                    &packaged_data.data,
415                    packaged_data.timestamp,
416                    CollectionMetadata::default(),
417                )?;
418            }
419        }
420        Ok(())
421    }
422
423    pub fn shutdown(&self) {
424        self.inner.lock().storage.close();
425    }
426
427    #[cfg(not(feature = "signatures"))]
428    fn verify_signature(&self) -> Result<()> {
429        debug!("{0}: signature verification skipped.", self.collection_name);
430        Ok(())
431    }
432
433    #[cfg(feature = "signatures")]
434    fn verify_signature(&self) -> Result<()> {
435        let mut inner = self.lock_inner()?;
436        let collection_url = inner.api_client.collection_url();
437        let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?;
438        let records = inner.storage.get_records(&collection_url)?;
439        let metadata = inner.storage.get_collection_metadata(&collection_url)?;
440        match (timestamp, &records, metadata) {
441            (Some(timestamp), Some(records), Some(metadata)) => {
442                // rc_crypto verifies that the provided certificates chain leads to our root certificate.
443                let expected_root_hash = if inner.api_client.is_prod_server()? {
444                    ROOT_CERT_SHA256_HASH_PROD
445                } else {
446                    ROOT_CERT_SHA256_HASH_NONPROD
447                };
448                // Iterate through the list of signatures, and verify that at least one of them is valid.
449                // This allows for key rotation without breaking clients that have an old certificate chain cached.
450                let mut result = Err(Error::IncompleteSignatureDataError(
451                    "No valid signatures found".into(),
452                ));
453                for signature in &metadata.signatures {
454                    let cert_chain_bytes = inner.api_client.fetch_cert(&signature.x5u)?;
455
456                    // The signer name is hard-coded. This would have to be modified in the very (very)
457                    // unlikely situation where we would add a new collection signer.
458                    // And clients code would have to be modified to handle this new collection anyway.
459                    // https://searchfox.org/mozilla-central/rev/df850fa290fe962c2c5ae8b63d0943ce768e3cc4/services/settings/remote-settings.sys.mjs#40-48
460                    let expected_leaf_cname = format!(
461                        "{}.content-signature.mozilla.org",
462                        if metadata.bucket.contains("security-state") {
463                            "onecrl"
464                        } else {
465                            "remote-settings"
466                        }
467                    );
468
469                    result = signatures::verify_signature(
470                        timestamp,
471                        records,
472                        signature.signature.as_bytes(),
473                        &cert_chain_bytes,
474                        epoch_seconds(),
475                        expected_root_hash,
476                        &expected_leaf_cname,
477                    )
478                    .inspect_err(|err| {
479                        debug!(
480                            "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'",
481                            self.collection_name, err, &signature.x5u, expected_leaf_cname
482                        );
483                    });
484                    // If verification succeeds, then we exit!
485                    if result.is_ok() {
486                        trace!("{0}: signature verification success.", self.collection_name);
487                        return Ok(());
488                    }
489                }
490                // If we tried all signatures and none worked, then we return an error.
491                result
492            }
493            _ => {
494                let missing_field = if timestamp.is_none() {
495                    "timestamp"
496                } else if records.is_none() {
497                    "records"
498                } else {
499                    "metadata"
500                };
501                Err(Error::IncompleteSignatureDataError(missing_field.into()))
502            }
503        }
504    }
505
506    /// Downloads an attachment from [attachment_location]. NOTE: there are no guarantees about a
507    /// maximum size, so use care when fetching potentially large attachments.
508    pub fn get_attachment(&self, record: &RemoteSettingsRecord) -> Result<Vec<u8>> {
509        let metadata = record
510            .attachment
511            .as_ref()
512            .ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
513
514        let mut inner = self.lock_inner()?;
515        let collection_url = inner.api_client.collection_url();
516
517        // First try storage - it will only return data that matches our metadata
518        if let Some(data) = inner
519            .storage
520            .get_attachment(&collection_url, metadata.clone())?
521        {
522            return Ok(data);
523        }
524
525        // Then try packaged data if we're in prod
526        if inner.api_client.is_prod_server()? {
527            if let Some((data, manifest)) = self.load_packaged_attachment(&record.id) {
528                if let Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) {
529                    if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
530                        && metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
531                    {
532                        // Store valid packaged data in storage because it was either empty or outdated
533                        inner
534                            .storage
535                            .set_attachment(&collection_url, &metadata.location, data)?;
536                        return Ok(data.to_vec());
537                    }
538                }
539            }
540        }
541
542        // Try to download the attachment because neither the storage nor the local data had it
543        let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
544
545        // Verify downloaded data
546        if attachment.len() as u64 != metadata.size {
547            return Err(Error::RecordAttachmentMismatchError(
548                "Downloaded attachment size mismatch".into(),
549            ));
550        }
551        let hash = format!("{:x}", Sha256::digest(&attachment));
552        if hash != metadata.hash {
553            return Err(Error::RecordAttachmentMismatchError(
554                "Downloaded attachment hash mismatch".into(),
555            ));
556        }
557
558        // Store verified download in storage
559        inner
560            .storage
561            .set_attachment(&collection_url, &metadata.location, &attachment)?;
562        Ok(attachment)
563    }
564
565    pub fn update_config(
566        &self,
567        server_url: BaseUrl,
568        bucket_name: String,
569        context: Option<RemoteSettingsContext>,
570    ) {
571        let mut pending_config = self.pending_config.lock();
572        *pending_config = Some(RemoteSettingsClientConfig {
573            server_url,
574            bucket_name,
575            context,
576        })
577    }
578}
579
580impl RemoteSettingsClient<ViaductApiClient> {
581    pub fn new(
582        server_url: BaseUrl,
583        bucket_name: String,
584        collection_name: String,
585        context: Option<RemoteSettingsContext>,
586        storage: Storage,
587    ) -> Self {
588        let api_client = ViaductApiClient::new(server_url, &bucket_name, &collection_name);
589        let jexl_filter = JexlFilter::new(context);
590
591        Self::new_from_parts(collection_name, storage, jexl_filter, api_client)
592    }
593}
594
595#[cfg_attr(test, mockall::automock)]
596pub trait ApiClient {
597    /// Create a new instance of the client
598    fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self;
599
600    /// Get the Bucket URL for this client.
601    ///
602    /// This is a URL that includes the server URL, bucket name, and collection name.  This is used
603    /// to check if the application has switched the remote settings config and therefore we should
604    /// throw away any cached data
605    ///
606    /// Returns it as a String, since that's what the storage expects
607    fn collection_url(&self) -> String;
608
609    /// Fetch records from the server
610    fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse>;
611
612    /// Fetch an attachment from the server
613    fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>>;
614
615    /// Fetch a server certificate
616    fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>>;
617
618    /// Check if this client is pointing to the production server
619    fn is_prod_server(&self) -> Result<bool>;
620}
621
622/// Client for Remote settings API requests
623pub struct ViaductApiClient {
624    endpoints: RemoteSettingsEndpoints,
625    remote_state: RemoteState,
626}
627
628impl ViaductApiClient {
629    fn new(base_url: BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
630        Self {
631            endpoints: RemoteSettingsEndpoints::new(&base_url, bucket_name, collection_name),
632            remote_state: RemoteState::default(),
633        }
634    }
635
636    fn make_request(&mut self, url: Url) -> Result<Response> {
637        trace!("make_request: {url}");
638        self.remote_state.ensure_no_backoff()?;
639
640        let req = Request::get(url);
641        let resp = req.send()?;
642
643        self.remote_state.handle_backoff_hint(&resp)?;
644
645        if resp.is_success() {
646            Ok(resp)
647        } else {
648            Err(Error::response_error(
649                &resp.url,
650                format!("status code: {}", resp.status),
651            ))
652        }
653    }
654}
655
656impl ApiClient for ViaductApiClient {
657    fn create(server_url: BaseUrl, bucket_name: String, collection_name: &str) -> Self {
658        Self::new(server_url, &bucket_name, collection_name)
659    }
660
661    fn collection_url(&self) -> String {
662        self.endpoints.collection_url.to_string()
663    }
664
665    fn fetch_changeset(&mut self, timestamp: Option<u64>) -> Result<ChangesetResponse> {
666        let mut url = self.endpoints.changeset_url.clone();
667        // 0 is used as an arbitrary value for `_expected` because the current implementation does
668        // not leverage push timestamps or polling from the monitor/changes endpoint. More
669        // details:
670        //
671        // https://remote-settings.readthedocs.io/en/latest/client-specifications.html#cache-busting
672        url.query_pairs_mut().append_pair("_expected", "0");
673        if let Some(timestamp) = timestamp {
674            url.query_pairs_mut()
675                .append_pair("_since", &format!("\"{}\"", timestamp));
676        }
677
678        let resp = self.make_request(url)?;
679
680        if resp.is_success() {
681            Ok(resp.json::<ChangesetResponse>()?)
682        } else {
683            Err(Error::response_error(
684                &resp.url,
685                format!("status code: {}", resp.status),
686            ))
687        }
688    }
689
690    fn fetch_attachment(&mut self, attachment_location: &str) -> Result<Vec<u8>> {
691        let attachments_base_url = match &self.remote_state.attachments_base_url {
692            Some(attachments_base_url) => attachments_base_url.to_owned(),
693            None => {
694                let server_info = self
695                    .make_request(self.endpoints.root_url.clone())?
696                    .json::<ServerInfo>()?;
697                let attachments_base_url = match server_info.capabilities.attachments {
698                    Some(capability) => Url::parse(&capability.base_url)?,
699                    None => Err(Error::AttachmentsUnsupportedError)?,
700                };
701                self.remote_state.attachments_base_url = Some(attachments_base_url.clone());
702                attachments_base_url
703            }
704        };
705
706        let resp = self.make_request(attachments_base_url.join(attachment_location)?)?;
707        Ok(resp.body)
708    }
709
710    fn is_prod_server(&self) -> Result<bool> {
711        Ok(self
712            .endpoints
713            .root_url
714            .as_str()
715            .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str()))
716    }
717
718    fn fetch_cert(&mut self, x5u: &str) -> Result<Vec<u8>> {
719        let resp = self.make_request(Url::parse(x5u)?)?;
720        Ok(resp.body)
721    }
722}
723
724/// Stores all the endpoints for a Remote Settings server
725///
726/// There's actually not to many of these, so we can just pack them all into a struct
727struct RemoteSettingsEndpoints {
728    /// Root URL for Remote Settings server
729    ///
730    /// This has the form `[base-url]/`. It's where we get the attachment base url from.
731    root_url: Url,
732    /// URL for the collections endpoint
733    ///
734    /// This has the form:
735    /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]`.
736    ///
737    /// It can be used to fetch some metadata about the collection, but the real reason we use it
738    /// is to get a URL that uniquely identifies the server + bucket name.  This is used by the
739    /// [Storage] component to know when to throw away cached records because the user has changed
740    /// one of these,
741    collection_url: Url,
742    /// URL for the changeset request
743    ///
744    /// This has the form:
745    /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]/changeset`.
746    ///
747    /// This is the URL for fetching records and changes to records
748    changeset_url: Url,
749}
750
751impl RemoteSettingsEndpoints {
752    /// Construct a new RemoteSettingsEndpoints
753    ///
754    /// `base_url` should have the form `https://[domain]/v1` (no trailing slash).
755    fn new(base_url: &BaseUrl, bucket_name: &str, collection_name: &str) -> Self {
756        let mut root_url = base_url.clone();
757        // Push the empty string to add the trailing slash.
758        root_url.path_segments_mut().push("");
759
760        let mut collection_url = base_url.clone();
761        collection_url
762            .path_segments_mut()
763            .push("buckets")
764            .push(bucket_name)
765            .push("collections")
766            .push(collection_name);
767
768        let mut changeset_url = collection_url.clone();
769        changeset_url.path_segments_mut().push("changeset");
770
771        Self {
772            root_url: root_url.into_inner(),
773            collection_url: collection_url.into_inner(),
774            changeset_url: changeset_url.into_inner(),
775        }
776    }
777}
778
779#[derive(Clone, Deserialize, Serialize)]
780pub struct ChangesetResponse {
781    changes: Vec<RemoteSettingsRecord>,
782    timestamp: u64,
783    metadata: CollectionMetadata,
784}
785
786#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
787pub struct CollectionMetadata {
788    pub bucket: String,
789    pub signatures: Vec<CollectionSignature>,
790}
791
792#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
793pub struct CollectionSignature {
794    pub signature: String,
795    /// X.509 certificate chain Url (x5u)
796    pub x5u: String,
797}
798
799/// A parsed Remote Settings record. Records can contain arbitrary fields, so clients
800/// are required to further extract expected values from the [fields] member.
801#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
802pub struct RemoteSettingsRecord {
803    pub id: String,
804    pub last_modified: u64,
805    /// Tombstone flag (see https://remote-settings.readthedocs.io/en/latest/client-specifications.html#local-state)
806    #[serde(default)]
807    pub deleted: bool,
808    pub attachment: Option<Attachment>,
809    #[serde(flatten)]
810    pub fields: RsJsonObject,
811}
812
813/// Attachment metadata that can be optionally attached to a [Record]. The [location] should
814/// included in calls to [Client::get_attachment].
815#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)]
816pub struct Attachment {
817    pub filename: String,
818    pub mimetype: String,
819    pub location: String,
820    pub hash: String,
821    pub size: u64,
822}
823
824// Define a UniFFI custom types to pass JSON objects across the FFI as a string
825//
826// This is named `RsJsonObject` because, UniFFI cannot currently rename iOS bindings and JsonObject
827// conflicted with the declaration in Nimbus. This shouldn't really impact Android, since the type
828// is converted into the platform JsonObject thanks to the UniFFI binding.
829pub type RsJsonObject = serde_json::Map<String, serde_json::Value>;
830uniffi::custom_type!(RsJsonObject, String, {
831    remote,
832    try_lift: |val| {
833        let json: serde_json::Value = serde_json::from_str(&val)?;
834
835        match json {
836            serde_json::Value::Object(obj) => Ok(obj),
837            _ => Err(uniffi::deps::anyhow::anyhow!(
838                "Unexpected JSON-non-object in the bagging area"
839            )),
840        }
841    },
842    lower: |obj| serde_json::Value::Object(obj).to_string(),
843});
844
845#[derive(Clone, Debug)]
846pub(crate) struct RemoteState {
847    attachments_base_url: Option<Url>,
848    backoff: BackoffState,
849}
850
851impl Default for RemoteState {
852    fn default() -> Self {
853        Self {
854            attachments_base_url: None,
855            backoff: BackoffState::Ok,
856        }
857    }
858}
859
860impl RemoteState {
861    pub fn handle_backoff_hint(&mut self, response: &Response) -> Result<()> {
862        let extract_backoff_header = |header| -> Result<u64> {
863            Ok(response
864                .headers
865                .get_as::<u64, _>(header)
866                .transpose()
867                .unwrap_or_default() // Ignore number parsing errors.
868                .unwrap_or(0))
869        };
870        // In practice these two headers are mutually exclusive.
871        let backoff = extract_backoff_header(HEADER_BACKOFF)?;
872        let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?;
873        let max_backoff = backoff.max(retry_after);
874
875        if max_backoff > 0 {
876            self.backoff = BackoffState::Backoff {
877                observed_at: Instant::now(),
878                duration: Duration::from_secs(max_backoff),
879            };
880        }
881        Ok(())
882    }
883
884    pub fn ensure_no_backoff(&mut self) -> Result<()> {
885        if let BackoffState::Backoff {
886            observed_at,
887            duration,
888        } = self.backoff
889        {
890            let elapsed_time = observed_at.elapsed();
891            if elapsed_time >= duration {
892                self.backoff = BackoffState::Ok;
893            } else {
894                let remaining = duration - elapsed_time;
895                return Err(Error::BackoffError(remaining.as_secs()));
896            }
897        }
898        Ok(())
899    }
900}
901
902/// Used in handling backoff responses from the Remote Settings server.
903#[derive(Clone, Copy, Debug)]
904pub(crate) enum BackoffState {
905    Ok,
906    Backoff {
907        observed_at: Instant,
908        duration: Duration,
909    },
910}
911
912#[derive(Deserialize)]
913struct ServerInfo {
914    capabilities: Capabilities,
915}
916
917#[derive(Deserialize)]
918struct Capabilities {
919    attachments: Option<AttachmentsCapability>,
920}
921
922#[derive(Deserialize)]
923struct AttachmentsCapability {
924    base_url: String,
925}
926
927#[cfg(test)]
928mod test_new_client {
929    use super::*;
930
931    #[test]
932    fn test_endpoints() {
933        let endpoints = RemoteSettingsEndpoints::new(
934            &BaseUrl::parse("http://rs.example.com/v1").unwrap(),
935            "main",
936            "test-collection",
937        );
938        assert_eq!(endpoints.root_url.to_string(), "http://rs.example.com/v1/");
939        assert_eq!(
940            endpoints.collection_url.to_string(),
941            "http://rs.example.com/v1/buckets/main/collections/test-collection",
942        );
943        assert_eq!(
944            endpoints.changeset_url.to_string(),
945            "http://rs.example.com/v1/buckets/main/collections/test-collection/changeset",
946        );
947    }
948}
949
950#[cfg(test)]
951mod jexl_tests {
952    use super::*;
953    use std::sync::{Arc, Weak};
954
955    #[test]
956    fn test_get_records_filtered_app_version_pass() {
957        let mut api_client = MockApiClient::new();
958        let records = vec![RemoteSettingsRecord {
959            id: "record-0001".into(),
960            last_modified: 100,
961            deleted: false,
962            attachment: None,
963            fields: serde_json::json!({
964                "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
965            })
966            .as_object()
967            .unwrap()
968            .clone(),
969        }];
970        let changeset = ChangesetResponse {
971            changes: records.clone(),
972            timestamp: 42,
973            metadata: CollectionMetadata::default(),
974        };
975        api_client.expect_collection_url().returning(|| {
976            "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
977        });
978        api_client.expect_fetch_changeset().returning({
979            let changeset = changeset.clone();
980            move |timestamp| {
981                assert_eq!(timestamp, None);
982                Ok(changeset.clone())
983            }
984        });
985        api_client.expect_is_prod_server().returning(|| Ok(false));
986
987        let context = RemoteSettingsContext {
988            app_version: Some("129.0.0".to_string()),
989            ..Default::default()
990        };
991
992        let mut storage = Storage::new(":memory:".into());
993        let _ = storage.insert_collection_content(
994            "http://rs.example.com/v1/buckets/main/collections/test-collection",
995            &records,
996            42,
997            CollectionMetadata::default(),
998        );
999
1000        let rs_client = RemoteSettingsClient::new_from_parts(
1001            "test-collection".into(),
1002            storage,
1003            JexlFilter::new(Some(context)),
1004            api_client,
1005        );
1006
1007        assert_eq!(
1008            rs_client.get_records(false).expect("Error getting records"),
1009            Some(records)
1010        );
1011    }
1012
1013    #[test]
1014    fn test_get_records_filtered_app_version_too_low() {
1015        let mut api_client = MockApiClient::new();
1016        let records = vec![RemoteSettingsRecord {
1017            id: "record-0001".into(),
1018            last_modified: 100,
1019            deleted: false,
1020            attachment: None,
1021            fields: serde_json::json!({
1022                "filter_expression": "env.version|versionCompare(\"128.0a1\") > 0"
1023            })
1024            .as_object()
1025            .unwrap()
1026            .clone(),
1027        }];
1028        let changeset = ChangesetResponse {
1029            changes: records.clone(),
1030            timestamp: 42,
1031            metadata: CollectionMetadata::default(),
1032        };
1033        api_client.expect_collection_url().returning(|| {
1034            "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1035        });
1036        api_client.expect_fetch_changeset().returning({
1037            let changeset = changeset.clone();
1038            move |timestamp| {
1039                assert_eq!(timestamp, None);
1040                Ok(changeset.clone())
1041            }
1042        });
1043        api_client.expect_is_prod_server().returning(|| Ok(false));
1044
1045        let context = RemoteSettingsContext {
1046            app_version: Some("127.0.0.".to_string()),
1047            ..Default::default()
1048        };
1049
1050        let mut storage = Storage::new(":memory:".into());
1051        let _ = storage.insert_collection_content(
1052            "http://rs.example.com/v1/buckets/main/collections/test-collection",
1053            &records,
1054            42,
1055            CollectionMetadata::default(),
1056        );
1057
1058        let rs_client = RemoteSettingsClient::new_from_parts(
1059            "test-collection".into(),
1060            storage,
1061            JexlFilter::new(Some(context)),
1062            api_client,
1063        );
1064
1065        assert_eq!(
1066            rs_client.get_records(false).expect("Error getting records"),
1067            Some(vec![])
1068        );
1069    }
1070
1071    #[test]
1072    fn test_update_jexl_context() {
1073        let mut api_client = MockApiClient::new();
1074        let records = vec![RemoteSettingsRecord {
1075            id: "record-0001".into(),
1076            last_modified: 100,
1077            deleted: false,
1078            attachment: None,
1079            fields: serde_json::json!({
1080                "filter_expression": "env.country == \"US\""
1081            })
1082            .as_object()
1083            .unwrap()
1084            .clone(),
1085        }];
1086        let changeset = ChangesetResponse {
1087            changes: records.clone(),
1088            timestamp: 42,
1089            metadata: CollectionMetadata::default(),
1090        };
1091        api_client.expect_collection_url().returning(|| {
1092            "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1093        });
1094        api_client.expect_fetch_changeset().returning({
1095            let changeset = changeset.clone();
1096            move |timestamp| {
1097                assert_eq!(timestamp, None);
1098                Ok(changeset.clone())
1099            }
1100        });
1101        api_client.expect_is_prod_server().returning(|| Ok(false));
1102
1103        let context = RemoteSettingsContext {
1104            country: Some("US".to_string()),
1105            ..Default::default()
1106        };
1107
1108        let mut storage = Storage::new(":memory:".into());
1109        let _ = storage.insert_collection_content(
1110            "http://rs.example.com/v1/buckets/main/collections/test-collection",
1111            &records,
1112            42,
1113            CollectionMetadata::default(),
1114        );
1115
1116        let rs_client = RemoteSettingsClient::new_from_parts(
1117            "test-collection".into(),
1118            storage,
1119            JexlFilter::new(Some(context)),
1120            api_client,
1121        );
1122
1123        assert_eq!(
1124            rs_client.get_records(false).expect("Error getting records"),
1125            Some(records)
1126        );
1127
1128        // We can't call `update_config` directly, since that only works with a real API client.
1129        // Instead, just execute the code from that method that updates the JEXL filter.
1130        rs_client.inner.lock().jexl_filter = JexlFilter::new(Some(RemoteSettingsContext {
1131            country: Some("UK".to_string()),
1132            ..Default::default()
1133        }));
1134
1135        assert_eq!(
1136            rs_client.get_records(false).expect("Error getting records"),
1137            Some(vec![])
1138        );
1139    }
1140
1141    // Test that we can't hit the deadlock described in
1142    // https://bugzilla.mozilla.org/show_bug.cgi?id=2012955
1143    #[test]
1144    fn test_update_config_deadlock() {
1145        let mut api_client = MockApiClient::new();
1146        let rs_client_ref: Arc<Mutex<Weak<RemoteSettingsClient<MockApiClient>>>> =
1147            Arc::new(Mutex::new(Weak::new()));
1148        let rs_client_ref2 = rs_client_ref.clone();
1149
1150        api_client.expect_collection_url().returning(move || {
1151            // While we're in the middle of `get_records()` and have the `RemoteSettingsClientInner`
1152            // locked, call `update_config` to try to trigger the deadlock.
1153            //
1154            // Note: this code path is impossible in practice, since the client never calls
1155            // `update_config` in the middle of `get_records()`. What happens on desktop is that
1156            // `get_records()` needs to execute some Necko code in the main thread, while
1157            // `update_config` is also running in the main thread and blocked getting the lock.
1158            //
1159            // The two scenarios are different, but if this one doesn't deadlock then the real-life
1160            // Desktop scenario won't either.
1161            rs_client_ref2
1162                .lock()
1163                .upgrade()
1164                .expect("rs_client_ref not set")
1165                .update_config(
1166                    BaseUrl::parse("https://example.com/").unwrap(),
1167                    "test-collection".to_string(),
1168                    None,
1169                );
1170            "http://rs.example.com/v1/buckets/main/collections/test-collection".into()
1171        });
1172        api_client.expect_is_prod_server().returning(|| Ok(false));
1173
1174        let context = RemoteSettingsContext {
1175            app_version: Some("129.0.0".to_string()),
1176            ..Default::default()
1177        };
1178        let storage = Storage::new(":memory:".into());
1179
1180        let rs_client = Arc::new(RemoteSettingsClient::new_from_parts(
1181            "test-collection".into(),
1182            storage,
1183            JexlFilter::new(Some(context)),
1184            api_client,
1185        ));
1186        *rs_client_ref.lock() = Arc::downgrade(&rs_client);
1187
1188        assert_eq!(
1189            rs_client.get_records(false).expect("Error getting records"),
1190            None,
1191        );
1192    }
1193}
1194
1195#[cfg(feature = "signatures")]
1196#[cfg(test)]
1197mod test_signatures {
1198    use core::assert_eq;
1199
1200    use crate::RemoteSettingsContext;
1201
1202    use super::*;
1203    use nss::ensure_initialized;
1204
1205    const VALID_CERTIFICATE: &str = "\
1206-----BEGIN CERTIFICATE-----
1207MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
1208AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
1209bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
1210dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
1211emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ
1212BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
1213biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
1214bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt
1215c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb
1216HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt
1217zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD
1218MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
1219GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0
1220dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
1221aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ
12229FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc
1223fS8//SQGTlCqKQ==
1224-----END CERTIFICATE-----
1225-----BEGIN CERTIFICATE-----
1226MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC
1227VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1228ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1229aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1230bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB
1231ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt
1232BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw
1233QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz
1234cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE
1235xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz
1236vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6
1237MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK
1238BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud
1239IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT
1240AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE
1241ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln
1242bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk
1243b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk
1244b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250
1245ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t
1246b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx
1247qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ
12489qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH
1249TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr
1250v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O
1251ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea
1252UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg
1253VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0
1254xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d
1255mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi
1256ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz
1257LC4GzWQGSCGDyD+JCVw=
1258-----END CERTIFICATE-----
1259-----BEGIN CERTIFICATE-----
1260MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC
1261VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK
1262ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu
1263aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv
1264bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB
1265qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW
1266aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0
1267LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz
1268dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC
1269DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8
1270rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5
1271eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k
127251uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj
1273khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD
1274OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf
12754sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM
1276NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+
1277uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh
12787v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y
1279JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB
1280o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/
1281BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk
1282IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls
1283bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd
1284MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw
1285CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx
1286HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk
1287b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl
1288cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA
1289nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap
1290Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD
1291DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+
1292AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF
12932iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d
1294PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA
1295dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8
12965xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy
1297iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO
12986pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg
1299IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE=
1300-----END CERTIFICATE-----";
1301    const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#;
1302    const VALID_CERT_EPOCH_SECONDS: u64 = 1615559719;
1303
1304    fn run_client_sync(
1305        diff_records: &[RemoteSettingsRecord],
1306        full_records: &[RemoteSettingsRecord],
1307        certificate: &str,
1308        signatures: &[CollectionSignature],
1309        epoch_secs: u64,
1310        bucket: &str,
1311    ) -> Result<()> {
1312        let collection_name = "pioneer-study-addons";
1313
1314        MOCK_TIME.with(|cell| cell.set(Some(epoch_secs)));
1315
1316        let some_metadata = CollectionMetadata {
1317            bucket: bucket.into(),
1318            signatures: signatures.to_vec(),
1319        };
1320        // Changeset for when client fetches diff.
1321        let diff_changeset = ChangesetResponse {
1322            changes: diff_records.to_vec(),
1323            timestamp: 1603992731957,
1324            metadata: some_metadata.clone(),
1325        };
1326        // Changeset for when client retries from scratch.
1327        let full_changeset = ChangesetResponse {
1328            changes: full_records.to_vec(),
1329            timestamp: 1603992731957,
1330            metadata: some_metadata.clone(),
1331        };
1332
1333        let mut api_client = MockApiClient::new();
1334        api_client
1335            .expect_collection_url()
1336            .returning(move || format!("http://server/{}", collection_name));
1337        api_client.expect_is_prod_server().returning(|| Ok(false));
1338        api_client.expect_fetch_changeset().returning(move |since| {
1339            Ok(if since.is_some() {
1340                diff_changeset.clone()
1341            } else {
1342                full_changeset.clone()
1343            })
1344        });
1345
1346        let certificate = certificate.to_string();
1347        api_client
1348            .expect_fetch_cert()
1349            .returning(move |_| Ok(certificate.clone().into_bytes()));
1350
1351        let storage = Storage::new(":memory:".into());
1352        let jexl_filter = JexlFilter::new(Some(RemoteSettingsContext::default()));
1353        let rs_client = RemoteSettingsClient::new_from_parts(
1354            collection_name.to_string(),
1355            storage,
1356            jexl_filter,
1357            api_client,
1358        );
1359
1360        rs_client.sync()
1361    }
1362
1363    #[test]
1364    fn test_valid_signature() -> Result<()> {
1365        ensure_initialized();
1366        run_client_sync(
1367            &[],
1368            &[],
1369            VALID_CERTIFICATE,
1370            &[CollectionSignature {
1371                signature: VALID_SIGNATURE.to_string(),
1372                x5u: "http://mocked".into(),
1373            }],
1374            VALID_CERT_EPOCH_SECONDS,
1375            "main",
1376        )
1377        .expect("Valid signature");
1378        Ok(())
1379    }
1380
1381    #[test]
1382    fn test_second_signature_is_valid() -> Result<()> {
1383        ensure_initialized();
1384        run_client_sync(
1385            &[],
1386            &[],
1387            VALID_CERTIFICATE,
1388            &[
1389                CollectionSignature {
1390                    signature: "invalid signature".to_string(),
1391                    x5u: "http://mocked".into(),
1392                },
1393                CollectionSignature {
1394                    signature: VALID_SIGNATURE.to_string(),
1395                    x5u: "http://mocked".into(),
1396                },
1397            ],
1398            VALID_CERT_EPOCH_SECONDS,
1399            "main",
1400        )
1401        .expect("Valid signature");
1402        Ok(())
1403    }
1404
1405    #[test]
1406    fn test_valid_signature_after_retry() -> Result<()> {
1407        ensure_initialized();
1408        run_client_sync(
1409            &[RemoteSettingsRecord {
1410                id: "bad-record".to_string(),
1411                last_modified: 9999,
1412                deleted: true,
1413                attachment: None,
1414                fields: serde_json::Map::new(),
1415            }],
1416            &[],
1417            VALID_CERTIFICATE,
1418            &[CollectionSignature {
1419                signature: VALID_SIGNATURE.to_string(),
1420                x5u: "http://mocked".into(),
1421            }],
1422            VALID_CERT_EPOCH_SECONDS,
1423            "main",
1424        )
1425        .expect("Valid signature");
1426        Ok(())
1427    }
1428
1429    #[test]
1430    fn test_invalid_signature_value() -> Result<()> {
1431        ensure_initialized();
1432        let err = run_client_sync(
1433            &[],
1434            &[],
1435            VALID_CERTIFICATE,
1436            &[CollectionSignature {
1437                signature: "invalid signature".to_string(),
1438                x5u: "http://mocked".into(),
1439            }],
1440            VALID_CERT_EPOCH_SECONDS,
1441            "main",
1442        )
1443        .unwrap_err();
1444        assert!(matches!(err, Error::SignatureError(_)));
1445        assert_eq!(format!("{}", err), "Signature could not be verified: Signature content error: Encoded text cannot have a 6-bit remainder.");
1446
1447        Ok(())
1448    }
1449
1450    #[test]
1451    fn test_invalid_certificate_value() -> Result<()> {
1452        ensure_initialized();
1453        let err = run_client_sync(
1454            &[],
1455            &[],
1456            "some bad PEM content",
1457            &[CollectionSignature {
1458                signature: VALID_SIGNATURE.to_string(),
1459                x5u: "http://mocked".into(),
1460            }],
1461            VALID_CERT_EPOCH_SECONDS,
1462            "main",
1463        )
1464        .unwrap_err();
1465
1466        assert!(matches!(err, Error::SignatureError(_)));
1467        assert_eq!(
1468            format!("{}", err),
1469            "Signature could not be verified: PEM content format error: Missing PEM data"
1470        );
1471
1472        Ok(())
1473    }
1474
1475    #[test]
1476    fn test_invalid_signature_expired_cert() -> Result<()> {
1477        ensure_initialized();
1478        let december_20_2024 = 1734651582;
1479
1480        let err = run_client_sync(
1481            &[],
1482            &[],
1483            VALID_CERTIFICATE,
1484            &[CollectionSignature {
1485                signature: VALID_SIGNATURE.to_string(),
1486                x5u: "http://mocked".into(),
1487            }],
1488            december_20_2024,
1489            "main",
1490        )
1491        .unwrap_err();
1492
1493        assert!(matches!(err, Error::SignatureError(_)));
1494        assert_eq!(
1495            format!("{}", err),
1496            "Signature could not be verified: Certificate not yet valid or expired"
1497        );
1498
1499        Ok(())
1500    }
1501
1502    #[test]
1503    fn test_invalid_signature_invalid_data() -> Result<()> {
1504        ensure_initialized();
1505        // The signature is valid for an empty list of records.
1506        let records = vec![RemoteSettingsRecord {
1507            id: "unexpected-data".to_string(),
1508            last_modified: 42,
1509            deleted: false,
1510            attachment: None,
1511            fields: serde_json::Map::new(),
1512        }];
1513        let err = run_client_sync(
1514            &records,
1515            &records,
1516            VALID_CERTIFICATE,
1517            &[CollectionSignature {
1518                signature: VALID_SIGNATURE.to_string(),
1519                x5u: "http://mocked".into(),
1520            }],
1521            VALID_CERT_EPOCH_SECONDS,
1522            "main",
1523        )
1524        .unwrap_err();
1525
1526        assert!(matches!(err, Error::SignatureError(_)));
1527        assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
1528
1529        Ok(())
1530    }
1531
1532    #[test]
1533    fn test_invalid_signature_invalid_signer_name() -> Result<()> {
1534        ensure_initialized();
1535        let err = run_client_sync(
1536            &[],
1537            &[],
1538            VALID_CERTIFICATE,
1539            &[CollectionSignature {
1540                signature: VALID_SIGNATURE.to_string(),
1541                x5u: "http://mocked".into(),
1542            }],
1543            VALID_CERT_EPOCH_SECONDS,
1544            "security-state",
1545        )
1546        .unwrap_err();
1547        assert!(matches!(err, Error::SignatureError(_)));
1548        assert_eq!(
1549            format!("{}", err),
1550            "Signature could not be verified: Certificate subject mismatch"
1551        );
1552
1553        Ok(())
1554    }
1555}
1556
1557#[cfg(test)]
1558mod test_reset_storage {
1559    use super::*;
1560
1561    #[test]
1562    fn test_reset_storage_deletes_records_and_attachments() {
1563        let collection_url = "http://rs.example.com/v1/buckets/main/collections/test-collection";
1564
1565        let mut api_client = MockApiClient::new();
1566        api_client
1567            .expect_collection_url()
1568            .returning(|| collection_url.into());
1569        api_client.expect_is_prod_server().returning(|| Ok(false));
1570
1571        let records = vec![RemoteSettingsRecord {
1572            id: "record-0001".into(),
1573            last_modified: 100,
1574            deleted: false,
1575            attachment: Some(Attachment {
1576                filename: "test-file.bin".into(),
1577                mimetype: "application/octet-stream".into(),
1578                location: "attachments/test-file.bin".into(),
1579                hash: "3a6eb0790f39ac87c94f3856b2dd2c5d110e6811602261a9a923d3bb23adc8b7".into(),
1580                size: 4,
1581            }),
1582            fields: serde_json::Map::new(),
1583        }];
1584
1585        let mut storage = Storage::new(":memory:".into());
1586        storage
1587            .insert_collection_content(collection_url, &records, 100, CollectionMetadata::default())
1588            .expect("Failed to insert records");
1589
1590        storage
1591            .set_attachment(collection_url, "attachments/test-file.bin", b"data")
1592            .expect("Failed to insert attachment");
1593
1594        // Verify data is present before reset
1595        assert!(storage.get_records(collection_url).unwrap().is_some());
1596        assert!(storage
1597            .get_attachment(collection_url, records[0].attachment.clone().unwrap())
1598            .unwrap()
1599            .is_some());
1600
1601        let rs_client = RemoteSettingsClient::new_from_parts(
1602            "test-collection".into(),
1603            storage,
1604            JexlFilter::new(None),
1605            api_client,
1606        );
1607
1608        rs_client.reset_storage().expect("Failed to reset storage");
1609
1610        // After reset, both records and attachments should be gone
1611        let mut inner = rs_client.inner.lock();
1612        assert_eq!(
1613            inner.storage.get_records(collection_url).unwrap(),
1614            None,
1615            "Records should be deleted after reset_storage"
1616        );
1617        assert_eq!(
1618            inner
1619                .storage
1620                .get_attachment(collection_url, records[0].attachment.clone().unwrap(),)
1621                .unwrap(),
1622            None,
1623            "Attachments should be deleted after reset_storage"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_reset_storage_reverts_to_packaged_data() {
1629        let collection_url = "http://rs.example.com/v1/buckets/main/collections/regions";
1630
1631        let mut api_client = MockApiClient::new();
1632        api_client
1633            .expect_collection_url()
1634            .returning(|| collection_url.into());
1635        // Must be prod for reset_storage to restore packaged data
1636        api_client.expect_is_prod_server().returning(|| Ok(true));
1637
1638        let synced_records = vec![RemoteSettingsRecord {
1639            id: "custom-synced-record".into(),
1640            last_modified: 99999,
1641            deleted: false,
1642            attachment: None,
1643            fields: serde_json::json!({"key": "synced-value"})
1644                .as_object()
1645                .unwrap()
1646                .clone(),
1647        }];
1648
1649        let mut storage = Storage::new(":memory:".into());
1650        storage
1651            .insert_collection_content(
1652                collection_url,
1653                &synced_records,
1654                99999,
1655                CollectionMetadata::default(),
1656            )
1657            .expect("Failed to insert synced records");
1658
1659        // Verify synced data is present
1660        let records_before = storage.get_records(collection_url).unwrap().unwrap();
1661        assert_eq!(records_before[0].id, "custom-synced-record");
1662
1663        let rs_client = RemoteSettingsClient::new_from_parts(
1664            "regions".into(),
1665            storage,
1666            JexlFilter::new(None),
1667            api_client,
1668        );
1669
1670        rs_client.reset_storage().expect("Failed to reset storage");
1671
1672        let mut inner = rs_client.inner.lock();
1673        let records = inner.storage.get_records(collection_url).unwrap();
1674        assert!(
1675            records.is_some(),
1676            "Packaged data should be restored after reset_storage on prod"
1677        );
1678        let records = records.unwrap();
1679        assert!(
1680            !records.is_empty(),
1681            "Packaged regions data should not be empty"
1682        );
1683        assert!(
1684            !records.iter().any(|r| r.id == "custom-synced-record"),
1685            "Synced data should be replaced by packaged data after reset"
1686        );
1687    }
1688}