idx-cli/crates/idx-core/src/api/stockbit/browser.rs
0xrsydn 362d314118 fix(stockbit): tell a polling agent when Stockbit refuses the sign-in
A refused cookie in idx's browser was only a stderr warning, which nobody
reads under idx-mcp: stockbit_status showed `waiting` until the timeout, and
the timeout message did not mention the refusal.

- runtime::on_warning sends a thread's warnings to a sink as well as
  stderr, until its guard drops.
- The background sign-in puts its warnings in `sign_in.warning`, cleared
  once it signs in.
- The errors that end a browser wait (timeout, window closed, browser
  exited) name a refusal it hit.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
2026-11-04 19:22:14 +07:00

1443 lines
53 KiB
Rust

//! Signing in to Stockbit inside a real browser, with no DevTools and no
//! copying.
//!
//! Stockbit publishes no OAuth client a third party can register, and its own
//! sign-in is a first-party flow guarded by reCAPTCHA, so a CLI cannot ask for
//! a token directly. What it can do is hand the sign-in to the browser that
//! already knows how to do it, then read the cookie that browser ends up with:
//!
//! 1. Launch a Chromium-family browser on Stockbit's sign-in page with a
//! profile directory only idx uses and `--remote-debugging-port=0`. Chrome
//! writes the port it picked and the WebSocket path to
//! `<profile>/DevToolsActivePort`.
//! 2. Connect to that endpoint over WebSocket and ask for the cookies.
//! 3. Keep asking until the `credentialStorage` cookie holds tokens `verify`
//! accepts, take it out of the profile, then close the browser.
//!
//! What Stockbit's web app does with that cookie (read from its own bundle,
//! 2026-10-29): it is the app's persisted credential store, written by
//! script, host-only on `stockbit.com`, for 30 days, as percent-encoded
//! `{"state":{"access","refresh","user"},"version":0}`. Every way in (password,
//! Google or Apple, a new device's one-time code, account recovery) and the
//! app's own `POST /login/refresh` end in that one store, so the cookie is
//! the single place a finished sign-in shows up. A signed-out page writes the
//! store too, without tokens, which is why a cookie counts only once
//! `auth::session_from_paste` finds tokens in it.
//!
//! The password, the one-time code and any reCAPTCHA stay in the browser and
//! on Stockbit's page: idx never sees them. The cookie travels in memory only,
//! and only as far as `verify`, which stores the two tokens.
//!
//! A dedicated profile is required by the browser (Chrome 136 and later refuse
//! remote debugging on the default profile) and wanted anyway: idx's session
//! never mixes with the user's everyday browsing.
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use base64::Engine;
use serde_json::{Value, json};
use crate::error::IdxError;
/// The cookie Stockbit keeps its session in.
const COOKIE: &str = "credentialStorage";
/// The page the browser opens, which is Stockbit's own sign-in.
const SIGN_IN_URL: &str = "https://stockbit.com/login";
/// Profile directory idx keeps its own browser session in, beside
/// `config.toml`.
pub const PROFILE_DIR: &str = "stockbit-browser";
/// How long the browser may take to publish its DevTools endpoint.
const START_TIMEOUT: Duration = Duration::from_secs(20);
/// Pause between cookie reads.
const POLL: Duration = Duration::from_millis(500);
/// How long a session check that failed for a passing reason (network, rate
/// limit) waits before the same cookie is checked again.
#[cfg(not(test))]
const VERIFY_RETRY: Duration = Duration::from_secs(5);
#[cfg(test)]
const VERIFY_RETRY: Duration = Duration::from_millis(50);
/// Socket timeout for one DevTools message.
const IO_TIMEOUT: Duration = Duration::from_secs(10);
/// How long the browser gets to quit after `Browser.close`.
const EXIT_GRACE: Duration = Duration::from_secs(3);
/// Largest DevTools message accepted.
const MAX_MESSAGE: u64 = 8 * 1024 * 1024;
/// Names the browser idx starts, ahead of any it finds.
pub const ENV_BROWSER: &str = "IDX_STOCKBIT_BROWSER";
/// File in the profile naming the executable that last opened it. A profile
/// belongs to one browser: its cookies are encrypted with that browser's own
/// key, and an older Chromium may refuse a profile a newer one wrote.
const MADE_BY: &str = "idx-browser";
/// A Chromium-family browser idx can drive over the DevTools protocol.
pub struct Browser {
/// What to call the browser in messages (`Google Chrome`).
pub name: String,
/// Its executable.
pub path: PathBuf,
}
impl Browser {
/// The browser to open `profile` with: the one `IDX_STOCKBIT_BROWSER`
/// names, else the one that opened that profile before, else the first
/// supported browser installed here.
///
/// Under the mock provider only the variable counts, so a test never
/// opens a browser it did not ask for.
pub fn find(profile: &Path) -> Result<Self, IdxError> {
if let Some(path) = std::env::var_os(ENV_BROWSER) {
let path = PathBuf::from(path);
if !path.is_file() {
return Err(IdxError::ConfigError(format!(
"{ENV_BROWSER} is {}, which is not a file",
path.display()
)));
}
return Ok(Self::at(path));
}
#[cfg(feature = "testing")]
if crate::api::mock::enabled() {
return Err(IdxError::Unsupported(format!(
"no browser under the mock provider; set {ENV_BROWSER}"
)));
}
if let Some(browser) = Self::recorded(profile) {
return Ok(browser);
}
candidates()
.into_iter()
.find(|(_, path)| path.is_file())
.map(|(name, path)| Self {
name: name.to_string(),
path,
})
.ok_or_else(|| {
IdxError::Unsupported(format!(
"no Chrome, Chromium, Edge, Brave or Helium found; set {ENV_BROWSER} to a \
Chromium-family browser"
))
})
}
/// A browser known only by its executable.
fn at(path: PathBuf) -> Self {
let name = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "the browser".to_string());
Self { name, path }
}
/// The browser that last opened `profile`, if it is still installed.
fn recorded(profile: &Path) -> Option<Self> {
let raw = std::fs::read_to_string(profile.join(MADE_BY)).ok()?;
let path = PathBuf::from(raw.trim());
path.is_file().then(|| Self::at(path))
}
/// Opens the browser on Stockbit's sign-in page and connects to it.
///
/// Returns once the browser answers over DevTools, so a browser that will
/// not start is reported here, before anyone waits on it.
pub fn open(&self, profile: &Path) -> Result<Window, IdxError> {
let launched = self.launch(profile)?;
let devtools = connect(profile, &|| launched.is_running())?;
Ok(Window { launched, devtools })
}
/// Starts the browser on its sign-in page.
fn launch(&self, profile: &Path) -> Result<Launched, IdxError> {
std::fs::create_dir_all(profile)
.map_err(|e| IdxError::Io(format!("cannot create idx's browser profile: {e}")))?;
owner_only(profile);
// A port file left by an earlier run would be read as this run's.
let _ = std::fs::remove_file(profile.join("DevToolsActivePort"));
let child = Command::new(&self.path)
.args(self.args(profile))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| IdxError::Io(format!("cannot open {}: {e}", self.name)))?;
// Best effort: without it, the next run searches again.
if let Some(path) = self.path.to_str() {
let _ = std::fs::write(profile.join(MADE_BY), path);
}
Ok(Launched {
child: std::cell::RefCell::new(child),
})
}
/// Arguments that keep idx's sign-in in its own profile and publish a
/// DevTools port instead of a fixed one.
fn args(&self, profile: &Path) -> Vec<String> {
vec![
format!("--user-data-dir={}", profile.display()),
"--remote-debugging-port=0".to_string(),
"--no-first-run".to_string(),
"--no-default-browser-check".to_string(),
"--disable-search-engine-choice-screen".to_string(),
SIGN_IN_URL.to_string(),
]
}
}
/// A browser window idx opened on Stockbit's sign-in page.
pub struct Window {
launched: Launched,
devtools: DevTools,
}
impl Window {
/// Waits for a session `verify` accepts, then closes the browser.
///
/// A session already in idx's profile that still verifies is taken at
/// once. One `verify` refuses is reported once and then left alone:
/// signing in again in the open window replaces it. Setting `cancel`
/// ends the wait and closes the browser too.
pub fn wait(
mut self,
timeout: Duration,
cancel: &AtomicBool,
verify: &dyn Fn(&str) -> Result<(), IdxError>,
) -> Result<(), IdxError> {
let launched = &self.launched;
let result = wait_for_session(&mut self.devtools, timeout, verify, &|| {
launched.is_running() && !cancel.load(Ordering::Relaxed)
});
self.launched.exit(EXIT_GRACE);
result
}
}
/// Connects to the browser that is starting on `profile`.
fn connect(profile: &Path, alive: &dyn Fn() -> bool) -> Result<DevTools, IdxError> {
let (port, path) = read_endpoint(profile, START_TIMEOUT, alive)?;
DevTools::connect(port, &path)
}
/// Waits on a running browser for a session `verify` accepts.
///
/// Apart from `Window` so tests can drive the wait against a stand-in
/// DevTools endpoint without launching a browser.
fn wait_for_session(
devtools: &mut DevTools,
timeout: Duration,
verify: &dyn Fn(&str) -> Result<(), IdxError>,
alive: &dyn Fn() -> bool,
) -> Result<(), IdxError> {
let deadline = Instant::now() + timeout;
let mut rejected: Option<String> = None;
// Why Stockbit refused that cookie, so the error that ends the wait says
// so: a polling frontend never saw the warning.
let mut refusal: Option<String> = None;
// After a check fails for a passing reason, the next is held back.
let mut retry_at: Option<Instant> = None;
let mut last_error: Option<String> = None;
// The window is watched so closing it ends the wait instead of running
// out the clock; the first polls may not see a page yet.
let mut saw_window = false;
let mut misses = 0;
loop {
match devtools.session_cookie() {
Ok(Some(cookie))
if rejected.as_deref() != Some(cookie.value.as_str())
&& retry_at.is_none_or(|at| Instant::now() >= at) =>
{
match verify(&cookie.value) {
Ok(()) => {
// The session now lives in idx's own file. Take the
// copy out of the browser, but leave Stockbit's
// device cookies in place.
if let Err(err) = devtools.forget_session(&cookie) {
crate::runtime::warn(format!(
"could not remove the session cookie from idx's browser \
profile ({err}); deleting that profile folder removes it"
));
}
devtools.close();
return Ok(());
}
Err(err) if super::check_is_transient(&err) => {
crate::runtime::warn(format!(
"could not check the session in idx's browser yet ({err}); \
retrying"
));
retry_at = Some(Instant::now() + VERIFY_RETRY);
}
Err(err) => {
crate::runtime::warn(format!(
"Stockbit refused the session in idx's browser ({err}); sign in \
again in that window"
));
refusal = Some(err.to_string());
rejected = Some(cookie.value);
retry_at = None;
}
}
}
// A later read that works clears an earlier failure, so the
// timeout message names only what is still wrong.
Ok(_) => last_error = None,
Err(err) => last_error = Some(err.to_string()),
}
let refused = || {
refusal
.as_ref()
.map(|err| format!("; Stockbit refused the session signed in there ({err})"))
.unwrap_or_default()
};
if !alive() {
devtools.close();
return Err(IdxError::Io(format!(
"the browser exited before idx got a session{}; run `idx stockbit login` again",
refused()
)));
}
match devtools.has_page() {
Ok(true) => {
saw_window = true;
misses = 0;
}
Ok(false) => {
misses += 1;
if saw_window && misses >= 2 {
devtools.close();
return Err(IdxError::Io(format!(
"the Stockbit sign-in window was closed before the session arrived{}; \
run `idx stockbit login` again",
refused()
)));
}
}
Err(err) => last_error = Some(err.to_string()),
}
if Instant::now() >= deadline {
devtools.close();
let mut message = format!(
"the Stockbit sign-in timed out after {}s and idx closed its browser window{}; \
re-run `idx stockbit login`, or add --no-browser for the local page",
timeout.as_secs(),
refused()
);
if let Some(err) = last_error {
message.push_str(&format!(" (last DevTools error: {err})"));
}
return Err(IdxError::Io(message));
}
std::thread::sleep(POLL);
}
}
/// Where idx keeps its own browser profile.
pub fn profile_dir() -> Result<PathBuf, IdxError> {
Ok(super::auth::session_dir()?.join(PROFILE_DIR))
}
/// Browsers to try, in order, for this platform.
fn candidates() -> Vec<(&'static str, PathBuf)> {
let mut found = Vec::new();
#[cfg(target_os = "macos")]
{
let homes: Vec<PathBuf> = ["/Applications", "/System/Applications"]
.iter()
.map(PathBuf::from)
.chain(std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Applications")))
.collect();
let apps = [
(
"Google Chrome",
"Google Chrome.app/Contents/MacOS/Google Chrome",
),
("Chromium", "Chromium.app/Contents/MacOS/Chromium"),
(
"Microsoft Edge",
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
),
("Brave", "Brave Browser.app/Contents/MacOS/Brave Browser"),
("Helium", "Helium.app/Contents/MacOS/Helium"),
(
"Google Chrome Canary",
"Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
),
];
for home in homes {
for (name, rel) in apps {
found.push((name, home.join(rel)));
}
}
}
#[cfg(target_os = "windows")]
{
let roots: Vec<PathBuf> = ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"]
.iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.collect();
for root in roots {
for (name, rel) in [
("Google Chrome", r"Google\Chrome\Application\chrome.exe"),
("Chromium", r"Chromium\Application\chrome.exe"),
("Microsoft Edge", r"Microsoft\Edge\Application\msedge.exe"),
(
"Brave",
r"BraveSoftware\Brave-Browser\Application\brave.exe",
),
] {
found.push((name, root.join(rel)));
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let names = [
("Google Chrome", "google-chrome"),
("Google Chrome", "google-chrome-stable"),
("Chromium", "chromium"),
("Chromium", "chromium-browser"),
("Microsoft Edge", "microsoft-edge"),
("Brave", "brave-browser"),
];
let dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect())
.unwrap_or_default();
for (name, exe) in names {
for dir in &dirs {
found.push((name, dir.join(exe)));
}
}
}
found
}
/// A browser process idx started and must not leave behind.
struct Launched {
// A `RefCell` because the wait needs liveness through an immutable
// borrow; the process is only touched from one thread.
child: std::cell::RefCell<Child>,
}
impl Launched {
/// Whether the browser is still running.
fn is_running(&self) -> bool {
matches!(self.child.borrow_mut().try_wait(), Ok(None))
}
/// Gives the browser `grace` to quit after `Browser.close`, then kills it.
fn exit(&self, grace: Duration) {
let deadline = Instant::now() + grace;
while Instant::now() < deadline {
if !self.is_running() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
impl Drop for Launched {
fn drop(&mut self) {
let child = self.child.get_mut();
let _ = child.kill();
let _ = child.wait();
}
}
/// Reads `<profile>/DevToolsActivePort`: the port Chrome picked, then the
/// WebSocket path to the browser endpoint.
///
/// A browser that exits before writing it has usually handed the window to
/// one already running on the same profile, which idx cannot drive.
fn read_endpoint(
profile: &Path,
timeout: Duration,
alive: &dyn Fn() -> bool,
) -> Result<(u16, String), IdxError> {
let file = profile.join("DevToolsActivePort");
let deadline = Instant::now() + timeout;
loop {
if let Ok(raw) = std::fs::read_to_string(&file)
&& let Some((port, path)) = parse_endpoint(&raw)
{
return Ok((port, path));
}
if !alive() {
return Err(IdxError::Io(
"the browser exited as it started; if a window for idx is already open, close \
it and try again"
.to_string(),
));
}
if Instant::now() >= deadline {
return Err(IdxError::Io(format!(
"the browser did not publish a DevTools port within {}s; if a window for idx is \
already open, close it and try again",
timeout.as_secs()
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
/// The two lines of a `DevToolsActivePort` file.
fn parse_endpoint(raw: &str) -> Option<(u16, String)> {
let mut lines = raw.lines();
let port = lines.next()?.trim().parse::<u16>().ok()?;
let path = lines.next()?.trim();
(port != 0 && path.starts_with('/')).then(|| (port, path.to_string()))
}
/// Stockbit's session cookie as the browser holds it: the value, and the
/// domain and path that name it for deletion.
struct SessionCookie {
value: String,
domain: String,
path: String,
}
/// The session cookie among DevTools' cookies, if the browser has one with
/// tokens in it; the store a signed-out page writes does not count.
fn session_cookie(cookies: &Value) -> Option<SessionCookie> {
cookies
.as_array()?
.iter()
.filter(|cookie| cookie["name"] == COOKIE)
.filter_map(|cookie| {
let domain = cookie["domain"].as_str()?;
is_stockbit(domain).then(|| SessionCookie {
value: cookie["value"].as_str().unwrap_or_default().to_string(),
domain: domain.to_string(),
path: cookie["path"].as_str().unwrap_or("/").to_string(),
})
})
.find(|cookie| super::auth::session_from_paste(&cookie.value).is_some())
}
/// `stockbit.com` or a subdomain, as a cookie's domain names it (host-only
/// cookies have no leading dot).
fn is_stockbit(domain: &str) -> bool {
let host = domain.strip_prefix('.').unwrap_or(domain);
host == "stockbit.com" || host.ends_with(".stockbit.com")
}
/// A DevTools protocol connection: one WebSocket, one request at a time.
struct DevTools {
stream: TcpStream,
next_id: u64,
}
impl DevTools {
fn connect(port: u16, path: &str) -> Result<Self, IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}"));
let mut stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).map_err(io)?;
stream.set_read_timeout(Some(IO_TIMEOUT)).map_err(io)?;
stream.set_write_timeout(Some(IO_TIMEOUT)).map_err(io)?;
let key = base64::engine::general_purpose::STANDARD.encode(random_bytes());
let request = format!(
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
);
stream.write_all(request.as_bytes()).map_err(io)?;
let head = read_head(&mut stream)?;
if !head.starts_with("HTTP/1.1 101") {
return Err(IdxError::Io(format!(
"browser DevTools refused the connection: {}",
head.lines().next().unwrap_or_default()
)));
}
Ok(Self { stream, next_id: 0 })
}
/// The `credentialStorage` cookie the browser holds, if it has tokens.
fn session_cookie(&mut self) -> Result<Option<SessionCookie>, IdxError> {
let result = self.call("Storage.getCookies", json!({}))?;
Ok(session_cookie(&result["cookies"]))
}
/// Asks the browser to quit; it usually closes the socket instead of
/// answering, which is not an error here.
fn close(&mut self) {
let _ = self
.stream
.set_read_timeout(Some(Duration::from_millis(500)));
let _ = self.call("Browser.close", json!({}));
}
/// Whether the browser still has a window open.
fn has_page(&mut self) -> Result<bool, IdxError> {
Ok(!self.pages()?.is_empty())
}
/// Removes the session cookie from the profile, keeping the rest, and
/// checks it is gone.
///
/// Stockbit's script holds the session in memory, writes it back to the
/// cookie, and syncs it across its tabs, so no Stockbit page may be open
/// when the cookie goes: idx opens a blank tab of its own, closes every
/// other one, and deletes from there. The cookie is named by the domain
/// and path the browser reported, since Stockbit sets it host-only.
fn forget_session(&mut self, cookie: &SessionCookie) -> Result<(), IdxError> {
let created = self.call("Target.createTarget", json!({ "url": "about:blank" }))?;
let blank = created["targetId"]
.as_str()
.ok_or_else(|| IdxError::Io("browser DevTools opened no tab".to_string()))?
.to_string();
for target in self.pages()? {
if target != blank {
self.call("Target.closeTarget", json!({ "targetId": target }))?;
}
}
let attached = self.call(
"Target.attachToTarget",
json!({ "targetId": blank, "flatten": true }),
)?;
let session = attached["sessionId"]
.as_str()
.ok_or_else(|| IdxError::Io("browser DevTools attached no session".to_string()))?
.to_string();
self.call_in(
Some(&session),
"Network.deleteCookies",
json!({ "name": COOKIE, "domain": cookie.domain, "path": cookie.path }),
)?;
if self.session_cookie()?.is_some() {
return Err(IdxError::Io(
"the session cookie was still there after deleting it".to_string(),
));
}
Ok(())
}
/// The ids of the browser's open tabs.
fn pages(&mut self) -> Result<Vec<String>, IdxError> {
let targets = self.call("Target.getTargets", json!({}))?;
Ok(targets["targetInfos"]
.as_array()
.map(|infos| {
infos
.iter()
.filter(|target| target["type"] == "page")
.filter_map(|target| target["targetId"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default())
}
/// One request and its reply, skipping events.
fn call(&mut self, method: &str, params: Value) -> Result<Value, IdxError> {
self.call_in(None, method, params)
}
/// A request in a session (a page attached with `flatten`), or in the
/// browser itself when `session` is `None`.
fn call_in(
&mut self,
session: Option<&str>,
method: &str,
params: Value,
) -> Result<Value, IdxError> {
self.next_id += 1;
let id = self.next_id;
let mut message = json!({ "id": id, "method": method, "params": params });
if let Some(session) = session {
message["sessionId"] = json!(session);
}
let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}"));
self.stream
.write_all(&frame(1, message.to_string().as_bytes()))
.map_err(io)?;
loop {
let payload = self.read_message()?;
let reply: Value = serde_json::from_slice(&payload)
.map_err(|e| IdxError::Io(format!("browser DevTools: {e}")))?;
if reply["id"].as_u64() != Some(id) {
continue;
}
if let Some(error) = reply.get("error") {
return Err(IdxError::Io(format!(
"browser DevTools {method}: {}",
error["message"].as_str().unwrap_or("failed")
)));
}
return Ok(reply.get("result").cloned().unwrap_or(Value::Null));
}
}
/// One whole message, joining fragments and answering pings.
fn read_message(&mut self) -> Result<Vec<u8>, IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}"));
let mut buffer = Vec::new();
loop {
let (fin, opcode, payload) = read_frame(&mut self.stream)?;
match opcode {
// ping: answer so the browser keeps the socket open.
0x9 => self.stream.write_all(&frame(0xA, &payload)).map_err(io)?,
// pong: nothing to do.
0xA => continue,
0x8 => {
return Err(IdxError::Io(
"browser DevTools closed the connection".to_string(),
));
}
// text, binary, or a continuation of the message in hand.
0x0 => buffer.extend_from_slice(&payload),
0x1 | 0x2 => {
buffer.clear();
buffer.extend_from_slice(&payload);
}
_ => continue,
}
if fin {
return Ok(buffer);
}
}
}
}
/// Reads the HTTP response head, up to the empty line.
fn read_head(stream: &mut TcpStream) -> Result<String, IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}"));
let mut raw = Vec::new();
let mut byte = [0u8; 1];
while !raw.ends_with(b"\r\n\r\n") {
if stream.read(&mut byte).map_err(io)? == 0 {
return Err(IdxError::Io(
"browser DevTools closed the connection".to_string(),
));
}
raw.push(byte[0]);
if raw.len() > 8192 {
return Err(IdxError::Io(
"browser DevTools sent an oversized handshake".to_string(),
));
}
}
Ok(String::from_utf8_lossy(&raw).into_owned())
}
/// A masked client frame: clients must mask, servers must not.
fn frame(opcode: u8, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(payload.len() + 14);
out.push(0x80 | opcode);
let mask = 0x80;
match payload.len() {
len if len < 126 => out.push(mask | len as u8),
len if len <= u16::MAX as usize => {
out.push(mask | 126);
out.extend_from_slice(&(len as u16).to_be_bytes());
}
len => {
out.push(mask | 127);
out.extend_from_slice(&(len as u64).to_be_bytes());
}
}
let key = fastrand::u32(..).to_be_bytes();
out.extend_from_slice(&key);
out.extend(
payload
.iter()
.enumerate()
.map(|(index, byte)| byte ^ key[index % 4]),
);
out
}
/// One frame: `(fin, opcode, payload)`.
fn read_frame(reader: &mut impl Read) -> Result<(bool, u8, Vec<u8>), IdxError> {
let io = |e: std::io::Error| IdxError::Io(format!("browser DevTools: {e}"));
let mut head = [0u8; 2];
reader.read_exact(&mut head).map_err(io)?;
let fin = head[0] & 0x80 != 0;
let opcode = head[0] & 0x0F;
let masked = head[1] & 0x80 != 0;
let length = match head[1] & 0x7F {
126 => {
let mut bytes = [0u8; 2];
reader.read_exact(&mut bytes).map_err(io)?;
u16::from_be_bytes(bytes) as u64
}
127 => {
let mut bytes = [0u8; 8];
reader.read_exact(&mut bytes).map_err(io)?;
u64::from_be_bytes(bytes)
}
short => u64::from(short),
};
if length > MAX_MESSAGE {
return Err(IdxError::Io(format!(
"browser DevTools sent an oversized message ({length} bytes)"
)));
}
let key = if masked {
let mut key = [0u8; 4];
reader.read_exact(&mut key).map_err(io)?;
Some(key)
} else {
None
};
let mut payload = vec![0u8; length as usize];
reader.read_exact(&mut payload).map_err(io)?;
if let Some(key) = key {
for (index, byte) in payload.iter_mut().enumerate() {
*byte ^= key[index % 4];
}
}
Ok((fin, opcode, payload))
}
fn random_bytes() -> [u8; 16] {
let mut bytes = [0u8; 16];
for chunk in bytes.chunks_mut(4) {
chunk.copy_from_slice(&fastrand::u32(..).to_be_bytes());
}
bytes
}
/// Owner-only permissions on the profile, which holds Stockbit's cookie.
fn owner_only(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = path;
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use std::net::TcpListener;
use super::*;
/// A masked client frame round-trips through the reader.
#[test]
fn frames_round_trip() {
for payload in [b"".as_slice(), b"hi", &[b'x'; 300], &[b'y'; 70_000]] {
let raw = frame(1, payload);
assert_eq!(raw[1] & 0x80, 0x80, "client frames are masked");
let (fin, opcode, decoded) = read_frame(&mut Cursor::new(raw)).unwrap();
assert!(fin);
assert_eq!(opcode, 1);
assert_eq!(decoded, payload);
}
}
/// The port file is two lines, and anything else is not an endpoint.
#[test]
fn endpoint_needs_a_port_and_a_path() {
assert_eq!(
parse_endpoint("9222\n/devtools/browser/abc\n"),
Some((9222, "/devtools/browser/abc".to_string()))
);
assert_eq!(parse_endpoint("0\n/devtools/browser/abc"), None);
assert_eq!(parse_endpoint("9222\n"), None);
assert_eq!(parse_endpoint(""), None);
}
/// What a signed-out Stockbit page stores: the credential store with no
/// tokens (constructed from the app's `partialize`, which drops unset
/// fields).
const SIGNED_OUT: &str = "%7B%22state%22%3A%7B%7D%2C%22version%22%3A0%7D";
/// A `credentialStorage` value with tokens in it.
fn session_value() -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
.trim()
.to_string()
}
/// Only Stockbit's own cookie counts, and only once it holds tokens.
#[test]
fn only_a_stockbit_cookie_with_tokens_counts() {
let session = session_value();
let cookies = json!([
{"name": COOKIE, "domain": ".stockbit.example", "path": "/", "value": session},
{"name": COOKIE, "domain": "notstockbit.com", "path": "/", "value": session},
{"name": "other", "domain": "stockbit.com", "path": "/", "value": session},
{"name": COOKIE, "domain": "stockbit.com", "path": "/", "value": SIGNED_OUT},
{"name": COOKIE, "domain": "stockbit.com", "path": "/", "value": session},
]);
let found = session_cookie(&cookies).expect("the session cookie");
assert_eq!(found.value, session);
assert_eq!(
found.domain, "stockbit.com",
"host-only, as Stockbit sets it"
);
assert_eq!(found.path, "/");
for value in [SIGNED_OUT, ""] {
let lone = json!([{"name": COOKIE, "domain": "stockbit.com", "value": value}]);
assert!(
session_cookie(&lone).is_none(),
"{value:?} is not a session"
);
}
assert!(session_cookie(&json!([])).is_none());
assert!(is_stockbit(".stockbit.com") && is_stockbit("exodus.stockbit.com"));
}
/// The browser must not share the user's profile, and must pick its port.
#[test]
fn launch_keeps_its_own_profile_and_port() {
let browser = Browser {
name: "test".to_string(),
path: PathBuf::from("/bin/true"),
};
let args = browser.args(Path::new("/tmp/idx-profile"));
assert!(args.contains(&"--user-data-dir=/tmp/idx-profile".to_string()));
assert!(args.contains(&"--remote-debugging-port=0".to_string()));
assert_eq!(args.last().map(String::as_str), Some(SIGN_IN_URL));
}
/// A whole sign-in against a stand-in browser: the session is handed to
/// `verify`, every Stockbit tab is closed before the cookie is deleted by
/// its own (host-only) domain, the cookie is gone afterwards, and the
/// browser is asked to close.
#[test]
fn a_sign_in_takes_the_cookie_and_removes_it_from_the_profile() {
let profile = temp_dir("sign-in");
let browser = FakeBrowser::start(Fake::default());
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let seen = std::cell::RefCell::new(Vec::new());
wait_for_session(
&mut devtools,
Duration::from_secs(5),
&|pasted| {
seen.borrow_mut().push(pasted.to_string());
Ok(())
},
&|| true,
)
.unwrap();
assert_eq!(seen.borrow().as_slice(), [session_value()]);
let log = browser.finish();
let at = |name: &str| log.methods.iter().position(|method| method == name);
assert!(
at("Target.closeTarget") < at("Network.deleteCookies"),
"Stockbit's tab closes before the cookie goes: {:?}",
log.methods
);
assert_eq!(log.closed, ["T1"], "the sign-in tab, not idx's blank one");
assert!(!log.cookie_left, "the session cookie is out of the profile");
assert_eq!(
log.methods.last().map(String::as_str),
Some("Browser.close")
);
let _ = std::fs::remove_dir_all(&profile);
}
/// A cookie that survives the delete is an error, not a silent success.
#[test]
fn a_cookie_that_survives_the_delete_is_reported() {
let profile = temp_dir("sticky");
let browser = FakeBrowser::start(Fake {
sticky: true,
..Fake::default()
});
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let cookie = devtools.session_cookie().unwrap().expect("a session");
let err = devtools.forget_session(&cookie).unwrap_err();
assert!(err.to_string().contains("still there"), "{err}");
devtools.close();
assert!(browser.finish().cookie_left);
let _ = std::fs::remove_dir_all(&profile);
}
/// The store a signed-out page writes is not offered to `verify`.
#[test]
fn a_signed_out_page_is_not_a_session() {
let profile = temp_dir("signed-out");
let browser = FakeBrowser::start(Fake {
cookie: Some(SIGNED_OUT.to_string()),
..Fake::default()
});
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let checks = std::cell::Cell::new(0);
let err = wait_for_session(
&mut devtools,
Duration::from_millis(300),
&|_| {
checks.set(checks.get() + 1);
Ok(())
},
&|| true,
)
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert_eq!(checks.get(), 0);
browser.finish();
let _ = std::fs::remove_dir_all(&profile);
}
/// A session idx refuses is reported once, and the wait goes on.
#[test]
fn a_refused_cookie_is_reported_once_and_the_wait_continues() {
let profile = temp_dir("refused");
let browser = FakeBrowser::start(Fake::default());
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let checks = std::cell::Cell::new(0);
let err = wait_for_session(
&mut devtools,
Duration::from_millis(300),
&|_| {
checks.set(checks.get() + 1);
Err(IdxError::AuthError("expired".to_string()))
},
&|| true,
)
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(
err.to_string()
.contains("Stockbit refused the session signed in there (auth error: expired)"),
"{err}"
);
assert_eq!(checks.get(), 1, "one refusal per distinct cookie");
browser.finish();
let _ = std::fs::remove_dir_all(&profile);
}
/// A check that fails for a passing reason (a timeout, a 429) is not a
/// refusal: the same cookie is checked again and can still sign in.
#[test]
fn a_check_that_fails_in_passing_is_retried() {
let profile = temp_dir("transient");
let browser = FakeBrowser::start(Fake::default());
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let checks = std::cell::Cell::new(0);
wait_for_session(
&mut devtools,
Duration::from_secs(5),
&|_| {
checks.set(checks.get() + 1);
match checks.get() {
1 => Err(IdxError::RateLimited),
_ => Ok(()),
}
},
&|| true,
)
.unwrap();
assert_eq!(checks.get(), 2);
browser.finish();
let _ = std::fs::remove_dir_all(&profile);
}
/// Closing the window ends the wait at once, not at the timeout.
#[test]
fn closing_the_window_ends_the_wait() {
let profile = temp_dir("closed");
let browser = FakeBrowser::start(Fake {
cookie: None,
pages_for: 1,
..Fake::default()
});
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let started = Instant::now();
let err = wait_for_session(&mut devtools, Duration::from_secs(30), &|_| Ok(()), &|| {
true
})
.unwrap_err();
assert!(err.to_string().contains("window was closed"), "{err}");
assert!(
started.elapsed() < Duration::from_secs(5),
"waited {:?}",
started.elapsed()
);
browser.finish();
let _ = std::fs::remove_dir_all(&profile);
}
/// A browser that quits ends the wait too.
#[test]
fn a_browser_that_quits_ends_the_wait() {
let profile = temp_dir("quit");
let browser = FakeBrowser::start(Fake {
cookie: None,
..Fake::default()
});
write_endpoint(&profile, browser.port);
let mut devtools = connect(&profile, &|| true).unwrap();
let err = wait_for_session(&mut devtools, Duration::from_secs(30), &|_| Ok(()), &|| {
false
})
.unwrap_err();
assert!(err.to_string().contains("browser exited"), "{err}");
browser.finish();
let _ = std::fs::remove_dir_all(&profile);
}
/// Writes a stand-in browser executable into `dir`.
#[cfg(unix)]
fn fake_executable(dir: &Path, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let script = dir.join("fake-browser.sh");
std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
script
}
/// Launch, endpoint, WebSocket, verify, close: one sign-in end to end,
/// with a stand-in executable in place of the browser.
#[cfg(unix)]
#[test]
fn a_sign_in_starts_the_browser_and_closes_it() {
let profile = temp_dir("launch");
let server = FakeBrowser::start(Fake::default());
// Writes the port file where the browser would, then waits to be
// closed.
let path = fake_executable(
&profile,
&format!(
"for arg in \"$@\"; do\n\
case \"$arg\" in --user-data-dir=*) dir=\"${{arg#--user-data-dir=}}\" ;; esac\n\
done\n\
printf '%s\\n%s\\n' {port} /devtools/browser/test > \"$dir/DevToolsActivePort\"\n\
sleep 30\n",
port = server.port
),
);
let browser = Browser {
name: "fake".to_string(),
path,
};
let started = Instant::now();
browser
.open(&profile)
.unwrap()
.wait(Duration::from_secs(5), &AtomicBool::new(false), &|_| Ok(()))
.unwrap();
assert_eq!(
server.finish().methods.last().map(String::as_str),
Some("Browser.close")
);
// The stand-in sleeps for 30 s; the sign-in must not wait for it.
assert!(
started.elapsed() < Duration::from_secs(10),
"the sign-in waited {:?}",
started.elapsed()
);
let _ = std::fs::remove_dir_all(&profile);
}
/// Cancelling ends the wait and closes the browser, so a server that
/// is exiting leaves no window behind.
#[cfg(unix)]
#[test]
fn cancelling_closes_the_browser() {
let profile = temp_dir("cancel");
let server = FakeBrowser::start(Fake {
cookie: None,
..Fake::default()
});
let path = fake_executable(
&profile,
&format!(
"for arg in \"$@\"; do\n\
case \"$arg\" in --user-data-dir=*) dir=\"${{arg#--user-data-dir=}}\" ;; esac\n\
done\n\
printf '%s\\n%s\\n' {port} /devtools/browser/test > \"$dir/DevToolsActivePort\"\n\
sleep 30\n",
port = server.port
),
);
let browser = Browser {
name: "fake".to_string(),
path,
};
let window = browser.open(&profile).unwrap();
let cancel = std::sync::Arc::new(AtomicBool::new(false));
let waiter = {
let cancel = cancel.clone();
std::thread::spawn(move || window.wait(Duration::from_secs(30), &cancel, &|_| Ok(())))
};
std::thread::sleep(Duration::from_millis(200));
let started = Instant::now();
cancel.store(true, Ordering::Relaxed);
assert!(waiter.join().unwrap().is_err());
assert!(
started.elapsed() < Duration::from_secs(6),
"cancelling took {:?}",
started.elapsed()
);
assert_eq!(
server.finish().methods.last().map(String::as_str),
Some("Browser.close")
);
let _ = std::fs::remove_dir_all(&profile);
}
/// The browser that opened a profile is named in it, and is the one used
/// again; one no longer installed is not.
#[cfg(unix)]
#[test]
fn a_profile_remembers_its_browser() {
let profile = temp_dir("made-by");
let path = fake_executable(&profile, "exit 0\n");
assert!(Browser::recorded(&profile).is_none());
let browser = Browser {
name: "Named".to_string(),
path: path.clone(),
};
// The launch records it even when the browser then goes away.
assert!(browser.open(&profile).is_err());
let again = Browser::recorded(&profile).expect("the recorded browser");
assert_eq!(again.path, path);
assert_eq!(again.name, "fake-browser");
std::fs::remove_file(&path).unwrap();
assert!(Browser::recorded(&profile).is_none());
let _ = std::fs::remove_dir_all(&profile);
}
/// A browser that hands its window to one already running exits at
/// once; that is reported then, not after the start timeout.
#[cfg(unix)]
#[test]
fn a_browser_that_exits_at_launch_fails_fast() {
let profile = temp_dir("handoff");
let browser = Browser {
name: "fake".to_string(),
path: fake_executable(&profile, "exit 0\n"),
};
let started = Instant::now();
let err = browser.open(&profile).err().expect("no window");
assert!(err.to_string().contains("exited as it started"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5));
let _ = std::fs::remove_dir_all(&profile);
}
/// The real thing: launches the browser named by `IDX_STOCKBIT_BROWSER`
/// and checks the wait ends on the timeout, not on a broken connection,
/// which is what proves the browser answered `Storage.getCookies`.
///
/// Ignored by default: it opens a browser window and loads Stockbit's
/// page, so it needs a machine with a browser and a network.
#[test]
#[ignore = "opens a real browser; set IDX_STOCKBIT_BROWSER"]
fn a_real_browser_answers_the_devtools_requests() {
let profile = temp_dir("real");
let Ok(browser) = Browser::find(&profile) else {
eprintln!("IDX_STOCKBIT_BROWSER is not set; nothing to launch");
return;
};
let err = browser
.open(&profile)
.unwrap()
.wait(Duration::from_secs(25), &AtomicBool::new(false), &|_| {
panic!("a fresh profile holds no session to check")
})
.unwrap_err();
let message = err.to_string();
assert!(message.contains("timed out"), "{message}");
assert!(
!message.contains("last DevTools error"),
"DevTools never answered: {message}"
);
let _ = std::fs::remove_dir_all(&profile);
}
/// How a stand-in browser behaves.
struct Fake {
/// The session cookie's value, if it holds one.
cookie: Option<String>,
/// `Target.getTargets` reports pages for this many calls, then none.
pages_for: usize,
/// The cookie survives `Network.deleteCookies`.
sticky: bool,
}
impl Default for Fake {
fn default() -> Self {
Self {
cookie: Some(session_value()),
pages_for: usize::MAX,
sticky: false,
}
}
}
/// What a stand-in browser was asked, and what it was left holding.
struct Log {
methods: Vec<String>,
/// Tabs closed with `Target.closeTarget`.
closed: Vec<String>,
cookie_left: bool,
}
/// A stand-in for a browser's DevTools endpoint: one WebSocket, scripted
/// answers, and the methods it was asked for.
struct FakeBrowser {
port: u16,
handle: Option<std::thread::JoinHandle<Log>>,
}
impl FakeBrowser {
fn start(fake: Fake) -> Self {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || serve(listener, fake));
Self {
port,
handle: Some(handle),
}
}
fn finish(mut self) -> Log {
self.handle.take().unwrap().join().unwrap()
}
}
/// Stockbit's cookie lives host-only on `stockbit.com`, and, as in
/// Chromium, a delete must name that exact domain and path.
fn serve(listener: TcpListener, fake: Fake) -> Log {
const DOMAIN: &str = "stockbit.com";
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let head = read_head(&mut stream).unwrap();
assert!(head.contains("Upgrade: websocket"), "{head}");
stream
.write_all(b"HTTP/1.1 101 Switching Protocols\r\n\r\n")
.unwrap();
let mut cookie = fake.cookie.clone();
let mut pages = vec!["T1".to_string()];
let mut log = Log {
methods: Vec::new(),
closed: Vec::new(),
cookie_left: false,
};
let mut target_calls = 0;
while let Ok(payload) = read_message(&mut stream) {
let request: Value = serde_json::from_slice(&payload).unwrap();
let method = request["method"].as_str().unwrap_or_default().to_string();
let params = &request["params"];
log.methods.push(method.clone());
if method == "Browser.close" {
let reply = json!({ "id": request["id"], "result": {} });
let _ = stream.write_all(&server_frame(reply.to_string().as_bytes()));
break;
}
let result = match method.as_str() {
"Storage.getCookies" => {
let cookies: Vec<Value> = cookie
.iter()
.map(|value| {
json!({ "name": COOKIE, "domain": DOMAIN, "path": "/", "value": value })
})
.collect();
json!({ "cookies": cookies })
}
"Target.getTargets" => {
target_calls += 1;
let infos: Vec<Value> = if target_calls <= fake.pages_for {
pages
.iter()
.map(|id| json!({ "targetId": id, "type": "page", "url": SIGN_IN_URL }))
.collect()
} else {
Vec::new()
};
json!({ "targetInfos": infos })
}
"Target.createTarget" => {
pages.push("BLANK".to_string());
json!({ "targetId": "BLANK" })
}
"Target.closeTarget" => {
let id = params["targetId"].as_str().unwrap_or_default().to_string();
pages.retain(|page| *page != id);
log.closed.push(id);
json!({ "success": true })
}
"Target.attachToTarget" => json!({ "sessionId": "S1" }),
"Network.deleteCookies" => {
assert_eq!(request["sessionId"], "S1", "deleted from a tab");
if !fake.sticky
&& params["name"] == COOKIE
&& params["domain"] == DOMAIN
&& params["path"] == "/"
{
cookie = None;
}
json!({})
}
_ => json!({}),
};
let reply = json!({ "id": request["id"], "result": result });
if stream
.write_all(&server_frame(reply.to_string().as_bytes()))
.is_err()
{
break;
}
}
log.cookie_left = cookie.is_some();
log
}
/// One unmasked server frame.
fn server_frame(payload: &[u8]) -> Vec<u8> {
let mut out = vec![0x81];
match payload.len() {
len if len < 126 => out.push(len as u8),
len => {
out.push(126);
out.extend_from_slice(&(len as u16).to_be_bytes());
}
}
out.extend_from_slice(payload);
out
}
fn read_message(stream: &mut TcpStream) -> Result<Vec<u8>, IdxError> {
let mut buffer = Vec::new();
loop {
let (fin, _, payload) = read_frame(stream)?;
buffer.extend_from_slice(&payload);
if fin {
return Ok(buffer);
}
}
}
fn write_endpoint(profile: &Path, port: u16) {
std::fs::write(
profile.join("DevToolsActivePort"),
format!("{port}\n/devtools/browser/test\n"),
)
.unwrap();
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"idx-browser-{name}-{}-{}",
std::process::id(),
fastrand::u32(..)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
}