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
Colby Knox
v2.0.740 (insiders): New verb pup_upload_file (pup-bridge#145): put a file from this PC on a page's file input with no…
63fa650
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
//! pup_upload_file (pup-bridge#145): put a file from this PC on a page's <input type=file>, with no OS picker,
//! in the background, through DOM.setFileInputFiles (what Puppeteer's uploadFile and Playwright's setInputFiles
//! use). Chrome reads the file itself when the page reads it, so the size costs nothing here.
//!
//! Measured on the extension lane, 2026-11-05 (Chrome for Testing 157), which is why the checks below exist:
//! - a path that does not exist still "succeeds": the input holds a phantom File with that name, the page's
//! change fires, and the upload fails later or sends nothing. So every path is checked here first.
//! - two files on an input without `multiple` silently keep only the first. So that is refused.
//! - Chrome fires input+change itself, once. Firing them again makes a page that uploads on change send the
//! file twice, so this verb never does.
//! - Chrome fires change only when the file list CHANGES, so a retry with the same file would do nothing and still
//! look fine. The input is emptied first (no events), so every call gives the page exactly one change.
//! - many upload widgets clear the input in their change handler, so the read-back alone would report a false
//! failure. A one-shot capture listener records what the page was handed.
use crate::input::resolve_tab;
use crate::{touch, Ctx};
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::time::Duration;
const T: Duration = Duration::from_secs(10);
fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }
// ── which paths may leave the PC ─────────────────────────────────────────────────────────────────
/// A file handed to a page is a file a website can read, and the AI driving the page may be following what a page
/// told it. Secrets live INSIDE the user's profile, so "stay under the user's folders" would not protect them: the
/// rule is this PC's local files, minus the system folders, other people's profiles and the places secrets are kept.
/// There is deliberately no override argument: an AI could just pass it. A person who really wants one of these
/// sent copies it somewhere ordinary first.
#[derive(Clone, Debug)]
pub struct PathRules { windows: bool, home: String, always_ok: Vec<String>, system: Vec<String> }
const SECRET_DIRS: &[&str] = &[".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".git", "$recycle.bin", "system volume information"];
const SECRET_NAMES: &[&str] = &[".env", ".netrc", "_netrc", ".git-credentials", ".npmrc", ".pypirc", "login data", "login data for account", "cookies", "web data", "local state", "ntuser.dat", "sam", "security"];
const SECRET_PREFIXES: &[&str] = &["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", ".env."];
const SECRET_EXTS: &[&str] = &["pem", "ppk", "key", "pfx", "p12", "kdbx", "keystore"];
fn norm(p: &str) -> String { let n = p.replace('\\', "/").to_lowercase(); let n = n.strip_prefix("//?/").unwrap_or(&n).to_string(); n.trim_end_matches('/').to_string() }
fn under(path: &str, root: &str) -> bool { !root.is_empty() && (path == root || (path.starts_with(root) && path.as_bytes().get(root.len()) == Some(&b'/'))) }
impl PathRules {
pub fn from_env(extra_ok: &[String]) -> PathRules {
let env = |k: &str| std::env::var(k).unwrap_or_default();
let windows = cfg!(windows);
let home = norm(&if windows { env("USERPROFILE") } else { env("HOME") });
let mut always_ok: Vec<String> = extra_ok.iter().map(|p| norm(p)).collect();
for k in ["TEMP", "TMP"] { let t = norm(&env(k)); if !t.is_empty() { always_ok.push(t); } }
let system = if windows { ["SystemRoot", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432", "ProgramData"].iter().map(|k| norm(&env(k))).filter(|s| !s.is_empty()).collect() }
else { ["/etc", "/var", "/usr", "/bin", "/sbin", "/boot", "/root", "/proc", "/sys", "/system", "/library", "/private"].iter().map(|s| s.to_string()).collect() };
PathRules { windows, home, always_ok, system }
}
/// Before the disk is touched: is this even a plain absolute local path? A \\server\share path is refused here
/// because just looking at it makes Windows authenticate to that server with the user's credentials.
pub fn precheck(&self, raw: &str) -> Result<(), (&'static str, String, String)> {
let send = "Files in your container are not on this PC: send it first with adom-bridge send_files {filePaths:[...]}, then pass the path it lands at (Downloads by default).";
let p = raw.trim();
if p.is_empty() { return Err(("file_path_required", "the file path is empty".into(), "Pass filePath: an absolute path on this PC, e.g. C:/Users/me/Downloads/gerbers.zip.".into())); }
if p.starts_with("\\\\") || p.starts_with("//") { return Err(("path_refused", format!("{raw} is a network or device path"), "Only files on this PC's own drives can be uploaded: opening a \\\\server path would make Windows sign in to that server as the user. Copy the file to a local folder first.".into())); }
let bytes = p.as_bytes();
let abs = if self.windows { bytes.len() > 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/') } else { bytes[0] == b'/' };
if !abs {
let looks_container = p.starts_with('/') || p.starts_with('~');
return Err(("path_not_absolute", format!("{raw} is not an absolute path on this PC"), if looks_container { send.to_string() } else { format!("Pass the full path, e.g. C:/Users/me/Downloads/gerbers.zip. {send}") }));
}
let rest = if self.windows { &p[2..] } else { p };
if rest.contains(':') { return Err(("path_refused", format!("{raw} names an alternate data stream"), "Pass the plain file path.".into())); }
if rest.split(['/', '\\']).any(|c| c == "..") { return Err(("path_refused", format!("{raw} contains .."), "Pass the file's direct path, without .. in it.".into())); }
Ok(())
}
/// On the REAL path (links and 8.3 names resolved): why this file may not be sent, if it may not.
pub fn refusal(&self, real: &str) -> Option<String> {
let path = norm(real);
let comps: Vec<&str> = path.split('/').collect();
let name = comps.last().copied().unwrap_or("");
let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
if SECRET_NAMES.contains(&name) || SECRET_PREFIXES.iter().any(|x| name.starts_with(x)) || SECRET_EXTS.contains(&ext) { return Some(format!("{name} looks like a key, password or login store")); }
if let Some(d) = comps[..comps.len().saturating_sub(1)].iter().find(|c| SECRET_DIRS.contains(c)) { return Some(format!("it is inside {d}, where keys and credentials are kept")); }
if self.always_ok.iter().any(|r| under(&path, r)) { return None; }
if let Some(s) = self.system.iter().find(|s| under(&path, s)) { return Some(format!("it is inside a system folder ({s})")); }
if under(&path, &self.home) {
let first = path[self.home.len()..].trim_start_matches('/').split('/').next().unwrap_or("");
if first == "appdata" || first == "library" { return Some("it is inside AppData, where browsers and apps keep logins, tokens and settings".to_string()); }
if first.starts_with('.') { return Some(format!("it is inside {first}, a hidden settings folder in the home folder where tools keep tokens")); }
return None;
}
if let Some((users, _)) = self.home.rsplit_once('/') {
if !users.is_empty() && under(&path, users) && !under(&path, &format!("{users}/public")) && !under(&path, &format!("{users}/shared")) { return Some("it is inside another person's profile".to_string()); }
}
None
}
}
#[derive(Debug, Clone)]
struct Local { path: String, name: String, size: u64 }
/// Check one path; the error is a full verb response.
fn check_file(rules: &PathRules, raw: &str) -> Result<Local, Value> {
let copy = "Copy the file to Downloads or a project folder first if the user really means to send it.";
let raw = raw.trim();
rules.precheck(raw).map_err(|(code, msg, hint)| pup_core::merge(err(code, msg, Some(&hint)), json!({ "filePath": raw })))?;
let not_found = || pup_core::merge(err("file_not_found", format!("{raw} does not exist on this PC"), Some("The path is read on the PC the browser runs on, not in your container. Send the file with adom-bridge send_files {filePaths:[...]} and pass the path it lands at. pup checks first because Chrome would accept a missing path and hand the page a phantom empty file.")), json!({ "filePath": raw }));
let real = std::fs::canonicalize(raw).map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { not_found() } else { pup_core::merge(err("file_unreadable", format!("cannot open {raw}: {e}"), Some("Check the file's permissions, and whether another app holds it open.")), json!({ "filePath": raw })) })?;
let real_s = real.to_string_lossy().to_string();
if let Some(why) = rules.refusal(&real_s) { return Err(pup_core::merge(err("path_refused", format!("pup will not hand {raw} to a web page: {why}"), Some(copy)), json!({ "filePath": raw, "resolvedPath": real_s.replace('\\', "/") }))); }
let meta = std::fs::metadata(&real).map_err(|e| err("file_unreadable", format!("cannot read {raw}: {e}"), None))?;
if !meta.is_file() { return Err(pup_core::merge(err("not_a_file", format!("{raw} is a folder, not a file"), Some("Pass a file. To send a folder, zip it first; a site that wants a whole folder needs a file list per file.")), json!({ "filePath": raw }))); }
if let Err(e) = std::fs::File::open(&real) { return Err(pup_core::merge(err("file_unreadable", format!("cannot open {raw}: {e}"), Some("Another app may hold it open exclusively, or this user may not be allowed to read it.")), json!({ "filePath": raw }))); }
let path = if cfg!(windows) { raw.replace('/', "\\") } else { raw.to_string() };
let name = raw.rsplit(['/', '\\']).next().unwrap_or(raw).to_string();
Ok(Local { path, name, size: meta.len() })
}
/// `accept` is advice to the OS picker; DevTools does not enforce it, so a page may reject the file afterwards.
fn accept_warning(accept: &str, files: &[Local]) -> Option<String> {
let tokens: Vec<String> = accept.split(',').map(|t| t.trim().to_lowercase()).filter(|t| !t.is_empty()).collect();
if tokens.is_empty() || tokens.iter().any(|t| !t.starts_with('.')) { return None; } // a MIME type: only the page can judge it
let off: Vec<&str> = files.iter().filter(|f| !tokens.iter().any(|t| f.name.to_lowercase().ends_with(t.as_str()))).map(|f| f.name.as_str()).collect();
if off.is_empty() { None } else { Some(format!("This input only offers {} in its picker and {} is not one; the page may reject it.", tokens.join(", "), off.join(", "))) }
}
// ── the page side ─────────────────────────────────────────────────────────────────────────────────
const WALK: &str = r#"const roots = []; let xframes = 0;
const walk = (r) => { roots.push(r); r.querySelectorAll('*').forEach((e) => { if (e.shadowRoot) walk(e.shadowRoot);
if (e.tagName === 'IFRAME' || e.tagName === 'FRAME') { let d = null; try { d = e.contentDocument; } catch (_) {} if (d) walk(d); else xframes++; } }); };
walk(document);
const inputs = () => roots.flatMap((r) => [...r.querySelectorAll('input[type=file]')]);"#;
fn finder(sel: Option<&str>, text: Option<&str>) -> String {
format!(r#"(() => {{ {WALK}
const sel = {s}, txt = {t};
const near = (el) => {{
if (el.matches && el.matches('input[type=file]')) return el;
const lab = el.tagName === 'LABEL' ? el : (el.closest && el.closest('label'));
if (lab && lab.control && lab.control.type === 'file') return lab.control;
if (el.querySelector) {{ const i = el.querySelector('input[type=file]'); if (i) return i; }}
let p = el.parentElement; for (let d = 0; p && d < 4; d++, p = p.parentElement) {{ const f = p.querySelectorAll('input[type=file]'); if (f.length === 1) return f[0]; if (f.length > 1) return null; }}
return null; }};
if (sel) {{ for (const r of roots) {{ const m = r.querySelector(sel); if (m) return near(m); }} return null; }}
if (txt) {{ const w = txt.trim().toLowerCase(); let best = null, bestLen = 1e9;
for (const r of roots) for (const n of r.querySelectorAll('button,a,label,input,[role=button],span,div')) {{ const own = (n.innerText || n.value || n.getAttribute('aria-label') || '').trim().toLowerCase(); if (own && own.includes(w) && own.length < bestLen) {{ best = n; bestLen = own.length; }} }}
return best ? near(best) : null; }}
const a = inputs(); return a.length === 1 ? a[0] : null; }})()"#, s = sel.map(js_str).unwrap_or("null".into()), t = text.map(js_str).unwrap_or("null".into()))
}
fn diag(sel: Option<&str>) -> String {
format!(r#"(() => {{ {WALK} const sel = {s}; let matched = null, bad = null;
if (sel) {{ try {{ matched = roots.some((r) => !!r.querySelector(sel)); }} catch (e) {{ bad = e.message; }} }}
return {{ matched, badSelector: bad, crossOriginFrames: xframes, fileInputs: inputs().slice(0, 12).map((i) => ({{ id: i.id || null, name: i.name || null, accept: i.accept || null, multiple: i.multiple, disabled: i.disabled, visible: !!(i.offsetWidth || i.offsetHeight) }})) }}; }})()"#, s = sel.map(js_str).unwrap_or("null".into()))
}
fn call_on(b: &crate::BrowserHandle, sid: &str, oid: &str, f: &str) -> Result<Value, pup_cdp::CdpError> {
b.cdp.send(Some(sid), "Runtime.callFunctionOn", json!({ "objectId": oid, "functionDeclaration": f, "returnByValue": true, "awaitPromise": false }), T).map(|r| r["result"]["value"].clone())
}
// ── a file chooser the page opens itself (the input is born in the Browse button's click handler) ───
struct ChooserState { armed: bool, served: Option<(Vec<String>, u64)> }
static CHOOSERS: std::sync::Mutex<Option<std::collections::HashMap<String, ChooserState>>> = std::sync::Mutex::new(None);
fn chooser_set(sid: &str, st: ChooserState) { CHOOSERS.lock().unwrap().get_or_insert_with(Default::default).insert(sid.to_string(), st); }
const ARM_SECS: u64 = 30;
/// A warm browser keeps the extension it started with, so right after an update it does not know the action yet.
fn old_worker(e: &Value) -> Option<Value> {
if !e["error"].as_str().unwrap_or("").contains("unsupported action") { return None; }
Some(err("browser_needs_restart", "this browser is still running the pup extension from before the last update", Some("pup_restart_browser brings every window back at its page with the new extension. pup_upload_file with a selector works without restarting.")))
}
/// CDP lane: the events are ours to wait on. (The extension lane streams none, so its worker runs nbe's arm.)
fn arm_cdp(b: std::sync::Arc<crate::BrowserHandle>, sid: String, files: Vec<Local>) -> Result<(), String> {
let _ = b.cdp.send(Some(&sid), "Page.enable", json!({}), T);
let rx = b.cdp.subscribe(Some(&sid), Some("Page.fileChooserOpened"));
b.cdp.send(Some(&sid), "Page.setInterceptFileChooserDialog", json!({ "enabled": true }), T).map_err(|e| e.to_string())?;
chooser_set(&sid, ChooserState { armed: true, served: None });
std::thread::Builder::new().name("pup-chooser".into()).spawn(move || {
let ev = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(ARM_SECS));
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
let served = ev.and_then(|e| b.cdp.send(Some(&sid), "DOM.setFileInputFiles", json!({ "files": paths, "backendNodeId": e.params["backendNodeId"] }), T).ok());
let _ = b.cdp.send(Some(&sid), "Page.setInterceptFileChooserDialog", json!({ "enabled": false }), T);
chooser_set(&sid, ChooserState { armed: false, served: served.map(|_| (files.iter().map(|f| f.name.clone()).collect(), pup_core::now_ms())) });
}).map_err(|e| e.to_string())?;
Ok(())
}
// ── the verb ──────────────────────────────────────────────────────────────────────────────────────
pub fn upload_file(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };
let base = json!({ "success": true, "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "windowEtiquette": "background" });
let ext = crate::transport::is_ext(&s);
let ext_tab = crate::transport::ext_id(&t);
if args["chooserStatus"] == json!(true) {
let (armed, served) = if ext {
match crate::extdrive::run(json!({ "action": "fileChooser", "status": true, "tabId": ext_tab }), T) { Ok(r) => (r["armed"] == json!(true), if r["served"] == json!(true) { Some(r["files"].clone()) } else { None }), Err(e) => return old_worker(&e).unwrap_or(e) }
} else {
let g = CHOOSERS.lock().unwrap();
let st = g.as_ref().and_then(|m| m.get(&t.cdp_session));
(st.map(|x| x.armed).unwrap_or(false), st.and_then(|x| x.served.as_ref().map(|(f, _)| json!(f))))
};
return ok(pup_core::merge(base, json!({ "armed": armed, "served": served.is_some(), "files": served, "_hint": if served.is_some() { "The page's file chooser was answered with your files. Check the page shows them; many sites need an Upload or Submit click next." } else if armed { "Still armed: pup_click the Browse or Upload control now." } else { "Not armed and nothing was served. The arm lasts 30 s; arm again and click straight after. A chooser opened by a script with no click behind it is refused by Chrome: use pup_click, not domClick." } })));
}
let raws: Vec<String> = match (args.get("filePaths").and_then(|v| v.as_array()), args.get("filePath").and_then(|v| v.as_str())) {
(Some(a), _) if !a.is_empty() => a.iter().filter_map(|v| v.as_str().map(|x| x.to_string())).collect(),
(_, Some(p)) => vec![p.to_string()],
_ => return err("file_path_required", "pup_upload_file needs {filePath} (or {filePaths:[...]}), an absolute path on the PC the browser runs on", Some("e.g. pup_upload_file {selector:\"input[type=file]\", filePath:\"C:/Users/me/Downloads/gerbers.zip\"}. A file in your container: adom-bridge send_files first, then pass where it lands.")),
};
let rules = PathRules::from_env(&[ctx.shots_dir().to_string_lossy().to_string()]); // pup's own screenshots may go into a bug-report form
let mut files = Vec::new();
for r in &raws { match check_file(&rules, r) { Ok(f) => files.push(f), Err(e) => return e } }
let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
let listed: Vec<Value> = files.iter().map(|f| json!({ "name": f.name, "sizeBytes": f.size })).collect();
let total: u64 = files.iter().map(|f| f.size).sum();
if args["armForNextChooser"] == json!(true) {
let armed = if ext {
match crate::extdrive::run(json!({ "action": "fileChooser", "arm": true, "filePaths": paths, "tabId": ext_tab }), T) {
Ok(_) => Ok(()),
Err(e) => { if let Some(o) = old_worker(&e) { return o; } Err(e["error"].as_str().unwrap_or("the extension did not arm").to_string()) }
}
}
else { arm_cdp(b.clone(), t.cdp_session.clone(), files.clone()) };
if let Err(e) = armed { return err("arm_failed", format!("could not arm the file chooser: {e}"), Some("pup_upload_file with a selector works when the input already exists in the page.")); }
touch(ctx, &s.id, None);
pup_core::log(format!("[upload] armed window {} for {} file(s), {} bytes", s.id, files.len(), total));
return ok(pup_core::merge(base, json!({ "armed": true, "armedForSec": ARM_SECS, "files": listed,
"_hint": "ARMED for 30 s: now pup_click the Browse or Upload control. The page's file chooser is answered with these files and no OS dialog opens. Confirm with pup_upload_file {chooserStatus:true}.",
"_next": ["pup_click {selector: the Browse control}", "pup_upload_file {chooserStatus:true}", "pup_screenshot"] })));
}
// direct: find the input as a live object
let (sel, text) = (args.get("selector").and_then(|v| v.as_str()), args.get("text").and_then(|v| v.as_str()));
let sid = t.cdp_session.as_str();
let found = b.cdp.send(Some(sid), "Runtime.evaluate", json!({ "expression": finder(sel, text), "returnByValue": false }), T);
let found = match found { Ok(r) => r, Err(e) => return err("eval_failed", format!("could not look for the file input: {e}"), None) };
let oid = found["result"]["objectId"].as_str().filter(|_| found["result"]["subtype"] == json!("node")).map(|x| x.to_string());
let Some(oid) = oid else {
let d = b.cdp.evaluate(sid, &diag(sel), T).unwrap_or(json!({}));
let n = d["fileInputs"].as_array().map(|a| a.len()).unwrap_or(0);
let xf = d["crossOriginFrames"].as_u64().unwrap_or(0);
let (code, hint) = if d["badSelector"].is_string() { ("bad_selector", format!("That selector is not valid CSS: {}", d["badSelector"].as_str().unwrap_or(""))) }
else if sel.is_none() && text.is_none() && n > 1 { ("ambiguous_input", "This page has several file inputs. Pick one with {selector}: fileInputs below lists them.".to_string()) }
else if d["matched"] == json!(true) { ("not_a_file_input", "The selector matched, but neither that element nor anything near it is a file input. fileInputs below lists the ones on the page.".to_string()) }
else if n > 0 { ("input_not_found", "Nothing matched, but the page has the file inputs listed in fileInputs below: use one of those, hidden or not.".to_string()) }
else if xf > 0 { ("input_not_found", format!("No file input in this document, and {xf} cross-origin frame(s) pup cannot look inside from here. If the input is in one, open that frame's address in its own tab; otherwise arm for the chooser: pup_upload_file {{armForNextChooser:true, filePath}}, then pup_click the Browse control.")) }
else { ("input_not_found", "No file input on the page right now. Sites often create it when Browse is clicked: pup_upload_file {armForNextChooser:true, filePath}, then pup_click the Browse control. Do NOT click Browse without arming: the OS dialog opens, comes to the front and freezes the window.".to_string()) };
return pup_core::merge(err(code, format!("no file input resolved from {}", sel.map(|x| format!("selector {x}")).or(text.map(|x| format!("text \"{x}\""))).unwrap_or("the page".into())), Some(&hint)), json!({ "fileInputs": d["fileInputs"], "crossOriginFrames": xf }));
};
let facts = call_on(&b, sid, &oid, "function(){ return { multiple: this.multiple, disabled: this.disabled, accept: this.accept || '', id: this.id || null, name: this.name || null }; }").unwrap_or(json!({}));
let release = |o: &str| { let _ = b.cdp.send(Some(sid), "Runtime.releaseObject", json!({ "objectId": o }), T); };
if facts["disabled"] == json!(true) { release(&oid); return err("input_disabled", "that file input is disabled", Some("Pages usually enable it once an earlier step is done. Finish that step, then retry.")); }
if files.len() > 1 && facts["multiple"] != json!(true) { release(&oid); return pup_core::merge(err("not_multiple", format!("that input takes one file and {} were given", files.len()), Some("Chrome would silently keep only the first. Zip them, or send one at a time if the page lists each upload.")), json!({ "input": facts })); }
// One capture listener, so a page that clears the input in its own change handler still counts as handed the file.
let _ = call_on(&b, sid, &oid, "function(){ const el = this; if (el.files && el.files.length) { el.value = ''; } el.addEventListener('change', function(){ el.__pupSeen = Array.from(el.files || []).map(function(f){ return { name: f.name, size: f.size }; }); }, { capture: true, once: true }); return true; }");
if let Err(e) = b.cdp.send(Some(sid), "DOM.setFileInputFiles", json!({ "files": paths, "objectId": oid }), T) {
release(&oid);
return err("upload_failed", format!("Chrome refused the file: {e}"), Some("Check the element is a real input[type=file] still in the page (a re-render replaces it: call this verb again)."));
}
std::thread::sleep(Duration::from_millis(60)); // change is dispatched on Chrome's side of the call
let back = call_on(&b, sid, &oid, "function(){ const seen = this.__pupSeen || null; try { delete this.__pupSeen; } catch (_) {} return { seen: seen, now: Array.from(this.files || []).map(function(f){ return { name: f.name, size: f.size }; }), connected: this.isConnected }; }").unwrap_or(json!({}));
release(&oid);
touch(ctx, &s.id, None);
let want: Vec<(String, u64)> = files.iter().map(|f| (f.name.clone(), f.size)).collect();
let as_list = |v: &Value| -> Vec<(String, u64)> { v.as_array().map(|a| a.iter().map(|x| (x["name"].as_str().unwrap_or("").to_string(), x["size"].as_u64().unwrap_or(0))).collect()).unwrap_or_default() };
let (now, seen) = (as_list(&back["now"]), as_list(&back["seen"]));
let on_input = now == want; let handed = seen == want;
if !on_input && !handed {
return pup_core::merge(err("attach_unverified", "Chrome took the call, but the page does not show these files on the input", Some("The page may have replaced the input while this ran. Screenshot it, then call this verb again.")), json!({ "wanted": listed, "onInput": back["now"], "seenAtChange": back["seen"] }));
}
let cleared = handed && now.is_empty();
let origin = t.url.split('/').take(3).collect::<Vec<_>>().join("/");
pup_core::log(format!("[upload] window {} put {} file(s), {} bytes, on an input at {}", s.id, files.len(), total, origin));
let warn = accept_warning(facts["accept"].as_str().unwrap_or(""), &files);
let mut v = pup_core::merge(base, json!({ "attached": true, "verified": true, "files": listed, "totalBytes": total, "input": facts, "clearedByPage": cleared, "pageOrigin": origin,
"_hint": if cleared { "The page received the file and cleared the input, which is what upload widgets do. The file has NOT necessarily reached the site yet: screenshot or poll the page for its own progress or error." } else { "The file is on the input and the page got its normal input and change events. It has NOT necessarily reached the site yet: many pages upload on change, others need an Upload or Submit click. Screenshot to see which." },
"_next": ["pup_screenshot", "pup_wait {selector: what the page shows when the upload finishes}"],
"pitfalls": ["Keep the file in place until the page has finished uploading: Chrome reads it from disk only then, and a changed or moved file fails the upload."] }));
if let Some(w) = warn { v["acceptWarning"] = json!(w); }
ok(v)
}
#[cfg(test)]
mod tests {
use super::*;
fn win() -> PathRules { PathRules { windows: true, home: "c:/users/me".into(), always_ok: vec!["c:/users/me/appdata/local/temp".into(), "c:/users/me/appdata/local/adom bridge/bridge-data/puppeteer/screenshots".into()], system: vec!["c:/windows".into(), "c:/program files".into(), "c:/program files (x86)".into(), "c:/programdata".into()] } }
#[test] fn ordinary_files_pass() {
let r = win();
for p in ["C:\\Users\\me\\Downloads\\gerbers.zip", "C:/Users/me/Documents/board/fab.zip", "C:\\Users\\me\\Desktop\\a.step", "D:\\boards\\v2\\gerbers.zip", "C:\\Projects\\x.zip", "C:\\Users\\Public\\x.zip",
"C:\\Users\\me\\AppData\\Local\\Temp\\staged\\x.zip", "C:\\Users\\me\\AppData\\Local\\Adom Bridge\\bridge-data\\puppeteer\\screenshots\\s.png", "\\\\?\\C:\\Users\\me\\Downloads\\y.zip", "C:\\Users\\me\\Downloads\\keyboard.zip", "C:\\Users\\me\\Downloads\\cookies.txt"] {
assert_eq!(r.refusal(p), None, "{p}");
}
}
#[test] fn secrets_and_system_refused() {
let r = win();
for p in ["C:\\Users\\me\\.ssh\\config", "C:\\Users\\me\\.adom\\config.json", "C:\\Users\\me\\.claude\\x.json", "C:\\Users\\me\\.gitconfig", "C:\\Users\\me\\AppData\\Roaming\\x.db",
"C:\\Users\\me\\AppData\\Local\\Google\\Chrome\\User Data\\Default\\Network\\Cookies", "C:\\Users\\me\\Downloads\\id_rsa", "C:\\Users\\me\\Documents\\id_ed25519.pub", "D:\\backup\\.ssh\\k", "D:\\x\\server.pem", "C:\\Users\\me\\vault.kdbx", "D:\\repo\\.env", "D:\\repo\\.env.local", "D:\\repo\\.git\\config", "C:\\Users\\me\\AppData\\Local\\Temp\\login data",
"C:\\Windows\\System32\\config\\SAM", "C:\\Windows\\win.ini", "C:\\Program Files\\x\\y.dll", "C:\\ProgramData\\x", "C:\\Users\\other\\Documents\\a.zip", "\\\\?\\C:\\Users\\me\\.ssh\\k"] {
assert!(r.refusal(p).is_some(), "{p}");
}
}
#[test] fn prefix_is_a_folder_not_a_string() { assert_eq!(win().refusal("C:\\Windows2\\a.zip"), None); assert_eq!(win().refusal("C:\\Users\\me2\\a.zip").is_some(), true); }
#[test] fn precheck_shapes() {
let r = win();
assert!(r.precheck("C:\\Users\\me\\x.zip").is_ok()); assert!(r.precheck("D:/a/b.zip").is_ok());
assert_eq!(r.precheck("\\\\evil\\share\\x").unwrap_err().0, "path_refused");
assert_eq!(r.precheck("//evil/share/x").unwrap_err().0, "path_refused");
assert_eq!(r.precheck("\\\\wsl$\\Ubuntu\\x").unwrap_err().0, "path_refused");
assert_eq!(r.precheck("/home/adom/x.zip").unwrap_err().0, "path_not_absolute");
assert_eq!(r.precheck("~/x.zip").unwrap_err().0, "path_not_absolute");
assert_eq!(r.precheck("x.zip").unwrap_err().0, "path_not_absolute");
assert_eq!(r.precheck("").unwrap_err().0, "file_path_required");
assert_eq!(r.precheck("C:\\x.txt:secret").unwrap_err().0, "path_refused");
assert_eq!(r.precheck("C:\\Users\\me\\..\\other\\x").unwrap_err().0, "path_refused");
assert!(r.precheck("C:\\Users\\me\\a..b.zip").is_ok());
}
#[test] fn accept_warning_only_for_extension_lists() {
let f = vec![Local { path: "x".into(), name: "G.ZIP".into(), size: 1 }];
assert_eq!(accept_warning("", &f), None); assert_eq!(accept_warning(".zip,.rar", &f), None); assert_eq!(accept_warning("application/zip", &f), None);
assert!(accept_warning(".png", &f).is_some());
}
}