use crate::addons::AddonSettings;
use crate::downloads::DownloadStore;
use crate::plugins::PluginRegistry;
use crate::privacy::{SearchProvider, Shields};
use crate::session::SessionStore;
use anyhow::{Context, bail};
use chrono::{DateTime, Utc};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use url::Url;
pub const DEFAULT_MAX_HISTORY: usize = 500;
pub const DEFAULT_MAX_BOOKMARKS: usize = 1_000;
pub const MAX_PROFILE_FILE_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BrowserTheme {
Professional,
Nebula,
Ember,
Glacier,
#[default]
Paper,
DarkPaper,
Cupertino,
Glass,
AgentInspect,
Ocean,
Forest,
Rose,
HighContrastLight,
HighContrastDark,
PrivateBrowsing,
Custom,
}
impl BrowserTheme {
pub const ALL: [Self; 16] = [
Self::Professional,
Self::Paper,
Self::DarkPaper,
Self::Cupertino,
Self::Glass,
Self::AgentInspect,
Self::Nebula,
Self::Ember,
Self::Glacier,
Self::Ocean,
Self::Forest,
Self::Rose,
Self::HighContrastLight,
Self::HighContrastDark,
Self::PrivateBrowsing,
Self::Custom,
];
pub fn id(&self) -> &'static str {
match self {
Self::Professional => "professional",
Self::Nebula => "nebula",
Self::Ember => "ember",
Self::Glacier => "glacier",
Self::Paper => "paper",
Self::DarkPaper => "dark-paper",
Self::Cupertino => "cupertino",
Self::Glass => "glass",
Self::AgentInspect => "agent-inspect",
Self::Ocean => "ocean",
Self::Forest => "forest",
Self::Rose => "rose",
Self::HighContrastLight => "high-contrast-light",
Self::HighContrastDark => "high-contrast-dark",
Self::PrivateBrowsing => "private-browsing",
Self::Custom => "custom",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Professional => "Browser Paper",
Self::Nebula => "Minimal Paper",
Self::Ember => "Graphite Paper",
Self::Glacier => "Edge Paper",
Self::Paper => "Paper",
Self::DarkPaper => "Dark Paper",
Self::Cupertino => "macOS Paper",
Self::Glass => "Glass",
Self::AgentInspect => "AI Inspect",
Self::Ocean => "Brave Blue",
Self::Forest => "Vivaldi Green",
Self::Rose => "Ubuntu Rose",
Self::HighContrastLight => "High Contrast Light",
Self::HighContrastDark => "High Contrast Dark",
Self::PrivateBrowsing => "Private Paper",
Self::Custom => "Custom",
}
}
pub fn css_class(&self) -> &'static str {
match self {
Self::Professional => "theme-professional",
Self::Nebula => "theme-nebula",
Self::Ember => "theme-ember",
Self::Glacier => "theme-glacier",
Self::Paper => "theme-paper",
Self::DarkPaper => "theme-dark-paper",
Self::Cupertino => "theme-cupertino",
Self::Glass => "theme-glass",
Self::AgentInspect => "theme-agent-inspect",
Self::Ocean => "theme-ocean",
Self::Forest => "theme-forest",
Self::Rose => "theme-rose",
Self::HighContrastLight => "theme-high-contrast-light",
Self::HighContrastDark => "theme-high-contrast-dark",
Self::PrivateBrowsing => "theme-private-browsing",
Self::Custom => "theme-custom",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|theme| theme.id() == id)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebAccelerationPolicy {
OnDemand,
#[default]
Always,
Never,
}
impl WebAccelerationPolicy {
pub const ALL: [Self; 2] = [Self::Always, Self::Never];
pub fn id(&self) -> &'static str {
match self {
Self::OnDemand => "on-demand",
Self::Always => "always",
Self::Never => "never",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::OnDemand => "On demand",
Self::Always => "Always",
Self::Never => "Never",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::OnDemand => {
"Legacy auto mode; Cephas upgrades this to Always for browser-like speed."
}
Self::Always => {
"Prefer GPU compositing, WebGL, accelerated canvas, and runtime WebGPU when available."
}
Self::Never => "Disable GPU acceleration for maximum compatibility.",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|policy| policy.id() == id)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MediaPlaybackPolicy {
#[default]
Balanced,
Full,
Strict,
}
impl MediaPlaybackPolicy {
pub const ALL: [Self; 3] = [Self::Balanced, Self::Full, Self::Strict];
pub fn id(&self) -> &'static str {
match self {
Self::Balanced => "balanced",
Self::Full => "full",
Self::Strict => "strict",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Balanced => "Balanced media",
Self::Full => "Full media",
Self::Strict => "Strict media",
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Balanced => {
"Enable modern playback, MediaSource, fullscreen, inline video, and encrypted media without camera or microphone capture."
}
Self::Full => {
"Enable the complete WebKit media surface, including camera and microphone capture prompts for sites that request them."
}
Self::Strict => {
"Keep playback support on, but require user gestures and keep capture devices disabled."
}
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|policy| policy.id() == id)
}
pub fn allows_capture(&self) -> bool {
matches!(self, Self::Full)
}
pub fn requires_user_gesture(&self) -> bool {
matches!(self, Self::Strict)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum StartupBehavior {
#[default]
NewTabPage,
ContinuePreviousSession,
}
impl StartupBehavior {
pub const ALL: [Self; 2] = [Self::NewTabPage, Self::ContinuePreviousSession];
pub fn id(&self) -> &'static str {
match self {
Self::NewTabPage => "new-tab-page",
Self::ContinuePreviousSession => "continue-previous-session",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::NewTabPage => "Open the New Tab page",
Self::ContinuePreviousSession => "Continue where you left off",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|behavior| behavior.id() == id)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CustomTheme {
pub name: String,
pub chrome: String,
#[serde(default = "default_custom_page")]
pub page: String,
pub surface: String,
pub text: String,
#[serde(default = "default_custom_muted")]
pub muted: String,
pub accent: String,
#[serde(default)]
pub radius: CustomThemeRadius,
#[serde(default)]
pub density: CustomThemeDensity,
}
impl Default for CustomTheme {
fn default() -> Self {
Self {
name: "Custom".to_string(),
chrome: "#202124".to_string(),
page: default_custom_page(),
surface: "#2b2d33".to_string(),
text: "#f8fafc".to_string(),
muted: default_custom_muted(),
accent: "#7aa2f7".to_string(),
radius: CustomThemeRadius::default(),
density: CustomThemeDensity::default(),
}
}
}
impl CustomTheme {
pub fn sanitized(&self) -> Self {
let default = Self::default();
Self {
name: sanitize_theme_name(&self.name),
chrome: sanitize_hex_color(&self.chrome).unwrap_or(default.chrome),
page: sanitize_hex_color(&self.page).unwrap_or(default.page),
surface: sanitize_hex_color(&self.surface).unwrap_or(default.surface),
text: sanitize_hex_color(&self.text).unwrap_or(default.text),
muted: sanitize_hex_color(&self.muted).unwrap_or(default.muted),
accent: sanitize_hex_color(&self.accent).unwrap_or(default.accent),
radius: self.radius,
density: self.density,
}
}
}
fn default_custom_page() -> String {
"#151821".to_string()
}
fn default_custom_muted() -> String {
"#a8b3c7".to_string()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CustomThemeRadius {
Square,
Soft,
#[default]
Rounded,
Pill,
}
impl CustomThemeRadius {
pub const ALL: [Self; 4] = [Self::Square, Self::Soft, Self::Rounded, Self::Pill];
pub fn id(&self) -> &'static str {
match self {
Self::Square => "square",
Self::Soft => "soft",
Self::Rounded => "rounded",
Self::Pill => "pill",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Square => "Square",
Self::Soft => "Soft",
Self::Rounded => "Rounded",
Self::Pill => "Pill",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|radius| radius.id() == id)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CustomThemeDensity {
Compact,
#[default]
Comfortable,
Spacious,
}
impl CustomThemeDensity {
pub const ALL: [Self; 3] = [Self::Compact, Self::Comfortable, Self::Spacious];
pub fn id(&self) -> &'static str {
match self {
Self::Compact => "compact",
Self::Comfortable => "comfortable",
Self::Spacious => "spacious",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Compact => "Compact",
Self::Comfortable => "Comfortable",
Self::Spacious => "Spacious",
}
}
pub fn from_id(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|density| density.id() == id)
}
}
fn sanitize_theme_name(name: &str) -> String {
let name = name.trim();
if name.is_empty() {
return "Custom".to_string();
}
name.chars().take(32).collect()
}
pub fn sanitize_hex_color(value: &str) -> Option<String> {
let value = value.trim();
let value = value.strip_prefix('#').unwrap_or(value);
if value.len() == 6 && value.chars().all(|ch| ch.is_ascii_hexdigit()) {
Some(format!("#{value}").to_ascii_lowercase())
} else {
None
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bookmark {
pub title: String,
pub url: Url,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryEntry {
pub title: String,
pub url: Url,
pub visited_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
#[serde(default = "default_home_url")]
pub home: Url,
#[serde(default)]
pub theme: BrowserTheme,
#[serde(default)]
pub custom_theme: CustomTheme,
#[serde(default)]
pub addons: AddonSettings,
#[serde(default)]
pub plugins: PluginRegistry,
#[serde(default)]
pub ai_browsing_enabled: bool,
#[serde(default)]
pub web_acceleration: WebAccelerationPolicy,
#[serde(default)]
pub media_playback: MediaPlaybackPolicy,
#[serde(default)]
pub startup_behavior: StartupBehavior,
#[serde(default)]
pub search_provider: SearchProvider,
#[serde(default)]
pub shields: Shields,
#[serde(default)]
pub bookmarks: Vec<Bookmark>,
#[serde(default)]
pub history: Vec<HistoryEntry>,
#[serde(default = "default_max_bookmarks")]
pub max_bookmarks: usize,
#[serde(default = "default_max_history")]
pub max_history: usize,
}
impl Default for Profile {
fn default() -> Self {
Self {
home: default_home_url(),
theme: BrowserTheme::default(),
custom_theme: CustomTheme::default(),
addons: AddonSettings::default(),
plugins: PluginRegistry::default(),
ai_browsing_enabled: false,
web_acceleration: WebAccelerationPolicy::default(),
media_playback: MediaPlaybackPolicy::default(),
startup_behavior: StartupBehavior::default(),
search_provider: SearchProvider::default(),
shields: Shields::default(),
bookmarks: Vec::new(),
history: Vec::new(),
max_bookmarks: DEFAULT_MAX_BOOKMARKS,
max_history: DEFAULT_MAX_HISTORY,
}
}
}
impl Profile {
pub fn enforce_limits(&mut self) {
self.custom_theme = self.custom_theme.sanitized();
if self.web_acceleration == WebAccelerationPolicy::OnDemand {
self.web_acceleration = WebAccelerationPolicy::Always;
}
if !matches!(self.home.scheme(), "http" | "https") {
self.home = default_home_url();
}
self.shields.enforce_limits();
self.plugins.enforce_limits();
self.max_history = self.max_history.clamp(1, DEFAULT_MAX_HISTORY);
self.max_bookmarks = self.max_bookmarks.clamp(1, DEFAULT_MAX_BOOKMARKS);
let max_history = self.max_history;
if self.history.len() > max_history {
let overflow = self.history.len() - max_history;
self.history.drain(0..overflow);
}
let max_bookmarks = self.max_bookmarks;
if self.bookmarks.len() > max_bookmarks {
let overflow = self.bookmarks.len() - max_bookmarks;
self.bookmarks.drain(0..overflow);
}
}
pub fn record_visit(&mut self, title: impl Into<String>, url: Url) {
self.history.push(HistoryEntry {
title: title.into(),
url,
visited_at: Utc::now(),
});
self.enforce_limits();
}
pub fn add_bookmark(&mut self, title: impl Into<String>, url: Url) -> bool {
if self.bookmarks.iter().any(|bookmark| bookmark.url == url) {
return false;
}
if self.bookmarks.len() >= self.max_bookmarks.max(1) {
return false;
}
self.bookmarks.push(Bookmark {
title: title.into(),
url,
created_at: Utc::now(),
});
true
}
pub fn forget_history_for_host(&mut self, host: &str) -> usize {
let before = self.history.len();
self.history
.retain(|entry| entry.url.host_str() != Some(host));
before - self.history.len()
}
}
fn default_home_url() -> Url {
Url::parse("https://www.google.com/").expect("valid default home URL")
}
fn default_max_bookmarks() -> usize {
DEFAULT_MAX_BOOKMARKS
}
fn default_max_history() -> usize {
DEFAULT_MAX_HISTORY
}
#[derive(Debug, Clone)]
pub struct ProfileStore {
path: PathBuf,
}
impl ProfileStore {
pub fn new(path: Option<PathBuf>) -> anyhow::Result<Self> {
let path = match path {
Some(path) => path,
None => default_profile_path()?,
};
Ok(Self { path })
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn session_store(&self) -> SessionStore {
SessionStore::new(self.sibling_path("session.json"))
}
pub fn download_store(&self) -> DownloadStore {
DownloadStore::new(self.sibling_path("downloads.json"))
}
pub fn load(&self) -> anyhow::Result<Profile> {
if !self.path.exists() {
return Ok(Profile::default());
}
let data = read_bounded_to_string(&self.path, MAX_PROFILE_FILE_BYTES)?;
let mut profile: Profile = serde_json::from_str(&data)
.with_context(|| format!("failed to parse profile {}", self.path.display()))?;
profile.enforce_limits();
Ok(profile)
}
pub fn save(&self, profile: &Profile) -> anyhow::Result<()> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("failed to create profile directory {}", parent.display())
})?;
}
let mut profile = profile.clone();
profile.enforce_limits();
let tmp = self.path.with_extension("tmp");
fs::write(&tmp, serde_json::to_vec(&profile)?)
.with_context(|| format!("failed to write profile temp file {}", tmp.display()))?;
fs::rename(&tmp, &self.path)
.with_context(|| format!("failed to replace profile {}", self.path.display()))
}
fn sibling_path(&self, file_name: &str) -> PathBuf {
self.path
.parent()
.map(|parent| parent.join(file_name))
.unwrap_or_else(|| PathBuf::from(file_name))
}
}
fn read_bounded_to_string(path: &Path, max_bytes: u64) -> anyhow::Result<String> {
let size = fs::metadata(path)
.with_context(|| format!("failed to inspect profile {}", path.display()))?
.len();
if size > max_bytes {
bail!(
"profile file {} is too large: {size} bytes exceeds {max_bytes}",
path.display()
);
}
fs::read_to_string(path).with_context(|| format!("failed to read profile {}", path.display()))
}
fn default_profile_path() -> anyhow::Result<PathBuf> {
let dirs = ProjectDirs::from("dev", "Cephas", "Cephas")
.context("could not determine platform profile directory")?;
Ok(dirs.data_local_dir().join("profile.json"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = ProfileStore::new(Some(dir.path().join("profile.json"))).unwrap();
let mut profile = Profile {
ai_browsing_enabled: true,
..Profile::default()
};
profile.add_bookmark("Example", Url::parse("https://example.com/").unwrap());
store.save(&profile).unwrap();
let loaded = store.load().unwrap();
assert!(loaded.ai_browsing_enabled);
assert_eq!(loaded.bookmarks.len(), 1);
assert_eq!(loaded.bookmarks[0].title, "Example");
}
#[test]
fn profile_load_rejects_oversized_file_before_parse() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("profile.json");
fs::write(&path, vec![b' '; (MAX_PROFILE_FILE_BYTES + 1) as usize]).unwrap();
let store = ProfileStore::new(Some(path)).unwrap();
assert!(store.load().is_err());
}
#[test]
fn profile_loads_when_new_fields_are_missing() {
let mut value = serde_json::to_value(Profile::default()).unwrap();
let object = value.as_object_mut().unwrap();
object.remove("home");
object.remove("theme");
object.remove("custom_theme");
object.remove("addons");
object.remove("plugins");
object.remove("ai_browsing_enabled");
object.remove("web_acceleration");
object.remove("media_playback");
object.remove("startup_behavior");
object.remove("search_provider");
object.remove("shields");
object.remove("bookmarks");
object.remove("history");
object.remove("max_bookmarks");
object.remove("max_history");
let loaded: Profile = serde_json::from_value(value).unwrap();
assert_eq!(loaded.home, default_home_url());
assert_eq!(loaded.theme, BrowserTheme::Paper);
assert!(loaded.bookmarks.is_empty());
assert!(loaded.history.is_empty());
assert_eq!(loaded.max_bookmarks, DEFAULT_MAX_BOOKMARKS);
assert_eq!(loaded.max_history, DEFAULT_MAX_HISTORY);
assert_eq!(loaded.custom_theme, CustomTheme::default());
assert!(loaded.addons.enabled);
assert!(!loaded.addons.installed.is_empty());
assert!(loaded.plugins.enabled);
assert!(!loaded.plugins.installed.is_empty());
assert!(!loaded.ai_browsing_enabled);
assert_eq!(loaded.web_acceleration, WebAccelerationPolicy::Always);
assert_eq!(loaded.media_playback, MediaPlaybackPolicy::Balanced);
assert_eq!(loaded.startup_behavior, StartupBehavior::NewTabPage);
assert_eq!(loaded.search_provider, SearchProvider::Google);
}
#[test]
fn built_in_theme_ids_are_unique_and_resolvable() {
let mut ids = std::collections::BTreeSet::new();
for theme in BrowserTheme::ALL {
assert!(ids.insert(theme.id()));
assert_eq!(BrowserTheme::from_id(theme.id()), Some(theme));
assert!(!theme.label().trim().is_empty());
assert!(!theme.css_class().trim().is_empty());
}
}
#[test]
fn web_acceleration_policy_ids_are_unique_and_resolvable() {
let mut ids = std::collections::BTreeSet::new();
for policy in WebAccelerationPolicy::ALL {
assert!(ids.insert(policy.id()));
assert_eq!(WebAccelerationPolicy::from_id(policy.id()), Some(policy));
assert!(!policy.label().trim().is_empty());
assert!(!policy.description().trim().is_empty());
}
}
#[test]
fn profile_upgrades_legacy_on_demand_acceleration() {
let mut profile = Profile {
web_acceleration: WebAccelerationPolicy::OnDemand,
..Profile::default()
};
profile.enforce_limits();
assert_eq!(profile.web_acceleration, WebAccelerationPolicy::Always);
}
#[test]
fn media_playback_policy_ids_are_unique_and_resolvable() {
let mut ids = std::collections::BTreeSet::new();
for policy in MediaPlaybackPolicy::ALL {
assert!(ids.insert(policy.id()));
assert_eq!(MediaPlaybackPolicy::from_id(policy.id()), Some(policy));
assert!(!policy.label().trim().is_empty());
assert!(!policy.description().trim().is_empty());
}
assert!(!MediaPlaybackPolicy::Balanced.allows_capture());
assert!(MediaPlaybackPolicy::Full.allows_capture());
assert!(MediaPlaybackPolicy::Strict.requires_user_gesture());
}
#[test]
fn startup_behavior_ids_are_unique_and_resolvable() {
let mut ids = std::collections::BTreeSet::new();
for behavior in StartupBehavior::ALL {
assert!(ids.insert(behavior.id()));
assert_eq!(StartupBehavior::from_id(behavior.id()), Some(behavior));
assert!(!behavior.label().trim().is_empty());
}
}
#[test]
fn custom_theme_sanitizes_invalid_values() {
let mut profile = Profile {
custom_theme: CustomTheme {
name: " My Theme With A Very Long Name That Will Be Trimmed ".to_string(),
chrome: "bad".to_string(),
page: "#010203".to_string(),
surface: "112233".to_string(),
text: "#ABCDEF".to_string(),
muted: "missing".to_string(),
accent: "#12xz99".to_string(),
radius: CustomThemeRadius::Pill,
density: CustomThemeDensity::Compact,
},
..Profile::default()
};
profile.enforce_limits();
assert_eq!(profile.custom_theme.name.len(), 32);
assert_eq!(profile.custom_theme.chrome, CustomTheme::default().chrome);
assert_eq!(profile.custom_theme.page, "#010203");
assert_eq!(profile.custom_theme.surface, "#112233");
assert_eq!(profile.custom_theme.text, "#abcdef");
assert_eq!(profile.custom_theme.muted, CustomTheme::default().muted);
assert_eq!(profile.custom_theme.accent, CustomTheme::default().accent);
assert_eq!(profile.custom_theme.radius, CustomThemeRadius::Pill);
assert_eq!(profile.custom_theme.density, CustomThemeDensity::Compact);
}
#[test]
fn custom_theme_loads_when_new_fields_are_missing() {
let value = serde_json::json!({
"name": "Legacy Custom",
"chrome": "#101010",
"surface": "#202020",
"text": "#f0f0f0",
"accent": "#3366ff"
});
let theme: CustomTheme = serde_json::from_value(value).unwrap();
assert_eq!(theme.page, CustomTheme::default().page);
assert_eq!(theme.muted, CustomTheme::default().muted);
assert_eq!(theme.radius, CustomThemeRadius::Rounded);
assert_eq!(theme.density, CustomThemeDensity::Comfortable);
}
#[test]
fn custom_theme_option_ids_are_unique_and_resolvable() {
let mut radius_ids = std::collections::BTreeSet::new();
for radius in CustomThemeRadius::ALL {
assert!(radius_ids.insert(radius.id()));
assert_eq!(CustomThemeRadius::from_id(radius.id()), Some(radius));
assert!(!radius.label().is_empty());
}
let mut density_ids = std::collections::BTreeSet::new();
for density in CustomThemeDensity::ALL {
assert!(density_ids.insert(density.id()));
assert_eq!(CustomThemeDensity::from_id(density.id()), Some(density));
assert!(!density.label().is_empty());
}
}
#[test]
fn profile_enforces_history_and_bookmark_limits() {
let mut profile = Profile {
max_history: 2,
max_bookmarks: 1,
..Profile::default()
};
for index in 0..4 {
profile.record_visit(
format!("Page {index}"),
Url::parse(&format!("https://example.com/{index}")).unwrap(),
);
}
assert_eq!(profile.history.len(), 2);
assert_eq!(profile.history[0].title, "Page 2");
assert!(profile.add_bookmark("One", Url::parse("https://one.example/").unwrap()));
assert!(!profile.add_bookmark("Two", Url::parse("https://two.example/").unwrap()));
assert_eq!(profile.bookmarks.len(), 1);
for index in 0..(crate::privacy::DEFAULT_MAX_SITE_ALLOWLIST + 10) {
profile
.shields
.site_allowlist
.insert(format!("allow-{index}.example"));
}
for index in 0..(crate::privacy::DEFAULT_MAX_BLOCKED_HOSTS + 10) {
profile
.shields
.blocked_hosts
.insert(format!("blocked-{index}.example"));
}
profile.enforce_limits();
assert_eq!(profile.max_history, 2);
assert_eq!(profile.max_bookmarks, 1);
assert_eq!(
profile.shields.site_allowlist.len(),
crate::privacy::DEFAULT_MAX_SITE_ALLOWLIST
);
assert_eq!(
profile.shields.blocked_hosts.len(),
crate::privacy::DEFAULT_MAX_BLOCKED_HOSTS
);
}
#[test]
fn profile_clamps_tampered_resource_maxima() {
let mut profile = Profile {
max_history: DEFAULT_MAX_HISTORY + 10_000,
max_bookmarks: DEFAULT_MAX_BOOKMARKS + 10_000,
..Profile::default()
};
profile.enforce_limits();
assert_eq!(profile.max_history, DEFAULT_MAX_HISTORY);
assert_eq!(profile.max_bookmarks, DEFAULT_MAX_BOOKMARKS);
profile.max_history = 0;
profile.max_bookmarks = 0;
profile.enforce_limits();
assert_eq!(profile.max_history, 1);
assert_eq!(profile.max_bookmarks, 1);
}
#[test]
fn profile_enforces_safe_home_scheme() {
let mut profile = Profile {
home: Url::parse("file:///etc/passwd").unwrap(),
..Profile::default()
};
profile.enforce_limits();
assert_eq!(profile.home, default_home_url());
}
}