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