Files
rustguac/src/browser.rs
T
Dave Kempe a046db06b3 deps: RustCrypto family + rand 0.10 batch upgrade
Coordinated bump of the RustCrypto stack and rand. These crates share
digest 0.11 traits and could not be bumped one at a time; pbkdf2 0.13.0
shipping stable was the trigger to harvest the group.

Cargo.toml:
- aes      0.8  -> 0.9
- cbc      0.1  -> 0.2
- hmac     0.12 -> 0.13
- pbkdf2   0.12 -> 0.13
- sha1     0.10 -> 0.11
- rand     0.9  -> 0.10

API call-site fixes:
- src/browser.rs (Chromium password encryption pipeline): cbc 0.2 renamed
  the BlockEncryptMut trait to BlockModeEncrypt and the encrypt_padded_mut
  method to encrypt_padded (now takes self by value).
- src/db.rs and src/session.rs: rand 0.10 renamed the Rng trait to RngExt;
  swap the import. fill() and random() call sites are otherwise unchanged.

The Chromium password encryption tests (5) all pass after the bump,
confirming the v10/PBKDF2/AES-128-CBC pipeline is bytewise unchanged.
cargo test (207 tests), clippy --all-targets -D warnings, and cargo audit
all clean.

Closes #107 (aes), #109 (pbkdf2), #111 (hmac), #113 (cbc), #117 (tracking),
#108 (rand).
2026-04-29 14:54:14 +10:00

