diff --git a/.buildconfig-android.yml b/.buildconfig-android.yml index 98aed1a0628..aee8364bc84 100644 --- a/.buildconfig-android.yml +++ b/.buildconfig-android.yml @@ -14,6 +14,13 @@ projects: - name: autofill type: aar description: Addresses and Credit Cards autofill. + containers: + path: components/containers/android + artifactId: containers + publications: + - name: containers + type: aar + description: Storage for Firefox containers. crashtest: path: components/crashtest/android artifactId: crashtest diff --git a/CHANGELOG.md b/CHANGELOG.md index 4409cda0adf..5523e337458 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ ## ✨ What's Changed ✨ +### Containers + +- Created a new component, `containers`, holding the list of Firefox containers and the format they are stored in. +- The component also stores the containers an enterprise policy owns: `create_for_policy()`, `policy_identities()`, `policy_identity()` and `remove_policy_identity()`. Which URLs load in one of them stays with the embedder. + ### Autofill - `update_address()` now sets `time_last_modified` to the time of the update, matching `update_credit_card()` and `update_passport()`. diff --git a/Cargo.lock b/Cargo.lock index 9ed004920a3..d535ecbc60d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -746,6 +746,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "containers" +version = "0.1.0" +dependencies = [ + "error-support", + "idna", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.20", + "uniffi", +] + [[package]] name = "context_id" version = "0.1.0" @@ -2740,6 +2753,7 @@ version = "0.1.0" dependencies = [ "ads-client", "autofill", + "containers", "crashtest", "error-support", "fxa-client", diff --git a/Cargo.toml b/Cargo.toml index e3e27dca1bd..07b9dbfa9fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "components/as-ohttp-client", "components/autofill", "components/breach-alerts", + "components/containers", "components/context_id", "components/crashtest", "components/example", @@ -109,6 +110,7 @@ default-members = [ "components/as-ohttp-client", "components/autofill", "components/breach-alerts", + "components/containers", "components/context_id", "components/crashtest", "components/fxa-client", diff --git a/components/containers/Cargo.toml b/components/containers/Cargo.toml new file mode 100644 index 00000000000..1a119fe770f --- /dev/null +++ b/components/containers/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "containers" +description = "Storage for Firefox containers" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[dependencies] +error-support = { path = "../support/error" } +idna = "1" +parking_lot = "0.12" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +uniffi = { version = "0.32" } diff --git a/components/containers/README.md b/components/containers/README.md new file mode 100644 index 00000000000..5ade07aeae1 --- /dev/null +++ b/components/containers/README.md @@ -0,0 +1,28 @@ +# Containers + +Storage for Firefox containers. + +The component never touches the filesystem. `ContainersStore` hands the +serialized document to a callback, and the embedder decides where it goes and +how durably. + +It owns the list, not the behaviour. Everything that gives a container its +meaning stays outside: + +- the origin attributes that isolate its cookies and storage +- clearing that storage when a container is removed +- resolving the localized labels of the shipped containers +- closing the tabs that belong to one +- deciding which site loads in which container, enterprise policy included + +## Tests + +Tests are run with + +```shell +cargo test -p containers +``` + +## Bugs + +We use Bugzilla to track bugs and feature work. You can use [this link](https://bugzilla.mozilla.org/enter_bug.cgi?product=Firefox&component=Containers) to file bugs in the `Firefox :: Containers` bug component. diff --git a/components/containers/android/build.gradle b/components/containers/android/build.gradle new file mode 100644 index 00000000000..3727d2f78da --- /dev/null +++ b/components/containers/android/build.gradle @@ -0,0 +1,10 @@ +apply from: "$appServicesRootDir/build-scripts/component-common.gradle" +apply from: "$publishDir/publish.gradle" + +android { + namespace 'org.mozilla.appservices.containers' +} + +ext.configureUniFFIBindgen("containers") +ext.dependsOnTheMegazord() +ext.configurePublish(appServicesGroupId, project.name, project.ext.description) diff --git a/components/containers/android/proguard-rules.pro b/components/containers/android/proguard-rules.pro new file mode 100644 index 00000000000..cf504086aa2 --- /dev/null +++ b/components/containers/android/proguard-rules.pro @@ -0,0 +1,22 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + diff --git a/components/containers/android/src/main/AndroidManifest.xml b/components/containers/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..269e22f05a4 --- /dev/null +++ b/components/containers/android/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + diff --git a/components/containers/src/container.rs b/components/containers/src/container.rs new file mode 100644 index 00000000000..a7f98b77723 --- /dev/null +++ b/components/containers/src/container.rs @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::data::Identity; +use crate::definitions::{self, ContainerColor, ContainerIcon, ContainerLabel}; + +/// A container as the embedder sees it. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct Container { + pub user_context_id: u32, + pub is_public: bool, + pub icon: Option, + pub color: Option, + pub label: ContainerLabel, + pub policy_id: Option, +} + +impl Container { + /// `default_label` is the label of the default identity that owns this + /// container's id, if any. It labels a default container the user has not + /// renamed, since the label of a default is not stored. + pub(crate) fn from_identity( + identity: &Identity, + default_label: Option<&ContainerLabel>, + ) -> Self { + Self { + user_context_id: identity.user_context_id, + is_public: identity.public, + icon: definitions::icon_from_name(&identity.icon), + color: definitions::color_from_name(&identity.color), + policy_id: identity + .policy + .then(|| identity.policy_id.clone()) + .flatten(), + label: match &identity.name { + Some(name) if !name.is_empty() => ContainerLabel::Name { name: name.clone() }, + _ => default_label.cloned().unwrap_or(ContainerLabel::Name { + name: String::new(), + }), + }, + } + } +} diff --git a/components/containers/src/data.rs b/components/containers/src/data.rs new file mode 100644 index 00000000000..18de789d069 --- /dev/null +++ b/components/containers/src/data.rs @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub(crate) const LATEST_VERSION: u32 = 8; + +/// Reserved for the IndexedDB backend of the extension storage.local API. Never +/// reassign it: extensions would lose access to data stored under it. +pub(crate) const MAX_USER_CONTEXT_ID: u32 = u32::MAX; + +/// Fields that no version of the format knows about are round-tripped verbatim +/// through `extra`, so that a document written by a newer Firefox keeps its +/// data when an older one rewrites it. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Identity { + #[serde(default)] + pub user_context_id: u32, + #[serde(default)] + pub public: bool, + #[serde(default)] + pub icon: String, + #[serde(default)] + pub color: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub policy: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_id: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ContainersData { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub last_user_context_id: u32, + #[serde(default)] + pub identities: Vec, + #[serde(default)] + pub site_associations: BTreeMap, + #[serde(flatten)] + pub extra: Map, +} + +impl ContainersData { + pub(crate) fn public_identities(&self) -> impl Iterator { + self.identities.iter().filter(|identity| identity.public) + } + + pub(crate) fn private_identities(&self) -> impl Iterator { + self.identities.iter().filter(|identity| !identity.public) + } + + pub(crate) fn policy_identities(&self) -> impl Iterator { + self.identities.iter().filter(|identity| identity.policy) + } + + pub(crate) fn find_private_by_name(&self, name: &str) -> Option<&Identity> { + self.identities.iter().find(|identity| { + !identity.public && !identity.policy && identity.name.as_deref() == Some(name) + }) + } + + pub(crate) fn find_policy_by_id(&self, policy_id: &str) -> Option<&Identity> { + self.identities + .iter() + .find(|identity| identity.policy && identity.policy_id.as_deref() == Some(policy_id)) + } +} diff --git a/components/containers/src/defaults.rs b/components/containers/src/defaults.rs new file mode 100644 index 00000000000..c4607d359bf --- /dev/null +++ b/components/containers/src/defaults.rs @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde_json::Map; + +use crate::data::{ContainersData, Identity, LATEST_VERSION, MAX_USER_CONTEXT_ID}; +use crate::definitions::{ContainerColor, ContainerIcon, ContainerLabel}; + +pub(crate) const THUMBNAIL_IDENTITY_NAME: &str = "userContextIdInternal.thumbnail"; +pub(crate) const WEBEXT_STORAGE_LOCAL_IDENTITY_NAME: &str = + "userContextIdInternal.webextStorageLocal"; + +/// One of the default containers the store was opened with, shipped or supplied +/// by the embedder. Enterprise policy can replace the shipped set, and names its +/// containers rather than localizing them, so its entries carry a +/// [`ContainerLabel::Name`]. +/// +/// They seed a fresh store, and they label the default containers the user has +/// not renamed: that label is never stored, so that renaming the string behind +/// it does not need a migration. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct DefaultIdentity { + pub icon: ContainerIcon, + pub color: ContainerColor, + pub label: ContainerLabel, +} + +impl DefaultIdentity { + fn new(icon: ContainerIcon, color: ContainerColor, label: ContainerLabel) -> Self { + Self { icon, color, label } + } +} + +pub(crate) fn shipped_defaults() -> Vec { + vec![ + DefaultIdentity::new( + ContainerIcon::Fingerprint, + ContainerColor::Blue, + ContainerLabel::Personal, + ), + DefaultIdentity::new( + ContainerIcon::Briefcase, + ContainerColor::Orange, + ContainerLabel::Work, + ), + DefaultIdentity::new( + ContainerIcon::Dollar, + ContainerColor::Green, + ContainerLabel::Banking, + ), + DefaultIdentity::new( + ContainerIcon::Cart, + ContainerColor::Pink, + ContainerLabel::Shopping, + ), + ] +} + +/// The label of the default identity that owns `user_context_id`, or `None` +/// when no default does. The nth default owns the nth id, as [`defaults_with`] +/// hands them out. +pub(crate) fn default_label( + defaults: &[DefaultIdentity], + user_context_id: u32, +) -> Option<&ContainerLabel> { + let index = usize::try_from(user_context_id.checked_sub(1)?).ok()?; + defaults.get(index).map(|default| &default.label) +} + +fn system_identity(user_context_id: u32, name: &str) -> Identity { + Identity { + user_context_id, + public: false, + icon: String::new(), + color: String::new(), + name: Some(name.to_string()), + policy: false, + policy_id: None, + extra: Map::new(), + } +} + +pub(crate) fn thumbnail_identity(user_context_id: u32) -> Identity { + system_identity(user_context_id, THUMBNAIL_IDENTITY_NAME) +} + +pub(crate) fn webext_storage_local_identity() -> Identity { + system_identity(MAX_USER_CONTEXT_ID, WEBEXT_STORAGE_LOCAL_IDENTITY_NAME) +} + +#[cfg(test)] +pub(crate) fn defaults() -> ContainersData { + defaults_with(&shipped_defaults()) +} + +pub(crate) fn defaults_with(defaults: &[DefaultIdentity]) -> ContainersData { + let mut identities = Vec::with_capacity(defaults.len() + 2); + let mut next_user_context_id = 1; + + for default in defaults { + let name = match &default.label { + ContainerLabel::Name { name } => Some(name.clone()), + _ => None, + }; + + identities.push(Identity { + user_context_id: next_user_context_id, + public: true, + icon: default.icon.name().to_string(), + color: default.color.name().to_string(), + name, + policy: false, + policy_id: None, + extra: Map::new(), + }); + next_user_context_id += 1; + } + + identities.push(thumbnail_identity(next_user_context_id)); + let last_user_context_id = next_user_context_id; + identities.push(webext_storage_local_identity()); + + ContainersData { + version: LATEST_VERSION, + last_user_context_id, + identities, + site_associations: Default::default(), + extra: Map::new(), + } +} diff --git a/components/containers/src/definitions.rs b/components/containers/src/definitions.rs new file mode 100644 index 00000000000..61899542342 --- /dev/null +++ b/components/containers/src/definitions.rs @@ -0,0 +1,373 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::collections::HashMap; + +/// Everything a color is, kept on one line per color. Borrowed, so it stays +/// inside the crate: borrowed data cannot cross an FFI boundary. +#[derive(Clone, Copy)] +struct ColorDef { + name: &'static str, + code: &'static str, + code_nova: &'static str, + gecko_l10n_id: &'static str, +} + +/// The colors a container can carry. +/// +/// A stored document holds the name, not the variant, and the same caveats as +/// [`ContainerIcon`] apply. Legacy names are not variants: they are resolved to +/// their canonical replacement by [`resolve_color`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum ContainerColor { + Gray, + Yellow, + Orange, + Red, + Pink, + Purple, + Violet, + Blue, + Cyan, + Green, +} + +impl ContainerColor { + /// Every color, in the order the container editor offers them. + pub const ALL: &'static [Self] = &[ + Self::Gray, + Self::Yellow, + Self::Orange, + Self::Red, + Self::Pink, + Self::Purple, + Self::Violet, + Self::Blue, + Self::Cyan, + Self::Green, + ]; + + fn def(self) -> ColorDef { + /// Positional, in the order [`ColorDef`] declares: the Fluent id is + /// spelled out rather than built from the name, so that Firefox can + /// rename one without renaming the other. + macro_rules! def { + ($name:literal, $code:literal, $code_nova:literal, $gecko_l10n_id:literal) => { + ColorDef { + name: $name, + code: $code, + code_nova: $code_nova, + gecko_l10n_id: $gecko_l10n_id, + } + }; + } + + match self { + Self::Gray => def!("gray", "#7c7c7d", "#949297", "user-context-color-gray"), + Self::Yellow => def!("yellow", "#ffcb00", "#db820e", "user-context-color-yellow"), + Self::Orange => def!("orange", "#ff9f00", "#f4682c", "user-context-color-orange"), + Self::Red => def!("red", "#ff613d", "#ed566e", "user-context-color-red"), + Self::Pink => def!("pink", "#ff4bda", "#db54bf", "user-context-color-pink"), + Self::Purple => def!("purple", "#af51f5", "#b864ee", "user-context-color-purple"), + Self::Violet => def!("violet", "#764edd", "#9871ff", "user-context-color-violet"), + Self::Blue => def!("blue", "#37adff", "#5a87fd", "user-context-color-blue"), + Self::Cyan => def!("cyan", "#00c79a", "#10a4ca", "user-context-color-cyan"), + Self::Green => def!("green", "#51cd00", "#11ae84", "user-context-color-green"), + } + } + + /// The name the document stores. + pub fn name(self) -> &'static str { + self.def().name + } + + /// The canonical color a stored document, an enterprise policy or a + /// WebExtension names, or `None` when the crate has no such color. A legacy + /// name has to go through [`resolve_color`] first. + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|color| color.name() == name) + } + + /// `nova` picks the refreshed value over the legacy one. Which to use + /// depends on a setting the embedder owns, so the caller decides. + pub fn code(self, nova: bool) -> &'static str { + let def = self.def(); + if nova { + def.code_nova + } else { + def.code + } + } + + /// The Fluent id Firefox Desktop labels the color with. See + /// [`ContainerIcon::gecko_l10n_id`] on why this is per platform. + pub fn gecko_l10n_id(self) -> &'static str { + self.def().gecko_l10n_id + } +} + +/// How a container gets its label. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum ContainerLabel { + Name { name: String }, + Personal, + Work, + Banking, + Shopping, +} + +impl ContainerLabel { + /// The Fluent id Firefox Desktop labels the container with, or `None` for a + /// container that carries its own name. See + /// [`ContainerIcon::gecko_l10n_id`] on why this is per platform. + pub fn gecko_l10n_id(&self) -> Option<&'static str> { + match self { + Self::Name { .. } => None, + Self::Personal => Some("user-context-personal2"), + Self::Work => Some("user-context-work2"), + Self::Banking => Some("user-context-banking2"), + Self::Shopping => Some("user-context-shopping2"), + } + } +} + +/// The icons a container can carry. The set is fixed, so a container cannot end +/// up with an icon the embedder has no artwork for. +/// +/// A stored document holds the name, not the variant: one written by a newer +/// Firefox can carry an icon this crate does not know, which reads back as +/// `None` and is left in the document untouched. +#[derive(Clone, Copy, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum ContainerIcon { + Fingerprint, + Briefcase, + Dollar, + Cart, + Vacation, + Gift, + Food, + Fruit, + Pet, + Tree, + Chill, + Circle, + Fence, +} + +impl ContainerIcon { + /// Every icon, in the order the container editor offers them. The first is + /// the one a new container starts with. + pub const ALL: &'static [Self] = &[ + Self::Fingerprint, + Self::Briefcase, + Self::Dollar, + Self::Cart, + Self::Vacation, + Self::Gift, + Self::Food, + Self::Fruit, + Self::Pet, + Self::Tree, + Self::Chill, + Self::Circle, + Self::Fence, + ]; + + /// The name the document stores. + pub fn name(self) -> &'static str { + match self { + Self::Fingerprint => "fingerprint", + Self::Briefcase => "briefcase", + Self::Dollar => "dollar", + Self::Cart => "cart", + Self::Vacation => "vacation", + Self::Gift => "gift", + Self::Food => "food", + Self::Fruit => "fruit", + Self::Pet => "pet", + Self::Tree => "tree", + Self::Chill => "chill", + Self::Circle => "circle", + Self::Fence => "fence", + } + } + + /// The icon a stored document, an enterprise policy or a WebExtension + /// names, or `None` when the crate has no such icon. + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|icon| icon.name() == name) + } + + /// The Fluent id Firefox Desktop labels the icon with, so that the embedder + /// does not keep a table of its own. Naming schemes do not carry across + /// platforms, so each one that needs it gets its own accessor. + pub fn gecko_l10n_id(self) -> &'static str { + match self { + Self::Fingerprint => "user-context-icon-fingerprint", + Self::Briefcase => "user-context-icon-briefcase", + Self::Dollar => "user-context-icon-dollar", + Self::Cart => "user-context-icon-cart", + Self::Vacation => "user-context-icon-vacation", + Self::Gift => "user-context-icon-gift", + Self::Food => "user-context-icon-food", + Self::Fruit => "user-context-icon-fruit", + Self::Pet => "user-context-icon-pet", + Self::Tree => "user-context-icon-tree", + Self::Chill => "user-context-icon-chill", + Self::Circle => "user-context-icon-circle", + Self::Fence => "user-context-icon-fence", + } + } +} + +/// Legacy color names, accepted at the WebExtension API boundary and rewritten +/// to their canonical replacement by the 5 -> 6 migration. +const ALIASES: &[(&str, &str)] = &[("turquoise", "cyan"), ("toolbar", "gray")]; + +#[uniffi::export] +pub fn container_colors() -> Vec { + ContainerColor::ALL.to_vec() +} + +#[uniffi::export] +pub fn container_color_aliases() -> HashMap { + ALIASES + .iter() + .map(|(legacy, canonical)| (legacy.to_string(), canonical.to_string())) + .collect() +} + +#[uniffi::export] +pub fn resolve_color(name: &str) -> String { + ALIASES + .iter() + .find(|(alias, _)| *alias == name) + .map(|(_, canonical)| (*canonical).to_string()) + .unwrap_or_else(|| name.to_string()) +} + +#[uniffi::export] +pub fn container_icons() -> Vec { + ContainerIcon::ALL.to_vec() +} + +#[uniffi::export] +pub fn icon_from_name(name: &str) -> Option { + ContainerIcon::from_name(name) +} + +#[uniffi::export] +pub fn icon_name(icon: ContainerIcon) -> String { + icon.name().to_string() +} + +#[uniffi::export] +pub fn icon_gecko_l10n_id(icon: ContainerIcon) -> String { + icon.gecko_l10n_id().to_string() +} + +#[uniffi::export] +pub fn label_gecko_l10n_id(label: ContainerLabel) -> Option { + label.gecko_l10n_id().map(str::to_string) +} + +#[uniffi::export] +pub fn color_from_name(name: &str) -> Option { + ContainerColor::from_name(name) +} + +#[uniffi::export] +pub fn color_name(color: ContainerColor) -> String { + color.name().to_string() +} + +#[uniffi::export] +pub fn color_code(color: ContainerColor, nova: bool) -> String { + color.code(nova).to_string() +} + +#[uniffi::export] +pub fn color_gecko_l10n_id(color: ContainerColor) -> String { + color.gecko_l10n_id().to_string() +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn every_icon_is_named_and_labelled_on_its_own() { + let names: HashSet<_> = ContainerIcon::ALL.iter().map(|icon| icon.name()).collect(); + let l10n_ids: HashSet<_> = ContainerIcon::ALL + .iter() + .map(|icon| icon.gecko_l10n_id()) + .collect(); + + assert_eq!(names.len(), ContainerIcon::ALL.len()); + assert_eq!(l10n_ids.len(), ContainerIcon::ALL.len()); + assert!(ContainerIcon::ALL + .iter() + .all(|icon| ContainerIcon::from_name(icon.name()) == Some(*icon))); + } + + #[test] + fn every_color_is_named_and_labelled_on_its_own() { + let all = ContainerColor::ALL; + let names: HashSet<_> = all.iter().map(|color| color.name()).collect(); + let l10n_ids: HashSet<_> = all.iter().map(|color| color.gecko_l10n_id()).collect(); + let codes: HashSet<_> = all + .iter() + .flat_map(|color| [color.code(false), color.code(true)]) + .collect(); + + assert_eq!(names.len(), all.len()); + assert_eq!(l10n_ids.len(), all.len()); + assert_eq!(codes.len(), all.len() * 2, "legacy and refreshed differ"); + assert!(all + .iter() + .all(|color| ContainerColor::from_name(color.name()) == Some(*color))); + } + + /// A legacy name is not a variant: it resolves to its replacement, and a + /// name that is neither is left alone for the document to keep. + #[test] + fn a_legacy_color_resolves_to_its_replacement() { + assert_eq!(resolve_color("turquoise"), "cyan"); + assert_eq!(resolve_color("toolbar"), "gray"); + assert_eq!(resolve_color("blue"), "blue"); + assert_eq!(resolve_color("chartreuse"), "chartreuse"); + + assert_eq!(ContainerColor::from_name("turquoise"), None); + assert_eq!( + ContainerColor::from_name(&resolve_color("turquoise")), + Some(ContainerColor::Cyan) + ); + } + + #[test] + fn every_default_label_has_its_own_fluent_id() { + let defaults = [ + ContainerLabel::Personal, + ContainerLabel::Work, + ContainerLabel::Banking, + ContainerLabel::Shopping, + ]; + let l10n_ids: HashSet<_> = defaults + .iter() + .map(|label| label.gecko_l10n_id().expect("a default is localized")) + .collect(); + + assert_eq!(l10n_ids.len(), defaults.len()); + assert_eq!( + ContainerLabel::Name { + name: "Mine".into() + } + .gecko_l10n_id(), + None, + "a name the user chose is not localized" + ); + } +} diff --git a/components/containers/src/error.rs b/components/containers/src/error.rs new file mode 100644 index 00000000000..2eef5593fcd --- /dev/null +++ b/components/containers/src/error.rs @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! The public errors carry nothing that has to be kept out of an error report, +//! so they double as the internal ones: the [`GetErrorHandling`] impls only +//! pick what gets logged and what gets reported, and `#[handle_error]` applies +//! that at the FFI boundary. See `components/support/error/README.md`. + +use error_support::{ErrorHandling, GetErrorHandling}; +use thiserror::Error; + +/// Internal: the embedder sees these folded into [`InitError`]. +#[derive(Clone, Debug, PartialEq, Eq, Error)] +pub(crate) enum ParseError { + #[error("malformed containers data: {0}")] + Malformed(String), + #[error("unsupported containers data version: {0}")] + UnsupportedVersion(u32), +} + +impl From for ParseError { + fn from(error: serde_json::Error) -> Self { + ParseError::Malformed(error.to_string()) + } +} + +/// Why a store could not be opened. +#[derive(Clone, Debug, PartialEq, Eq, Error, uniffi::Error)] +#[non_exhaustive] +pub enum InitError { + /// Carried as text rather than as a `serde_json::Error`, to keep the + /// serialization library out of the public surface. + #[error("malformed containers data: {reason}")] + Malformed { reason: String }, + #[error("unsupported containers data version: {version}")] + UnsupportedVersion { version: u32 }, +} + +impl From for InitError { + fn from(error: ParseError) -> Self { + match error { + ParseError::Malformed(reason) => InitError::Malformed { reason }, + ParseError::UnsupportedVersion(version) => InitError::UnsupportedVersion { version }, + } + } +} + +impl GetErrorHandling for InitError { + type ExternalError = Self; + + fn get_error_handling(&self) -> ErrorHandling { + match self { + // Unreadable data costs the user their containers, so we want to + // hear about it. + Self::Malformed { .. } => { + ErrorHandling::convert(self.clone()).report_error("containers-malformed-data") + } + + // Version 1 predates every migration path: an old enough profile, + // not a bug. + Self::UnsupportedVersion { version: 1 } => { + ErrorHandling::convert(self.clone()).log_warning() + } + + // Any other unreadable version means a downgrade, or a migration we + // should have had. + Self::UnsupportedVersion { .. } => { + ErrorHandling::convert(self.clone()).report_error("containers-unsupported-version") + } + } + } +} + +/// A mutation that could not be applied. The store is left untouched. +#[derive(Clone, Debug, PartialEq, Eq, Error, uniffi::Error)] +#[non_exhaustive] +pub enum StoreError { + #[error("container names cannot contain only whitespace")] + EmptyName, + #[error("a policy container needs a policy id")] + EmptyPolicyId, + #[error("unknown container {user_context_id}")] + NoSuchContainer { user_context_id: u32 }, + #[error("invalid site for a container association")] + InvalidSite, + #[error("no userContextId left to assign")] + IdSpaceExhausted, +} + +impl GetErrorHandling for StoreError { + type ExternalError = Self; + + fn get_error_handling(&self) -> ErrorHandling { + match self { + // Four billion containers is not a thing, so the counter is broken. + Self::IdSpaceExhausted => { + ErrorHandling::convert(self.clone()).report_error("containers-id-space-exhausted") + } + + // The rest is just rejected input. + _ => ErrorHandling::convert(self.clone()), + } + } +} diff --git a/components/containers/src/format/migrations.rs b/components/containers/src/format/migrations.rs new file mode 100644 index 00000000000..b775cef45b8 --- /dev/null +++ b/components/containers/src/format/migrations.rs @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use serde_json::Value; + +use crate::data::ContainersData; +use crate::defaults; +use crate::definitions; + +/// Bug 1419591: nothing to rewrite, the version alone had to move. +pub(crate) fn migrate_2_to_3(data: &mut ContainersData) { + data.version = 3; +} + +/// Bug 1406181: reserve the identity backing the extension storage.local API. +pub(crate) fn migrate_3_to_4(data: &mut ContainersData) { + data.identities + .push(defaults::webext_storage_local_identity()); + data.version = 4; +} + +/// Bug 1814969: StringBundle labels give way to Fluent identifiers. +pub(crate) fn migrate_4_to_5(data: &mut ContainersData) { + for identity in &mut data.identities { + let legacy = identity.extra.remove("l10nID"); + identity.extra.remove("accessKey"); + + let Some(Value::String(legacy)) = legacy else { + continue; + }; + + // Anything outside the four shipped labels keeps whatever it had. + let fluent = match legacy.as_str() { + "userContextPersonal.label" => Some("user-context-personal"), + "userContextWork.label" => Some("user-context-work"), + "userContextBanking.label" => Some("user-context-banking"), + "userContextShopping.label" => Some("user-context-shopping"), + _ => None, + }; + + if let Some(fluent) = fluent { + identity + .extra + .insert("l10nId".to_string(), Value::String(fluent.to_string())); + } + } + + data.version = 5; +} + +/// The color refresh: stored identities keep only canonical names. +pub(crate) fn migrate_5_to_6(data: &mut ContainersData) { + for identity in &mut data.identities { + if !identity.color.is_empty() { + identity.color = definitions::resolve_color(&identity.color); + } + } + + data.version = 6; +} + +/// Bug 2071753: the shipped labels' Fluent identifiers were renamed when the +/// container menu items lost their accesskeys. +pub(crate) fn migrate_6_to_7(data: &mut ContainersData) { + for identity in &mut data.identities { + let Some(Value::String(l10n_id)) = identity.extra.get_mut("l10nId") else { + continue; + }; + + let renamed = match l10n_id.as_str() { + "user-context-personal" => "user-context-personal2", + "user-context-work" => "user-context-work2", + "user-context-banking" => "user-context-banking2", + "user-context-shopping" => "user-context-shopping2", + _ => continue, + }; + + *l10n_id = renamed.to_string(); + } + + data.version = 7; +} + +/// Bug 2072625: the default containers' Fluent identifiers are no longer +/// stored. They come from the default identities the store was opened with, so +/// renaming one no longer needs a migration. +pub(crate) fn migrate_7_to_8(data: &mut ContainersData) { + for identity in &mut data.identities { + identity.extra.remove("l10nId"); + } + + data.version = 8; +} diff --git a/components/containers/src/format/mod.rs b/components/containers/src/format/mod.rs new file mode 100644 index 00000000000..bee4be1f0ef --- /dev/null +++ b/components/containers/src/format/mod.rs @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Turning stored bytes into a [`ContainersData`] and back, applying the +//! migrations between the document's versions along the way. + +use crate::data::{ContainersData, LATEST_VERSION}; +use crate::error::ParseError; + +mod migrations; + +#[cfg(test)] +mod tests; + +/// Reads a stored document, applying every migration needed to reach +/// [`LATEST_VERSION`]. The flag reports whether a migration ran, and the +/// document therefore has to be written back even though nothing the user did +/// changed it. +/// +/// An error means the stored data is unusable and the store has to be seeded +/// from the defaults, discarding the data held by the previous containers. +pub(crate) fn parse(bytes: &[u8]) -> Result<(ContainersData, bool), ParseError> { + let mut data: ContainersData = serde_json::from_slice(bytes)?; + + // Version 1 predates every migration path. + if data.version == 1 { + return Err(ParseError::UnsupportedVersion(1)); + } + + let mut migrated = false; + + if data.version == 2 { + migrations::migrate_2_to_3(&mut data); + migrated = true; + } + + if data.version == 3 { + migrations::migrate_3_to_4(&mut data); + migrated = true; + } + + if data.version == 4 { + migrations::migrate_4_to_5(&mut data); + migrated = true; + } + + if data.version == 5 { + migrations::migrate_5_to_6(&mut data); + migrated = true; + } + + if data.version == 6 { + migrations::migrate_6_to_7(&mut data); + migrated = true; + } + + if data.version == 7 { + migrations::migrate_7_to_8(&mut data); + migrated = true; + } + + if data.version != LATEST_VERSION { + return Err(ParseError::UnsupportedVersion(data.version)); + } + + Ok((data, migrated)) +} + +pub(crate) fn serialize(data: &ContainersData) -> Vec { + serde_json::to_vec(data).expect("containers data is always serializable") +} diff --git a/components/containers/src/format/tests.rs b/components/containers/src/format/tests.rs new file mode 100644 index 00000000000..a06d793251d --- /dev/null +++ b/components/containers/src/format/tests.rs @@ -0,0 +1,470 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +//! Fixtures mirroring toolkit/components/contextualidentity/tests/unit/test_migratedFile.js, +//! so that both implementations are pinned to the same corpus. + +use serde_json::{json, Value}; + +use super::{parse, serialize}; +use crate::data::{ContainersData, Identity, LATEST_VERSION, MAX_USER_CONTEXT_ID}; +use crate::defaults::{defaults, WEBEXT_STORAGE_LOCAL_IDENTITY_NAME}; +use crate::error::ParseError; + +fn bytes(document: Value) -> Vec { + serde_json::to_vec(&document).unwrap() +} + +fn load(document: Value) -> ContainersData { + parse(&bytes(document)).expect("fixture should load").0 +} + +/// The four shipped identities as stored before version 5. +fn string_bundle_defaults() -> Vec { + vec![ + json!({ + "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue", + "l10nID": "userContextPersonal.label", "accessKey": "userContextPersonal.accesskey" + }), + json!({ + "userContextId": 2, "public": true, "icon": "briefcase", "color": "orange", + "l10nID": "userContextWork.label", "accessKey": "userContextWork.accesskey" + }), + json!({ + "userContextId": 3, "public": true, "icon": "dollar", "color": "green", + "l10nID": "userContextBanking.label", "accessKey": "userContextBanking.accesskey" + }), + json!({ + "userContextId": 4, "public": true, "icon": "cart", "color": "pink", + "l10nID": "userContextShopping.label", "accessKey": "userContextShopping.accesskey" + }), + ] +} + +/// The same four identities from version 5 to version 6. +fn fluent_defaults() -> Vec { + vec![ + json!({ "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue", "l10nId": "user-context-personal" }), + json!({ "userContextId": 2, "public": true, "icon": "briefcase", "color": "orange", "l10nId": "user-context-work" }), + json!({ "userContextId": 3, "public": true, "icon": "dollar", "color": "green", "l10nId": "user-context-banking" }), + json!({ "userContextId": 4, "public": true, "icon": "cart", "color": "pink", "l10nId": "user-context-shopping" }), + ] +} + +/// The same four identities at version 7, where the Fluent identifiers had been +/// renamed but were still stored. +fn renamed_fluent_defaults() -> Vec { + vec![ + json!({ "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue", "l10nId": "user-context-personal2" }), + json!({ "userContextId": 2, "public": true, "icon": "briefcase", "color": "orange", "l10nId": "user-context-work2" }), + json!({ "userContextId": 3, "public": true, "icon": "dollar", "color": "green", "l10nId": "user-context-banking2" }), + json!({ "userContextId": 4, "public": true, "icon": "cart", "color": "pink", "l10nId": "user-context-shopping2" }), + ] +} + +/// The same four identities from version 8 on, where a label the user has not +/// replaced is not stored at all. +fn unlabelled_defaults() -> Vec { + vec![ + json!({ "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue" }), + json!({ "userContextId": 2, "public": true, "icon": "briefcase", "color": "orange" }), + json!({ "userContextId": 3, "public": true, "icon": "dollar", "color": "green" }), + json!({ "userContextId": 4, "public": true, "icon": "cart", "color": "pink" }), + ] +} + +fn thumbnail(legacy: bool) -> Value { + let mut identity = json!({ + "userContextId": 5, "public": false, "icon": "", "color": "", + "name": "userContextIdInternal.thumbnail" + }); + if legacy { + identity["accessKey"] = json!(""); + } + identity +} + +fn webext_storage_local(legacy: bool) -> Value { + let mut identity = json!({ + "userContextId": MAX_USER_CONTEXT_ID, "public": false, "icon": "", "color": "", + "name": WEBEXT_STORAGE_LOCAL_IDENTITY_NAME + }); + if legacy { + identity["accessKey"] = json!(""); + } + identity +} + +fn custom_identity(user_context_id: u32, color: &str, name: &str) -> Value { + json!({ + "userContextId": user_context_id, "public": true, "icon": "gift", + "color": color, "name": name + }) +} + +fn named<'a>(data: &'a ContainersData, name: &str) -> &'a Identity { + data.identities + .iter() + .find(|identity| identity.name.as_deref() == Some(name)) + .expect("identity should exist") +} + +fn with_id(data: &ContainersData, user_context_id: u32) -> &Identity { + data.identities + .iter() + .find(|identity| identity.user_context_id == user_context_id) + .expect("identity should exist") +} + +/// From version 8 on no identity carries a Fluent identifier: the labels of the +/// defaults live in the code. +fn assert_no_stored_labels(data: &ContainersData) { + assert!(data + .identities + .iter() + .all(|identity| !identity.extra.contains_key("l10nId"))); +} + +#[test] +fn version_1_has_no_migration_path() { + let error = parse(&bytes(json!({ + "version": 1, + "lastUserContextId": 6, + "identities": [custom_identity(6, "purple", "Custom user-created identity")], + }))) + .expect_err("version 1 should be rejected"); + + assert!(matches!(error, ParseError::UnsupportedVersion(1))); +} + +#[test] +fn version_2_runs_the_whole_chain() { + let mut identities = string_bundle_defaults(); + identities.push(thumbnail(true)); + identities.push(custom_identity(6, "pink", "Custom user-created identity")); + + let data = load(json!({ + "version": 2, + "lastUserContextId": 6, + "identities": identities, + })); + + assert_eq!(data.version, LATEST_VERSION); + assert_eq!(data.public_identities().count(), 5); + assert!(data + .find_private_by_name(WEBEXT_STORAGE_LOCAL_IDENTITY_NAME) + .is_some()); + assert!(data.identities.iter().all(|identity| { + !identity.extra.contains_key("l10nID") && !identity.extra.contains_key("accessKey") + })); + assert_no_stored_labels(&data); +} + +#[test] +fn version_3_adds_the_reserved_identity_and_drops_the_labels() { + let mut identities = string_bundle_defaults(); + identities.push(thumbnail(true)); + identities.push(custom_identity(6, "purple", "Custom user-created identity")); + + let data = load(json!({ + "version": 3, + "lastUserContextId": 6, + "identities": identities, + })); + + let reserved = data + .find_private_by_name(WEBEXT_STORAGE_LOCAL_IDENTITY_NAME) + .expect("3 -> 4 adds the reserved extension storage identity"); + assert_eq!(reserved.user_context_id, MAX_USER_CONTEXT_ID); + + assert_no_stored_labels(&data); + assert_eq!(data.public_identities().count(), 5); + assert!(data.site_associations.is_empty()); +} + +#[test] +fn version_4_does_not_duplicate_the_reserved_identity() { + let mut identities = string_bundle_defaults(); + identities.push(thumbnail(true)); + identities.push(webext_storage_local(true)); + identities.push(custom_identity(6, "purple", "Custom user-created identity")); + + let data = load(json!({ + "version": 4, + "lastUserContextId": 6, + "identities": identities, + })); + + assert_eq!( + data.identities + .iter() + .filter(|identity| identity.user_context_id == MAX_USER_CONTEXT_ID) + .count(), + 1 + ); + assert_no_stored_labels(&data); +} + +#[test] +fn version_5_resolves_color_aliases() { + let mut identities = fluent_defaults(); + identities.push(thumbnail(false)); + identities.push(webext_storage_local(false)); + identities.push(custom_identity(6, "turquoise", "Aliased to cyan")); + identities.push(custom_identity(7, "toolbar", "Aliased to gray")); + + let data = load(json!({ + "version": 5, + "lastUserContextId": 7, + "identities": identities, + })); + + assert_eq!(named(&data, "Aliased to cyan").color, "cyan"); + assert_eq!(named(&data, "Aliased to gray").color, "gray"); + assert_eq!(with_id(&data, 1).color, "blue"); + // The system identities have no color to resolve. + assert_eq!(named(&data, "userContextIdInternal.thumbnail").color, ""); +} + +/// Version 7 renamed the Fluent identifiers and version 8 stopped storing them, +/// so a version 6 document loses them outright. +#[test] +fn version_6_drops_the_stored_labels() { + let mut identities = fluent_defaults(); + identities.push(thumbnail(false)); + identities.push(webext_storage_local(false)); + identities.push(custom_identity(6, "purple", "Custom user-created identity")); + + let data = load(json!({ + "version": 6, + "lastUserContextId": 6, + "identities": identities, + })); + + assert_eq!(data.version, LATEST_VERSION); + assert_no_stored_labels(&data); + // A name the user chose is not a label the code owns, so it stays. + assert_eq!( + named(&data, "Custom user-created identity").user_context_id, + 6 + ); +} + +/// A version 7 document carries the renamed identifiers; version 8 drops them +/// in favour of the code's default identities. +#[test] +fn version_7_drops_the_stored_labels() { + let mut identities = renamed_fluent_defaults(); + identities.push(thumbnail(false)); + identities.push(webext_storage_local(false)); + // A default the user renamed keeps its name and never had an identifier. + identities[3] = custom_identity(4, "pink", "Renamed default"); + + let data = load(json!({ + "version": 7, + "lastUserContextId": 5, + "identities": identities, + })); + + assert_eq!(data.version, LATEST_VERSION); + assert_no_stored_labels(&data); + assert_eq!(named(&data, "Renamed default").user_context_id, 4); +} + +#[test] +fn version_8_is_loaded_verbatim() { + let mut identities = unlabelled_defaults(); + identities.push(thumbnail(false)); + identities.push(webext_storage_local(false)); + identities.push(custom_identity(6, "purple", "Custom user-created identity")); + + let (data, migrated) = parse(&bytes(json!({ + "version": 8, + "lastUserContextId": 6, + "identities": identities, + "siteAssociations": { "example.org": 1, "example.com": 6 }, + }))) + .expect("current version should load"); + + assert!(!migrated); + assert_eq!(data.public_identities().count(), 5); + assert_eq!(named(&data, "Custom user-created identity").color, "purple"); + assert_eq!(data.site_associations.get("example.org"), Some(&1)); + assert_eq!(data.site_associations.get("example.com"), Some(&6)); + assert_eq!(data.site_associations.get("unassociated.example"), None); +} + +#[test] +fn migrated_documents_report_that_they_need_a_write() { + let (_, migrated) = parse(&bytes(json!({ + "version": 5, + "lastUserContextId": 5, + "identities": fluent_defaults(), + }))) + .unwrap(); + + assert!(migrated); +} + +#[test] +fn a_version_from_the_future_is_rejected() { + let error = parse(&bytes(json!({ + "version": LATEST_VERSION + 1, + "lastUserContextId": 6, + "identities": unlabelled_defaults(), + "siteAssociations": { "example.org": 1 }, + }))) + .expect_err("an unknown version should be rejected"); + + assert!(matches!( + error, + ParseError::UnsupportedVersion(version) if version == LATEST_VERSION + 1 + )); +} + +#[test] +fn malformed_data_is_rejected() { + let error = parse(b"{ vers").expect_err("malformed data should be rejected"); + assert!(matches!(error, ParseError::Malformed(_))); +} + +#[test] +fn unknown_fields_survive_a_round_trip() { + let mut identity = custom_identity(6, "purple", "Custom user-created identity"); + identity["guid"] = json!("a-stable-identifier"); + + let mut identities = unlabelled_defaults(); + identities.push(identity); + + let data = load(json!({ + "version": 8, + "lastUserContextId": 6, + "identities": identities, + "unknownTopLevelKey": 42, + })); + + let round_tripped: Value = serde_json::from_slice(&serialize(&data)).unwrap(); + + assert_eq!(round_tripped["unknownTopLevelKey"], json!(42)); + assert_eq!( + round_tripped["identities"][4]["guid"], + json!("a-stable-identifier") + ); +} + +/// The shape ContextualIdentityService writes for a container an enterprise +/// policy owns. +fn policy_identity(user_context_id: u32, policy_id: &str) -> Value { + json!({ + "userContextId": user_context_id, "public": false, + "name": policy_id, "policy": true, "policyId": policy_id + }) +} + +#[test] +fn a_policy_identity_is_loaded_into_its_own_fields() { + let mut identities = unlabelled_defaults(); + identities.push(thumbnail(false)); + identities.push(policy_identity(6, "corp")); + + let data = load(json!({ + "version": 8, + "lastUserContextId": 6, + "identities": identities, + })); + + let identity = named(&data, "corp"); + assert!(identity.policy); + assert_eq!(identity.policy_id.as_deref(), Some("corp")); + assert!(identity.extra.is_empty(), "not round-tripped as unknown"); + + assert_eq!(data.policy_identities().count(), 1); + assert_eq!(data.public_identities().count(), 4); + // Private, so that "clear all containers" leaves its data alone. + assert_eq!(data.private_identities().count(), 2); +} + +/// Gecko writes a policy identity without an icon and a color, where every +/// other identity it writes has both. The crate always writes them, so a +/// rewritten policy identity gains two empty strings. This pins that: they read +/// back as the empty icon and color the identity already had. +#[test] +fn a_policy_identity_round_trips_with_an_empty_icon_and_color() { + let mut identities = unlabelled_defaults(); + identities.push(policy_identity(6, "corp")); + + let data = load(json!({ + "version": 8, + "lastUserContextId": 6, + "identities": identities, + })); + + let round_tripped: Value = serde_json::from_slice(&serialize(&data)).unwrap(); + + let identity = &round_tripped["identities"][4]; + assert_eq!( + *identity, + json!({ + "userContextId": 6, "public": false, "icon": "", "color": "", + "name": "corp", "policy": true, "policyId": "corp" + }) + ); + + // Nothing but a policy identity carries the flag. + assert!(round_tripped["identities"][0].get("policy").is_none()); + assert!(round_tripped["identities"][0].get("policyId").is_none()); +} + +/// A profile that had policy containers before the migration keeps them, with +/// the flag intact and the color resolution applied to everything else. +#[test] +fn migrating_keeps_the_policy_identities() { + let mut identities = fluent_defaults(); + identities.push(policy_identity(6, "corp")); + identities.push(custom_identity(7, "turquoise", "Aliased to cyan")); + + let data = load(json!({ + "version": 5, + "lastUserContextId": 7, + "identities": identities, + })); + + assert_eq!(data.version, LATEST_VERSION); + assert_eq!(named(&data, "Aliased to cyan").color, "cyan"); + assert_eq!( + data.find_policy_by_id("corp").map(|i| i.user_context_id), + Some(6) + ); +} + +#[test] +fn defaults_seed_a_usable_store() { + let data = defaults(); + + assert_eq!(data.version, LATEST_VERSION); + assert_eq!(data.public_identities().count(), 4); + assert_eq!(data.private_identities().count(), 2); + // The reserved identity is excluded when computing the next available id. + assert_eq!(data.last_user_context_id, 5); + assert_eq!( + data.find_private_by_name(WEBEXT_STORAGE_LOCAL_IDENTITY_NAME) + .unwrap() + .user_context_id, + MAX_USER_CONTEXT_ID + ); + assert!(data.site_associations.is_empty()); + // The shipped labels are localized, and a localized label is not stored. + assert!(data + .public_identities() + .all(|identity| identity.name.is_none())); + assert_no_stored_labels(&data); +} + +#[test] +fn defaults_round_trip_through_the_format() { + let data = defaults(); + let reloaded = parse(&serialize(&data)).expect("defaults should reload").0; + + assert_eq!(data, reloaded); +} diff --git a/components/containers/src/lib.rs b/components/containers/src/lib.rs new file mode 100644 index 00000000000..869129eb4d8 --- /dev/null +++ b/components/containers/src/lib.rs @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#![warn(unreachable_pub)] + +//! Storage for Firefox containers. +//! +//! The crate owns the container list, the shape of the document it is stored +//! in, and the migrations between that document's versions. It does not own the +//! storage itself and never touches the filesystem: [`ContainersStore`] hands +//! the serialized bytes to its callback, and where they end up and how durably +//! is the embedder's decision. + +uniffi::setup_scaffolding!("containers"); + +mod container; +mod data; +mod defaults; +mod definitions; +mod error; +mod format; +mod store; + +pub use container::Container; +pub use defaults::DefaultIdentity; +pub use definitions::{ + color_code, color_from_name, color_gecko_l10n_id, color_name, container_color_aliases, + container_colors, container_icons, icon_from_name, icon_gecko_l10n_id, icon_name, + label_gecko_l10n_id, resolve_color, ContainerColor, ContainerIcon, ContainerLabel, +}; +pub use error::{InitError, StoreError}; +pub use store::{normalize_site, ContainersCallback, ContainersStore, SiteAssociation}; + +#[uniffi::export] +pub fn latest_version() -> u32 { + data::LATEST_VERSION +} + +#[uniffi::export] +pub fn max_user_context_id() -> u32 { + data::MAX_USER_CONTEXT_ID +} diff --git a/components/containers/src/store.rs b/components/containers/src/store.rs new file mode 100644 index 00000000000..6c20224fd41 --- /dev/null +++ b/components/containers/src/store.rs @@ -0,0 +1,448 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use error_support::handle_error; +use parking_lot::{Mutex, MutexGuard, RwLock, RwLockReadGuard}; + +use crate::container::Container; +use crate::data::{ContainersData, Identity, MAX_USER_CONTEXT_ID}; +use crate::defaults::{self, DefaultIdentity}; +use crate::definitions::{ContainerColor, ContainerIcon}; +use crate::error::{InitError, StoreError}; +use crate::format::{parse, serialize}; + +/// How the store reaches the outside world. +/// +/// The callback may call `serialize`, but mutating the store from it deadlocks +/// on the callback lock. Mutate on a later turn. +#[uniffi::export(callback_interface)] +pub trait ContainersCallback: Send + Sync { + fn persist(&self); +} + +struct NoopCallback; + +impl ContainersCallback for NoopCallback { + fn persist(&self) {} +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct SiteAssociation { + pub site: String, + pub user_context_id: u32, +} + +#[derive(uniffi::Object)] +pub struct ContainersStore { + data: Mutex, + default_identities: Vec, + callback: RwLock>, +} + +#[uniffi::export] +impl ContainersStore { + /// Seeds the store from a stored document, or from the default identities + /// when `bytes` is `None`. If a migration ran, the document is persisted + /// before this returns. + /// + /// `default_identities` replaces the shipped public identities, for + /// embedders that let enterprise policy define them. They are consulted + /// whether or not there is a stored document: the label of a default + /// container the user has not renamed comes from them, since it is not + /// stored. + #[uniffi::constructor] + #[handle_error(InitError)] + pub fn new( + bytes: Option>, + default_identities: Option>, + callback: Box, + ) -> Result { + let default_identities = default_identities.unwrap_or_else(defaults::shipped_defaults); + + let (data, migrated) = match bytes { + Some(bytes) => parse(&bytes)?, + None => (defaults::defaults_with(&default_identities), true), + }; + + let store = Self { + data: Mutex::new(data), + default_identities, + callback: RwLock::new(callback), + }; + + if migrated { + store.persist(); + } + + Ok(store) + } + + /// Drops the callback, so that a late mutation during teardown cannot reach + /// an embedder that is already gone. + /// + /// One way: there is no putting it back. From here on the store keeps + /// working in memory but persists nothing. + pub fn unset_callback(&self) { + *self.callback.write() = Box::new(NoopCallback); + } + + /// The document as it stands, for the embedder to write. + pub fn serialize(&self) -> Vec { + serialize(&self.data()) + } + + pub fn public_identities(&self) -> Vec { + self.data() + .public_identities() + .map(|identity| self.container(identity)) + .collect() + } + + pub fn public_user_context_ids(&self) -> Vec { + self.data() + .public_identities() + .map(|identity| identity.user_context_id) + .collect() + } + + pub fn private_user_context_ids(&self) -> Vec { + self.data() + .private_identities() + .map(|identity| identity.user_context_id) + .collect() + } + + pub fn public_identity_from_id(&self, user_context_id: u32) -> Option { + self.data() + .identities + .iter() + .find(|identity| identity.public && identity.user_context_id == user_context_id) + .map(|identity| self.container(identity)) + } + + pub fn private_identity(&self, name: &str) -> Option { + self.data() + .find_private_by_name(name) + .map(|identity| self.container(identity)) + } + + #[handle_error(StoreError)] + pub fn create( + &self, + name: &str, + icon: ContainerIcon, + color: ContainerColor, + ) -> Result { + if name.trim().is_empty() { + return Err(StoreError::EmptyName); + } + + let identity = { + let mut data = self.data(); + + let identity = Identity { + user_context_id: next_user_context_id(&mut data)?, + public: true, + icon: icon.name().to_string(), + color: color.name().to_string(), + name: Some(name.to_string()), + policy: false, + policy_id: None, + extra: Default::default(), + }; + data.identities.push(identity.clone()); + + identity + }; + + self.persist(); + + Ok(self.container(&identity)) + } + + #[handle_error(StoreError)] + pub fn create_for_policy(&self, policy_id: &str) -> Result { + if policy_id.trim().is_empty() { + return Err(StoreError::EmptyPolicyId); + } + + let identity = { + let mut data = self.data(); + + let identity = Identity { + user_context_id: next_user_context_id(&mut data)?, + public: false, + icon: String::new(), + color: String::new(), + name: Some(policy_id.to_string()), + policy: true, + policy_id: Some(policy_id.to_string()), + extra: Default::default(), + }; + data.identities.push(identity.clone()); + + identity + }; + + self.persist(); + + Ok(self.container(&identity)) + } + + pub fn policy_identities(&self) -> Vec { + self.data() + .policy_identities() + .map(|identity| self.container(identity)) + .collect() + } + + pub fn policy_identity(&self, policy_id: &str) -> Option { + self.data() + .find_policy_by_id(policy_id) + .map(|identity| self.container(identity)) + } + + pub fn remove_policy_identity(&self, user_context_id: u32) -> Option { + let identity = { + let mut data = self.data(); + let index = data.identities.iter().position(|identity| { + identity.policy && identity.user_context_id == user_context_id + })?; + + // A site can only be bound to a public container, so there should be + // nothing to drop here. Cheap enough to not depend on that. + data.site_associations + .retain(|_, id| *id != user_context_id); + + data.identities.remove(index) + }; + + self.persist(); + + Some(self.container(&identity)) + } + + #[handle_error(StoreError)] + pub fn update( + &self, + user_context_id: u32, + name: &str, + icon: ContainerIcon, + color: ContainerColor, + ) -> Result, StoreError> { + if name.trim().is_empty() { + return Err(StoreError::EmptyName); + } + + let identity = + { + let mut data = self.data(); + let Some(identity) = data.identities.iter_mut().find(|identity| { + identity.public && identity.user_context_id == user_context_id + }) else { + return Ok(None); + }; + + identity.name = Some(name.to_string()); + identity.icon = icon.name().to_string(); + identity.color = color.name().to_string(); + + identity.clone() + }; + + self.persist(); + + Ok(Some(self.container(&identity))) + } + + /// Removes a public container. Clearing the data stored under its origin + /// attributes is the embedder's job. + pub fn remove(&self, user_context_id: u32) -> Option { + let identity = { + let mut data = self.data(); + let index = data.identities.iter().position(|identity| { + identity.public && identity.user_context_id == user_context_id + })?; + + data.site_associations + .retain(|_, id| *id != user_context_id); + + data.identities.remove(index) + }; + + self.persist(); + + Some(self.container(&identity)) + } + + /// Moves the given public containers before `position`, or to the end when + /// it is -1. Positions below -1 are rejected. + pub fn move_containers(&self, user_context_ids: Vec, position: i64) -> bool { + if position < -1 { + return false; + } + + { + let mut data = self.data(); + + let moved: Vec = data + .identities + .iter() + .filter(|identity| { + identity.public && user_context_ids.contains(&identity.user_context_id) + }) + .cloned() + .collect(); + + if moved.is_empty() { + return false; + } + + let mut destination = if position == -1 { + data.identities.len() as i64 + } else { + position + }; + + // Skip over the private identities that sit before the destination. + for (index, identity) in data.identities.iter().enumerate() { + if !identity.public && destination >= index as i64 { + destination += 1; + } + } + + data.identities.retain(|identity| { + !identity.public || !user_context_ids.contains(&identity.user_context_id) + }); + + let destination = (destination.max(0) as usize).min(data.identities.len()); + for (offset, identity) in moved.into_iter().enumerate() { + data.identities.insert(destination + offset, identity); + } + } + + self.persist(); + + true + } + + #[handle_error(StoreError)] + pub fn set_site_association(&self, site: &str, user_context_id: u32) -> Result<(), StoreError> { + { + let mut data = self.data(); + + if !data + .identities + .iter() + .any(|identity| identity.public && identity.user_context_id == user_context_id) + { + return Err(StoreError::NoSuchContainer { user_context_id }); + } + + let host = normalize_site(site).ok_or(StoreError::InvalidSite)?; + + if data.site_associations.get(&host) == Some(&user_context_id) { + return Ok(()); + } + + data.site_associations.insert(host, user_context_id); + } + + self.persist(); + + Ok(()) + } + + pub fn remove_site_association(&self, site: &str) { + let Some(host) = normalize_site(site) else { + return; + }; + + if self.data().site_associations.remove(&host).is_none() { + return; + } + + self.persist(); + } + + /// The container bound to `site`, or 0 when there is none. + pub fn get_site_association(&self, site: &str) -> u32 { + normalize_site(site) + .and_then(|host| self.data().site_associations.get(&host).copied()) + .unwrap_or(0) + } + + pub fn get_site_associations(&self, user_context_id: Option) -> Vec { + self.data() + .site_associations + .iter() + .filter(|(_, id)| user_context_id.is_none_or(|wanted| **id == wanted)) + .map(|(site, id)| SiteAssociation { + site: site.clone(), + user_context_id: *id, + }) + .collect() + } +} + +/// Reserves the next id, or reports that there is none left. The counter is +/// shared by every kind of container, policy-owned ones included, so that an id +/// is never handed out twice. +fn next_user_context_id(data: &mut ContainersData) -> Result { + // The reserved id is the last valid one, so it has to stay free. + if data.last_user_context_id >= MAX_USER_CONTEXT_ID - 1 { + return Err(StoreError::IdSpaceExhausted); + } + + data.last_user_context_id += 1; + Ok(data.last_user_context_id) +} + +/// Not exported: these hand out lock guards, which have no meaning across the +/// FFI boundary. +impl ContainersStore { + fn data(&self) -> MutexGuard<'_, ContainersData> { + self.data.lock() + } + + /// Mirrors getUserContextLabel: only a public container falls back to the + /// label of the default identity that owns its id. + fn container(&self, identity: &Identity) -> Container { + let default_label = identity + .public + .then(|| defaults::default_label(&self.default_identities, identity.user_context_id)) + .flatten(); + + Container::from_identity(identity, default_label) + } + + fn callback(&self) -> RwLockReadGuard<'_, Box> { + self.callback.read() + } + + /// Always called with the data lock released, so that the callback is free + /// to serialize. + fn persist(&self) { + self.callback().persist(); + } +} + +/// Lower cased, IDN-encoded host, or `None` when `site` cannot be bound to a +/// container. +#[uniffi::export] +pub fn normalize_site(site: &str) -> Option { + // Strict applies the STD3 ASCII rules, which is what keeps out everything + // that is not a host: ports, paths, userinfo, percent escapes, brackets, + // wildcards and whitespace. The lenient variant accepts all of them. + // + // It also rejects a trailing dot, which does name the DNS root and which + // Gecko accepts, so validate without it and put it back. + let (bare, root) = match site.strip_suffix('.') { + Some(bare) => (bare, "."), + None => (site, ""), + }; + + let host = idna::domain_to_ascii_strict(bare).ok()?; + Some(format!("{host}{root}")) +} diff --git a/components/containers/tests/store.rs b/components/containers/tests/store.rs new file mode 100644 index 00000000000..f3692612b45 --- /dev/null +++ b/components/containers/tests/store.rs @@ -0,0 +1,864 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; + +use serde_json::json; + +use containers::{ + max_user_context_id, ContainerColor, ContainerIcon, ContainerLabel, ContainersCallback, + ContainersStore, DefaultIdentity, StoreError, +}; + +#[derive(Default)] +struct Recorded { + persists: Mutex, +} + +#[derive(Clone, Default)] +struct Recorder { + inner: Arc, +} + +impl Recorder { + fn persist_count(&self) -> usize { + *self.inner.persists.lock().unwrap() + } +} + +impl ContainersCallback for Recorder { + fn persist(&self) { + *self.inner.persists.lock().unwrap() += 1; + } +} + +fn seeded() -> (ContainersStore, Recorder) { + let recorder = Recorder::default(); + let store = ContainersStore::new(None, None, Box::new(recorder.clone())).unwrap(); + (store, recorder) +} + +fn current_document() -> Vec { + serde_json::to_vec(&json!({ + "version": 8, + "lastUserContextId": 5, + "identities": [ + { "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue" }, + { "userContextId": 2, "public": true, "icon": "briefcase", "color": "orange" }, + { "userContextId": 5, "public": false, "icon": "", "color": "", "name": "userContextIdInternal.thumbnail" }, + ], + "siteAssociations": {}, + })) + .unwrap() +} + +#[test] +fn seeding_from_defaults_persists_once() { + let (store, recorder) = seeded(); + + assert_eq!(store.public_identities().len(), 4); + assert_eq!(recorder.persist_count(), 1); +} + +#[test] +fn loading_a_current_document_does_not_persist() { + let recorder = Recorder::default(); + let store = + ContainersStore::new(Some(current_document()), None, Box::new(recorder.clone())).unwrap(); + + assert_eq!(store.public_identities().len(), 2); + assert_eq!(recorder.persist_count(), 0); +} + +#[test] +fn loading_a_migrated_document_persists() { + let recorder = Recorder::default(); + let document = serde_json::to_vec(&json!({ + "version": 5, + "lastUserContextId": 1, + "identities": [ + { "userContextId": 1, "public": true, "icon": "gift", "color": "turquoise", "name": "Aliased" }, + ], + })) + .unwrap(); + + let store = ContainersStore::new(Some(document), None, Box::new(recorder.clone())).unwrap(); + + assert_eq!( + store.public_identity_from_id(1).unwrap().color, + Some(ContainerColor::Cyan) + ); + assert_eq!(recorder.persist_count(), 1); +} + +#[test] +fn create_assigns_the_next_id_and_persists() { + let (store, recorder) = seeded(); + + let container = store + .create("Reading", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap(); + + assert_eq!(container.user_context_id, 6); + assert_eq!( + container.label, + ContainerLabel::Name { + name: "Reading".into() + } + ); + assert!(container.is_public); + assert_eq!(store.public_identities().len(), 5); + assert_eq!(recorder.persist_count(), 2); +} + +#[test] +fn create_rejects_a_blank_name() { + let (store, recorder) = seeded(); + let before = recorder.persist_count(); + + assert_eq!( + store.create(" ", ContainerIcon::Tree, ContainerColor::Purple), + Err(StoreError::EmptyName) + ); + assert_eq!(store.public_identities().len(), 4); + assert_eq!(recorder.persist_count(), before); +} + +#[test] +fn update_replaces_the_label_of_a_default() { + let (store, _) = seeded(); + + let updated = store + .update(1, "Mine", ContainerIcon::Fence, ContainerColor::Red) + .unwrap() + .unwrap(); + + assert_eq!( + updated.label, + ContainerLabel::Name { + name: "Mine".into() + } + ); + assert_eq!(updated.icon, Some(ContainerIcon::Fence)); + assert_eq!(updated.color, Some(ContainerColor::Red)); + + // The name is what the store keeps, and it wins over the localized label + // the default identity would otherwise give. + let persisted: serde_json::Value = serde_json::from_slice(&store.serialize()).unwrap(); + let identity = &persisted["identities"][0]; + assert_eq!(identity["name"], json!("Mine")); + assert!(identity.get("l10nId").is_none()); + assert_eq!( + store.public_identity_from_id(1).unwrap().label, + updated.label + ); +} + +/// Mirrors resetDefault: the shipped containers name themselves, and nothing +/// about their label reaches the document. +#[test] +fn the_shipped_labels_are_not_stored() { + let (store, _) = seeded(); + + assert_eq!( + store + .public_identities() + .into_iter() + .map(|container| container.label) + .collect::>(), + vec![ + ContainerLabel::Personal, + ContainerLabel::Work, + ContainerLabel::Banking, + ContainerLabel::Shopping, + ] + ); + + let persisted: serde_json::Value = serde_json::from_slice(&store.serialize()).unwrap(); + assert_eq!(persisted["version"], json!(8)); + for identity in persisted["identities"].as_array().unwrap().iter().take(4) { + assert!(identity.get("l10nId").is_none()); + assert!(identity.get("name").is_none()); + } +} + +#[test] +fn update_ignores_unknown_and_private_containers() { + let (store, _) = seeded(); + + assert!(store + .update(999, "Mine", ContainerIcon::Fence, ContainerColor::Red) + .unwrap() + .is_none()); + // The thumbnail identity is private. + assert!(store + .update(5, "Mine", ContainerIcon::Fence, ContainerColor::Red) + .unwrap() + .is_none()); +} + +#[test] +fn remove_drops_the_container_and_its_associations() { + let (store, _) = seeded(); + store.set_site_association("example.org", 1).unwrap(); + store.set_site_association("example.com", 2).unwrap(); + + let removed = store.remove(1).expect("container 1 should be removed"); + + assert_eq!(removed.user_context_id, 1); + assert_eq!(store.public_identities().len(), 3); + assert_eq!(store.get_site_association("example.org"), 0); + assert_eq!(store.get_site_association("example.com"), 2); +} + +#[test] +fn remove_ignores_unknown_and_private_containers() { + let (store, _) = seeded(); + + assert!(store.remove(999).is_none()); + assert!(store.remove(5).is_none()); + assert!(store + .private_identity("userContextIdInternal.webextStorageLocal") + .is_some()); +} + +#[test] +fn move_reorders_public_containers() { + let (store, _) = seeded(); + + assert!(store.move_containers(vec![4], 0)); + + assert_eq!( + store.public_user_context_ids(), + vec![4, 1, 2, 3], + "the moved container lands at the front" + ); +} + +#[test] +fn move_to_minus_one_appends() { + let (store, _) = seeded(); + + assert!(store.move_containers(vec![1], -1)); + + assert_eq!(store.public_user_context_ids(), vec![2, 3, 4, 1]); +} + +#[test] +fn move_rejects_positions_below_minus_one() { + let (store, recorder) = seeded(); + let before = recorder.persist_count(); + + assert!(!store.move_containers(vec![1], -2)); + assert_eq!(store.public_user_context_ids(), vec![1, 2, 3, 4]); + assert_eq!(recorder.persist_count(), before); +} + +#[test] +fn move_is_a_no_op_without_matching_containers() { + let (store, recorder) = seeded(); + let before = recorder.persist_count(); + + assert!(!store.move_containers(vec![999], 0)); + assert_eq!(recorder.persist_count(), before); +} + +#[test] +fn site_associations_round_trip() { + let (store, _) = seeded(); + + store.set_site_association("Example.ORG", 2).unwrap(); + + assert_eq!(store.get_site_association("example.org"), 2); + assert_eq!(store.get_site_association("other.example"), 0); + + store.remove_site_association("example.org"); + assert_eq!(store.get_site_association("example.org"), 0); +} + +#[test] +fn setting_the_same_association_twice_does_not_persist() { + let (store, recorder) = seeded(); + store.set_site_association("example.org", 2).unwrap(); + let after_first = recorder.persist_count(); + + store.set_site_association("example.org", 2).unwrap(); + + assert_eq!(recorder.persist_count(), after_first); +} + +#[test] +fn associations_require_a_known_public_container() { + let (store, _) = seeded(); + + assert_eq!( + store.set_site_association("example.org", 999), + Err(StoreError::NoSuchContainer { + user_context_id: 999 + }) + ); + // The thumbnail identity is private. + assert_eq!( + store.set_site_association("example.org", 5), + Err(StoreError::NoSuchContainer { user_context_id: 5 }) + ); +} + +#[test] +fn wildcards_are_not_valid_sites() { + let (store, _) = seeded(); + + assert_eq!( + store.set_site_association("*.example.org", 1), + Err(StoreError::InvalidSite) + ); +} + +#[test] +fn get_site_associations_filters_by_container() { + let (store, _) = seeded(); + store.set_site_association("one.example", 1).unwrap(); + store.set_site_association("two.example", 2).unwrap(); + + assert_eq!(store.get_site_associations(None).len(), 2); + assert_eq!(store.get_site_associations(Some(1)).len(), 1); + assert_eq!(store.get_site_associations(Some(1))[0].site, "one.example"); +} + +#[test] +fn mutations_are_visible_in_the_serialized_document() { + let (store, _) = seeded(); + + store + .create("Reading", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap(); + + let persisted: serde_json::Value = serde_json::from_slice(&store.serialize()).unwrap(); + assert_eq!(persisted["lastUserContextId"], json!(6)); + + // create() appends, so the new container sits after the system identities. + let identities = persisted["identities"].as_array().unwrap(); + assert_eq!(identities.last().unwrap()["name"], json!("Reading")); +} + +#[test] +fn unset_callback_stops_delivery() { + let (store, recorder) = seeded(); + let before = recorder.persist_count(); + + store.unset_callback(); + store + .create("Reading", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap(); + + assert_eq!(recorder.persist_count(), before); + assert_eq!(store.public_identities().len(), 5); +} + +fn store_with_last_id(last_user_context_id: u32) -> ContainersStore { + let document = serde_json::to_vec(&json!({ + "version": 6, + "lastUserContextId": last_user_context_id, + "identities": [ + { "userContextId": max_user_context_id(), "public": false, "icon": "", "color": "", + "name": "userContextIdInternal.webextStorageLocal" }, + ], + })) + .unwrap(); + + ContainersStore::new(Some(document), None, Box::new(Recorder::default())).unwrap() +} + +#[test] +fn the_last_assignable_id_is_the_one_below_the_reserved_one() { + let store = store_with_last_id(max_user_context_id() - 2); + + let identity = store + .create("Last one", ContainerIcon::Circle, ContainerColor::Gray) + .unwrap(); + + assert_eq!(identity.user_context_id, max_user_context_id() - 1); +} + +#[test] +fn create_fails_once_the_id_space_is_exhausted() { + let store = store_with_last_id(max_user_context_id() - 1); + + assert_eq!( + store.create("One too many", ContainerIcon::Circle, ContainerColor::Gray), + Err(StoreError::IdSpaceExhausted) + ); + assert!(store.public_identities().is_empty()); +} + +/// Gecko increments before validating, so there a rejected name leaves a hole +/// in the sequence. This pins the deliberate difference. +#[test] +fn a_rejected_name_does_not_consume_an_id() { + let (store, _) = seeded(); + + assert!(store + .create(" ", ContainerIcon::Tree, ContainerColor::Purple) + .is_err()); + + assert_eq!( + store + .create("Reading", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap() + .user_context_id, + 6 + ); +} + +#[test] +fn an_unknown_container_is_reported_before_an_unusable_site() { + let (store, _) = seeded(); + + assert_eq!( + store.set_site_association("*.example.org", 999), + Err(StoreError::NoSuchContainer { + user_context_id: 999 + }) + ); +} + +fn reloaded(store: &ContainersStore) -> ContainersStore { + ContainersStore::new(Some(store.serialize()), None, Box::new(Recorder::default())).unwrap() +} + +#[test] +fn every_mutation_leaves_a_document_that_reloads_identically() { + let (store, _) = seeded(); + + store + .create("Reading", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap(); + assert_eq!( + reloaded(&store).public_identities(), + store.public_identities() + ); + + store + .update(1, "Mine", ContainerIcon::Fence, ContainerColor::Red) + .unwrap(); + assert_eq!( + reloaded(&store).public_identities(), + store.public_identities() + ); + + store.set_site_association("example.org", 2).unwrap(); + assert_eq!( + reloaded(&store).get_site_associations(None), + store.get_site_associations(None) + ); + + assert!(store.move_containers(vec![1], -1)); + assert_eq!( + reloaded(&store).public_user_context_ids(), + store.public_user_context_ids() + ); + + // Removing container 2 also drops the association bound to it. + store.remove(2).unwrap(); + let reloaded = reloaded(&store); + assert_eq!(reloaded.public_identities(), store.public_identities()); + assert_eq!( + reloaded.get_site_associations(None), + store.get_site_associations(None) + ); +} + +#[test] +fn concurrent_creates_get_distinct_ids() { + let store = Arc::new(ContainersStore::new(None, None, Box::new(Recorder::default())).unwrap()); + + let handles: Vec<_> = (0..8) + .map(|i| { + let store = Arc::clone(&store); + std::thread::spawn(move || { + store + .create( + &format!("Container {i}"), + ContainerIcon::Circle, + ContainerColor::Gray, + ) + .unwrap() + .user_context_id + }) + }) + .collect(); + + let ids: HashSet = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect(); + + assert_eq!(ids.len(), 8, "every concurrent create gets its own id"); + assert_eq!(store.public_identities().len(), 12); +} + +fn seed(name: &str, icon: ContainerIcon, color: ContainerColor) -> DefaultIdentity { + DefaultIdentity { + icon, + color, + label: ContainerLabel::Name { + name: name.to_string(), + }, + } +} + +fn seeded_with(default_identity: DefaultIdentity) -> ContainersStore { + ContainersStore::new( + None, + Some(vec![default_identity]), + Box::new(Recorder::default()), + ) + .unwrap() +} + +/// An embedder-supplied default names its icon and color through the enums, so +/// there is no longer an unrenderable one to reject. +#[test] +fn a_seed_identity_lands_in_the_store() { + let store = seeded_with(seed("Work", ContainerIcon::Briefcase, ContainerColor::Cyan)); + + let containers = store.public_identities(); + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].icon, Some(ContainerIcon::Briefcase)); + assert_eq!(containers[0].color, Some(ContainerColor::Cyan)); + assert_eq!( + containers[0].label, + ContainerLabel::Name { + name: "Work".into() + } + ); +} + +/// Mirrors getUserContextLabel: a non-empty name wins, an empty one falls +/// through to the label of the default identity owning the id, and a container +/// no default owns is not left without a label. +#[test] +fn the_label_follows_geckos_precedence() { + let document = serde_json::to_vec(&json!({ + "version": 8, + "lastUserContextId": 9, + "identities": [ + { "userContextId": 1, "public": true, "icon": "cart", "color": "blue", + "name": "Renamed" }, + { "userContextId": 2, "public": true, "icon": "cart", "color": "blue", + "name": "" }, + { "userContextId": 3, "public": true, "icon": "cart", "color": "blue" }, + { "userContextId": 9, "public": true, "icon": "cart", "color": "blue" }, + ], + })) + .unwrap(); + + let store = ContainersStore::new(Some(document), None, Box::new(Recorder::default())).unwrap(); + let label = |id| store.public_identity_from_id(id).unwrap().label; + + assert_eq!( + label(1), + ContainerLabel::Name { + name: "Renamed".into() + } + ); + assert_eq!(label(2), ContainerLabel::Work); + assert_eq!(label(3), ContainerLabel::Banking); + assert_eq!( + label(9), + ContainerLabel::Name { + name: String::new() + } + ); +} + +/// A version 6 document still holds the Fluent ids from before Bug 2071753 +/// renamed them, and a version 7 one holds the renamed ones. Either way the +/// crate never reads them: the labels come from the default identities. +/// Mirrors test_migratedFile.js's migratedFileV6. +#[test] +fn a_version_6_document_takes_the_labels_from_the_defaults() { + let document = serde_json::to_vec(&json!({ + "version": 6, + "lastUserContextId": 5, + "identities": [ + { "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue", "l10nId": "user-context-personal" }, + { "userContextId": 2, "public": true, "icon": "briefcase", "color": "orange", "l10nId": "user-context-work" }, + { "userContextId": 3, "public": true, "icon": "dollar", "color": "green", "l10nId": "user-context-banking" }, + { "userContextId": 4, "public": true, "icon": "cart", "color": "pink", "l10nId": "user-context-shopping" }, + { "userContextId": 5, "public": true, "icon": "cart", "color": "pink", "name": "Custom user-created identity" }, + ], + })) + .unwrap(); + + let recorder = Recorder::default(); + let store = ContainersStore::new(Some(document), None, Box::new(recorder.clone())).unwrap(); + + assert_eq!( + store + .public_identities() + .into_iter() + .map(|container| container.label) + .collect::>(), + vec![ + ContainerLabel::Personal, + ContainerLabel::Work, + ContainerLabel::Banking, + ContainerLabel::Shopping, + ContainerLabel::Name { + name: "Custom user-created identity".into() + }, + ] + ); + + // The migration is written back, without the Fluent ids. + assert_eq!(recorder.persist_count(), 1); + let persisted: serde_json::Value = serde_json::from_slice(&store.serialize()).unwrap(); + assert_eq!(persisted["version"], json!(8)); + assert!(persisted["identities"] + .as_array() + .unwrap() + .iter() + .all(|identity| identity.get("l10nId").is_none())); +} + +/// A version 7 document holds the renamed Fluent ids, and a default the user +/// renamed. Mirrors test_migratedFile.js's migratedFileV7. +#[test] +fn a_version_7_document_keeps_a_renamed_default() { + let document = serde_json::to_vec(&json!({ + "version": 7, + "lastUserContextId": 5, + "identities": [ + { "userContextId": 1, "public": true, "icon": "fingerprint", "color": "blue", "l10nId": "user-context-personal2" }, + { "userContextId": 4, "public": true, "icon": "cart", "color": "pink", "name": "Renamed default" }, + { "userContextId": 5, "public": true, "icon": "cart", "color": "pink", "name": "Custom user-created identity" }, + ], + })) + .unwrap(); + + let store = ContainersStore::new(Some(document), None, Box::new(Recorder::default())).unwrap(); + let label = |id| store.public_identity_from_id(id).unwrap().label; + + assert_eq!(label(1), ContainerLabel::Personal); + assert_eq!( + label(4), + ContainerLabel::Name { + name: "Renamed default".into() + } + ); + assert_eq!( + label(5), + ContainerLabel::Name { + name: "Custom user-created identity".into() + } + ); +} + +/// The default identities label the containers whether or not the store was +/// seeded from them, as gecko's _defaultIdentities do. +#[test] +fn the_default_identities_label_a_stored_document() { + let store = ContainersStore::new( + Some(current_document()), + Some(vec![ + seed("Corp", ContainerIcon::Briefcase, ContainerColor::Orange), + seed( + "Corp private", + ContainerIcon::Fingerprint, + ContainerColor::Blue, + ), + ]), + Box::new(Recorder::default()), + ) + .unwrap(); + + assert_eq!( + store.public_identity_from_id(1).unwrap().label, + ContainerLabel::Name { + name: "Corp".into() + } + ); + assert_eq!( + store.public_identity_from_id(2).unwrap().label, + ContainerLabel::Name { + name: "Corp private".into() + } + ); + // The shipped labels no longer apply: they belong to the shipped set. + assert!(store + .public_identities() + .iter() + .all(|container| matches!(container.label, ContainerLabel::Name { .. }))); +} + +/// Mirrors ContextualIdentityService.createForPolicy: not public, named after +/// the policy id, and carrying it. +#[test] +fn create_for_policy_takes_the_next_id_and_is_not_public() { + let (store, recorder) = seeded(); + + let container = store.create_for_policy("corp").unwrap(); + + assert_eq!(container.user_context_id, 6); + assert!(!container.is_public); + assert_eq!(container.policy_id.as_deref(), Some("corp")); + assert_eq!( + container.label, + ContainerLabel::Name { + name: "corp".into() + } + ); + assert_eq!(container.icon, None); + assert_eq!(container.color, None); + assert_eq!(recorder.persist_count(), 2); + + assert_eq!(store.policy_identities(), vec![container]); + assert_eq!(store.public_identities().len(), 4); + assert!(store.public_identity_from_id(6).is_none()); + + // Kept alongside the system identities, so that a "clear all containers" + // leaves the data of a policy container alone. + assert!(store.private_user_context_ids().contains(&6)); +} + +#[test] +fn create_for_policy_rejects_a_blank_policy_id() { + let (store, recorder) = seeded(); + let before = recorder.persist_count(); + + assert_eq!( + store.create_for_policy(" "), + Err(StoreError::EmptyPolicyId) + ); + assert!(store.policy_identities().is_empty()); + assert_eq!(recorder.persist_count(), before); +} + +#[test] +fn policy_and_user_containers_share_the_id_counter() { + let (store, _) = seeded(); + + assert_eq!(store.create_for_policy("corp").unwrap().user_context_id, 6); + assert_eq!( + store + .create("Reading", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap() + .user_context_id, + 7 + ); + assert_eq!(store.create_for_policy("other").unwrap().user_context_id, 8); +} + +#[test] +fn create_for_policy_fails_once_the_id_space_is_exhausted() { + let store = store_with_last_id(max_user_context_id() - 1); + + assert_eq!( + store.create_for_policy("corp"), + Err(StoreError::IdSpaceExhausted) + ); + assert!(store.policy_identities().is_empty()); +} + +#[test] +fn a_policy_container_is_out_of_reach_of_the_public_api() { + let (store, _) = seeded(); + let container = store.create_for_policy("corp").unwrap(); + let id = container.user_context_id; + + assert!(store + .update(id, "Mine", ContainerIcon::Fence, ContainerColor::Red) + .unwrap() + .is_none()); + assert!(store.remove(id).is_none()); + assert!(!store.move_containers(vec![id], 0)); + assert_eq!( + store.set_site_association("example.org", id), + Err(StoreError::NoSuchContainer { + user_context_id: id + }) + ); + + assert_eq!(store.policy_identities(), vec![container]); +} + +#[test] +fn remove_policy_identity_only_takes_policy_containers() { + let (store, _) = seeded(); + let id = store.create_for_policy("corp").unwrap().user_context_id; + + // A public container, and the thumbnail identity: neither is a policy one. + assert!(store.remove_policy_identity(1).is_none()); + assert!(store.remove_policy_identity(5).is_none()); + assert!(store.remove_policy_identity(999).is_none()); + + let removed = store + .remove_policy_identity(id) + .expect("the policy container should be removed"); + + assert_eq!(removed.policy_id.as_deref(), Some("corp")); + assert!(store.policy_identities().is_empty()); + assert_eq!(store.public_identities().len(), 4); + assert!(store.public_identity_from_id(1).is_some()); +} + +/// How Policies.sys.mjs reconciles the policy against what is stored. +#[test] +fn policy_identity_looks_up_by_policy_id() { + let (store, _) = seeded(); + let id = store.create_for_policy("corp").unwrap().user_context_id; + + assert_eq!(store.policy_identity("corp").unwrap().user_context_id, id); + assert!(store.policy_identity("unknown").is_none()); + // The name matches, but a public container is not a policy one. + store + .create("corp", ContainerIcon::Tree, ContainerColor::Purple) + .unwrap(); + assert_eq!(store.policy_identity("corp").unwrap().user_context_id, id); +} + +/// A policy names its container whatever it likes, and that must not let it +/// stand in for one of the identities Gecko looks up by name. +#[test] +fn a_policy_container_does_not_shadow_a_system_identity() { + let (store, _) = seeded(); + + store + .create_for_policy("userContextIdInternal.thumbnail") + .unwrap(); + + let thumbnail = store + .private_identity("userContextIdInternal.thumbnail") + .expect("the system identity should still be found"); + + assert_eq!(thumbnail.user_context_id, 5); + assert_eq!(thumbnail.policy_id, None); +} + +#[test] +fn a_policy_container_reloads_identically() { + let (store, _) = seeded(); + store.create_for_policy("corp").unwrap(); + + let persisted: serde_json::Value = serde_json::from_slice(&store.serialize()).unwrap(); + let identity = persisted["identities"].as_array().unwrap().last().unwrap(); + assert_eq!(identity["policy"], json!(true)); + assert_eq!(identity["policyId"], json!("corp")); + assert_eq!(identity["public"], json!(false)); + assert_eq!(identity["name"], json!("corp")); + + // Nothing but a policy container carries the flag. + assert!(persisted["identities"][0].get("policy").is_none()); + assert!(persisted["identities"][0].get("policyId").is_none()); + + let reloaded = reloaded(&store); + assert_eq!(reloaded.policy_identities(), store.policy_identities()); + assert_eq!(reloaded.public_identities(), store.public_identities()); + assert_eq!( + reloaded.private_user_context_ids(), + store.private_user_context_ids() + ); +} diff --git a/components/containers/uniffi.toml b/components/containers/uniffi.toml new file mode 100644 index 00000000000..4e3b6b1e584 --- /dev/null +++ b/components/containers/uniffi.toml @@ -0,0 +1,3 @@ +[bindings.kotlin] +package_name = "mozilla.appservices.containers" +omit_checksums = true diff --git a/megazords/full/Cargo.toml b/megazords/full/Cargo.toml index da6d3a77133..23800e48fcf 100644 --- a/megazords/full/Cargo.toml +++ b/megazords/full/Cargo.toml @@ -23,6 +23,7 @@ rust-log-forwarder = { path = "../../components/support/rust-log-forwarder" } viaduct = { path = "../../components/viaduct", features = ["ohttp"] } nimbus-sdk = { path = "../../components/nimbus" } autofill = { path = "../../components/autofill" } +containers = { path = "../../components/containers" } crashtest = { path = "../../components/crashtest" } error-support = { path = "../../components/support/error" } suggest = { path = "../../components/suggest" } diff --git a/megazords/full/src/lib.rs b/megazords/full/src/lib.rs index c76ecbef6bd..b31c0412712 100644 --- a/megazords/full/src/lib.rs +++ b/megazords/full/src/lib.rs @@ -10,6 +10,7 @@ use std::os::raw::c_char; pub use ads_client; pub use autofill; +pub use containers; pub use crashtest; pub use error_support; pub use fxa_client;