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