feat: Local Storage transfer

This commit is contained in:
zhom committed 2026-10-12 07:43:51 +04:00
1 parent d594fe4bfd
commit 74c507bd9b
23 files changed
+1552 -38

No files matched your search

+116 -1
View File
@@ -274,6 +274,24 @@ impl From<crate::profile_statuses::ProfileStatus> for ApiStatusResponse {
}
}
/// Playwright's `storageState`: cookies and each origin's Local Storage.
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiStorageState {
/// Playwright cookies (`name`, `value`, `domain`, `path`, `expires`,
/// `httpOnly`, `secure`, `sameSite`).
#[serde(default)]
#[schema(value_type = Vec<Object>)]
cookies: Vec<serde_json::Value>,
#[serde(default)]
origins: Vec<crate::local_storage::OriginStorage>,
}
#[derive(Debug, Deserialize)]
struct StorageStateQuery {
#[serde(default)]
include_cookies: bool,
}
#[derive(Debug, Deserialize, ToSchema)]
struct TemplateRequest {
/// 1 to 60 characters, unique (case-insensitive).
@@ -762,6 +780,8 @@ struct ImportProxiesResponse {
detect_import_profiles,
import_profiles_api,
import_profile_cookies,
export_storage_state,
import_storage_state,
get_groups,
get_group,
create_group,
@@ -884,6 +904,10 @@ struct ImportProxiesResponse {
crate::proxy_distribution::ProxyAssignmentResult,
OpenUrlRequest,
ImportCookiesRequest,
ApiStorageState,
crate::local_storage::OriginStorage,
crate::local_storage::StorageItem,
crate::local_storage::StorageStateImport,
ImportCookiesResponse,
CreateExtensionRequest,
UpdateExtensionRequest,
@@ -1120,6 +1144,7 @@ fn build_v1_router() -> Router<ApiServerState> {
.routes(routes!(detect_import_profiles))
.routes(routes!(import_profiles_api))
.routes(routes!(import_profile_cookies))
.routes(routes!(export_storage_state, import_storage_state))
.routes(routes!(get_groups, create_group))
.routes(routes!(get_group, update_group, delete_group))
.routes(routes!(get_statuses, create_status))
@@ -1440,7 +1465,12 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
let status = if code.ends_with("_NOT_FOUND") {
StatusCode::NOT_FOUND
} else if LAUNCH_CONFLICT_CODES.contains(&code) || code == "BULK_CREATE_BUSY" {
} else if LAUNCH_CONFLICT_CODES.contains(&code)
|| code == "BULK_CREATE_BUSY"
|| code == "LOCAL_STORAGE_BROWSER_RUNNING"
|| code == "LOCAL_STORAGE_REMOTE_SESSION"
|| code == "LOCAL_STORAGE_PROFILE_PROTECTED"
{
// Someone or something else holds this profile: another team member, a
// browser already open, or a remote session whose work has not been
// pulled back yet. All of them are "try again later", not "your request
@@ -4953,6 +4983,74 @@ async fn import_profiles_api(
.map_err(manager_error_response)
}
#[utoipa::path(
get,
path = "/v1/profiles/{id}/storage-state",
params(
("id" = String, Path, description = "Profile ID"),
("include_cookies" = Option<bool>, Query, description = "Also export the profile's cookies; false when left out")
),
responses(
(status = 200, description = "Playwright storageState: each origin's Local Storage, and the cookies when asked for. Readable while the browser runs; writes from the last seconds may not be on disk yet", body = ApiStorageState),
(status = 400, description = "The profile is ephemeral and keeps no Local Storage on disk"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "The profile is password-protected"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn export_storage_state(
Path(id): Path<String>,
Query(query): Query<StorageStateQuery>,
) -> Result<Json<ApiStorageState>, (StatusCode, String)> {
let text = tokio::task::spawn_blocking(move || {
crate::local_storage::export_storage_state(&id, query.include_cookies)
})
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.map_err(manager_error_response)?;
serde_json::from_str(&text)
.map(Json)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/storage-state",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = ApiStorageState,
responses(
(status = 200, description = "Imported. An item replaces the one of the same name; the origin's other items stay", body = crate::local_storage::StorageStateImport),
(status = 400, description = "Not a storageState, an origin that is not http(s), more than 10 MiB for one origin, or an ephemeral profile"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 409, description = "The browser is running, the profile is password-protected, or a remote session owns it"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "cookies"
)]
async fn import_storage_state(
State(state): State<ApiServerState>,
Path(id): Path<String>,
Json(body): Json<ApiStorageState>,
) -> Result<Json<crate::local_storage::StorageStateImport>, (StatusCode, String)> {
let content =
serde_json::to_string(&body).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
crate::local_storage::import_storage_state(&state.app_handle, &id, &content)
.await
.map(Json)
.map_err(manager_error_response)
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cookies/import",
@@ -6195,6 +6293,23 @@ mod tests {
assert!(spec["paths"]["/v1/templates/{id}/profiles"]["post"]["responses"]["429"].is_object());
}
#[test]
fn openapi_documents_the_storage_state() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
let path = &spec["paths"]["/v1/profiles/{id}/storage-state"];
assert!(path["get"].is_object() && path["post"].is_object());
assert!(path["post"]["responses"]["409"].is_object());
let origin = &spec["components"]["schemas"]["OriginStorage"];
assert!(
origin["properties"]["localStorage"].is_object(),
"Playwright's spelling"
);
assert!(
schema_required(&spec, "ApiStorageState").is_empty(),
"cookies and origins may each be left out"
);
}
#[test]
fn openapi_documents_the_profile_dns_server() {
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
+4
View File
@@ -148,6 +148,7 @@ mod cookie_bot;
mod cookie_manager;
mod cookie_paste;
pub mod events;
mod local_storage;
mod mcp_integrations;
mod mcp_remote;
mod mcp_server;
@@ -3539,6 +3540,9 @@ pub fn run_with_builder(
add_mcp_to_agent,
remove_mcp_from_agent,
set_mcp_toolsets,
local_storage::get_profile_local_storage_stats,
local_storage::export_profile_storage_state,
local_storage::import_profile_storage_state,
start_mcp_remote_bridge,
stop_mcp_remote_bridge,
get_mcp_remote_status,
+627
View File
@@ -0,0 +1,627 @@
//! A profile's Local Storage, read and written in the shape of Playwright's
//! `storageState`, so a signed-in session moves between Donut and automation
//! tools: `{ "cookies": [...], "origins": [{ "origin", "localStorage": [...] }] }`.
//!
//! Chromium keeps Local Storage in a LevelDB under
//! `Default/Local Storage/leveldb`:
//!
//! - `VERSION` is `1`;
//! - `META:<origin>` holds when the origin's data last changed and its size,
//! `METAACCESS:<origin>` when it was last read (small protobufs);
//! - `_<origin>\0<key>` holds a value. A key and a value each start with a
//! format byte: `0` for UTF-16LE, `1` for Latin-1.
//!
//! A read opens a copy of the folder, so it never changes the profile. A write
//! needs the browser closed: a running browser holds the database and would
//! lose the write or corrupt the files.
use crate::profile::{BrowserProfile, ProfileManager};
use rusty_leveldb::{CompressorId, LdbIterator, Options, WriteBatch, DB};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// The most an import may carry, cookies included.
pub const MAX_IMPORT_BYTES: usize = 32 * 1024 * 1024;
/// Chromium's own limit per origin: five million UTF-16 code units.
const MAX_ORIGIN_BYTES: u64 = 10 * 1024 * 1024;
const MAX_ORIGINS: usize = 10_000;
/// Seconds from 1601-01-01 (Chromium's time origin) to the Unix epoch.
const CHROMIUM_EPOCH_OFFSET_SECS: i64 = 11_644_473_600;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct StorageItem {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct OriginStorage {
/// `https://example.com`, the way a page's `origin` reads.
pub origin: String,
#[serde(rename = "localStorage", default)]
pub local_storage: Vec<StorageItem>,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct LocalStorageStats {
pub origins: usize,
pub items: usize,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct StorageStateImport {
pub origins: usize,
pub items: usize,
pub cookies_imported: usize,
pub cookies_replaced: usize,
/// Codes of the cookies that could not be imported.
pub cookie_errors: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StorageState {
#[serde(default)]
origins: Vec<OriginStorage>,
#[serde(default)]
cookies: Vec<serde_json::Value>,
}
fn code(code: &str) -> String {
crate::backend_error(code)
}
fn database_dir(profile: &BrowserProfile) -> PathBuf {
profile
.get_profile_data_path(&ProfileManager::instance().get_profiles_dir())
.join("Default")
.join("Local Storage")
.join("leveldb")
}
/// The profile, when its Local Storage can be read at all: an ephemeral
/// profile keeps none on disk, and a password-protected one keeps it sealed.
fn readable_profile(profile_id: &str) -> Result<BrowserProfile, String> {
let profile = ProfileManager::instance()
.list_profiles()
.map_err(|e| crate::wrap_backend_error(e, "Failed to list profiles"))?
.into_iter()
.find(|profile| profile.id.to_string() == profile_id)
.ok_or_else(|| code("PROFILE_NOT_FOUND"))?;
if profile.ephemeral {
return Err(code("LOCAL_STORAGE_EPHEMERAL"));
}
if profile.password_protected {
return Err(code("LOCAL_STORAGE_PROFILE_PROTECTED"));
}
Ok(profile)
}
// --- The database format ----------------------------------------------------
fn decode_string(bytes: &[u8]) -> Option<String> {
let (format, rest) = bytes.split_first()?;
match format {
0 => {
if rest.len() % 2 != 0 {
return None;
}
let units: Vec<u16> = rest
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
String::from_utf16(&units).ok()
}
1 => Some(rest.iter().map(|&b| char::from(b)).collect()),
_ => None,
}
}
/// Latin-1 when every character fits, as Chromium writes it; else UTF-16LE.
fn encode_string(text: &str) -> Vec<u8> {
if text.chars().all(|c| (c as u32) <= 0xFF) {
let mut out = Vec::with_capacity(text.len() + 1);
out.push(1);
out.extend(text.chars().map(|c| c as u8));
out
} else {
let mut out = Vec::with_capacity(text.len() * 2 + 1);
out.push(0);
for unit in text.encode_utf16() {
out.extend_from_slice(&unit.to_le_bytes());
}
out
}
}
fn data_prefix(origin: &str) -> Vec<u8> {
let mut key = Vec::with_capacity(origin.len() + 2);
key.push(b'_');
key.extend_from_slice(origin.as_bytes());
key.push(0);
key
}
fn data_key(origin: &str, name: &str) -> Vec<u8> {
let mut key = data_prefix(origin);
key.extend(encode_string(name));
key
}
/// `(origin, name)` from a data key, or `None` for any other entry.
fn parse_data_key(key: &[u8]) -> Option<(String, String)> {
let rest = key.strip_prefix(b"_")?;
let split = rest.iter().position(|&b| b == 0)?;
let origin = std::str::from_utf8(&rest[..split]).ok()?.to_string();
let name = decode_string(&rest[split + 1..])?;
Some((origin, name))
}
fn varint(mut value: u64, out: &mut Vec<u8>) {
while value >= 0x80 {
out.push((value as u8) | 0x80);
value >>= 7;
}
out.push(value as u8);
}
/// Microseconds since 1601-01-01, Chromium's `Time` internal value.
fn chromium_now() -> i64 {
let unix_micros = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_micros() as i64);
unix_micros + CHROMIUM_EPOCH_OFFSET_SECS * 1_000_000
}
/// `LocalStorageAreaWriteMetaData { last_modified = 1; size_bytes = 2; }`.
fn write_meta(last_modified: i64, size_bytes: u64) -> Vec<u8> {
let mut out = vec![0x08];
varint(last_modified as u64, &mut out);
out.push(0x10);
varint(size_bytes, &mut out);
out
}
/// `LocalStorageAreaAccessMetaData { last_accessed = 1; }`.
fn access_meta(last_accessed: i64) -> Vec<u8> {
let mut out = vec![0x08];
varint(last_accessed as u64, &mut out);
out
}
/// What an item weighs against the per-origin limit: UTF-16 code units, two
/// bytes each, the way Chromium counts.
fn item_bytes(name: &str, value: &str) -> u64 {
((name.encode_utf16().count() + value.encode_utf16().count()) * 2) as u64
}
fn open(dir: &Path) -> Result<DB, String> {
let options = Options {
compressor: rusty_leveldb::compressor::SnappyCompressor::ID,
..Options::default()
};
DB::open(dir, options).map_err(|e| e.to_string())
}
fn read_database(dir: &Path) -> Result<Vec<OriginStorage>, String> {
let mut db = open(dir)?;
let mut iter = db.new_iter().map_err(|e| e.to_string())?;
let mut origins: Vec<OriginStorage> = Vec::new();
iter.seek(b"_");
while iter.valid() {
let Some((key, value)) = iter.current() else {
break;
};
if !key.starts_with(b"_") {
break;
}
if let (Some((origin, name)), Some(value)) = (parse_data_key(&key), decode_string(&value)) {
match origins.last_mut() {
Some(last) if last.origin == origin => last.local_storage.push(StorageItem { name, value }),
_ => origins.push(OriginStorage {
origin,
local_storage: vec![StorageItem { name, value }],
}),
}
}
if !iter.advance() {
break;
}
}
drop(iter);
let _ = db.close();
Ok(origins)
}
/// Write `origins` into the database at `dir`, creating it when the profile
/// has none yet. An item replaces the one of the same name; the origin's
/// other items stay.
fn write_database(dir: &Path, origins: &[OriginStorage]) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let mut db = open(dir)?;
let now = chromium_now();
if db.get(b"VERSION").is_none() {
db.put(b"VERSION", b"1").map_err(|e| e.to_string())?;
}
for origin in origins {
let mut batch = WriteBatch::default();
for item in &origin.local_storage {
batch.put(
&data_key(&origin.origin, &item.name),
&encode_string(&item.value),
);
}
db.write(batch, false).map_err(|e| e.to_string())?;
let prefix = data_prefix(&origin.origin);
let mut size = 0u64;
let mut iter = db.new_iter().map_err(|e| e.to_string())?;
iter.seek(&prefix);
while iter.valid() {
let Some((key, value)) = iter.current() else {
break;
};
if !key.starts_with(&prefix) {
break;
}
if let (Some((_, name)), Some(value)) = (parse_data_key(&key), decode_string(&value)) {
size += item_bytes(&name, &value);
}
if !iter.advance() {
break;
}
}
drop(iter);
if size > MAX_ORIGIN_BYTES {
return Err(
serde_json::json!({
"code": "LOCAL_STORAGE_ORIGIN_TOO_LARGE",
"params": { "origin": origin.origin }
})
.to_string(),
);
}
let meta_key = format!("META:{}", origin.origin);
let access_key = format!("METAACCESS:{}", origin.origin);
db.put(meta_key.as_bytes(), &write_meta(now, size))
.map_err(|e| e.to_string())?;
db.put(access_key.as_bytes(), &access_meta(now))
.map_err(|e| e.to_string())?;
}
db.flush().map_err(|e| e.to_string())?;
db.close().map_err(|e| e.to_string())
}
/// A copy of the folder to read from, without the browser's lock file.
fn copy_database(from: &Path) -> Result<Option<tempfile::TempDir>, String> {
if !from.exists() {
return Ok(None);
}
let copy = tempfile::tempdir().map_err(|e| e.to_string())?;
for entry in std::fs::read_dir(from).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name();
if name == "LOCK" || !entry.path().is_file() {
continue;
}
std::fs::copy(entry.path(), copy.path().join(&name)).map_err(|e| e.to_string())?;
}
Ok(Some(copy))
}
/// `https://example.com`, from whatever spelling of an origin was imported.
fn normalized_origin(raw: &str) -> Result<String, String> {
let invalid = || {
serde_json::json!({
"code": "LOCAL_STORAGE_ORIGIN_INVALID",
"params": { "origin": raw.chars().take(200).collect::<String>() }
})
.to_string()
};
let url = url::Url::parse(raw.trim()).map_err(|_| invalid())?;
if !matches!(url.scheme(), "http" | "https") {
return Err(invalid());
}
let origin = url.origin();
if !origin.is_tuple() {
return Err(invalid());
}
Ok(origin.ascii_serialization())
}
// --- What the app and the API call -----------------------------------------
pub fn read(profile_id: &str) -> Result<Vec<OriginStorage>, String> {
let profile = readable_profile(profile_id)?;
let Some(copy) = copy_database(&database_dir(&profile))? else {
return Ok(Vec::new());
};
read_database(copy.path())
.map_err(|e| crate::backend_error_with_detail("LOCAL_STORAGE_UNREADABLE", e))
}
pub fn stats(profile_id: &str) -> Result<LocalStorageStats, String> {
let origins = read(profile_id)?;
Ok(LocalStorageStats {
origins: origins.len(),
items: origins.iter().map(|o| o.local_storage.len()).sum(),
})
}
/// Playwright's cookie shape from a stored cookie.
fn playwright_cookie(cookie: &crate::cookie_manager::UnifiedCookie) -> serde_json::Value {
let same_site = match cookie.same_site {
0 => "None",
2 => "Strict",
// Unspecified behaves as Lax in Chromium, so Lax is what it travels as.
_ => "Lax",
};
serde_json::json!({
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"expires": if cookie.expires == 0 { -1 } else { cookie.expires },
"httpOnly": cookie.is_http_only,
"secure": cookie.is_secure,
"sameSite": same_site,
})
}
pub fn export_storage_state(profile_id: &str, include_cookies: bool) -> Result<String, String> {
let origins = read(profile_id)?;
let cookies: Vec<serde_json::Value> = if include_cookies {
crate::cookie_manager::CookieManager::read_cookies(profile_id)
.map_err(|e| crate::backend_error_with_detail("COOKIE_DB_UNAVAILABLE", e))?
.domains
.iter()
.flat_map(|domain| domain.cookies.iter().map(playwright_cookie))
.collect()
} else {
Vec::new()
};
let count: usize = origins.iter().map(|o| o.local_storage.len()).sum();
log::info!(
"Storage state exported profile={profile_id} origins={} items={count} cookies={}",
origins.len(),
cookies.len()
);
serde_json::to_string_pretty(&serde_json::json!({ "cookies": cookies, "origins": origins }))
.map_err(|e| e.to_string())
}
/// Check and tidy what an import carries, before anything is written.
fn checked_origins(origins: Vec<OriginStorage>) -> Result<Vec<OriginStorage>, String> {
if origins.len() > MAX_ORIGINS {
return Err(code("LOCAL_STORAGE_TOO_MANY_ORIGINS"));
}
let mut merged: Vec<OriginStorage> = Vec::new();
for origin in origins {
let name = normalized_origin(&origin.origin)?;
let size: u64 = origin
.local_storage
.iter()
.map(|item| item_bytes(&item.name, &item.value))
.sum();
if size > MAX_ORIGIN_BYTES {
return Err(
serde_json::json!({
"code": "LOCAL_STORAGE_ORIGIN_TOO_LARGE",
"params": { "origin": name }
})
.to_string(),
);
}
match merged.iter_mut().find(|o| o.origin == name) {
Some(existing) => existing.local_storage.extend(origin.local_storage),
None => merged.push(OriginStorage {
origin: name,
local_storage: origin.local_storage,
}),
}
}
Ok(merged)
}
pub async fn import_storage_state(
app_handle: &tauri::AppHandle,
profile_id: &str,
content: &str,
) -> Result<StorageStateImport, String> {
if content.len() > MAX_IMPORT_BYTES {
return Err(code("LOCAL_STORAGE_IMPORT_TOO_LARGE"));
}
let state: StorageState =
serde_json::from_str(content).map_err(|_| code("STORAGE_STATE_INVALID"))?;
let profile = readable_profile(profile_id)?;
let running = ProfileManager::instance()
.check_browser_status(app_handle.clone(), &profile)
.await
.unwrap_or(false);
if running {
return Err(code("LOCAL_STORAGE_BROWSER_RUNNING"));
}
if crate::remote_handoff::state_for(profile_id).is_some() {
return Err(code("LOCAL_STORAGE_REMOTE_SESSION"));
}
let origins = checked_origins(state.origins)?;
let items: usize = origins.iter().map(|o| o.local_storage.len()).sum();
let (mut cookies_imported, mut cookies_replaced, mut cookie_errors) = (0, 0, Vec::new());
if !state.cookies.is_empty() {
let cookies = serde_json::json!({ "cookies": state.cookies }).to_string();
let result =
crate::cookie_manager::CookieManager::import_cookies(app_handle, profile_id, &cookies)
.await?;
cookies_imported = result.cookies_imported;
cookies_replaced = result.cookies_replaced;
cookie_errors = result.errors;
}
if !origins.is_empty() {
let dir = database_dir(&profile);
let to_write = origins.clone();
tauri::async_runtime::spawn_blocking(move || write_database(&dir, &to_write))
.await
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
.map_err(|e| {
if e.starts_with('{') {
e
} else {
crate::backend_error_with_detail("LOCAL_STORAGE_UNWRITABLE", e)
}
})?;
}
log::info!(
"Storage state imported profile={profile_id} origins={} items={items} cookies={cookies_imported}",
origins.len()
);
Ok(StorageStateImport {
origins: origins.len(),
items,
cookies_imported,
cookies_replaced,
cookie_errors,
})
}
#[tauri::command]
pub async fn get_profile_local_storage_stats(
profile_id: String,
) -> Result<LocalStorageStats, String> {
tauri::async_runtime::spawn_blocking(move || stats(&profile_id))
.await
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
}
#[tauri::command]
pub async fn export_profile_storage_state(
profile_id: String,
include_cookies: bool,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || export_storage_state(&profile_id, include_cookies))
.await
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?
}
#[tauri::command]
pub async fn import_profile_storage_state(
app_handle: tauri::AppHandle,
profile_id: String,
content: String,
) -> Result<StorageStateImport, String> {
import_storage_state(&app_handle, &profile_id, &content).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strings_round_trip_in_both_formats() {
for text in ["plain", "", "ümlaut ÿ", "日本語 🍩"] {
let encoded = encode_string(text);
assert_eq!(decode_string(&encoded).as_deref(), Some(text));
}
assert_eq!(encode_string("ab")[0], 1, "Latin-1 when it fits");
assert_eq!(encode_string("日")[0], 0, "UTF-16 when it does not");
assert_eq!(decode_string(&[0, 1]), None, "an odd UTF-16 length");
assert_eq!(decode_string(&[9, 1]), None);
}
#[test]
fn a_data_key_names_its_origin_and_item() {
let key = data_key("https://example.com", "token");
assert!(key.starts_with(b"_https://example.com\0\x01"));
assert_eq!(
parse_data_key(&key),
Some(("https://example.com".to_string(), "token".to_string()))
);
assert_eq!(parse_data_key(b"META:https://example.com"), None);
}
#[test]
fn meta_is_the_protobuf_chromium_reads() {
assert_eq!(write_meta(1, 300), vec![0x08, 0x01, 0x10, 0xAC, 0x02]);
assert_eq!(access_meta(150), vec![0x08, 0x96, 0x01]);
}
#[test]
fn a_written_database_reads_back_and_keeps_other_items() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("leveldb");
let item = |name: &str, value: &str| StorageItem {
name: name.to_string(),
value: value.to_string(),
};
write_database(
&path,
&[OriginStorage {
origin: "https://shop.example".to_string(),
local_storage: vec![item("cart", "3 items"), item("lang", "日本語")],
}],
)
.unwrap();
write_database(
&path,
&[
OriginStorage {
origin: "https://shop.example".to_string(),
local_storage: vec![item("cart", "empty")],
},
OriginStorage {
origin: "https://mail.example".to_string(),
local_storage: vec![item("session", "abc")],
},
],
)
.unwrap();
let copy = copy_database(&path).unwrap().unwrap();
let read = read_database(copy.path()).unwrap();
assert_eq!(read.len(), 2);
let shop = read
.iter()
.find(|o| o.origin == "https://shop.example")
.unwrap();
assert_eq!(
shop.local_storage,
vec![item("cart", "empty"), item("lang", "日本語")]
);
let mut db = open(&path).unwrap();
assert_eq!(db.get(b"VERSION").as_deref(), Some(&b"1"[..]));
assert!(db.get(b"META:https://mail.example").is_some());
}
#[test]
fn imported_origins_are_checked_and_spelled_as_a_page_reads_them() {
let origin = |name: &str| OriginStorage {
origin: name.to_string(),
local_storage: vec![StorageItem {
name: "k".to_string(),
value: "v".to_string(),
}],
};
let tidy = checked_origins(vec![
origin("https://Example.com:443/path"),
origin("https://example.com"),
])
.unwrap();
assert_eq!(tidy.len(), 1);
assert_eq!(tidy[0].origin, "https://example.com");
assert_eq!(tidy[0].local_storage.len(), 2);
for bad in ["file:///etc", "javascript:alert(1)", "not a url"] {
assert!(checked_origins(vec![origin(bad)])
.unwrap_err()
.contains("LOCAL_STORAGE_ORIGIN_INVALID"));
}
let huge = OriginStorage {
origin: "https://big.example".to_string(),
local_storage: vec![StorageItem {
name: "k".to_string(),
value: "x".repeat(6_000_000),
}],
};
assert!(checked_origins(vec![huge])
.unwrap_err()
.contains("LOCAL_STORAGE_ORIGIN_TOO_LARGE"));
}
}
+21 -4
View File
@@ -3209,6 +3209,7 @@ mod tests {
proxy_path: &std::path::Path,
) -> Result<(std::process::ExitStatus, String, Duration), Box<dyn std::error::Error>> {
let stdout = tempfile::NamedTempFile::new()?;
let stderr = tempfile::NamedTempFile::new()?;
let mut cmd = Command::new(proxy_path);
cmd
.args([
@@ -3221,13 +3222,29 @@ mod tests {
"--type",
"http",
])
.env("RUST_LOG", "debug")
.stdin(Stdio::null())
.stdout(Stdio::from(stdout.reopen()?))
.stderr(Stdio::null());
.stderr(Stdio::from(stderr.reopen()?))
.kill_on_drop(true);
let started = std::time::Instant::now();
let status = tokio::time::timeout(CLI_EXIT_LIMIT, cmd.status())
.await
.map_err(|_| "CLI command timed out - this indicates improper detachment")??;
let mut child = cmd.spawn()?;
let status = match tokio::time::timeout(CLI_EXIT_LIMIT, child.wait()).await {
Ok(status) => status?,
Err(_) => {
let _ = child.kill().await;
let log = std::fs::read_to_string(stderr.path()).unwrap_or_default();
let tail: Vec<&str> = log.lines().rev().take(25).collect();
let tail: Vec<&str> = tail.into_iter().rev().collect();
return Err(
format!(
"CLI command timed out - this indicates improper detachment. Its log:\n{}",
tail.join("\n")
)
.into(),
);
}
};
Ok((
status,
std::fs::read_to_string(stdout.path())?,