1use std::collections::{HashMap, HashSet};
11
12use serde_json::{json, Value};
13
14use crate::backend::browser_profiles_store::BrowserProfile;
15use crate::backend::obj::{Block, MetaMapType};
16use crate::server::AppState;
17
18pub(crate) const IDENTITY_META_KEY: &str = "browser:identity";
19
20pub(crate) const MAX_INCOGNITO_JARS: usize = 8;
23
24pub(crate) fn is_incognito(identity: &str) -> bool {
26 identity
27 .strip_prefix("incognito:")
28 .is_some_and(|jar| (8..=64).contains(&jar.len()) && jar.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-'))
29}
30
31pub(crate) fn profile_id(identity: &str) -> Option<&str> {
33 identity
34 .strip_prefix("profile:")
35 .filter(|id| crate::backend::browser_profiles_store::is_profile_id(id))
36}
37
38pub(crate) fn profile_ids() -> Option<HashSet<String>> {
40 match crate::server::app_api::browser_profiles_list() {
41 Ok(ps) => Some(ps.into_iter().map(|p| p.id).collect()),
42 Err(e) => {
43 tracing::warn!(error = %e, "[browser-identity] couldn't read the browser profiles");
44 None
45 }
46 }
47}
48
49fn identity_of(meta: &MetaMapType) -> Option<&str> {
50 meta.get(IDENTITY_META_KEY).and_then(Value::as_str)
51}
52
53pub(crate) fn live_jars(state: &AppState) -> Option<Vec<String>> {
57 let blocks = match state.mstore.get_all::<Block>() {
58 Ok(b) => b,
59 Err(e) => {
60 tracing::warn!(error = %e, "[browser-identity] couldn't list blocks; the host keeps its jars");
61 return None;
62 }
63 };
64 let set: HashSet<String> = blocks
65 .iter()
66 .filter_map(|b| identity_of(&b.meta))
67 .filter(|i| is_incognito(i))
68 .map(str::to_string)
69 .collect();
70 Some(set.into_iter().collect())
71}
72
73pub(crate) fn check_new_tab(state: &AppState, meta: &MetaMapType) -> Result<(), String> {
78 let Some(v) = meta.get(IDENTITY_META_KEY) else { return Ok(()) };
79 let identity = v.as_str().unwrap_or("");
80 if let Some(id) = profile_id(identity) {
81 let known = profile_ids().ok_or("couldn't read the browser profiles")?;
82 return if known.contains(id) { Ok(()) } else { Err(format!("there is no browser profile {id:?}")) };
83 }
84 if !is_incognito(identity) {
85 return Err(format!("{IDENTITY_META_KEY} must be incognito:<id> or profile:<id>"));
86 }
87 let open = live_jars(state).ok_or("couldn't count the Incognito tabs open")?;
88 if !open.iter().any(|j| j == identity) && open.len() >= MAX_INCOGNITO_JARS {
89 return Err(format!(
90 "at most {MAX_INCOGNITO_JARS} Incognito tabs can be open at once: close one to open another"
91 ));
92 }
93 Ok(())
94}
95
96pub(crate) fn inherit(meta: &mut MetaMapType, opener: &Block) {
99 if let Some(identity) = identity_of(&opener.meta).filter(|i| is_incognito(i) || profile_id(i).is_some()) {
100 meta.insert(IDENTITY_META_KEY.to_string(), json!(identity));
101 }
102}
103
104pub(crate) fn jars_available(state: &AppState) -> usize {
107 live_jars(state).map_or(0, |open| MAX_INCOGNITO_JARS.saturating_sub(open.len()))
108}
109
110pub(crate) fn refresh_for_replay(
117 meta: &mut Value,
118 fresh: &mut HashMap<String, String>,
119 available: usize,
120 profiles: Option<&HashSet<String>>,
121) -> bool {
122 let Some(obj) = meta.as_object_mut() else { return true };
123 if let Some(id) = obj.get(IDENTITY_META_KEY).and_then(Value::as_str).and_then(profile_id) {
126 return profiles.is_some_and(|known| known.contains(id));
127 }
128 let Some(old) = obj.get(IDENTITY_META_KEY).and_then(Value::as_str).filter(|i| is_incognito(i)).map(str::to_string) else {
129 return true;
130 };
131 if !fresh.contains_key(&old) && fresh.len() >= available {
132 tracing::info!("[browser-identity] a replayed Incognito tab left out: no jar to spare");
133 return false;
134 }
135 let new = fresh.entry(old).or_insert_with(|| format!("incognito:{}", uuid::Uuid::new_v4())).clone();
136 obj.insert(IDENTITY_META_KEY.to_string(), json!(new));
137 true
138}
139
140pub(crate) fn identity_for_agent(requested: &str, profiles: &[BrowserProfile]) -> Result<Option<String>, String> {
146 let requested = requested.trim();
147 if requested.is_empty() || requested.eq_ignore_ascii_case("personal") {
148 return Ok(None);
149 }
150 if requested.eq_ignore_ascii_case("incognito") {
151 return Ok(Some(format!("incognito:{}", uuid::Uuid::new_v4())));
152 }
153 let allowed = || {
154 let names: Vec<&str> = profiles.iter().filter(|p| p.agents_allowed).map(|p| p.name.as_str()).collect();
155 if names.is_empty() {
156 "none".to_string()
157 } else {
158 names.join(", ")
159 }
160 };
161 let Some(p) = profiles.iter().find(|p| p.name.eq_ignore_ascii_case(requested) || p.id == requested) else {
162 return Err(format!(
163 "there is no browser profile called {requested:?}. Use \"incognito\", or a profile the user lets agents use: {}",
164 allowed()
165 ));
166 };
167 if !p.agents_allowed {
168 return Err(format!(
169 "the user hasn't let agents use the browser profile {:?}: they can switch on \"Agents may use it\" for it in Settings → Browser. Use \"incognito\", or a profile agents may use: {}",
170 p.name,
171 allowed()
172 ));
173 }
174 Ok(Some(format!("profile:{}", p.id)))
175}
176
177pub(crate) fn for_agent_open(state: &AppState, meta: &mut MetaMapType, requested: Option<&str>) -> Result<(), String> {
180 let Some(requested) = requested else { return Ok(()) };
181 let profiles = crate::server::app_api::browser_profiles_list()?;
182 if let Some(identity) = identity_for_agent(requested, &profiles)? {
183 meta.insert(IDENTITY_META_KEY.to_string(), json!(identity));
184 check_new_tab(state, meta)?;
185 }
186 Ok(())
187}
188
189pub(crate) fn check_agent_may_use(meta: &MetaMapType) -> Result<(), String> {
192 let Some(id) = identity_of(meta).and_then(profile_id) else { return Ok(()) };
193 let profiles = crate::server::app_api::browser_profiles_list()?;
194 match profiles.iter().find(|p| p.id == id) {
195 Some(p) if p.agents_allowed => Ok(()),
196 Some(p) => Err(format!("the user hasn't let agents use the browser profile {:?}", p.name)),
197 None => Err(format!("there is no browser profile {id:?}")),
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 fn profile(id: &str, name: &str, agents_allowed: bool) -> BrowserProfile {
206 BrowserProfile { id: id.into(), name: name.into(), color: "#3b82f6".into(), created_at: 0, agents_allowed }
207 }
208
209 #[test]
210 fn an_agent_gets_personal_incognito_or_a_profile_it_is_allowed() {
211 let ps = [profile("p-work", "Work", true), profile("p-bank", "Bank", false)];
212 assert_eq!(identity_for_agent("", &ps), Ok(None));
213 assert_eq!(identity_for_agent("Personal", &ps), Ok(None));
214 let a = identity_for_agent("incognito", &ps).unwrap().unwrap();
215 let b = identity_for_agent("Incognito", &ps).unwrap().unwrap();
216 assert!(is_incognito(&a) && is_incognito(&b) && a != b, "a fresh jar each time");
217 assert_eq!(identity_for_agent("work", &ps), Ok(Some("profile:p-work".into())));
218 assert_eq!(identity_for_agent("p-work", &ps), Ok(Some("profile:p-work".into())));
219 let refused = identity_for_agent("Bank", &ps).unwrap_err();
220 assert!(refused.contains("hasn't let agents use") && refused.contains("Work"), "{refused}");
221 assert!(refused.contains("\"Agents may use it\" for it in Settings → Browser") && !refused.contains(" "), "{refused}");
222 let unknown = identity_for_agent("Nope", &ps).unwrap_err();
223 assert!(unknown.contains("no browser profile") && !unknown.contains("Bank"), "{unknown}");
224 }
225
226 #[test]
227 fn only_well_formed_incognito_identities_pass() {
228 assert!(is_incognito("incognito:0f0e2d1c-aaaa-bbbb-cccc-0123456789ab"));
229 for bad in ["incognito:short", "incognito:has spaces x", "profile:work", "incognito:", ""] {
230 assert!(!is_incognito(bad), "{bad:?}");
231 }
232 }
233
234 #[test]
235 fn a_replayed_layout_gets_fresh_jars_and_keeps_shared_ones_shared() {
236 let mut fresh = HashMap::new();
237 let mut a = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:aaaaaaaa-1111" });
238 let mut b = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:aaaaaaaa-1111" });
239 let mut c = json!({ "view": "browser" });
240 assert!(refresh_for_replay(&mut a, &mut fresh, 8, None));
241 assert!(refresh_for_replay(&mut b, &mut fresh, 8, None));
242 assert!(refresh_for_replay(&mut c, &mut fresh, 8, None));
243 let ia = a[IDENTITY_META_KEY].as_str().unwrap();
244 assert_ne!(ia, "incognito:aaaaaaaa-1111");
245 assert!(is_incognito(ia));
246 assert_eq!(a[IDENTITY_META_KEY], b[IDENTITY_META_KEY]);
247 assert!(c.get(IDENTITY_META_KEY).is_none());
248 let mut d = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:dddddddd-4444" });
251 assert!(!refresh_for_replay(&mut d, &mut fresh, 1, None));
252 let mut e = json!({ "view": "browser", IDENTITY_META_KEY: "incognito:aaaaaaaa-1111" });
253 assert!(refresh_for_replay(&mut e, &mut fresh, 1, None));
254 let known: HashSet<String> = ["p-work".to_string()].into();
256 let mut f = json!({ "view": "browser", IDENTITY_META_KEY: "profile:p-work" });
257 assert!(refresh_for_replay(&mut f, &mut fresh, 0, Some(&known)));
258 assert_eq!(f[IDENTITY_META_KEY], json!("profile:p-work"));
259 let mut g = json!({ "view": "browser", IDENTITY_META_KEY: "profile:p-gone" });
260 assert!(!refresh_for_replay(&mut g, &mut fresh, 0, Some(&known)));
261 }
262
263 #[test]
264 fn a_popup_pane_inherits_an_incognito_jar_only() {
265 let mut opener = Block::default();
266 opener.meta.insert(IDENTITY_META_KEY.into(), json!("incognito:bbbbbbbb-2222"));
267 let mut meta = MetaMapType::new();
268 inherit(&mut meta, &opener);
269 assert_eq!(meta[IDENTITY_META_KEY], json!("incognito:bbbbbbbb-2222"));
270 let mut meta = MetaMapType::new();
271 inherit(&mut meta, &Block::default());
272 assert!(meta.get(IDENTITY_META_KEY).is_none());
273 let mut work = Block::default();
274 work.meta.insert(IDENTITY_META_KEY.into(), json!("profile:p-work"));
275 let mut meta = MetaMapType::new();
276 inherit(&mut meta, &work);
277 assert_eq!(meta[IDENTITY_META_KEY], json!("profile:p-work"));
278 }
279
280 #[tokio::test]
281 async fn a_new_tab_identity_is_checked() {
282 let state = crate::server::tests::test_state();
283 let with = |v: Value| {
284 let mut m = MetaMapType::new();
285 m.insert(IDENTITY_META_KEY.into(), v);
286 m
287 };
288 assert!(check_new_tab(&state, &MetaMapType::new()).is_ok());
289 assert!(check_new_tab(&state, &with(json!("incognito:short"))).is_err());
290 let unknown = check_new_tab(&state, &with(json!("profile:p-none"))).unwrap_err();
292 assert!(unknown.contains("there is no browser profile"), "{unknown}");
293 assert!(check_new_tab(&state, &with(json!("profile:Bad Id"))).is_err());
294 assert!(check_new_tab(&state, &with(json!(5))).is_err());
295 let ok = check_new_tab(&state, &with(json!("incognito:cccccccc-3333")));
296 assert!(ok.is_ok(), "{ok:?}");
297 }
298}