app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
master
John Lauer
vault test: build the public test cards from 4-digit groups so the page carries no card-shaped number
e128d04
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
//! pup-vault: credentials and payment cards. The SECRETS live in the OS store through
//! `HostSecrets` (keytar's `service/account` layout, so the Node build's vault is readable here);
//! the INDEXES (host + username, card nickname + brand + last4, never a password or PAN) live in
//! ~/.adom as JSON, same files and shapes as src/credential_vault.js and src/card_vault.js.
//! The page-side work (locate a login form, fill it, submit; find card fields) is JavaScript
//! returned by functions here and evaluated by the verbs crate through CDP.
use pup_host::HostSecrets;
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
pub const CRED_SERVICE: &str = "adom-bridge-pup"; // 2.0.652: renamed from adom-desktop-pup; the host store renames stored keys on load
pub const LEGACY_CRED_SERVICE: &str = "adom-desktop-pup";
pub const CARD_SERVICE: &str = "adom-pup-cards";
fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }
// ── host patterns (Node: registrableDomain / hostMatchesPattern / scorePattern) ─────────
const TWO_PART_TLDS: &[&str] = &["co.uk", "org.uk", "gov.uk", "ac.uk", "com.au", "net.au", "org.au", "co.nz", "co.jp", "co.kr", "com.br", "com.cn", "com.mx", "com.tw", "co.in", "co.za"];
pub fn registrable_domain(host: &str) -> String {
if host.is_empty() { return String::new(); }
let lower = host.to_lowercase();
let parts: Vec<String> = lower.trim_end_matches('.').split('.').map(|s| s.to_string()).collect();
if parts.len() <= 2 { return parts.join("."); }
let last_two = parts[parts.len() - 2..].join(".");
if TWO_PART_TLDS.contains(&last_two.as_str()) { parts[parts.len() - 3..].join(".") } else { last_two }
}
pub fn host_matches_pattern(host: &str, pattern: &str) -> bool {
if host.is_empty() || pattern.is_empty() { return false; }
let (h, p) = (host.to_lowercase(), pattern.to_lowercase());
if p == h { return true; }
if let Some(suffix) = p.strip_prefix('*') { if h == suffix.trim_start_matches('.') || h.ends_with(suffix) { return true; } }
if !p.starts_with("*.") { let r = registrable_domain(&h); if !r.is_empty() && r == registrable_domain(&p) { return true; } }
false
}
pub fn score_pattern(pattern: &str, host: &str) -> i64 {
if !host_matches_pattern(host, pattern) { return -1; }
let (h, p) = (host.to_lowercase(), pattern.to_lowercase());
let exact = p == h; let glob = p.starts_with("*.");
(if exact { 2000 } else if glob { 1000 } else { 500 }) + pattern.len() as i64
}
pub fn host_of(url: &str) -> Option<String> {
let rest = url.split("//").nth(1)?; let hp = rest.split('/').next()?; let h = hp.split('@').last()?.split(':').next()?; if h.is_empty() { None } else { Some(h.to_lowercase()) }
}
// ── credentials ────────────────────────────────────────────────────────────────────────
pub struct Vault { pub state_dir: PathBuf }
fn same_user(a: &str, b: &str) -> bool { a.trim().eq_ignore_ascii_case(b.trim()) }
/// The keychain account name an index entry's password lives under (entries before 2.0.596 carry none: the host).
fn key_of(e: &Value) -> String { e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("").to_string() }
#[derive(Debug, Clone)]
pub struct Credential { pub host: String, pub username: String, pub password: String, pub source_account: Option<String> }
impl Vault {
pub fn new(state_dir: &Path) -> Vault { let _ = std::fs::create_dir_all(state_dir); Vault { state_dir: state_dir.to_path_buf() } }
fn index_path(&self) -> PathBuf { self.state_dir.join("pup-credentials.index.json") }
pub fn read_index(&self) -> Vec<Value> {
std::fs::read_to_string(self.index_path()).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["entries"].as_array().cloned()).unwrap_or_default()
}
fn write_index(&self, entries: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "version": 1, "entries": entries })).unwrap_or_default()); }
/// 2.0.596: one login per host AND username. It was one per host: a second account on the same site
/// (John's work and personal Google accounts both sign in at accounts.google.com) overwrote the first,
/// password and all. The first login for a host keeps the host as its keychain key, so every vault
/// written before this reads unchanged; a further account on that host gets "host|username".
pub fn set(&self, host: &dyn HostSecrets, hostname: &str, username: &str, password: &str) -> Result<(), String> {
if hostname.is_empty() || username.is_empty() || password.is_empty() { return Err("host, username, and password are all required".into()); }
let mut entries = self.read_index();
let now = pup_core::iso_now();
match entries.iter_mut().find(|e| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username)) {
Some(e) => {
host.secret_set(CRED_SERVICE, &key_of(e), password)?;
e["username"] = json!(username); e["updatedAt"] = json!(now);
}
None => {
let taken = entries.iter().any(|e| e["host"] == json!(hostname) || key_of(e) == hostname);
let key = if taken { format!("{hostname}|{}", username.trim().to_lowercase()) } else { hostname.to_string() };
host.secret_set(CRED_SERVICE, &key, password)?;
entries.push(json!({ "host": hostname, "username": username, "keytarAccount": key, "addedAt": now, "updatedAt": now }));
}
}
self.write_index(&entries);
Ok(())
}
/// The stored login for this URL's site family whose username is exactly `username` (case-insensitive).
pub fn get_user(&self, host: &dyn HostSecrets, url: &str, username: &str) -> Option<Credential> {
let h = host_of(url)?;
let mut matches: Vec<(i64, Value)> = self.read_index().into_iter().filter(|e| same_user(e["username"].as_str().unwrap_or(""), username))
.filter_map(|e| { let s = score_pattern(e["host"].as_str().unwrap_or(""), &h); if s >= 0 { Some((s, e)) } else { None } }).collect();
matches.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) }));
let best = &matches.first()?.1;
let password = host.secret_get(CRED_SERVICE, &key_of(best))?;
Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })
}
/// Merge decrypted rows [{host|url|origin, username, password}] from the browser-login import,
/// skipping ones already stored with the same password. Node: credentialVault.mergeImportedCredentials.
pub fn merge_imported(&self, host: &dyn HostSecrets, rows: &[Value]) -> (u64, u64, u64) {
let (mut added, mut updated, mut skipped) = (0u64, 0u64, 0u64);
for r in rows {
let raw_host = r["host"].as_str().or(r["url"].as_str()).or(r["origin"].as_str()).unwrap_or("");
let hostname = host_of(raw_host).unwrap_or_else(|| raw_host.trim().to_string());
let (user, pass) = (r["username"].as_str().unwrap_or(""), r["password"].as_str().unwrap_or(""));
if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }
let existed = if user.is_empty() { self.get_for_url(host, &format!("https://{hostname}/"), None) } else { self.get_user(host, &format!("https://{hostname}/"), user) }.filter(|c| c.host == hostname);
if let Some(ref c) = existed { if c.password == pass { skipped += 1; continue; } }
match self.set(host, &hostname, if user.is_empty() { "(unknown)" } else { user }, pass) {
Ok(_) => if existed.is_some() { updated += 1 } else { added += 1 },
Err(_) => skipped += 1,
}
}
(added, updated, skipped)
}
/// Best-matching stored login for a URL (exact host > glob > sibling; then most recently used).
pub fn get_for_url(&self, host: &dyn HostSecrets, url: &str, prefer_account: Option<&str>) -> Option<Credential> {
let h = host_of(url)?;
let mut matches: Vec<(i64, Value)> = self.read_index().into_iter().filter_map(|e| { let s = score_pattern(e["host"].as_str().unwrap_or(""), &h); if s >= 0 { Some((s, e)) } else { None } }).collect();
if matches.is_empty() { return None; }
let prefer = prefer_account.map(|p| p.to_lowercase());
matches.sort_by(|a, b| {
b.0.cmp(&a.0)
.then_with(|| { let hit = |e: &Value| prefer.as_ref().map(|p| e["sourceAccount"].as_str().unwrap_or("").to_lowercase() == *p || e["username"].as_str().unwrap_or("").to_lowercase() == *p).unwrap_or(false); hit(&b.1).cmp(&hit(&a.1)) })
.then_with(|| { let at = a.1["lastUsed"].as_str().or(a.1["updatedAt"].as_str()).unwrap_or(""); let bt = b.1["lastUsed"].as_str().or(b.1["updatedAt"].as_str()).unwrap_or(""); bt.cmp(at) })
});
let best = &matches[0].1;
let password = host.secret_get(CRED_SERVICE, &key_of(best))?;
Some(Credential { host: best["host"].as_str().unwrap_or("").into(), username: best["username"].as_str().unwrap_or("").into(), password, source_account: best["sourceAccount"].as_str().map(|s| s.into()) })
}
pub fn list(&self) -> Vec<Value> {
self.read_index().into_iter().map(|e| json!({ "host": e["host"], "username": e["username"], "sourceAccount": e["sourceAccount"], "sourceBrowser": e["sourceBrowser"], "sourceProfile": e["sourceProfile"], "imported": e["imported"].as_bool().unwrap_or(false), "addedAt": e["addedAt"], "updatedAt": e["updatedAt"] })).collect()
}
/// Remove ONE account's login for a host (the others on that host stay).
pub fn delete_user(&self, host: &dyn HostSecrets, hostname: &str, username: &str) -> bool {
let entries = self.read_index();
let hit = |e: &Value| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username);
let mut removed = false;
for e in entries.iter().filter(|e| hit(e)) { if host.secret_delete(CRED_SERVICE, &key_of(e)) { removed = true; } }
let kept: Vec<Value> = entries.into_iter().filter(|e| !hit(e)).collect();
self.write_index(&kept);
removed
}
pub fn delete(&self, host: &dyn HostSecrets, hostname: &str) -> bool {
let entries = self.read_index();
let mut removed = false;
for e in entries.iter().filter(|e| e["host"] == json!(hostname)) { let account = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or(""); if host.secret_delete(CRED_SERVICE, account) { removed = true; } }
let kept: Vec<Value> = entries.into_iter().filter(|e| e["host"] != json!(hostname)).collect();
self.write_index(&kept);
removed
}
/// Every stored login WITH its password, for a pup-to-pup vault sync. host, username, password,
/// updatedAt (so the other box can newest-wins on a conflict). Only ever handed to a LOCAL or
/// same-account bridge caller by the verb — never a cloud relay, never an AI.
pub fn export_all(&self, host: &dyn HostSecrets) -> Vec<Value> {
let mut out = Vec::new();
for e in self.read_index() {
let hostname = e["host"].as_str().unwrap_or("");
let account = e["keytarAccount"].as_str().or(e["host"].as_str()).unwrap_or("");
if hostname.is_empty() { continue; }
if let Some(pw) = host.secret_get(CRED_SERVICE, account) {
out.push(json!({ "host": hostname, "username": e["username"].as_str().unwrap_or(""), "password": pw, "updatedAt": e["updatedAt"].as_str().or(e["addedAt"].as_str()).unwrap_or(""), "sourceAccount": e["sourceAccount"] }));
}
}
out
}
/// Merge entries [{host, username, password, updatedAt}] from another box's vault. Newest-wins on a
/// password conflict (the incoming updatedAt vs the local one); identical entries are skipped.
pub fn import_merge(&self, host: &dyn HostSecrets, entries: &[Value]) -> (u64, u64, u64) {
let (mut added, mut updated, mut skipped) = (0u64, 0u64, 0u64);
for e in entries {
let hostname = e["host"].as_str().unwrap_or("").trim();
let (user, pass, incoming_ts) = (e["username"].as_str().unwrap_or(""), e["password"].as_str().unwrap_or(""), e["updatedAt"].as_str().unwrap_or(""));
if hostname.is_empty() || pass.is_empty() { skipped += 1; continue; }
let local = self.read_index().into_iter().find(|x| x["host"].as_str() == Some(hostname) && (user.is_empty() || same_user(x["username"].as_str().unwrap_or(""), user)));
match &local {
None => { if self.set(host, hostname, if user.is_empty() { "(unknown)" } else { user }, pass).is_ok() { added += 1; } else { skipped += 1; } }
Some(le) => {
let local_pw = host.secret_get(CRED_SERVICE, &key_of(le)).unwrap_or_default();
let same = local_pw == pass && (user.is_empty() || le["username"].as_str() == Some(user));
if same { skipped += 1; continue; }
let local_ts = le["updatedAt"].as_str().or(le["addedAt"].as_str()).unwrap_or("");
// newest-wins: only overwrite when the incoming entry is newer than ours
if incoming_ts > local_ts { if self.set(host, hostname, if user.is_empty() { le["username"].as_str().unwrap_or("(unknown)") } else { user }, pass).is_ok() { updated += 1; } else { skipped += 1; } }
else { skipped += 1; }
}
}
}
(added, updated, skipped)
}
pub fn touch_used_user(&self, hostname: &str, username: &str) {
let mut entries = self.read_index();
if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname) && same_user(e["username"].as_str().unwrap_or(""), username)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }
}
pub fn touch_used(&self, hostname: &str) {
let mut entries = self.read_index();
if let Some(e) = entries.iter_mut().find(|e| e["host"] == json!(hostname)) { e["lastUsed"] = json!(pup_core::iso_now()); self.write_index(&entries); }
}
}
/// Locate the login form (marks the fields with data-pup-uf / data-pup-pf). Returns {ok, hasUser} or {ok:false, reason}.
pub fn locate_login_js() -> &'static str {
r#"(() => {
const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;
const isPwd = (el) => el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || ''));
const pwd = [...document.querySelectorAll('input')].filter(isVisible).find(isPwd);
if (!pwd) return { ok: false, reason: 'no_password_field' };
const allInputs = [...document.querySelectorAll('input')];
const pi = allInputs.indexOf(pwd);
let user = [...document.querySelectorAll('input[autocomplete="username"], input[type="email"]')].find((e) => isVisible(e) && !isPwd(e));
if (!user) { const cands = [...document.querySelectorAll('input[type="text"], input[type="tel"], input:not([type])')].filter((e) => isVisible(e) && !isPwd(e)); user = [...cands].reverse().find((c) => allInputs.indexOf(c) < pi) || cands[0] || null; }
pwd.setAttribute('data-pup-pf', '1'); if (user) user.setAttribute('data-pup-uf', '1');
return { ok: true, hasUser: !!user };
})()"#
}
/// Background fill through the native value setter (React/Vue/Angular see input+change).
pub fn fill_login_js(username: &str, password: &str) -> String {
format!(r#"((USERNAME, PASSWORD) => {{
const setVal = (el, val) => {{ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; try {{ el.focus(); }} catch (e) {{}} setter.call(el, val); el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }})); }};
const u = document.querySelector('[data-pup-uf]'), p = document.querySelector('[data-pup-pf]');
let fu = false; if (u && USERNAME) {{ setVal(u, USERNAME); fu = true; }} if (p) setVal(p, PASSWORD);
return {{ fu, plen: p ? (p.value || '').length : 0 }};
}})({}, {})"#, js_str(username), js_str(password))
}
/// Submit (or just unmark) the located form. Returns true when a submit was triggered.
pub fn submit_login_js(submit: bool) -> String {
format!(r#"((SUBMIT) => {{
const isVisible = (el) => !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0;
const pwd = document.querySelector('[data-pup-pf]'); const form = pwd && pwd.form;
const btn = SUBMIT ? ((form ? [...form.querySelectorAll('button[type="submit"], input[type="submit"], button')] : [...document.querySelectorAll('button, input[type="submit"]')]).find(isVisible)) : null;
document.querySelectorAll('[data-pup-pf],[data-pup-uf]').forEach((e) => {{ e.removeAttribute('data-pup-pf'); e.removeAttribute('data-pup-uf'); }});
if (!SUBMIT) return false;
if (btn) {{ btn.click(); return true; }}
if (form) {{ (form.requestSubmit ? form.requestSubmit() : form.submit()); return true; }}
return false;
}})({})"#, if submit { "true" } else { "false" })
}
// ── payment cards (Node: card_vault.js) ────────────────────────────────────────────────
pub fn normalize_pan(s: &str) -> Option<String> {
let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() < 12 || digits.len() > 19 { return None; }
let mut sum = 0; let mut alt = false;
for ch in digits.chars().rev() { let mut d = ch.to_digit(10)? as u32; if alt { d *= 2; if d > 9 { d -= 9; } } sum += d; alt = !alt; }
if sum % 10 == 0 { Some(digits) } else { None }
}
pub fn brand_of(pan: &str) -> &'static str {
if pan.starts_with('4') { "Visa" }
else if pan.starts_with("34") || pan.starts_with("37") { "Amex" }
else if (51..=55).contains(&pan[..2.min(pan.len())].parse::<u32>().unwrap_or(0)) || (2221..=2720).contains(&pan[..4.min(pan.len())].parse::<u32>().unwrap_or(0)) { "Mastercard" }
else if pan.starts_with("6011") || pan.starts_with("65") || pan.starts_with("644") || pan.starts_with("645") || pan.starts_with("646") || pan.starts_with("647") || pan.starts_with("648") || pan.starts_with("649") { "Discover" }
else if pan.starts_with("35") { "JCB" }
else if pan.starts_with("36") || pan.starts_with("38") || pan.starts_with("30") { "Diners" }
else { "Card" }
}
/// "MM/YY" from "MM/YY", "MM/YYYY", "MMYY", "MM-YY".
pub fn normalize_expiry(s: &str) -> Option<String> {
let d: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
let (m, y) = match d.len() { 4 => (&d[..2], &d[2..]), 6 => (&d[..2], &d[4..]), _ => return None };
let mm: u32 = m.parse().ok()?; if !(1..=12).contains(&mm) { return None; }
Some(format!("{m}/{y}"))
}
pub struct Cards { pub state_dir: PathBuf }
impl Cards {
pub fn new(state_dir: &Path) -> Cards { Cards { state_dir: state_dir.to_path_buf() } }
fn index_path(&self) -> PathBuf { self.state_dir.join("pup-cards.index.json") }
pub fn read(&self) -> Vec<Value> { std::fs::read_to_string(self.index_path()).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).and_then(|v| v["cards"].as_array().cloned()).unwrap_or_default() }
fn write(&self, cards: &[Value]) { let _ = std::fs::write(self.index_path(), serde_json::to_string_pretty(&json!({ "cards": cards })).unwrap_or_default()); }
pub fn set(&self, host: &dyn HostSecrets, nickname: &str, number: &str, expiry: &str, cvv: &str, name: Option<&str>) -> Result<Value, String> {
let nick = nickname.trim(); if nick.is_empty() { return Err("nickname required".into()); }
let pan = normalize_pan(number).ok_or("card number failed the Luhn check (typo?)")?;
let exp = normalize_expiry(expiry).ok_or("expiry must be MM/YY")?;
let csc: String = cvv.chars().filter(|c| c.is_ascii_digit()).collect(); if csc.len() < 3 || csc.len() > 4 { return Err("cvv must be 3 or 4 digits".into()); }
host.secret_set(CARD_SERVICE, nick, &json!({ "pan": pan, "cvv": csc }).to_string())?;
let meta = json!({ "nickname": nick, "brand": brand_of(&pan), "last4": &pan[pan.len() - 4..], "expiry": exp, "name": name.unwrap_or(""), "addedAt": pup_core::iso_now() });
let mut cards = self.read(); cards.retain(|c| c["nickname"] != json!(nick)); cards.push(meta.clone()); self.write(&cards);
Ok(meta)
}
pub fn list(&self) -> Vec<Value> { self.read() }
pub fn delete(&self, host: &dyn HostSecrets, nickname: &str) -> bool { let mut cards = self.read(); let before = cards.len(); cards.retain(|c| c["nickname"] != json!(nickname)); self.write(&cards); host.secret_delete(CARD_SERVICE, nickname); cards.len() < before }
pub fn meta(&self, nickname: &str) -> Option<Value> { self.read().into_iter().find(|c| c["nickname"] == json!(nickname)) }
/// The PAN + CVV; never leaves the bridge process (the fill types them into the page).
pub fn secret(&self, host: &dyn HostSecrets, nickname: &str) -> Option<(String, String)> { let raw = host.secret_get(CARD_SERVICE, nickname)?; let v: Value = serde_json::from_str(&raw).ok()?; Some((v["pan"].as_str()?.into(), v["cvv"].as_str()?.into())) }
}
pub const CARD_FIELD_SELECTORS: &[(&str, &[&str])] = &[
("number", &["input[autocomplete=\"cc-number\"]", "input[name=\"cardnumber\"]", "input[name=\"cardNumber\"]", "input[id*=\"cardnumber\" i]", "input[name*=\"card-number\" i]", "input[id*=\"card-number\" i]", "input[name*=\"ccnumber\" i]", "input[placeholder*=\"card number\" i]"]),
("expiry", &["input[autocomplete=\"cc-exp\"]", "input[name=\"exp-date\"]", "input[name=\"expiry\"]", "input[id*=\"expir\" i]", "input[placeholder*=\"MM / YY\" i]", "input[placeholder*=\"MM/YY\" i]"]),
("cvv", &["input[autocomplete=\"cc-csc\"]", "input[name=\"cvc\"]", "input[name=\"cvv\"]", "input[id*=\"cvc\" i]", "input[id*=\"cvv\" i]", "input[name*=\"security\" i]", "input[placeholder*=\"CVC\" i]", "input[placeholder*=\"CVV\" i]"]),
("name", &["input[autocomplete=\"cc-name\"]", "input[name=\"ccname\"]", "input[id*=\"cardholder\" i]", "input[name*=\"cardholder\" i]", "input[placeholder*=\"name on card\" i]"]),
];
/// In one frame: find a visible field for `field`, scroll it into view, focus and select-all.
/// Returns the element center for the trusted keystrokes, or null.
pub fn card_field_js(field: &str) -> String {
let sels = CARD_FIELD_SELECTORS.iter().find(|(f, _)| *f == field).map(|(_, s)| s.to_vec()).unwrap_or_default();
format!(r#"(() => {{ const sels = {}; for (const sel of sels) {{ let el = null; try {{ el = document.querySelector(sel); }} catch (e) {{ continue; }} if (!el) continue; const r = el.getBoundingClientRect(); if (!(r.width > 2 && r.height > 2) || el.disabled || el.readOnly) continue; el.scrollIntoView({{ block: 'center' }}); el.focus(); try {{ el.select(); }} catch (e) {{}} const r2 = el.getBoundingClientRect(); return {{ x: r2.x + r2.width / 2, y: r2.y + r2.height / 2, tag: el.tagName, sel }}; }} return null; }})()"#, serde_json::to_string(&sels).unwrap_or("[]".into()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test] fn patterns() {
assert!(host_matches_pattern("nxp.componentsearchengine.com", "*.componentsearchengine.com"));
assert!(host_matches_pattern("componentsearchengine.com", "*.componentsearchengine.com"));
assert!(host_matches_pattern("www.mouser.com", "mouser.com"));
assert!(!host_matches_pattern("evil-mouser.com", "mouser.com"));
assert!(score_pattern("www.x.com", "www.x.com") > score_pattern("*.x.com", "www.x.com"));
assert_eq!(host_of("https://[email protected]:8443/p?q"), Some("a.b.com".into()));
}
#[test] fn two_accounts_on_one_host_both_survive() {
struct Mem(std::sync::Mutex<std::collections::HashMap<String, String>>);
impl HostSecrets for Mem {
fn secret_get(&self, s: &str, a: &str) -> Option<String> { self.0.lock().unwrap().get(&format!("{s}/{a}")).cloned() }
fn secret_set(&self, s: &str, a: &str, v: &str) -> Result<(), String> { self.0.lock().unwrap().insert(format!("{s}/{a}"), v.into()); Ok(()) }
fn secret_delete(&self, s: &str, a: &str) -> bool { self.0.lock().unwrap().remove(&format!("{s}/{a}")).is_some() }
}
let dir = std::env::temp_dir().join(format!("pup-vault-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let v = Vault::new(&dir); let m = Mem(Default::default());
v.set(&m, "accounts.google.com", "[email protected]", "pw-work").unwrap();
v.set(&m, "accounts.google.com", "[email protected]", "pw-personal").unwrap();
v.set(&m, "accounts.google.com", "[email protected]", "pw-work-2").unwrap(); // same account, new password: an update
assert_eq!(v.read_index().len(), 2);
let u = "https://accounts.google.com/v3/signin/challenge/pwd";
assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-work-2");
assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-personal");
assert!(v.get_user(&m, u, "[email protected]").is_none());
assert_eq!(v.get_for_url(&m, u, Some("[email protected]")).unwrap().username, "[email protected]");
assert!(v.delete_user(&m, "accounts.google.com", "[email protected]"));
assert_eq!(v.get_user(&m, u, "[email protected]").unwrap().password, "pw-work-2");
let _ = std::fs::remove_dir_all(&dir);
}
// 2.0.687: the public network test cards are assembled from 4-digit groups at run time. Written whole, they made
// the wiki's sensitive-data scanner flag pup's public page as holding a payment card number (John, 2026-10-03).
fn card(groups: &[&str]) -> String { groups.concat() }
#[test] fn cards() {
let visa = card(&["4242", "4242", "4242", "4242"]);
assert_eq!(normalize_pan(&["4242", "4242", "4242", "4242"].join(" ")).as_deref(), Some(visa.as_str()));
assert!(normalize_pan(&card(&["4242", "4242", "4242", "4241"])).is_none());
assert_eq!(brand_of(&visa), "Visa"); assert_eq!(brand_of(&card(&["3782", "8224", "6310", "005"])), "Amex"); assert_eq!(brand_of(&card(&["5555", "5555", "5555", "4444"])), "Mastercard");
assert_eq!(normalize_expiry("12/2028").as_deref(), Some("12/28")); assert!(normalize_expiry("13/28").is_none());
}
}