/* This Source Code Form is subject to the terms of the Mozilla Public *License,v.2.0.IfacopyoftheMPLwasnotdistributedwiththis
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
usecrate::config::RemoteSettingsConfig; usecrate::error::{Error, Result}; #[cfg(feature = "jexl")] usecrate::jexl_filter::JexlFilter; #[cfg(feature = "signatures")] usecrate::signatures; usecrate::storage::Storage; #[cfg(feature = "jexl")] usecrate::RemoteSettingsContext; usecrate::{
packaged_attachments, packaged_collections, RemoteSettingsServer, UniffiCustomTypeConverter,
}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{
borrow::Cow,
time::{Duration, Instant},
}; use url::Url; use viaduct::{Request, Response};
#[cfg(feature = "signatures")] #[cfg(not(test))] use std::time::{SystemTime, UNIX_EPOCH};
#[cfg(feature = "signatures")] #[cfg(not(test))] fn epoch_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap() // Time won't go backwards.
.as_secs()
}
/// Hard-coded SHA256 of our root certificates. This is used by rc_crypto/pkixc to verify that the /// certificates chains used in content signatures verification were produced from our root certificate. /// See https://bugzilla.mozilla.org/show_bug.cgi?id=1940903 to align with desktop implementation. #[cfg(feature = "signatures")] const 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"; #[cfg(feature = "signatures")] const 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";
/// Internal Remote settings client API /// /// This stores an ApiClient implementation. In the real-world, this is always ViaductApiClient, /// but the tests use a mock client. pubstruct RemoteSettingsClient<C = ViaductApiClient> { // This is immutable, so it can be outside the mutex
collection_name: String, #[cfg(feature = "jexl")]
jexl_filter: JexlFilter,
inner: Mutex<RemoteSettingsClientInner<C>>,
}
// Add your local packaged data you want to work with here impl<C: ApiClient> RemoteSettingsClient<C> { // One line per bucket + collection
packaged_collections! {
("main", "search-telemetry-v2"),
("main", "regions"),
}
// You have to specify // - bucket + collection_name: ("main", "regions") // - One line per file you want to add (e.g. "world") // // This will automatically also include the NAME.meta.json file // for internal validation against hash and size // // The entries line up with the `Attachment::filename` field, // and check for the folder + name in // `remote_settings/dumps/{bucket}/attachments/{collection}/{filename}
packaged_attachments! {
("main", "regions") => [ "world", "world-buffered",
],
}
}
fn load_packaged_data(&self) -> Option<CollectionData> { // Using the macro generated `get_packaged_data` in macros.rs Self::get_packaged_data(&self.collection_name)
.and_then(|data| serde_json::from_str(data).ok())
}
fn load_packaged_attachment(&self, filename: &str) -> Option<(&n style='color:blue'>'static [u8], &'static str)> { // Using the macro generated `get_packaged_attachment` in macros.rs Self::get_packaged_attachment(&self.collection_name, filename)
}
/// Filters records based on the presence and evaluation of `filter_expression`. #[cfg(feature = "jexl")] fn filter_records(&self, records: Vec<RemoteSettingsRecord>) -> Vec<RemoteSettingsRecord> {
records
.into_iter()
.filter(|record| match record.fields.get("filter_expression") {
Some(serde_json::Value::String(filter_expr)) => { self.jexl_filter.evaluate(filter_expr).unwrap_or(false)
}
_ => true, // Include records without a valid filter expression by default
})
.collect()
}
/// Get the current set of records. /// /// If records are not present in storage this will normally return None. Use `sync_if_empty = /// true` to change this behavior and perform a network request in this case. pubfn get_records(&self, sync_if_empty: bool) -> Result<Option<Vec<RemoteSettingsRecord>>> { letmut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url(); let is_prod = inner.api_client.is_prod_server()?; let packaged_data = if is_prod { self.load_packaged_data()
} else {
None
};
// Case 1: The packaged data is more recent than the cache // // This happens when there's no cached data or when we get new packaged data because of a // product update iflet Some(packaged_data) = packaged_data { let cached_timestamp = inner
.storage
.get_last_modified_timestamp(&collection_url)?
.unwrap_or(0); if packaged_data.timestamp > cached_timestamp { // Remove previously cached data (packaged data does not have tombstones like diff responses do).
inner.storage.empty()?; // Insert new packaged data.
inner.storage.insert_collection_content(
&collection_url,
&packaged_data.data,
packaged_data.timestamp,
CollectionMetadata::default(),
)?; return Ok(Some(self.filter_records(packaged_data.data)));
}
}
let cached_records = inner.storage.get_records(&collection_url)?;
Ok(match (cached_records, sync_if_empty) { // Case 2: We have cached records // // Note: we should return these even if it's an empty list and `sync_if_empty=true`. // The "if empty" part refers to the cache being empty, not the list.
(Some(cached_records), _) => Some(self.filter_records(cached_records)), // Case 3: sync_if_empty=true
(None, true) => { let changeset = inner.api_client.fetch_changeset(None)?;
inner.storage.insert_collection_content(
&collection_url,
&changeset.changes,
changeset.timestamp,
changeset.metadata,
)?;
Some(self.filter_records(changeset.changes))
} // Case 4: Nothing to return
(None, false) => None,
})
}
/// Synchronizes the local collection with the remote server by performing the following steps: /// 1. Fetches the last modified timestamp of the collection from local storage. /// 2. Fetches the changeset from the remote server based on the last modified timestamp. /// 3. Inserts the fetched changeset into local storage. fn perform_sync_operation(&self) -> Result<()> { letmut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url(); let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?; let changeset = inner.api_client.fetch_changeset(timestamp)?;
log::debug!( "{0}: apply {1} change(s) locally.", self.collection_name,
changeset.changes.len()
);
inner.storage.insert_collection_content(
&collection_url,
&changeset.changes,
changeset.timestamp,
changeset.metadata,
)
}
pubfn sync(&self) -> Result<()> { // First attempt self.perform_sync_operation()?; // Verify that inserted data has valid signature ifself.verify_signature().is_err() {
log::debug!( "{0}: signature verification failed. Reset and retry.", self.collection_name
); // Retry with packaged dataset as base self.reset_storage()?; self.perform_sync_operation()?; // Verify signature again self.verify_signature().inspect_err(|_| { // And reset with packaged data if it fails again. self.reset_storage()
.expect("Failed to reset storage after verification failure");
})?;
}
log::trace!("{0}: sync done.", self.collection_name);
Ok(())
}
fn reset_storage(&self) -> Result<()> {
log::trace!("{0}: reset local storage.", self.collection_name); letmut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url(); // Clear existing storage
inner.storage.empty()?; // Load packaged data only for production if inner.api_client.is_prod_server()? { iflet Some(packaged_data) = self.load_packaged_data() {
log::trace!("{0}: restore packaged dump.", self.collection_name);
inner.storage.insert_collection_content(
&collection_url,
&packaged_data.data,
packaged_data.timestamp,
CollectionMetadata::default(),
)?;
}
}
Ok(())
}
#[cfg(feature = "signatures")] fn verify_signature(&self) -> Result<()> { letmut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url(); let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?; let records = inner.storage.get_records(&collection_url)?; let metadata = inner.storage.get_collection_metadata(&collection_url)?; match (timestamp, &records, metadata) {
(Some(timestamp), Some(records), Some(metadata)) => { let cert_chain_bytes = inner.api_client.fetch_cert(&metadata.signature.x5u)?; // rc_crypto verifies that the provided certificates chain leads to our root certificate. let expected_root_hash = if inner.api_client.is_prod_server()? {
ROOT_CERT_SHA256_HASH_PROD
} else {
ROOT_CERT_SHA256_HASH_NONPROD
};
// The signer name is hard-coded. This would have to be modified in the very (very) // unlikely situation where we would add a new collection signer. // And clients code would have to be modified to handle this new collection anyway. // https://searchfox.org/mozilla-central/rev/df850fa290fe962c2c5ae8b63d0943ce768e3cc4/services/settings/remote-settings.sys.mjs#40-48 let expected_leaf_cname = format!( "{}.content-signature.mozilla.org", if metadata.bucket.contains("security-state") { "onecrl"
} else { "remote-settings"
}
);
signatures::verify_signature(
timestamp,
records,
metadata.signature.signature.as_bytes(),
&cert_chain_bytes,
epoch_seconds(),
expected_root_hash,
&expected_leaf_cname,
)
.inspect_err(|err| {
log::debug!( "{0}: bad signature ({1:?}) using certificate {2} and signer '{3}'", self.collection_name,
err,
&metadata.signature.x5u,
expected_leaf_cname
);
})?;
log::trace!("{0}: signature verification success.", self.collection_name);
Ok(())
}
_ => { let missing_field = if timestamp.is_none() { "timestamp"
} elseif records.is_none() { "records"
} else { "metadata"
};
Err(Error::IncompleteSignatureDataError(missing_field.into()))
}
}
}
/// Downloads an attachment from [attachment_location]. NOTE: there are no guarantees about a /// maximum size, so use care when fetching potentially large attachments. pubfn get_attachment(&self, record: RemoteSettingsRecord) -> Result<Vec<u8>> { let metadata = record
.attachment
.ok_or_else(|| Error::RecordAttachmentMismatchError("No attachment metadata".into()))?;
letmut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url();
// First try storage - it will only return data that matches our metadata iflet Some(data) = inner
.storage
.get_attachment(&collection_url, metadata.clone())?
{ return Ok(data);
}
// Then try packaged data if we're in prod if inner.api_client.is_prod_server()? { iflet Some((data, manifest)) = self.load_packaged_attachment(&metadata.location) { iflet Ok(manifest_data) = serde_json::from_str::<serde_json::Value>(manifest) { if metadata.hash == manifest_data["hash"].as_str().unwrap_or_default()
&& metadata.size == manifest_data["size"].as_u64().unwrap_or_default()
{ // Store valid packaged data in storage because it was either empty or outdated
inner
.storage
.set_attachment(&collection_url, &metadata.location, data)?; return Ok(data.to_vec());
}
}
}
}
// Try to download the attachment because neither the storage nor the local data had it let attachment = inner.api_client.fetch_attachment(&metadata.location)?;
// Verify downloaded data if attachment.len() as u64 != metadata.size { return Err(Error::RecordAttachmentMismatchError( "Downloaded attachment size mismatch".into(),
));
} let hash = format!("{:x}", Sha256::digest(&attachment)); if hash != metadata.hash { return Err(Error::RecordAttachmentMismatchError( "Downloaded attachment hash mismatch".into(),
));
}
// Store verified download in storage
inner
.storage
.set_attachment(&collection_url, &metadata.location, &attachment)?;
Ok(attachment)
}
}
#[cfg_attr(test, mockall::automock)] pubtrait ApiClient { /// Get the Bucket URL for this client. /// /// This is a URL that includes the server URL, bucket name, and collection name. This is used /// to check if the application has switched the remote settings config and therefore we should /// throw away any cached data /// /// Returns it as a String, since that's what the storage expects fn collection_url(&self) -> String;
/// Fetch records from the server fn fetch_changeset(&mutself, timestamp: Option<u64>) -> Result<ChangesetResponse>;
/// Fetch an attachment from the server fn fetch_attachment(&mutself, attachment_location: &str) -> Result<Vec<u8>>;
/// Fetch a server certificate fn fetch_cert(&mutself, x5u: &str) -> Result<Vec<u8>>;
/// Check if this client is pointing to the production server fn is_prod_server(&self) -> Result<bool>;
}
/// Client for Remote settings API requests pubstruct ViaductApiClient {
endpoints: RemoteSettingsEndpoints,
remote_state: RemoteState,
}
fn handle_backoff_hint(&mutself, response: &Response) -> Result<()> { let extract_backoff_header = |header| -> Result<u64> {
Ok(response
.headers
.get_as::<u64, _>(header)
.transpose()
.unwrap_or_default() // Ignore number parsing errors.
.unwrap_or(0))
}; // In practice these two headers are mutually exclusive. let backoff = extract_backoff_header(HEADER_BACKOFF)?; let retry_after = extract_backoff_header(HEADER_RETRY_AFTER)?; let max_backoff = backoff.max(retry_after);
fn fetch_changeset(&mutself, timestamp: Option<u64>) -> Result<ChangesetResponse> { letmut url = self.endpoints.changeset_url.clone(); // 0 is used as an arbitrary value for `_expected` because the current implementation does // not leverage push timestamps or polling from the monitor/changes endpoint. More // details: // // https://remote-settings.readthedocs.io/en/latest/client-specifications.html#cache-busting
url.query_pairs_mut().append_pair("_expected", "0"); iflet Some(timestamp) = timestamp {
url.query_pairs_mut()
.append_pair("_since", &format!("\"{}\"", timestamp));
}
/// A simple HTTP client that can retrieve Remote Settings data using the properties by [ClientConfig]. /// Methods defined on this will fetch data from /// <base_url>/buckets/<bucket_name>/collections/<collection_name>/ pubstruct Client {
endpoints: RemoteSettingsEndpoints, pub(crate) remote_state: Mutex<RemoteState>,
}
impl Client { /// Create a new [Client] with properties matching config. pubfn new(config: RemoteSettingsConfig) -> Result<Self> { let server = match (config.server, config.server_url) {
(Some(server), None) => server,
(None, Some(server_url)) => RemoteSettingsServer::Custom { url: server_url },
(None, None) => RemoteSettingsServer::Prod,
(Some(_), Some(_)) => Err(Error::ConfigError( "`RemoteSettingsConfig` takes either `server` or `server_url`, not both".into(),
))?,
};
let bucket_name = config.bucket_name.unwrap_or_else(|| String::from("main")); let endpoints = RemoteSettingsEndpoints::new(
&server.get_url()?,
&bucket_name,
&config.collection_name,
)?;
/// Fetches all records for a collection that can be found in the server, /// bucket, and collection defined by the [ClientConfig] used to generate /// this [Client]. pubfn get_records(&self) -> Result<RemoteSettingsResponse> { self.get_records_with_options(&GetItemsOptions::new())
}
/// Fetches all records for a collection that can be found in the server, /// bucket, and collection defined by the [ClientConfig] used to generate /// this [Client]. This function will return the raw network [Response]. pubfn get_records_raw(&self) -> Result<Response> { self.get_records_raw_with_options(&GetItemsOptions::new())
}
/// Fetches all records that have been published since provided timestamp /// for a collection that can be found in the server, bucket, and /// collection defined by the [ClientConfig] used to generate this [Client]. pubfn get_records_since(&self, timestamp: u64) -> Result<RemoteSettingsResponse> { self.get_records_with_options(
GetItemsOptions::new().filter_gt("last_modified", timestamp.to_string()),
)
}
/// Fetches records from this client's collection with the given options. pubfn get_records_with_options(
&self,
options: &GetItemsOptions,
) -> Result<RemoteSettingsResponse> { let resp = self.get_records_raw_with_options(options)?; let records = resp.json::<RecordsResponse>()?.data; let etag = resp
.headers
.get(HEADER_ETAG)
.ok_or_else(|| Error::ResponseError("no etag header".into()))?; // Per https://docs.kinto-storage.org/en/stable/api/1.x/timestamps.html, // the `ETag` header value is a quoted integer. Trim the quotes before // parsing. let last_modified = etag.trim_matches('"').parse().map_err(|_| {
Error::ResponseError(format!( "expected quoted integer in etag header; got `{}`",
etag
))
})?;
Ok(RemoteSettingsResponse {
records,
last_modified,
})
}
/// Fetches a raw network [Response] for records from this client's /// collection with the given options. pubfn get_records_raw_with_options(&self, options: &GetItemsOptions) -> Result<Response> { letmut url = self.endpoints.records_url.clone(); for (name, value) in options.iter_query_pairs() {
url.query_pairs_mut().append_pair(&name, &value);
} self.make_request(url)
}
/// Downloads an attachment from [attachment_location]. NOTE: there are no /// guarantees about a maximum size, so use care when fetching potentially /// large attachments. pubfn get_attachment(&self, attachment_location: &str) -> Result<Vec<u8>> {
Ok(self.get_attachment_raw(attachment_location)?.body)
}
/// Fetches a raw network [Response] for an attachment. pubfn get_attachment_raw(&self, attachment_location: &str) -> Result<Response> { // Important: We use a `let` binding here to ensure that the mutex is // unlocked immediately after cloning the URL. If we matched directly on // the `.lock()` expression, the mutex would stay locked until the end // of the `match`, causing a deadlock. let maybe_attachments_base_url = self.remote_state.lock().attachments_base_url.clone();
let attachments_base_url = match maybe_attachments_base_url {
Some(attachments_base_url) => attachments_base_url,
None => { let server_info = self
.make_request(self.endpoints.root_url.clone())?
.json::<ServerInfo>()?; let attachments_base_url = match server_info.capabilities.attachments {
Some(capability) => Url::parse(&capability.base_url)?,
None => Err(Error::AttachmentsUnsupportedError)?,
}; self.remote_state.lock().attachments_base_url = Some(attachments_base_url.clone());
attachments_base_url
}
};
/// Stores all the endpoints for a Remote Settings server /// /// There's actually not to many of these, so we can just pack them all into a struct struct RemoteSettingsEndpoints { /// Root URL for Remote Settings server /// /// This has the form `[base-url]/`. It's where we get the attachment base url from.
root_url: Url, /// URL for the collections endpoint /// /// This has the form: /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]`. /// /// It can be used to fetch some metadata about the collection, but the real reason we use it /// is to get a URL that uniquely identifies the server + bucket name. This is used by the /// [Storage] component to know when to throw away cached records because the user has changed /// one of these,
collection_url: Url, /// URL for the changeset request /// /// This has the form: /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]/changeset`. /// /// This is the URL for fetching records and changes to records
changeset_url: Url, /// URL for the records request /// /// This has the form: /// `[base-url]/buckets/[bucket-name]/collections/[collection-name]/records`. /// /// This is the old/deprecated way to get records
records_url: Url,
}
impl RemoteSettingsEndpoints { /// Construct a new RemoteSettingsEndpoints /// /// `base_url` should have the form `https://[domain]/v1` (no trailing slash). fn new(base_url: &Url, bucket_name: &str, collection_name: &str) -> Result<Self> { letmut root_url = base_url.clone(); // Push the empty string to add the trailing slash. Self::path_segments_mut(&mut root_url)?.push("");
/// Utility method for calling [Url::path_segments_mut] /// /// The issue we're working around is that path_segments_mut uses `()` as the error type, which /// can't be converted into our `Error` type. fn path_segments_mut(url: &mut Url) -> Result<url::PathSegmentsMut<'_>> {
url.path_segments_mut() // path_segments_mut uses `()` as the error type, but the docs say that it only will // error for cannot-be-a-base URLs.
.map_err(|_| Error::UrlParsingError(url::ParseError::RelativeUrlWithCannotBeABaseBase))
}
}
/// Data structure representing the top-level response from the Remote Settings. /// [last_modified] will be extracted from the etag header of the response. #[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize, uniffi::Record)] pubstruct RemoteSettingsResponse { pub records: Vec<RemoteSettingsRecord>, pub last_modified: u64,
}
/// A parsed Remote Settings record. Records can contain arbitrary fields, so clients /// are required to further extract expected values from the [fields] member. #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)] pubstruct RemoteSettingsRecord { pub id: String, pub last_modified: u64, /// Tombstone flag (see https://remote-settings.readthedocs.io/en/latest/client-specifications.html#local-state) #[serde(default)] pub deleted: bool, pub attachment: Option<Attachment>, #[serde(flatten)] pub fields: RsJsonObject,
}
/// Attachment metadata that can be optionally attached to a [Record]. The [location] should /// included in calls to [Client::get_attachment]. #[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq, uniffi::Record)] pubstruct Attachment { pub filename: String, pub mimetype: String, pub location: String, pub hash: String, pub size: u64,
}
// Define a UniFFI custom types to pass JSON objects across the FFI as a string // // This is named `RsJsonObject` because, UniFFI cannot currently rename iOS bindings and JsonObject // conflicted with the declaration in Nimbus. This shouldn't really impact Android, since the type // is converted into the platform JsonObject thanks to the UniFFI binding. pubtype RsJsonObject = serde_json::Map<String, serde_json::Value>;
uniffi::custom_type!(RsJsonObject, String);
impl UniffiCustomTypeConverter for RsJsonObject { type Builtin = String; fn into_custom(val: Self::Builtin) -> uniffi::Result<Self> { let json: serde_json::Value = serde_json::from_str(&val)?;
match json {
serde_json::Value::Object(obj) => Ok(obj),
_ => Err(uniffi::deps::anyhow::anyhow!( "Unexpected JSON-non-object in the bagging area"
)),
}
}
/// Sets an option to only return items whose `field` is equal to the given /// `value`. /// /// `field` can be a simple or dotted field name, like `author` or /// `author.name`. `value` can be a bare number or string (like /// `2` or `Ben`), or a stringified JSON value (`"2.0"`, `[1, 2]`, /// `{"checked": true}`). pubfn filter_eq(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Eq(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is not equal to the /// given `value`. pubfn filter_not(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Not(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is an array that /// contains the given `value`. If `value` is a stringified JSON array, the /// field must contain all its elements. pubfn filter_contains(
&mutself,
field: impl Into<String>,
value: impl Into<String>,
) -> &mutSelf { self.filters
.push(Filter::Contains(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is strictly less /// than the given `value`. pubfn filter_lt(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Lt(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is strictly greater /// than the given `value`. pubfn filter_gt(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Gt(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is less than or equal /// to the given `value`. pubfn filter_max(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Max(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is greater than or /// equal to the given `value`. pubfn filter_min(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Min(field.into(), value.into())); self
}
/// Sets an option to only return items whose `field` is a string that /// contains the substring `value`. `value` can contain `*` wildcards. pubfn filter_like(&mutself, field: impl Into<String>, value: impl Into<String>) -> &mutSelf { self.filters.push(Filter::Like(field.into(), value.into())); self
}
/// Sets an option to only return items that have the given `field`. pubfn filter_has(&mutself, field: impl Into<String>) -> &'color:red'>mutSelf { self.filters.push(Filter::Has(field.into())); self
}
/// Sets an option to only return items that do not have the given `field`. pubfn filter_has_not(&mutself, field: impl Into<String>) -> &yle='color:red'>mutSelf { self.filters.push(Filter::HasNot(field.into())); self
}
/// Sets an option to return items in `order` for the given `field`. pubfn sort(&mutself, field: impl Into<String>, order: SortOrder) -> &pan style='color:red'>mut Self { self.sort.push(Sort(field.into(), order)); self
}
/// Sets an option to only return the given `field` of each item. /// /// The special `id` and `last_modified` fields are always returned. pubfn field(&mutself, field: impl Into<String>) -> &mutSelf { self.fields.push(field.into()); self
}
/// Sets the option to return at most `count` items. pubfn limit(&mutself, count: u64) -> &mutSelf { self.limit = Some(count); self
}
/// Returns an iterator of (name, value) query pairs for these options. pubfn iter_query_pairs(&self) -> impl Iterator<Item = (Cow<str>, Cow<str>)> { self.filters
.iter()
.map(Filter::as_query_pair)
.chain({ // For sorting (https://docs.kinto-storage.org/en/latest/api/1.x/sorting.html), // the query pair syntax is `_sort=field1,-field2`, where the // fields to sort by are specified in a comma-separated ordered // list, and `-` indicates descending order.
(!self.sort.is_empty()).then(|| {
( "_sort".into(),
(self
.sort
.iter()
.map(Sort::as_query_value)
.collect::<Vec<_>>()
.join(","))
.into(),
)
})
})
.chain({ // For selecting fields (https://docs.kinto-storage.org/en/latest/api/1.x/selecting_fields.html), // the query pair syntax is `_fields=field1,field2`.
(!self.fields.is_empty()).then(|| ("_fields".into(), self.fields.join(",").into()))
})
.chain({ // For pagination (https://docs.kinto-storage.org/en/latest/api/1.x/pagination.html), // the query pair syntax is `_limit={count}`. self.limit
.map(|count| ("_limit".into(), count.to_string().into()))
})
}
}
/// The order in which to return items. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pubenum SortOrder { /// Smaller values first.
Ascending, /// Larger values first.
Descending,
}
let client = Client::new(config).unwrap(); let first_resp = client.get_attachment(attachment_location).unwrap(); let second_resp = client.get_attachment(attachment_location).unwrap();
// Note, don't make any api_client.expect_*() calls, the RemoteSettingsClient should not // attempt to make any requests for this scenario let storage = Storage::new(":memory:".into()).expect("Error creating storage");
// First get the packaged data to know its timestamp let rs_client =
RemoteSettingsClient::new_from_parts("search-telemetry-v2".into(), storage, api_client); let packaged_data = rs_client
.load_packaged_data()
.expect("Packaged data should exist");
let rs_client =
RemoteSettingsClient::new_from_parts("search-telemetry-v2".into(), storage, api_client);
let records = rs_client.get_records(false)?;
assert!(records.is_some()); let records = records.unwrap();
assert!(!records.is_empty());
// Verify the new records replaced old ones letmut inner = rs_client.inner.lock(); let cached = inner.storage.get_records(collection_url)?.unwrap();
assert!(cached[0].last_modified > old_record.last_modified);
assert_eq!(cached.len(), packaged_data.data.len());
Ok(())
}
#[test] fn test_no_cached_data_no_packaged_data_sync_if_empty_true() -> Result<()> { let collection_name = "nonexistent-collection"; // A collection without packaged data
// Verify the packaged data file does not exist let file_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("dumps")
.join("main")
.join(format!("{}.json", collection_name));
assert!(
!file_path.exists(), "Packaged data should not exist for this test"
);
letmut api_client = MockApiClient::new(); let storage = Storage::new(":memory:".into())?;
let rs_client =
RemoteSettingsClient::new_from_parts(collection_name.to_string(), storage, api_client);
// Call get_records with sync_if_empty = true let records = rs_client.get_records(true)?;
assert!(
records.is_some(), "Records should be fetched from the remote server"
); let records = records.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "remote");
Ok(())
}
#[test] fn test_no_cached_data_no_packaged_data_sync_if_empty_false() -> Result<()> { let collection_name = "nonexistent-collection"; // A collection without packaged data
// Verify the packaged data file does not exist let file_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("dumps")
.join("main")
.join(format!("{}.json", collection_name));
assert!(
!file_path.exists(), "Packaged data should not exist for this test"
);
letmut api_client = MockApiClient::new(); let storage = Storage::new(":memory:".into())?;
// Since sync_if_empty is false, get_records should not be called // No need to set expectation for api_client.fetch_changeset
let rs_client =
RemoteSettingsClient::new_from_parts(collection_name.to_string(), storage, api_client);
// Call get_records with sync_if_empty = false let records = rs_client.get_records(false)?;
assert!(
records.is_none(), "Records should be None when no cache, no packaged data, and sync_if_empty is false"
);
let rs_client =
RemoteSettingsClient::new_from_parts(collection_name.to_string(), storage, api_client);
// Call get_records with any sync_if_empty value let records = rs_client.get_records(true)?;
assert!(
records.is_some(), "Records should be returned from the cached data"
); let records = records.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "cached1");
// Set up empty cached records let cached_records: Vec<RemoteSettingsRecord> = vec![];
storage.insert_collection_content(
&collection_url,
&cached_records, 42,
CollectionMetadata::default(),
)?;
let rs_client =
RemoteSettingsClient::new_from_parts(collection_name.to_string(), storage, api_client);
// Call get_records with sync_if_empty = false let records = rs_client.get_records(false)?;
assert!(records.is_some(), "Empty cached records should be returned"); let records = records.unwrap();
assert!(records.is_empty(), "Cached records should be empty");
Ok(())
}
}
#[cfg(not(feature = "jexl"))] #[cfg(test)] mod test_packaged_metadata { usesuper::*; use std::path::PathBuf;
#[test] fn test_no_cached_data_use_packaged_attachment() -> Result<()> { let collection_name = "regions"; let attachment_name = "world";
// Verify our packaged attachment exists with its manifest let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("dumps")
.join("main")
.join("attachments")
.join(collection_name);
let file_path = base_path.join(attachment_name); let manifest_path = base_path.join(format!("{}.meta.json", attachment_name));
assert!(
file_path.exists(), "Packaged attachment should exist for this test"
);
assert!(
manifest_path.exists(), "Manifest file should exist for this test"
);
let manifest_content = std::fs::read_to_string(manifest_path)?; let manifest: serde_json::Value = serde_json::from_str(&manifest_content)?;
letmut api_client = MockApiClient::new(); let storage = Storage::new(":memory:".into())?;
let rs_client =
RemoteSettingsClient::new_from_parts(collection_name.to_string(), storage, api_client);
let record = RemoteSettingsRecord {
id: "test-record".to_string(),
last_modified: 12345,
deleted: false,
attachment: Some(attachment_metadata),
fields: serde_json::json!({}).as_object().unwrap().clone(),
};
let attachment_data = rs_client.get_attachment(record)?;
// Verify we got the mock API data, not the packaged data
assert_eq!(attachment_data, vec![1, 2, 3, 4, 5]);
Ok(())
}
}
#[cfg(feature = "signatures")] #[cfg(feature = "jexl")] // Assuming tests are run with `--all-features` #[cfg(test)] mod test_signatures { use core::assert_eq;
assert!(matches!(err, Error::SignatureError(_)));
assert_eq!(
format!("{}", err), "Signature could not be verified: PEM content format error: Missing PEM data"
);
Ok(())
}
#[test] fn test_invalid_signature_expired_cert() -> Result<()> { let december_20_2024 = 1734651582;
assert!(matches!(err, Error::SignatureError(_)));
assert_eq!(
format!("{}", err), "Signature could not be verified: Certificate not yet valid or expired"
);
Ok(())
}
#[test] fn test_invalid_signature_invalid_data() -> Result<()> { // The signature is valid for an empty list of records. let records = vec![RemoteSettingsRecord {
id: "unexpected-data".to_string(),
last_modified: 42,
deleted: false,
attachment: None,
fields: serde_json::Map::new(),
}]; let err = run_client_sync(
&records,
&records,
VALID_CERTIFICATE,
VALID_SIGNATURE,
VALID_CERT_EPOCH_SECONDS, "main",
)
.unwrap_err();
assert!(matches!(err, Error::SignatureError(_)));
assert_eq!(format!("{}", err), "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182 ");
Ok(())
}
#[test] fn test_invalid_signature_invalid_signer_name() -> Result<()> { let err = run_client_sync(
&[],
&[],
VALID_CERTIFICATE,
VALID_SIGNATURE,
VALID_CERT_EPOCH_SECONDS, "security-state",
)
.unwrap_err();
assert!(matches!(err, Error::SignatureError(_)));
assert_eq!(
format!("{}", err), "Signature could not be verified: Certificate subject mismatch"
);
Ok(())
}
}
Messung V0.5 in Prozent
¤ Dauer der Verarbeitung: 0.40 Sekunden
(vorverarbeitet am 2026-06-20)
¤
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.