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 c901cd13446..2c72c068e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ ## ✨ What's Changed ✨ +### Containers + +- Created a new component, `containers`, holding the list of Firefox containers and the format they are stored in. + ### 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 625905a7e70..43aed3bc432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,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.3", + "uniffi", +] + [[package]] name = "context_id" version = "0.1.0" @@ -2605,6 +2618,7 @@ version = "0.1.0" dependencies = [ "ads-client", "autofill", + "containers", "crashtest", "error-support", "fxa-client", diff --git a/Cargo.toml b/Cargo.toml index c8379d4d827..5cd63c896ed 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..2e62bf32cdb --- /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.31" } diff --git a/components/containers/README.md b/components/containers/README.md new file mode 100644 index 00000000000..28910a55d78 --- /dev/null +++ b/components/containers/README.md @@ -0,0 +1,27 @@ +# 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 + +## 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..6adb5c4a55b --- /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; + +/// How a container gets its label. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Enum)] +pub enum ContainerLabel { + /// Empty only for a stored container that carries no usable label at all. + Name { name: String }, + /// An identifier the embedder resolves against its own catalogue. + L10nId { l10n_id: String }, +} + +/// 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: String, + pub color: String, + pub label: ContainerLabel, +} + +impl Container { + pub(crate) fn from_identity(identity: &Identity) -> Self { + Self { + user_context_id: identity.user_context_id, + is_public: identity.public, + icon: identity.icon.clone(), + color: identity.color.clone(), + label: match (&identity.name, &identity.l10n_id) { + (Some(name), _) if !name.is_empty() => ContainerLabel::Name { name: name.clone() }, + (_, Some(l10n_id)) => ContainerLabel::L10nId { + l10n_id: l10n_id.clone(), + }, + _ => 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..de2fbaa0299 --- /dev/null +++ b/components/containers/src/data.rs @@ -0,0 +1,67 @@ +/* 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 = 6; + +/// 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 = "Option::is_none")] + pub l10n_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 find_private_by_name(&self, name: &str) -> Option<&Identity> { + self.identities + .iter() + .find(|identity| !identity.public && identity.name.as_deref() == Some(name)) + } +} diff --git a/components/containers/src/defaults.rs b/components/containers/src/defaults.rs new file mode 100644 index 00000000000..f5a75b13faf --- /dev/null +++ b/components/containers/src/defaults.rs @@ -0,0 +1,134 @@ +/* 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, Value}; + +use crate::container::ContainerLabel; +use crate::data::{ContainersData, Identity, LATEST_VERSION, MAX_USER_CONTEXT_ID}; +use crate::definitions; +use crate::error::InitError; + +pub(crate) const THUMBNAIL_IDENTITY_NAME: &str = "userContextIdInternal.thumbnail"; +pub(crate) const WEBEXT_STORAGE_LOCAL_IDENTITY_NAME: &str = + "userContextIdInternal.webextStorageLocal"; + +/// A public identity to seed a fresh store with. Enterprise policy can replace +/// the shipped set, so the caller may supply its own. +/// +/// Icon and color are validated when the store is opened rather than when the +/// value is built, so that this stays a plain record the embedder constructs +/// directly. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct UserIdentitySpec { + pub icon: String, + pub color: String, + pub label: ContainerLabel, +} + +impl UserIdentitySpec { + /// Rejects an icon or color the crate cannot render, resolving legacy color + /// names the way the WebExtension boundary does. + pub(crate) fn validated(&self) -> Result { + if !definitions::is_known_icon(&self.icon) { + return Err(InitError::InvalidSeedIcon { + icon: self.icon.clone(), + }); + } + + let color = definitions::canonical_color(&self.color).ok_or_else(|| { + InitError::InvalidSeedColor { + color: self.color.clone(), + } + })?; + + Ok(Self { + color, + ..self.clone() + }) + } + + fn localized(icon: &str, color: &str, l10n_id: &str) -> Self { + Self { + icon: icon.to_string(), + color: color.to_string(), + label: ContainerLabel::L10nId { + l10n_id: l10n_id.to_string(), + }, + } + } +} + +pub(crate) fn shipped_user_identities() -> Vec { + vec![ + UserIdentitySpec::localized("fingerprint", "blue", "user-context-personal"), + UserIdentitySpec::localized("briefcase", "orange", "user-context-work"), + UserIdentitySpec::localized("dollar", "green", "user-context-banking"), + UserIdentitySpec::localized("cart", "pink", "user-context-shopping"), + ] +} + +/// The system identities still carry an empty `accessKey`, which predates the +/// move to Fluent. Kept so that a freshly seeded store matches what Firefox +/// writes today. +fn system_identity(user_context_id: u32, name: &str) -> Identity { + let mut extra = Map::new(); + extra.insert("accessKey".to_string(), Value::String(String::new())); + + Identity { + user_context_id, + public: false, + icon: String::new(), + color: String::new(), + name: Some(name.to_string()), + l10n_id: None, + extra, + } +} + +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) +} + +pub(crate) fn defaults() -> ContainersData { + defaults_with(&shipped_user_identities()) +} + +pub(crate) fn defaults_with(user_identities: &[UserIdentitySpec]) -> ContainersData { + let mut identities = Vec::with_capacity(user_identities.len() + 2); + let mut next_user_context_id = 1; + + for spec in user_identities { + let (name, l10n_id) = match &spec.label { + ContainerLabel::Name { name } => (Some(name.clone()), None), + ContainerLabel::L10nId { l10n_id } => (None, Some(l10n_id.clone())), + }; + + identities.push(Identity { + user_context_id: next_user_context_id, + public: true, + icon: spec.icon.clone(), + color: spec.color.clone(), + name, + l10n_id, + 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..662d215531c --- /dev/null +++ b/components/containers/src/definitions.rs @@ -0,0 +1,238 @@ +/* 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; + +/// The tables are static and borrowed; the types the embedder sees own their +/// strings, because borrowed data cannot cross an FFI boundary. +struct ColorDef { + name: &'static str, + code: &'static str, + code_nova: &'static str, + l10n_id: &'static str, +} + +struct IconDef { + name: &'static str, + l10n_id: &'static str, +} + +/// `code` is the legacy value, `code_nova` the refreshed one; picking between +/// them depends on a setting the embedder owns, so both are exposed and the +/// caller decides. +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ContainerColor { + pub name: String, + pub code: String, + pub code_nova: String, + pub l10n_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, uniffi::Record)] +pub struct ContainerIcon { + pub name: String, + pub l10n_id: String, +} + +const COLORS: &[ColorDef] = &[ + ColorDef { + name: "gray", + code: "#7c7c7d", + code_nova: "#949297", + l10n_id: "user-context-color-gray", + }, + ColorDef { + name: "yellow", + code: "#ffcb00", + code_nova: "#db820e", + l10n_id: "user-context-color-yellow", + }, + ColorDef { + name: "orange", + code: "#ff9f00", + code_nova: "#f4682c", + l10n_id: "user-context-color-orange", + }, + ColorDef { + name: "red", + code: "#ff613d", + code_nova: "#ed566e", + l10n_id: "user-context-color-red", + }, + ColorDef { + name: "pink", + code: "#ff4bda", + code_nova: "#db54bf", + l10n_id: "user-context-color-pink", + }, + ColorDef { + name: "purple", + code: "#af51f5", + code_nova: "#b864ee", + l10n_id: "user-context-color-purple", + }, + ColorDef { + name: "violet", + code: "#764edd", + code_nova: "#9871ff", + l10n_id: "user-context-color-violet", + }, + ColorDef { + name: "blue", + code: "#37adff", + code_nova: "#5a87fd", + l10n_id: "user-context-color-blue", + }, + ColorDef { + name: "cyan", + code: "#00c79a", + code_nova: "#10a4ca", + l10n_id: "user-context-color-cyan", + }, + ColorDef { + name: "green", + code: "#51cd00", + code_nova: "#11ae84", + l10n_id: "user-context-color-green", + }, +]; + +/// 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")]; + +const ICONS: &[IconDef] = &[ + IconDef { + name: "fingerprint", + l10n_id: "user-context-icon-fingerprint", + }, + IconDef { + name: "briefcase", + l10n_id: "user-context-icon-briefcase", + }, + IconDef { + name: "dollar", + l10n_id: "user-context-icon-dollar", + }, + IconDef { + name: "cart", + l10n_id: "user-context-icon-cart", + }, + IconDef { + name: "vacation", + l10n_id: "user-context-icon-vacation", + }, + IconDef { + name: "gift", + l10n_id: "user-context-icon-gift", + }, + IconDef { + name: "food", + l10n_id: "user-context-icon-food", + }, + IconDef { + name: "fruit", + l10n_id: "user-context-icon-fruit", + }, + IconDef { + name: "pet", + l10n_id: "user-context-icon-pet", + }, + IconDef { + name: "tree", + l10n_id: "user-context-icon-tree", + }, + IconDef { + name: "chill", + l10n_id: "user-context-icon-chill", + }, + IconDef { + name: "circle", + l10n_id: "user-context-icon-circle", + }, + IconDef { + name: "fence", + l10n_id: "user-context-icon-fence", + }, +]; + +#[uniffi::export] +pub fn container_colors() -> Vec { + COLORS + .iter() + .map(|color| ContainerColor { + name: color.name.to_string(), + code: color.code.to_string(), + code_nova: color.code_nova.to_string(), + l10n_id: color.l10n_id.to_string(), + }) + .collect() +} + +#[uniffi::export] +pub fn container_icons() -> Vec { + ICONS + .iter() + .map(|icon| ContainerIcon { + name: icon.name.to_string(), + l10n_id: icon.l10n_id.to_string(), + }) + .collect() +} + +#[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()) +} + +fn find_color(name: &str) -> Option<&'static ColorDef> { + COLORS.iter().find(|color| color.name == name) +} + +fn find_icon(name: &str) -> Option<&'static IconDef> { + ICONS.iter().find(|icon| icon.name == name) +} + +#[uniffi::export] +pub fn color_code(name: &str, nova: bool) -> Option { + find_color(name).map(|color| { + if nova { + color.code_nova.to_string() + } else { + color.code.to_string() + } + }) +} + +#[uniffi::export] +pub fn color_l10n_id(name: &str) -> Option { + find_color(name).map(|color| color.l10n_id.to_string()) +} + +#[uniffi::export] +pub fn icon_l10n_id(name: &str) -> Option { + find_icon(name).map(|icon| icon.l10n_id.to_string()) +} + +pub(crate) fn canonical_color(name: &str) -> Option { + let resolved = resolve_color(name); + find_color(&resolved).map(|_| resolved) +} + +#[uniffi::export] +pub fn is_known_icon(name: &str) -> bool { + find_icon(name).is_some() +} diff --git a/components/containers/src/error.rs b/components/containers/src/error.rs new file mode 100644 index 00000000000..771b50a4833 --- /dev/null +++ b/components/containers/src/error.rs @@ -0,0 +1,112 @@ +/* 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 }, + #[error("unknown container icon in seed: {icon}")] + InvalidSeedIcon { icon: String }, + #[error("unknown container color in seed: {color}")] + InvalidSeedColor { color: String }, +} + +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") + } + + // The seed is the embedder's to get right. + Self::InvalidSeedIcon { .. } | Self::InvalidSeedColor { .. } => { + ErrorHandling::convert(self.clone()).log_warning() + } + } + } +} + +/// 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("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..e92edd8d697 --- /dev/null +++ b/components/containers/src/format/migrations.rs @@ -0,0 +1,59 @@ +/* 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.l10n_id = Some(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; +} diff --git a/components/containers/src/format/mod.rs b/components/containers/src/format/mod.rs new file mode 100644 index 00000000000..72b34943244 --- /dev/null +++ b/components/containers/src/format/mod.rs @@ -0,0 +1,62 @@ +/* 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 != 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..e3b5c986e76 --- /dev/null +++ b/components/containers/src/format/tests.rs @@ -0,0 +1,313 @@ +/* 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 on. +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" }), + ] +} + +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") +} + +#[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") + })); +} + +#[test] +fn version_3_adds_the_reserved_identity_and_migrates_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_eq!( + data.public_identities() + .filter(|identity| identity.l10n_id.is_some()) + .count(), + 4 + ); + 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_eq!( + data.public_identities() + .filter(|identity| identity.l10n_id.is_some()) + .count(), + 4 + ); +} + +#[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!( + data.identities + .iter() + .find(|identity| identity.l10n_id.as_deref() == Some("user-context-personal")) + .unwrap() + .color, + "blue" + ); + // The system identities have no color to resolve. + assert_eq!(named(&data, "userContextIdInternal.thumbnail").color, ""); +} + +#[test] +fn version_6_is_loaded_verbatim() { + 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, migrated) = parse(&bytes(json!({ + "version": 6, + "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": fluent_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 = fluent_defaults(); + identities.push(identity); + + let data = load(json!({ + "version": 6, + "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") + ); +} + +#[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()); +} + +#[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..e1664565183 --- /dev/null +++ b/components/containers/src/lib.rs @@ -0,0 +1,42 @@ +/* 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, ContainerLabel}; +pub use defaults::UserIdentitySpec; +pub use definitions::{ + color_code, color_l10n_id, container_color_aliases, container_colors, container_icons, + icon_l10n_id, is_known_icon, resolve_color, ContainerColor, ContainerIcon, +}; +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..3a7cdbbde0c --- /dev/null +++ b/components/containers/src/store.rs @@ -0,0 +1,376 @@ +/* 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, UserIdentitySpec}; +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, + callback: RwLock>, +} + +#[uniffi::export] +impl ContainersStore { + /// Seeds the store from a stored document, or from the defaults when + /// `bytes` is `None`. If a migration ran, the document is persisted before + /// this returns. + /// + /// `seed` replaces the shipped public identities, for embedders that let + /// enterprise policy define them. It is only consulted when there is no + /// stored document to load. + #[uniffi::constructor] + #[handle_error(InitError)] + pub fn new( + bytes: Option>, + seed: Option>, + callback: Box, + ) -> Result { + let (data, migrated) = match bytes { + Some(bytes) => parse(&bytes)?, + None => { + let data = match seed { + Some(seed) => { + let seed = seed + .iter() + .map(UserIdentitySpec::validated) + .collect::, _>>()?; + defaults::defaults_with(&seed) + } + None => defaults::defaults(), + }; + (data, true) + } + }; + + let store = Self { + data: Mutex::new(data), + 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(Container::from_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(Container::from_identity) + } + + pub fn private_identity(&self, name: &str) -> Option { + self.data() + .find_private_by_name(name) + .map(Container::from_identity) + } + + #[handle_error(StoreError)] + pub fn create(&self, name: &str, icon: &str, color: &str) -> Result { + if name.trim().is_empty() { + return Err(StoreError::EmptyName); + } + + let identity = { + let mut data = self.data(); + + // 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); + } + let user_context_id = data.last_user_context_id + 1; + data.last_user_context_id = user_context_id; + + let identity = Identity { + user_context_id, + public: true, + icon: icon.to_string(), + color: color.to_string(), + name: Some(name.to_string()), + l10n_id: None, + extra: Default::default(), + }; + data.identities.push(identity.clone()); + + identity + }; + + self.persist(); + + Ok(Container::from_identity(&identity)) + } + + #[handle_error(StoreError)] + pub fn update( + &self, + user_context_id: u32, + name: &str, + icon: &str, + color: &str, + ) -> 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.to_string(); + identity.color = color.to_string(); + identity.l10n_id = None; + + identity.clone() + }; + + self.persist(); + + Ok(Some(Container::from_identity(&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(Container::from_identity(&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() + } + + pub fn container_for_navigation(&self, host: &str, baseline_user_context_id: u32) -> u32 { + match self.get_site_association(host) { + 0 => baseline_user_context_id, + user_context_id => 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() + } + + 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..66c76313960 --- /dev/null +++ b/components/containers/tests/store.rs @@ -0,0 +1,540 @@ +/* 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, ContainerLabel, ContainersCallback, ContainersStore, InitError, + StoreError, UserIdentitySpec, +}; + +#[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": 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": 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, "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", "tree", "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(" ", "tree", "purple"), + Err(StoreError::EmptyName) + ); + assert_eq!(store.public_identities().len(), 4); + assert_eq!(recorder.persist_count(), before); +} + +#[test] +fn update_replaces_the_label_and_drops_the_fluent_id() { + let (store, _) = seeded(); + + let updated = store.update(1, "Mine", "fence", "red").unwrap().unwrap(); + + assert_eq!( + updated.label, + ContainerLabel::Name { + name: "Mine".into() + } + ); + assert_eq!(updated.icon, "fence"); + assert_eq!(updated.color, "red"); + + // A non-empty name wins whether or not the Fluent id is still there, so + // only the stored document can show that it is gone. + 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()); +} + +#[test] +fn update_ignores_unknown_and_private_containers() { + let (store, _) = seeded(); + + assert!(store.update(999, "Mine", "fence", "red").unwrap().is_none()); + // The thumbnail identity is private. + assert!(store.update(5, "Mine", "fence", "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 container_for_navigation_falls_back_to_the_baseline() { + let (store, _) = seeded(); + store.set_site_association("example.org", 3).unwrap(); + + assert_eq!(store.container_for_navigation("example.org", 0), 3); + assert_eq!(store.container_for_navigation("example.org", 7), 3); + assert_eq!(store.container_for_navigation("other.example", 7), 7); + assert_eq!(store.container_for_navigation("", 7), 7); +} + +#[test] +fn mutations_are_visible_in_the_serialized_document() { + let (store, _) = seeded(); + + store.create("Reading", "tree", "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", "tree", "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", "circle", "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", "circle", "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(" ", "tree", "purple").is_err()); + + assert_eq!( + store + .create("Reading", "tree", "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", "tree", "purple").unwrap(); + assert_eq!( + reloaded(&store).public_identities(), + store.public_identities() + ); + + store.update(1, "Mine", "fence", "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}"), "circle", "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: &str, color: &str) -> UserIdentitySpec { + UserIdentitySpec { + icon: icon.to_string(), + color: color.to_string(), + label: ContainerLabel::Name { + name: name.to_string(), + }, + } +} + +fn seeded_with(spec: UserIdentitySpec) -> Result { + ContainersStore::new(None, Some(vec![spec]), Box::new(Recorder::default())) +} + +#[test] +fn a_seed_identity_rejects_an_unknown_icon_or_color() { + assert_eq!( + seeded_with(seed("Work", "spaceship", "blue")) + .err() + .unwrap(), + InitError::InvalidSeedIcon { + icon: "spaceship".into() + } + ); + assert_eq!( + seeded_with(seed("Work", "briefcase", "chartreuse")) + .err() + .unwrap(), + InitError::InvalidSeedColor { + color: "chartreuse".into() + } + ); +} + +#[test] +fn a_seed_identity_resolves_legacy_colors() { + let store = + seeded_with(seed("Work", "briefcase", "turquoise")).expect("a legacy color is accepted"); + + let containers = store.public_identities(); + assert_eq!(containers.len(), 1); + assert_eq!(containers[0].color, "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 localized id, and a container with neither is not left +/// without a label. +#[test] +fn the_label_follows_geckos_precedence() { + let document = serde_json::to_vec(&json!({ + "version": 6, + "lastUserContextId": 3, + "identities": [ + { "userContextId": 1, "public": true, "icon": "cart", "color": "blue", + "name": "Renamed", "l10nId": "user-context-personal" }, + { "userContextId": 2, "public": true, "icon": "cart", "color": "blue", + "name": "", "l10nId": "user-context-work" }, + { "userContextId": 3, "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::L10nId { + l10n_id: "user-context-work".into() + } + ); + assert_eq!( + label(3), + ContainerLabel::Name { + name: String::new() + } + ); +} 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/fenix-dylib/megazord_stub.c b/megazords/fenix-dylib/megazord_stub.c index 2d59c462746..5653b8cfd12 100644 --- a/megazords/fenix-dylib/megazord_stub.c +++ b/megazords/fenix-dylib/megazord_stub.c @@ -12,6 +12,7 @@ // This is an arbitrary choice - any symbol will do, but we chose these because every uniffi crate has it. extern int MOZ_EXPORT ffi_ads_client_uniffi_contract_version(); extern int MOZ_EXPORT ffi_autofill_uniffi_contract_version(); +extern int MOZ_EXPORT ffi_containers_uniffi_contract_version(); extern int MOZ_EXPORT ffi_crashtest_uniffi_contract_version(); extern int MOZ_EXPORT ffi_fxa_client_uniffi_contract_version(); extern int MOZ_EXPORT ffi_init_rust_components_uniffi_contract_version(); @@ -76,6 +77,7 @@ extern int MOZ_EXPORT UNIFFI_META_SYNC15_ENUM_DEVICETYPE(); void _local_megazord_dummy_symbol() { ffi_ads_client_uniffi_contract_version(); ffi_autofill_uniffi_contract_version(); + ffi_containers_uniffi_contract_version(); ffi_crashtest_uniffi_contract_version(); ffi_fxa_client_uniffi_contract_version(); ffi_init_rust_components_uniffi_contract_version(); 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 62b19107279..57d1ea64b6e 100644 --- a/megazords/full/src/lib.rs +++ b/megazords/full/src/lib.rs @@ -11,6 +11,7 @@ use std::os::raw::c_char; // NOTE if you add or remove crates below here, please make a corresponding change in ../fenix-dylib/megazord_stub.c pub use ads_client; pub use autofill; +pub use containers; pub use crashtest; pub use error_support; pub use fxa_client;