//! The app's side of the browser protocol: pairing, encrypted sessions and request handling. //! //! Outer frames (JSON, field `t`), relayed unchanged by `kestrove-nmh`: //! //! | extension → app | app → extension | //! |---|---| //! | `{t:"nmh_hello", caller}` (sent by the host itself) | `{t:"ok"}` | //! | `{t:"ping"}` | `{t:"pong", version}` | //! | `{t:"pair_init", pub, name}` | `{t:"pair_started", pairing_id, pub}` (app shows the words) | //! | `{t:"pair_confirm", pairing_id, proof}` | `{t:"pair_waiting"}` / `{t:"pair_done"}` / error | //! | `{t:"open", pairing_id, nonce}` | `{t:"opened", nonce}` / `{t:"error", code:"not_paired"}` | //! | `{t:"msg", n, c}` | `{t:"msg", n, c}` / `{t:"error", code:"bad_session"}` | //! //! `msg` plaintexts are `{rid, body}` where `body` is a `NativeRequest` / `NativeResponse` from //! kestrove-browser/src/protocol.ts. Origins come from the extension's `sender`, and are //! re-checked here against the vault before any secret leaves the app. use std::io::{self, Read, Write}; use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; use kv_ffi::{CoreError, ItemDto, ItemKindDto, VaultCore}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use zeroize::Zeroizing; use crate::channel::{random, verify_confirm, Ephemeral, Session}; use crate::framing::{read_frame, write_json}; use crate::private_file; /// How long a pairing request waits for the user in the app. const PAIRING_TTL: Duration = Duration::from_secs(300); /// How long a save prompt stays in the app. const SAVE_TTL: Duration = Duration::from_secs(600); const MAX_PENDING_SAVES: usize = 8; /// What the app UI must show. Implemented with Tauri events in the app, and auto-approving in tests. pub trait Ui: Send + Sync { fn pairing_requested(&self, req: &PairingView); fn save_requested(&self, req: &SaveView); /// A user-driven request (a fill) arrived: counts as activity for auto-lock. fn activity(&self) {} } #[derive(Debug, Clone, Serialize)] pub struct PairingView { pub id: String, pub name: String, pub caller: String, pub words: Vec, } #[derive(Debug, Clone, Serialize)] pub struct SaveView { pub id: String, pub origin: String, pub username: String, /// Some item already has this username for this site: saving updates its password. pub update: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredPairing { pub id: String, pub name: String, pub caller: String, pub words: Vec, pub created_at: u64, key: String, } impl StoredPairing { fn key(&self) -> Option> { let raw = Zeroizing::new(B64.decode(&self.key).ok()?); let arr: [u8; 32] = raw.as_slice().try_into().ok()?; Some(Zeroizing::new(arr)) } } /// Persisted pairings (`pairings.json`, 0600). The pairing key only authenticates the /// extension to the app; it can't decrypt the vault. Every secret still needs an unlocked vault. pub struct PairingStore { path: Option, items: Mutex>, } impl PairingStore { pub fn in_memory() -> Self { Self { path: None, items: Mutex::new(Vec::new()) } } pub fn open(path: PathBuf) -> io::Result { let items = match private_file::read(&path)? { Some(b) => serde_json::from_slice(&b).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?, None => Vec::new(), }; Ok(Self { path: Some(path), items: Mutex::new(items) }) } fn guard(&self) -> MutexGuard<'_, Vec> { self.items.lock().unwrap_or_else(|e| e.into_inner()) } fn persist(&self, items: &[StoredPairing]) -> io::Result<()> { match &self.path { Some(path) => private_file::write(path, &serde_json::to_vec_pretty(items)?), None => Ok(()), } } pub fn list(&self) -> Vec { self.guard().clone() } fn get(&self, id: &str) -> Option { self.guard().iter().find(|p| p.id == id).cloned() } fn add(&self, p: StoredPairing) -> io::Result<()> { let mut items = self.guard(); items.push(p); self.persist(&items) } pub fn remove(&self, id: &str) -> io::Result { let mut items = self.guard(); let before = items.len(); items.retain(|p| p.id != id); self.persist(&items)?; Ok(items.len() != before) } } struct PendingPairing { view: PairingView, key: Zeroizing<[u8; 32]>, at: Instant, } struct PendingSave { id: String, origin: String, username: String, password: Zeroizing, at: Instant, } pub struct Broker { core: Arc, ui: Arc, pairings: PairingStore, pending_pairing: Mutex>, saves: Mutex>, } fn lock(m: &Mutex) -> MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn err(code: &str) -> Value { json!({ "type": "error", "code": code }) } fn core_err(e: &CoreError) -> Value { match e { CoreError::Locked | CoreError::NoAccount => err("locked"), CoreError::NotFound => err("origin_mismatch"), _ => err("internal"), } } fn b64_array(v: &Value) -> Option<[u8; N]> { B64.decode(v.as_str()?).ok()?.try_into().ok() } /// `scheme://host[:port]` for http(s) origins; anything else (paths, credentials, other schemes) is rejected. pub fn normalize_origin(raw: &str) -> Option { let u = url::Url::parse(raw).ok()?; if !matches!(u.scheme(), "https" | "http") || u.host_str().is_none_or(str::is_empty) { return None; } if !u.username().is_empty() || u.password().is_some() { return None; } Some(u.origin().ascii_serialization()) } /// Per-connection state. pub struct Conn { caller: String, session: Option, } impl Broker { pub fn new(core: Arc, ui: Arc, pairings: PairingStore) -> Arc { Arc::new(Self { core, ui, pairings, pending_pairing: Mutex::new(None), saves: Mutex::new(Vec::new()) }) } pub fn core(&self) -> &Arc { &self.core } /// Serves one `kestrove-nmh` connection until it closes. Strict request → response. pub fn serve(&self, mut io: impl Read + Write) -> io::Result<()> { let mut conn = Conn { caller: String::new(), session: None }; while let Some(frame) = read_frame(&mut io)? { let reply = match serde_json::from_slice::(&frame) { Ok(v) => self.handle_frame(&mut conn, &v), Err(_) => json!({ "t": "error", "code": "bad_request" }), }; write_json(&mut io, &reply)?; } Ok(()) } pub fn handle_frame(&self, conn: &mut Conn, v: &Value) -> Value { let outer_err = |code: &str| json!({ "t": "error", "code": code }); match v.get("t").and_then(Value::as_str) { Some("nmh_hello") => { conn.caller = v.get("caller").and_then(Value::as_str).unwrap_or("").chars().take(200).collect(); json!({ "t": "ok" }) } Some("ping") => json!({ "t": "pong", "version": env!("CARGO_PKG_VERSION") }), Some("pair_init") => { let Some(ext_pub) = v.get("pub").and_then(b64_array::<32>) else { return outer_err("bad_request") }; let name: String = v.get("name").and_then(Value::as_str).unwrap_or("Browser").chars().take(80).collect(); self.pair_init(conn, &ext_pub, name).unwrap_or_else(|| outer_err("bad_request")) } Some("pair_confirm") => { let (Some(id), Some(proof)) = ( v.get("pairing_id").and_then(Value::as_str), v.get("proof").and_then(Value::as_str).and_then(|p| B64.decode(p).ok()), ) else { return outer_err("bad_request"); }; self.pair_confirm(id, &proof) } Some("open") => { let (Some(id), Some(ne)) = (v.get("pairing_id").and_then(Value::as_str), v.get("nonce").and_then(b64_array::<16>)) else { return outer_err("bad_request"); }; let Some(key) = self.pairings.get(id).and_then(|p| p.key()) else { return outer_err("not_paired") }; let na = random::<16>(); conn.session = Some(Session::app(&key, &ne, &na)); json!({ "t": "opened", "nonce": B64.encode(na) }) } Some("msg") => { let Some(session) = conn.session.as_mut() else { return outer_err("bad_session") }; let (Some(n), Some(c)) = ( v.get("n").and_then(Value::as_u64), v.get("c").and_then(Value::as_str).and_then(|c| B64.decode(c).ok()), ) else { return outer_err("bad_request"); }; let Ok(pt) = session.open(n, &c) else { conn.session = None; // the extension re-opens a fresh session return outer_err("bad_session"); }; let parsed: Option = serde_json::from_slice(&pt).ok(); drop(pt); let (rid, body) = match &parsed { Some(p) => { (p.get("rid").cloned().unwrap_or(Value::Null), p.get("body").cloned().unwrap_or(Value::Null)) } None => (Value::Null, Value::Null), }; let reply = Zeroizing::new( serde_json::to_vec(&json!({ "rid": rid, "body": self.handle_request(&body) })).unwrap_or_default(), ); let session = conn.session.as_mut().expect("session still open"); let (n, c) = session.seal(&reply); json!({ "t": "msg", "n": n, "c": B64.encode(c) }) } _ => outer_err("unknown_type"), } } // ------------------------------------------------------------ pairing fn pair_init(&self, conn: &Conn, ext_pub: &[u8; 32], name: String) -> Option { let eph = Ephemeral::generate(); let p = eph.pair(ext_pub)?; let view = PairingView { id: uuid::Uuid::new_v4().to_string(), name, caller: conn.caller.clone(), words: p.words.iter().map(|w| w.to_string()).collect(), }; let reply = json!({ "t": "pair_started", "pairing_id": view.id, "pub": B64.encode(eph.public) }); self.ui.pairing_requested(&view); // One pending request at a time; a new one replaces the old (and its words). *lock(&self.pending_pairing) = Some(PendingPairing { view, key: p.key, at: Instant::now() }); Some(reply) } fn pair_confirm(&self, id: &str, proof: &[u8]) -> Value { if let Some(key) = self.pairings.get(id).and_then(|p| p.key()) { return if verify_confirm(&key, id, proof) { json!({ "t": "pair_done" }) } else { json!({ "t": "error", "code": "not_paired" }) }; } match lock(&self.pending_pairing).as_ref() { Some(p) if p.view.id == id && p.at.elapsed() < PAIRING_TTL && verify_confirm(&p.key, id, proof) => { json!({ "t": "pair_waiting" }) } _ => json!({ "t": "error", "code": "not_paired" }), } } pub fn pending_pairing(&self) -> Option { let mut g = lock(&self.pending_pairing); if g.as_ref().is_some_and(|p| p.at.elapsed() >= PAIRING_TTL) { *g = None; } g.as_ref().map(|p| p.view.clone()) } /// The user compared the words and accepted (or rejected) the pairing. Requires an unlocked /// vault, so someone at a locked machine can't pair a new extension. pub fn resolve_pairing(&self, id: &str, accept: bool) -> Result<(), String> { let mut g = lock(&self.pending_pairing); let Some(p) = g.take_if(|p| p.view.id == id) else { return Err("This pairing request expired.".into()) }; if !accept { return Ok(()); } if self.core.is_locked() { *g = Some(p); return Err("Unlock Kestrove to pair a browser.".into()); } if p.at.elapsed() >= PAIRING_TTL { return Err("This pairing request expired.".into()); } self.pairings .add(StoredPairing { id: p.view.id, name: p.view.name, caller: p.view.caller, words: p.view.words, created_at: now_secs(), key: B64.encode(p.key.as_ref()), }) .map_err(|e| format!("Could not save the pairing: {e}")) } pub fn pairings(&self) -> Vec { self.pairings.list() } pub fn revoke_pairing(&self, id: &str) -> Result<(), String> { self.pairings.remove(id).map(|_| ()).map_err(|e| e.to_string()) } // ------------------------------------------------------------ requests pub fn handle_request(&self, body: &Value) -> Value { let Some(kind) = body.get("type").and_then(Value::as_str) else { return err("bad_request") }; if kind == "hello" { return json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": true }); } let Some(origin) = body.get("origin").and_then(Value::as_str).and_then(normalize_origin) else { return err("bad_request"); }; let item_id = || body.get("itemId").and_then(Value::as_str).filter(|s| !s.is_empty()); match kind { "query" => match self.core.items_for_url(origin) { Ok(items) => json!({ "type": "identities", "items": items.iter().map(|i| json!({ "id": i.id, "title": i.title, "username": i.username.clone().unwrap_or_default(), "totp": i.has_totp, })).collect::>(), }), Err(e) => core_err(&e), }, "fill" => { let Some(id) = item_id() else { return err("bad_request") }; self.ui.activity(); match self.matching_item(&origin, id) { Ok(d) => json!({ "type": "credential", "username": d.username.clone().unwrap_or_default(), "password": d.password.clone().unwrap_or_default(), }), Err(v) => v, } } "totp" => { let Some(id) = item_id() else { return err("bad_request") }; self.ui.activity(); match self.matching_item(&origin, id) { Ok(d) => match d.totp.as_deref().map(|t| kv_ffi::totp_code(t.to_string(), now_secs())) { Some(Ok(c)) => { json!({ "type": "totp", "code": c.code, "secondsRemaining": c.seconds_remaining }) } _ => err("bad_request"), }, Err(v) => v, } } "save" => { let username = body.get("username").and_then(Value::as_str).unwrap_or(""); let Some(password) = body.get("password").and_then(Value::as_str).filter(|p| !p.is_empty()) else { return err("bad_request"); }; if username.chars().count() > 512 || password.chars().count() > 4096 { return err("bad_request"); } self.queue_save(origin, username.to_string(), Zeroizing::new(password.to_string())) } _ => err("unknown_type"), } } /// The item, if (and only if) it matches the requesting origin. The id alone is never enough. fn matching_item(&self, origin: &str, id: &str) -> Result { let candidates = self.core.items_for_url(origin.to_string()).map_err(|e| core_err(&e))?; if !candidates.iter().any(|i| i.id == id) { return Err(err("origin_mismatch")); } self.core.get_item(id.to_string()).map_err(|e| core_err(&e)) } /// The existing login on this origin with this username, if the vault is unlocked. fn existing(&self, origin: &str, username: &str) -> Option { let items = self.core.items_for_url(origin.to_string()).ok()?; let s = items.into_iter().find(|i| i.username.as_deref().unwrap_or("") == username)?; self.core.get_item(s.id).ok() } fn queue_save(&self, origin: String, username: String, password: Zeroizing) -> Value { if let Some(e) = self.existing(&origin, &username) { if e.password.as_deref() == Some(password.as_str()) { return json!({ "type": "saved" }); // already up to date: no prompt } } let save = PendingSave { id: uuid::Uuid::new_v4().to_string(), origin, username, password, at: Instant::now() }; let view = self.save_view(&save); { let mut q = lock(&self.saves); q.retain(|s| s.at.elapsed() < SAVE_TTL && !(s.origin == save.origin && s.username == save.username)); if q.len() >= MAX_PENDING_SAVES { q.remove(0); } q.push(save); } self.ui.save_requested(&view); json!({ "type": "save_prompted" }) } fn save_view(&self, s: &PendingSave) -> SaveView { SaveView { id: s.id.clone(), origin: s.origin.clone(), username: s.username.clone(), update: self.existing(&s.origin, &s.username).is_some(), } } pub fn pending_saves(&self) -> Vec { let mut q = lock(&self.saves); q.retain(|s| s.at.elapsed() < SAVE_TTL); let pending: Vec<_> = q.iter().map(|s| (s.id.clone(), s.origin.clone(), s.username.clone())).collect(); drop(q); pending .into_iter() .map(|(id, origin, username)| SaveView { update: self.existing(&origin, &username).is_some(), id, origin, username, }) .collect() } /// The user answered a save prompt in the app. Returns the saved item id. pub fn resolve_save(&self, id: &str, accept: bool) -> Result, String> { let s = { let mut q = lock(&self.saves); let Some(pos) = q.iter().position(|s| s.id == id) else { return Err("This save request expired.".into()) }; if accept && self.core.is_locked() { return Err("Unlock Kestrove to save.".into()); } q.remove(pos) }; if !accept { return Ok(None); } let item = match self.existing(&s.origin, &s.username) { Some(mut e) => { e.password = Some(s.password.to_string()); e } None => ItemDto { id: String::new(), kind: ItemKindDto::Login, title: url::Url::parse(&s.origin) .ok() .and_then(|u| u.host_str().map(|h| h.trim_start_matches("www.").to_string())) .unwrap_or_else(|| s.origin.clone()), username: (!s.username.is_empty()).then(|| s.username.clone()), password: Some(s.password.to_string()), urls: vec![s.origin.clone()], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, }, }; self.core.save_item(item).map(Some).map_err(|e| e.to_string()) } } #[cfg(test)] mod tests { use super::*; use crate::channel::{make_confirm, Session as Sess}; struct AutoUi; impl Ui for AutoUi { fn pairing_requested(&self, _: &PairingView) {} fn save_requested(&self, _: &SaveView) {} } fn broker_with_item() -> (Arc, String) { let core = VaultCore::new_in_memory(); core.create_account("correct horse battery".into()).unwrap(); let id = core .save_item(ItemDto { id: String::new(), kind: ItemKindDto::Login, title: "Example".into(), username: Some("alice".into()), password: Some("s3cret".into()), urls: vec!["https://example.com".into()], totp: Some("JBSWY3DPEHPK3PXP".into()), notes: None, favorite: false, tags: vec![], modified_at: 0, }) .unwrap(); (Broker::new(core, Arc::new(AutoUi), PairingStore::in_memory()), id) } /// Plays the extension: pairs, has the "user" approve in the app, opens a session. fn pair_and_open(b: &Broker, conn: &mut Conn) -> Sess { let ext = Ephemeral::generate(); let r = b.handle_frame(conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public), "name": "Test" })); assert_eq!(r["t"], "pair_started"); let id = r["pairing_id"].as_str().unwrap().to_string(); let app_pub: [u8; 32] = B64.decode(r["pub"].as_str().unwrap()).unwrap().try_into().unwrap(); let shared_ext = ext.pair_as_ext(&app_pub).unwrap(); assert_eq!(shared_ext.words.to_vec(), b.pending_pairing().unwrap().words); let proof = B64.encode(make_confirm(&shared_ext.key, &id)); let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof })); assert_eq!(r["t"], "pair_waiting"); b.resolve_pairing(&id, true).unwrap(); let r = b.handle_frame(conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": proof })); assert_eq!(r["t"], "pair_done"); let ne = random::<16>(); let r = b.handle_frame(conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode(ne) })); assert_eq!(r["t"], "opened"); let na: [u8; 16] = B64.decode(r["nonce"].as_str().unwrap()).unwrap().try_into().unwrap(); Sess::ext(&shared_ext.key, &ne, &na) } fn call(b: &Broker, conn: &mut Conn, s: &mut Sess, body: Value) -> Value { let (n, c) = s.seal(&serde_json::to_vec(&json!({ "rid": 7, "body": body })).unwrap()); let r = b.handle_frame(conn, &json!({ "t": "msg", "n": n, "c": B64.encode(c) })); assert_eq!(r["t"], "msg", "{r}"); let pt = s.open(r["n"].as_u64().unwrap(), &B64.decode(r["c"].as_str().unwrap()).unwrap()).unwrap(); let v: Value = serde_json::from_slice(&pt).unwrap(); assert_eq!(v["rid"], 7); v["body"].clone() } #[test] fn pair_query_fill_and_origin_checks() { let (b, id) = broker_with_item(); let mut conn = Conn { caller: String::new(), session: None }; let mut s = pair_and_open(&b, &mut conn); let r = call(&b, &mut conn, &mut s, json!({ "type": "query", "origin": "https://example.com" })); assert_eq!(r["items"][0]["username"], "alice"); assert!(r["items"][0].get("password").is_none()); let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id })); assert_eq!(r, json!({ "type": "credential", "username": "alice", "password": "s3cret" })); let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://evil.test", "itemId": id })); assert_eq!(r["code"], "origin_mismatch"); let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "javascript:alert(1)", "itemId": id })); assert_eq!(r["code"], "bad_request"); let r = call(&b, &mut conn, &mut s, json!({ "type": "totp", "origin": "https://example.com", "itemId": id })); assert_eq!(r["type"], "totp"); assert_eq!(r["code"].as_str().unwrap().len(), 6); b.core().lock(); let r = call(&b, &mut conn, &mut s, json!({ "type": "fill", "origin": "https://example.com", "itemId": id })); assert_eq!(r["code"], "locked"); } #[test] fn replayed_frame_kills_the_session() { let (b, _) = broker_with_item(); let mut conn = Conn { caller: String::new(), session: None }; let mut s = pair_and_open(&b, &mut conn); let (n, c) = s.seal(br#"{"rid":1,"body":{"type":"hello","version":"1"}}"#); let frame = json!({ "t": "msg", "n": n, "c": B64.encode(c) }); assert_eq!(b.handle_frame(&mut conn, &frame)["t"], "msg"); assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session"); assert_eq!(b.handle_frame(&mut conn, &frame)["code"], "bad_session"); } #[test] fn unpaired_and_revoked_extensions_are_refused() { let (b, _) = broker_with_item(); let mut conn = Conn { caller: String::new(), session: None }; let r = b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": "nope", "nonce": B64.encode([0u8; 16]) })); assert_eq!(r["code"], "not_paired"); let r = b.handle_frame(&mut conn, &json!({ "t": "msg", "n": 0, "c": "AAAA" })); assert_eq!(r["code"], "bad_session"); let _ = pair_and_open(&b, &mut conn); let id = b.pairings()[0].id.clone(); b.revoke_pairing(&id).unwrap(); let r = b.handle_frame(&mut conn, &json!({ "t": "open", "pairing_id": id, "nonce": B64.encode([0u8; 16]) })); assert_eq!(r["code"], "not_paired"); } #[test] fn pairing_needs_unlock_and_rejects_bad_proof() { let (b, _) = broker_with_item(); let mut conn = Conn { caller: String::new(), session: None }; let ext = Ephemeral::generate(); let r = b.handle_frame(&mut conn, &json!({ "t": "pair_init", "pub": B64.encode(ext.public) })); let id = r["pairing_id"].as_str().unwrap().to_string(); let r = b .handle_frame(&mut conn, &json!({ "t": "pair_confirm", "pairing_id": id, "proof": B64.encode([0u8; 40]) })); assert_eq!(r["code"], "not_paired"); b.core().lock(); assert!(b.resolve_pairing(&id, true).is_err()); assert!(b.pending_pairing().is_some(), "still pending after a locked attempt"); assert!(b.pairings().is_empty()); } #[test] fn save_prompts_then_creates_or_updates() { let (b, id) = broker_with_item(); let r = b.handle_request( &json!({ "type": "save", "origin": "https://example.com", "username": "alice", "password": "s3cret" }), ); assert_eq!(r["type"], "saved"); // identical: nothing to ask let r = b.handle_request( &json!({ "type": "save", "origin": "https://example.com/login", "username": "alice", "password": "new" }), ); assert_eq!(r["type"], "save_prompted"); let views = b.pending_saves(); assert_eq!((views.len(), views[0].update, views[0].origin.as_str()), (1, true, "https://example.com")); assert_eq!(b.resolve_save(&views[0].id, true).unwrap().as_deref(), Some(id.as_str())); assert_eq!(b.core().get_item(id).unwrap().password.as_deref(), Some("new")); b.handle_request( &json!({ "type": "save", "origin": "https://new.example.org", "username": "bob", "password": "pw" }), ); let v = b.pending_saves().pop().unwrap(); assert!(!v.update); let new_id = b.resolve_save(&v.id, true).unwrap().unwrap(); let d = b.core().get_item(new_id).unwrap(); assert_eq!((d.title.as_str(), d.urls[0].as_str()), ("new.example.org", "https://new.example.org")); assert!(b.pending_saves().is_empty()); } #[test] fn normalizes_origins() { assert_eq!(normalize_origin("https://Example.com:443/x?y").as_deref(), Some("https://example.com")); assert_eq!(normalize_origin("http://localhost:8080").as_deref(), Some("http://localhost:8080")); assert_eq!(normalize_origin("https://u:p@example.com"), None); assert_eq!(normalize_origin("file:///etc/passwd"), None); assert_eq!(normalize_origin("chrome-extension://abc/"), None); } #[test] fn pairing_store_persists_with_private_mode() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("pairings.json"); let s = PairingStore::open(path.clone()).unwrap(); s.add(StoredPairing { id: "a".into(), name: "n".into(), caller: String::new(), words: vec![], created_at: 1, key: B64.encode([1u8; 32]), }) .unwrap(); assert_eq!(PairingStore::open(path.clone()).unwrap().list().len(), 1); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); } assert!(s.remove("a").unwrap()); assert!(PairingStore::open(path).unwrap().list().is_empty()); } }