918 lines
35 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Process lifecycle manager for Xvnc + Chromium browser sessions.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tokio::net::TcpStream;
use tokio::process::{Child, Command};
use tokio::time::{timeout, Duration};
use aes::cipher::{block_padding::Pkcs7, BlockModeEncrypt, KeyIvInit};
use hmac::Hmac;
use sha1::Sha1;
/// Allocates numbers from a fixed range pool.
struct RangeAllocator {
in_use: Mutex<HashSet<u32>>,
range_start: u32,
range_end: u32,
}
impl RangeAllocator {
fn new(range_start: u32, range_end: u32) -> Self {
Self {
in_use: Mutex::new(HashSet::new()),
range_start,
range_end,
}
}
fn allocate(&self) -> Option<u32> {
let mut in_use = self.in_use.lock().unwrap();
for n in self.range_start..=self.range_end {
if !in_use.contains(&n) {
in_use.insert(n);
return Some(n);
}
}
None
}
fn release(&self, n: u32) {
let mut in_use = self.in_use.lock().unwrap();
in_use.remove(&n);
}
}
/// Handles for the spawned Xvnc and Chromium processes.
pub struct BrowserSession {
pub display: u32,
pub vnc_port: u16,
pub xvnc_child: Child,
pub chromium_child: Child,
pub profile_dir: PathBuf,
/// CDP port allocated for this session (if login script requested).
pub cdp_port: Option<u16>,
}
/// Manages spawning and killing browser sessions.
pub struct BrowserManager {
display_allocator: RangeAllocator,
cdp_allocator: RangeAllocator,
xvnc_path: String,
chromium_path: String,
login_scripts_dir: PathBuf,
login_script_timeout_secs: u64,
}
impl BrowserManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
xvnc_path: String,
chromium_path: String,
display_range_start: u32,
display_range_end: u32,
cdp_port_range_start: u16,
cdp_port_range_end: u16,
login_scripts_dir: PathBuf,
login_script_timeout_secs: u64,
) -> Self {
Self {
display_allocator: RangeAllocator::new(display_range_start, display_range_end),
cdp_allocator: RangeAllocator::new(
cdp_port_range_start as u32,
cdp_port_range_end as u32,
),
xvnc_path,
chromium_path,
login_scripts_dir,
login_script_timeout_secs,
}
}
/// Spawn Xvnc and Chromium for the given URL.
/// If `need_cdp` is true, allocates a CDP port and starts Chromium with
/// `--remote-debugging-port` so login scripts can connect via DevTools Protocol.
/// If `autofill_credentials` is provided, pre-populates Chromium's Login Data
/// SQLite before launch so autofill works natively on matching forms.
pub async fn spawn(
&self,
url: &str,
width: u32,
height: u32,
need_cdp: bool,
autofill_credentials: Option<&[(String, String, String)]>,
allowed_domains: Option<&[String]>,
) -> Result<BrowserSession, BrowserError> {
let display_num = self.display_allocator.allocate().ok_or_else(|| {
tracing::error!(
"No X display numbers available (range {}–{})",
self.display_allocator.range_start,
self.display_allocator.range_end
);
BrowserError::NoDisplayAvailable
})?;
let cdp_port = if need_cdp {
let port = self.cdp_allocator.allocate().ok_or_else(|| {
self.display_allocator.release(display_num);
tracing::error!(
"No CDP ports available (range {}–{})",
self.cdp_allocator.range_start,
self.cdp_allocator.range_end
);
BrowserError::NoCdpPortAvailable
})?;
Some(port as u16)
} else {
None
};
let vnc_port = 5900 + display_num as u16;
let geometry = format!("{}x{}", width, height);
// Create a unique profile directory for this session (UUID avoids stale crash state)
let profile_dir =
std::env::temp_dir().join(format!("rustguac-chromium-{}", uuid::Uuid::new_v4()));
let _ = std::fs::remove_dir_all(&profile_dir); // clean slate
if let Err(e) = std::fs::create_dir_all(&profile_dir) {
self.display_allocator.release(display_num);
if let Some(p) = cdp_port {
self.cdp_allocator.release(p as u32);
}
let msg = format!("Failed to create profile dir {:?}: {}", profile_dir, e);
tracing::error!("{}", msg);
return Err(BrowserError::ChromiumSpawn(msg));
}
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&profile_dir, std::fs::Permissions::from_mode(0o700));
}
// Pre-populate Chromium autofill database if credentials are provided
if let Some(creds) = autofill_credentials {
if let Err(e) = populate_login_data(&profile_dir, creds) {
tracing::warn!(
error = %e,
"Failed to populate Chromium autofill (session continues without autofill)"
);
} else {
tracing::info!(
count = creds.len(),
"Pre-populated Chromium Login Data with {} credential(s)",
creds.len()
);
}
}
tracing::info!(
xvnc_path = %self.xvnc_path,
display = %display_num,
vnc_port = %vnc_port,
geometry = %geometry,
"Spawning Xvnc"
);
// Spawn Xvnc
let mut xvnc_child = Command::new(&self.xvnc_path)
.arg(format!(":{}", display_num))
.args([
"-geometry",
&geometry,
"-depth",
"24",
"-SecurityTypes",
"None",
"-localhost",
"-AlwaysShared",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
self.display_allocator.release(display_num);
if let Some(p) = cdp_port {
self.cdp_allocator.release(p as u32);
}
let _ = std::fs::remove_dir_all(&profile_dir);
let msg = format!("Failed to spawn '{}': {}", self.xvnc_path, e);
tracing::error!("{}", msg);
BrowserError::XvncSpawn(msg)
})?;
tracing::info!(
display = %display_num,
pid = ?xvnc_child.id(),
"Xvnc process spawned, waiting for VNC port {} to accept connections",
vnc_port
);
// Wait for VNC port to accept connections (up to 2s)
let addr = format!("127.0.0.1:{}", vnc_port);
let port_ready = timeout(Duration::from_secs(2), async {
loop {
if TcpStream::connect(&addr).await.is_ok() {
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
})
.await;
if port_ready.is_err() {
// Collect stderr to help diagnose why Xvnc didn't start
let stderr_output = collect_stderr(&mut xvnc_child).await;
let _ = xvnc_child.kill().await;
self.display_allocator.release(display_num);
if let Some(p) = cdp_port {
self.cdp_allocator.release(p as u32);
}
let _ = std::fs::remove_dir_all(&profile_dir);
let msg = format!(
"Xvnc did not start listening on port {} within 2s{}",
vnc_port,
if stderr_output.is_empty() {
String::new()
} else {
format!("; stderr: {}", stderr_output)
}
);
tracing::error!("{}", msg);
return Err(BrowserError::XvncSpawn(msg));
}
tracing::info!(display = %display_num, vnc_port = %vnc_port, "Xvnc is ready and accepting connections");
tracing::info!(
chromium_path = %self.chromium_path,
display = %display_num,
profile_dir = %profile_dir.display(),
url = %url,
cdp_port = ?cdp_port,
"Spawning Chromium"
);
// Spawn Chromium with isolated profile
let window_size = format!("--window-size={},{}", width, height);
let user_data_dir = format!("--user-data-dir={}", profile_dir.display());
let cdp_arg = cdp_port.map(|p| format!("--remote-debugging-port={}", p));
let mut chromium_args = vec![
"--start-fullscreen",
"--no-first-run",
"--noerrdialogs",
"--disable-infobars",
"--disable-translate",
"--disable-features=TranslateUI,VizDisplayCompositor,AutofillServerCommunication,MediaRouter,PasswordImport",
// GPU / rendering — safe for headless VMs without GPU
"--disable-gpu",
"--disable-gpu-compositing",
"--disable-software-rasterizer",
"--disable-dev-shm-usage",
"--use-gl=angle",
"--use-angle=swiftshader",
"--in-process-gpu",
// Stability
"--disable-background-networking",
"--disable-sync",
"--disable-breakpad",
"--disable-crash-reporter",
"--no-default-browser-check",
"--window-position=0,0",
];
// Owned strings that need to outlive the args slice
chromium_args.push(&window_size);
chromium_args.push(&user_data_dir);
if let Some(ref arg) = cdp_arg {
chromium_args.push(arg);
}
// Per-session domain allowlist via --host-rules.
// Maps all hosts to a non-routable address except the allowed ones.
// Also adds --enable-automation to suppress the "unsupported flag" infobar.
let host_rules_arg = allowed_domains.and_then(|domains| {
if domains.is_empty() {
return None;
}
let mut rules = String::from("MAP * ~NOTFOUND");
for domain in domains {
let d = domain.trim();
if d.is_empty() {
continue;
}
rules.push_str(&format!(", EXCLUDE {}", d));
if !d.starts_with("*.") {
rules.push_str(&format!(", EXCLUDE *.{}", d));
}
}
rules.push_str(", EXCLUDE localhost, EXCLUDE 127.0.0.1");
Some(format!("--host-rules={}", rules))
});
if let Some(ref arg) = host_rules_arg {
chromium_args.push(arg);
// Suppress the "unsupported command-line flag" infobar.
// Shows "controlled by automated test software" bar instead, which is acceptable.
// Cannot use --test-type here as it disables the password manager (breaks autofill).
chromium_args.push("--enable-automation");
}
// Add --no-sandbox when running as root (e.g. Docker containers).
// Chromium refuses to start as root without this flag. The container
// itself provides the security boundary in this case.
let no_sandbox;
if unsafe { libc::geteuid() } == 0 {
no_sandbox = "--no-sandbox".to_string();
chromium_args.push(&no_sandbox);
tracing::debug!("Running as root, adding --no-sandbox to Chromium");
}
chromium_args.push(url);
let chromium_result = Command::new(&self.chromium_path)
.env("DISPLAY", format!(":{}", display_num))
.args(&chromium_args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn();
let mut chromium_child = match chromium_result {
Ok(child) => {
tracing::info!(
display = %display_num,
pid = ?child.id(),
url = %url,
"Chromium process spawned"
);
child
}
Err(e) => {
let _ = xvnc_child.kill().await;
self.display_allocator.release(display_num);
if let Some(p) = cdp_port {
self.cdp_allocator.release(p as u32);
}
let _ = std::fs::remove_dir_all(&profile_dir);
let msg = format!("Failed to spawn '{}': {}", self.chromium_path, e);
tracing::error!("{}", msg);
return Err(BrowserError::ChromiumSpawn(msg));
}
};
// Post-spawn liveness check: give Chromium a moment to crash on startup
// (e.g. sandbox failures, missing libs). If it exits immediately, capture
// stderr so we can log something useful instead of a silent black screen.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
match chromium_child.try_wait() {
Ok(Some(status)) => {
// Chromium exited already — capture stderr for diagnostics
let mut stderr_msg = String::new();
if let Some(mut stderr) = chromium_child.stderr.take() {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
stderr_msg = String::from_utf8_lossy(&buf).to_string();
// Trim to a reasonable length for logging
if stderr_msg.len() > 2000 {
stderr_msg.truncate(2000);
stderr_msg.push_str("...(truncated)");
}
}
let _ = xvnc_child.kill().await;
self.display_allocator.release(display_num);
if let Some(p) = cdp_port {
self.cdp_allocator.release(p as u32);
}
let _ = std::fs::remove_dir_all(&profile_dir);
let msg = format!(
"Chromium exited immediately ({}). stderr: {}",
status,
if stderr_msg.is_empty() {
"(empty)".to_string()
} else {
stderr_msg
}
);
tracing::error!(display = %display_num, "{}", msg);
return Err(BrowserError::ChromiumSpawn(msg));
}
Ok(None) => {
// Still running — good
tracing::debug!(display = %display_num, "Chromium still alive after 500ms");
}
Err(e) => {
tracing::warn!(display = %display_num, "Could not check Chromium status: {}", e);
}
}
Ok(BrowserSession {
display: display_num,
vnc_port,
xvnc_child,
chromium_child,
profile_dir,
cdp_port,
})
}
/// Kill both Chromium and Xvnc, release the display number and CDP port,
/// and clean up the profile dir.
pub async fn kill(&self, session: &mut BrowserSession) {
tracing::info!(
display = %session.display,
chromium_pid = ?session.chromium_child.id(),
xvnc_pid = ?session.xvnc_child.id(),
"Killing browser session processes"
);
let _ = session.chromium_child.kill().await;
let _ = session.xvnc_child.kill().await;
self.display_allocator.release(session.display);
if let Some(p) = session.cdp_port.take() {
self.cdp_allocator.release(p as u32);
}
// Clean up the per-session Chromium profile directory
let profile_dir = session.profile_dir.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = std::fs::remove_dir_all(&profile_dir) {
tracing::warn!(path = %profile_dir.display(), error = %e, "Failed to clean up Chromium profile dir");
}
});
tracing::info!(display = %session.display, "Browser session cleaned up, display released");
}
/// Run a login script as a child process with env vars for CDP port, credentials, etc.
/// Returns a `JoinHandle` that completes when the script finishes (or times out).
/// Script failures log a warning but do not kill the session.
#[allow(clippy::too_many_arguments)]
pub fn run_login_script(
&self,
script_name: &str,
display: u32,
cdp_port: u16,
url: &str,
username: Option<&str>,
password: Option<&str>,
session_id: &str,
) -> Result<tokio::task::JoinHandle<()>, BrowserError> {
// Validate: resolve relative to login_scripts_dir, block path traversal
let script_path = self.login_scripts_dir.join(script_name);
let canonical = script_path.canonicalize().map_err(|e| {
BrowserError::LoginScript(format!("login script '{}' not found: {}", script_name, e))
})?;
let canonical_base = self.login_scripts_dir.canonicalize().map_err(|e| {
BrowserError::LoginScript(format!(
"login_scripts_dir '{}' not found: {}",
self.login_scripts_dir.display(),
e
))
})?;
if !canonical.starts_with(&canonical_base) {
return Err(BrowserError::LoginScript(format!(
"login script '{}' is outside scripts directory",
script_name
)));
}
// Check the script is executable
if !is_executable(&canonical) {
return Err(BrowserError::LoginScript(format!(
"login script '{}' is not executable",
script_name
)));
}
let timeout_secs = self.login_script_timeout_secs;
let script_path_owned = canonical;
let url_owned = url.to_string();
let username_owned = username.unwrap_or("").to_string();
let password_owned = password.unwrap_or("").to_string();
let session_id_owned = session_id.to_string();
let handle = tokio::spawn(async move {
tracing::info!(
script = %script_path_owned.display(),
session_id = %session_id_owned,
cdp_port = %cdp_port,
"Running login script"
);
// Build credentials JSON for stdin
let stdin_json = serde_json::json!({
"username": username_owned,
"password": password_owned,
"url": url_owned,
"cdp_port": cdp_port,
"session_id": session_id_owned,
})
.to_string();
let result = timeout(Duration::from_secs(timeout_secs), async {
let mut child = match Command::new(&script_path_owned)
.env("DISPLAY", format!(":{}", display))
.env("RUSTGUAC_CDP_PORT", cdp_port.to_string())
.env("RUSTGUAC_URL", &url_owned)
.env("RUSTGUAC_SESSION_ID", &session_id_owned)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
tracing::warn!(
script = %script_path_owned.display(),
error = %e,
"Failed to spawn login script"
);
return;
}
};
// Write credentials JSON to stdin, then close
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(stdin_json.as_bytes()).await;
// stdin dropped here, closing the pipe
}
match child.wait_with_output().await {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
tracing::info!(
script = %script_path_owned.display(),
session_id = %session_id_owned,
"Login script completed successfully"
);
} else {
tracing::warn!(
script = %script_path_owned.display(),
session_id = %session_id_owned,
exit_code = ?output.status.code(),
"Login script failed"
);
}
if !stdout.is_empty() {
tracing::info!(
session_id = %session_id_owned,
"Login script stdout: {}",
stdout.trim()
);
}
if !stderr.is_empty() {
tracing::warn!(
session_id = %session_id_owned,
"Login script stderr: {}",
stderr.trim()
);
}
}
Err(e) => {
tracing::warn!(
script = %script_path_owned.display(),
error = %e,
"Failed to wait for login script"
);
}
}
})
.await;
if result.is_err() {
tracing::warn!(
script = %script_path_owned.display(),
session_id = %session_id_owned,
timeout_secs = timeout_secs,
"Login script timed out, killing"
);
}
});
Ok(handle)
}
}
/// Encrypt a password using Chromium's Linux "basic" os_crypt backend.
///
/// On Linux without a keyring (our case — headless Xvnc), Chromium uses:
/// 1. PBKDF2("peanuts", "saltysalt", 1 iteration, SHA-1) → 16-byte AES key
/// 2. AES-128-CBC with IV = 16 × 0x20 (space chars)
/// 3. Blob format: "v10" prefix + encrypted ciphertext
fn encrypt_chromium_password(plaintext: &str) -> Result<Vec<u8>, String> {
// Derive the AES key: PBKDF2(password="peanuts", salt="saltysalt", iterations=1, dkLen=16)
let mut key = [0u8; 16];
pbkdf2::pbkdf2::<Hmac<Sha1>>(b"peanuts", b"saltysalt", 1, &mut key)
.map_err(|e| format!("PBKDF2 derivation failed: {}", e))?;
// IV is 16 space characters (0x20)
let iv = [0x20u8; 16];
// Encrypt with AES-128-CBC + PKCS7 padding
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
let plaintext_bytes = plaintext.as_bytes();
// Buffer needs room for plaintext + up to one block of padding
let mut buf = vec![0u8; plaintext_bytes.len() + 16];
buf[..plaintext_bytes.len()].copy_from_slice(plaintext_bytes);
let encrypted = Aes128CbcEnc::new(&key.into(), &iv.into())
.encrypt_padded::<Pkcs7>(&mut buf, plaintext_bytes.len())
.map_err(|e| format!("AES encryption failed: {}", e))?;
// Prepend "v10" version tag
let mut blob = Vec::with_capacity(3 + encrypted.len());
blob.extend_from_slice(b"v10");
blob.extend_from_slice(encrypted);
Ok(blob)
}
/// Pre-populate Chromium's Login Data SQLite with credentials for autofill.
///
/// Creates the `{profile_dir}/Default/Login Data` SQLite database with the
/// `logins` table and inserts encrypted credentials for each (origin_url,
/// username, password) tuple.
fn populate_login_data(
profile_dir: &Path,
credentials: &[(String, String, String)],
) -> Result<(), String> {
let default_dir = profile_dir.join("Default");
std::fs::create_dir_all(&default_dir).map_err(|e| format!("create Default dir: {}", e))?;
// Write Preferences to enable the password manager and autofill
let prefs = serde_json::json!({
"credentials_enable_service": true,
"credentials_enable_autosignin": true,
"profile": {
"password_manager_enabled": true
},
"autofill": {
"enabled": true
},
"password_manager": {
"saving_enabled": false
},
"download": {
"prompt_for_download": false
}
});
let prefs_path = default_dir.join("Preferences");
std::fs::write(&prefs_path, prefs.to_string())
.map_err(|e| format!("write Preferences: {}", e))?;
let db_path = default_dir.join("Login Data");
let conn =
rusqlite::Connection::open(&db_path).map_err(|e| format!("open Login Data: {}", e))?;
// Schema must match what Chromium expects exactly, including the meta table
// with version/last_compatible_version. Without this, Chromium crashes on startup.
// Schema sourced from Chromium 134 on Debian 13 (version 43).
conn.execute_batch(
"CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('mmap_status','-1');
INSERT INTO meta VALUES('version','43');
INSERT INTO meta VALUES('last_compatible_version','40');
CREATE TABLE logins (
origin_url VARCHAR NOT NULL,
action_url VARCHAR,
username_element VARCHAR,
username_value VARCHAR,
password_element VARCHAR,
password_value BLOB,
submit_element VARCHAR,
signon_realm VARCHAR NOT NULL,
date_created INTEGER NOT NULL,
blacklisted_by_user INTEGER NOT NULL,
scheme INTEGER NOT NULL,
password_type INTEGER,
times_used INTEGER,
form_data BLOB,
display_name VARCHAR,
icon_url VARCHAR,
federation_url VARCHAR,
skip_zero_click INTEGER,
generation_upload_status INTEGER,
possible_username_pairs BLOB,
id INTEGER PRIMARY KEY AUTOINCREMENT,
date_last_used INTEGER NOT NULL DEFAULT 0,
moving_blocked_for BLOB,
date_password_modified INTEGER NOT NULL DEFAULT 0,
sender_email VARCHAR,
sender_name VARCHAR,
date_received INTEGER,
sharing_notification_displayed INTEGER NOT NULL DEFAULT 0,
keychain_identifier BLOB,
sender_profile_image_url VARCHAR,
date_last_filled INTEGER NOT NULL DEFAULT 0,
actor_login_approved INTEGER NOT NULL DEFAULT 0,
UNIQUE (origin_url, username_element, username_value, password_element, signon_realm)
);
CREATE INDEX logins_signon ON logins (signon_realm);
CREATE TABLE sync_entities_metadata (storage_key INTEGER PRIMARY KEY AUTOINCREMENT, metadata VARCHAR NOT NULL);
CREATE TABLE sync_model_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, model_metadata VARCHAR NOT NULL);
CREATE TABLE insecure_credentials (parent_id INTEGER REFERENCES logins ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, insecurity_type INTEGER NOT NULL, create_time INTEGER NOT NULL, is_muted INTEGER NOT NULL DEFAULT 0, trigger_notification_from_backend INTEGER NOT NULL DEFAULT 0, UNIQUE (parent_id, insecurity_type));
CREATE INDEX foreign_key_index ON insecure_credentials (parent_id);
CREATE TABLE password_notes (id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER NOT NULL REFERENCES logins ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, key VARCHAR NOT NULL, value BLOB, date_created INTEGER NOT NULL, confidential INTEGER, UNIQUE (parent_id, key));
CREATE INDEX foreign_key_index_notes ON password_notes (parent_id);
CREATE TABLE stats (origin_domain VARCHAR NOT NULL, username_value VARCHAR, dismissal_count INTEGER, update_time INTEGER NOT NULL, UNIQUE(origin_domain, username_value));
CREATE INDEX stats_origin ON stats(origin_domain);",
)
.map_err(|e| format!("create Login Data schema: {}", e))?;
let now = chrono::Utc::now().timestamp_micros();
for (origin_url, username, password) in credentials {
let encrypted = encrypt_chromium_password(password)?;
// signon_realm is the origin with trailing slash (Chromium's format for HTML forms)
let signon_realm = match url::Url::parse(origin_url) {
Ok(u) => format!(
"{}://{}{}/",
u.scheme(),
u.host_str().unwrap_or(""),
u.port().map(|p| format!(":{}", p)).unwrap_or_default()
),
Err(_) => format!("{}/", origin_url),
};
// Ensure origin_url has trailing slash to match Chromium's convention
let origin_with_slash = if origin_url.ends_with('/') {
origin_url.clone()
} else {
format!("{}/", origin_url)
};
conn.execute(
"INSERT INTO logins (origin_url, action_url, username_element, username_value, password_element, password_value, submit_element, signon_realm, date_created, blacklisted_by_user, scheme, password_type, times_used, skip_zero_click, date_last_used, date_password_modified)
VALUES (?1, '', '', ?2, '', ?3, '', ?4, ?5, 0, 0, 0, 1, 0, ?5, ?5)",
rusqlite::params![origin_with_slash, username, encrypted, signon_realm, now],
)
.map_err(|e| format!("insert login: {}", e))?;
}
Ok(())
}
/// Check if a path is an executable file.
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
meta.is_file() && (meta.permissions().mode() & 0o111 != 0)
} else {
false
}
}
#[cfg(not(unix))]
{
path.is_file()
}
}
/// Read whatever stderr is available from a child process (non-blocking, best-effort).
async fn collect_stderr(child: &mut Child) -> String {
use tokio::io::AsyncReadExt;
if let Some(ref mut stderr) = child.stderr {
let mut buf = vec![0u8; 4096];
match timeout(Duration::from_millis(200), stderr.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => String::from_utf8_lossy(&buf[..n]).trim().to_string(),
_ => String::new(),
}
} else {
String::new()
}
}
#[derive(Debug)]
pub enum BrowserError {
NoDisplayAvailable,
NoCdpPortAvailable,
XvncSpawn(String),
ChromiumSpawn(String),
LoginScript(String),
}
impl std::fmt::Display for BrowserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BrowserError::NoDisplayAvailable => write!(f, "no X display numbers available"),
BrowserError::NoCdpPortAvailable => write!(f, "no CDP ports available"),
BrowserError::XvncSpawn(msg) => write!(f, "Xvnc spawn failed: {}", msg),
BrowserError::ChromiumSpawn(msg) => write!(f, "Chromium spawn failed: {}", msg),
BrowserError::LoginScript(msg) => write!(f, "login script error: {}", msg),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_chromium_password_v10_prefix() {
let blob = encrypt_chromium_password("secret").unwrap();
assert_eq!(&blob[..3], b"v10");
}
#[test]
fn test_encrypt_chromium_password_deterministic() {
let a = encrypt_chromium_password("test123").unwrap();
let b = encrypt_chromium_password("test123").unwrap();
assert_eq!(a, b);
}
#[test]
fn test_encrypt_chromium_password_different_inputs() {
let a = encrypt_chromium_password("password1").unwrap();
let b = encrypt_chromium_password("password2").unwrap();
assert_ne!(a, b);
}
#[test]
fn test_encrypt_chromium_password_block_aligned() {
// AES-128-CBC with PKCS7: output is always multiple of 16 bytes
let blob = encrypt_chromium_password("short").unwrap();
let ciphertext_len = blob.len() - 3; // minus "v10" prefix
assert_eq!(ciphertext_len % 16, 0);
}
#[test]
fn test_encrypt_chromium_password_empty() {
let blob = encrypt_chromium_password("").unwrap();
assert_eq!(&blob[..3], b"v10");
// Empty plaintext + PKCS7 padding = one full block
assert_eq!(blob.len(), 3 + 16);
}
#[test]
fn test_populate_login_data_creates_db() {
let dir = std::env::temp_dir().join("rustguac-test-login-data");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let creds = vec![(
"https://example.com".into(),
"alice".into(),
"secret".into(),
)];
populate_login_data(&dir, &creds).unwrap();
let db_path = dir.join("Default/Login Data");
assert!(db_path.exists(), "Login Data SQLite should be created");
// Verify the database is valid SQLite and has data
let conn = rusqlite::Connection::open(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM logins", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
let origin: String = conn
.query_row("SELECT origin_url FROM logins", [], |r| r.get(0))
.unwrap();
assert_eq!(origin, "https://example.com/");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_populate_login_data_multiple_creds() {
let dir = std::env::temp_dir().join("rustguac-test-login-data-multi");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let creds = vec![
("https://app.com".into(), "user1".into(), "pass1".into()),
("https://idp.com".into(), "user2".into(), "pass2".into()),
];
populate_login_data(&dir, &creds).unwrap();
let db_path = dir.join("Default/Login Data");
let conn = rusqlite::Connection::open(&db_path).unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM logins", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_range_allocator() {
let alloc = RangeAllocator::new(100, 102);
let a = alloc.allocate().unwrap();
let b = alloc.allocate().unwrap();
let c = alloc.allocate().unwrap();
assert_ne!(a, b);
assert_ne!(b, c);
// Pool exhausted
assert!(alloc.allocate().is_none());
// Release one and re-allocate
alloc.release(b);
let d = alloc.allocate().unwrap();
assert_eq!(d, b);
}
}