From 7cdd8ebcef9cd3b7ee496b6f91a64509e4379c76 Mon Sep 17 00:00:00 2001 From: Dan Walmsley <4672627+danwalmsley@users.noreply.github.com> Date: Fri, 18 Sep 2026 19:21:32 +0100 Subject: [PATCH] Persist cookies and local storage in browser profiles --- README.md | 1 + docs/browser-profiles.md | 59 ++ .../CMakeLists.txt | 14 +- .../native/webscene_native_engine.cpp | 47 ++ .../native/webscene_native_engine.exports | 1 + .../native/webscene_native_engine.h | 29 + .../native/webscene_profile_storage.cpp | 599 ++++++++++++++++++ .../native/webscene_profile_storage.h | 115 ++++ .../native/webscene_v8_runtime.cpp | 187 +++++- .../webscene_v8_runtime_cache_and_frames.inc | 13 +- .../native/webscene_v8_runtime_dom_core.inc | 13 +- .../native/webscene_v8_runtime_lifecycle.inc | 51 ++ .../native/webscene_v8_runtime_navigation.inc | 12 + .../native/webscene_v8_runtime_resources.inc | 31 +- .../native/webscene_v8_runtime_state.inc | 2 + .../webscene_v8_runtime_state_types.inc | 6 + ...tive_v8_runtime_frame_scheduling_tests.inc | 13 + ...ative_v8_runtime_response_cookie_tests.inc | 165 +++++ .../tests/native_v8_runtime_tests.cpp | 9 + .../tests/profile_storage_tests.cpp | 322 ++++++++++ 20 files changed, 1675 insertions(+), 14 deletions(-) create mode 100644 docs/browser-profiles.md create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.cpp create mode 100644 experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.h create mode 100644 experiments/WebScene.NativeEngine.Probe/tests/profile_storage_tests.cpp diff --git a/README.md b/README.md index 89eba2a82..8ef9a59b8 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ interop guides for both presenters: - [Content and resource loading](docfx/articles/content-and-resources.md) - [Lifecycle and diagnostics](docfx/articles/lifecycle-and-diagnostics.md) - [Compatibility and security](docfx/articles/compatibility-and-security.md) +- [Durable browser profiles](docs/browser-profiles.md) - [Troubleshooting](docfx/articles/troubleshooting.md) Build the documentation site locally with `./build-docs.sh`. diff --git a/docs/browser-profiles.md b/docs/browser-profiles.md new file mode 100644 index 000000000..510e187b1 --- /dev/null +++ b/docs/browser-profiles.md @@ -0,0 +1,59 @@ +# Durable browser profiles + +WebScene is ephemeral unless the host supplies both +`webscene_engine_options.storage_directory` and a stable +`storage_partition_key`. The same identity and root are shared by cookies, +`localStorage`, and IndexedDB. Origins remain separate below the profile, so a +stable application partition does not merge unrelated web origins. + +## Lifecycle and failure model + +- A profile takes a non-blocking operating-system file lock. A concurrent + process cannot open or clear that partition; storage falls back to ephemeral + operation and the standalone clear API returns `BUSY`. +- Persistent cookies and `localStorage` load when the runtime worker starts. + Mutations update memory synchronously and enqueue a coalesced profile + checkpoint. File creation, flush, and replacement run on the storage worker. +- Orderly engine destruction checkpoints the latest admitted snapshot before + returning. There is at most one active write and one coalesced pending + snapshot. Atomic replace means interruption exposes either the prior or next + complete schema, never a partial commit. +- The qualification budget for orderly shutdown is 100 ms per close on average + (5 seconds for 50 open/mutate/close cycles). The regression test also checks + that file descriptors and temporary files stay bounded across those cycles. +- The binary profile carries magic, schema version, partition identity, record + bounds, revision, payload length, and checksum. Unknown, truncated, or + corrupt data is ignored as an empty profile and replaced by the next valid + mutation. No cookie names, values, tokens, or serialized contents appear in + diagnostics. + +Only cookies with a future `Expires` or positive `Max-Age` are checkpointed. +Session cookies and all `sessionStorage` data remain memory-only. Cookie +domain, host-only, path, creation order, expiry, `Secure`, `HttpOnly`, and +`SameSite` attributes round-trip through the profile. Normal replacement, +deletion, expiry pruning, visibility checks, and bounded cookie limits operate +on the restored jar. + +`localStorage` is partitioned by serialized origin and retains insertion order. +Each origin has a 5 MiB limit (or the smaller configured profile quota), with a +synchronous `QuotaExceededError` before mutation. Opaque origins receive +`SecurityError`. The configured quota also bounds the complete profile file. + +## Clearing and local-data threat model + +Call `webscene_profile_clear_data_v1` only after closing engines for that +partition. Flags independently clear cookies, `localStorage`, or all data in +the partition; all-site-data also removes IndexedDB database files. Traversal +has a fixed entry bound. Partition keys are hashed and are never interpreted +as paths; clearing cannot escape `storage_directory` or delete the root itself. + +Profile directories and files use owner-only permissions where the platform +supports POSIX modes. The data is not encrypted by WebScene and is readable by +the same OS account. Hosts should choose an OS-protected application-data +directory and may place it on encrypted storage or integrate platform +credential protection according to their threat model. + +IndexedDB keeps its existing per-database transactional files from issue #56. +The browser profile composes with the same host root, partition hashing, atomic +replacement, quota, and worker-lifecycle model without treating cookies or Web +Storage mutations as IndexedDB transactions. diff --git a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt index e9467f940..f0902df8f 100644 --- a/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt +++ b/experiments/WebScene.NativeEngine.Probe/CMakeLists.txt @@ -174,6 +174,17 @@ if(BUILD_TESTING) COMMAND webscene_indexeddb_storage_tests) set_tests_properties(webscene_indexeddb_storage_tests PROPERTIES LABELS "storage;durability;performance" TIMEOUT 20) + add_executable(webscene_profile_storage_tests + tests/profile_storage_tests.cpp + native/webscene_profile_storage.cpp) + target_compile_features(webscene_profile_storage_tests PRIVATE cxx_std_20) + target_include_directories(webscene_profile_storage_tests PRIVATE native) + target_link_libraries(webscene_profile_storage_tests PRIVATE Threads::Threads) + add_test(NAME webscene_profile_storage_tests + COMMAND webscene_profile_storage_tests) + set_tests_properties(webscene_profile_storage_tests PROPERTIES + LABELS "storage;cookies;web-storage;durability;security;performance" + TIMEOUT 20) add_executable(webscene_graphics_scene_abi_layout_tests tests/graphics_scene_abi_layout_tests.c) target_compile_features(webscene_graphics_scene_abi_layout_tests PRIVATE c_std_11) target_include_directories(webscene_graphics_scene_abi_layout_tests PRIVATE native) @@ -250,7 +261,8 @@ endif() add_library(webscene_native_engine ${webscene_runtime_kind} native/webscene_native_engine.cpp native/webscene_secure_random.cpp - native/webscene_indexeddb_storage.cpp) + native/webscene_indexeddb_storage.cpp + native/webscene_profile_storage.cpp) if(TARGET webscene_core) target_link_libraries(webscene_native_engine PRIVATE webscene_core) else() diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 498bc9340..749993544 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -4,6 +4,7 @@ #include "webscene_native_dom.h" #include "webscene_v8_runtime.h" #include "webscene_runtime_diagnostics.h" +#include "webscene_profile_storage.h" #include "webscene_frame_trace.h" #include "graphics/engine_wake.h" #include "graphics/webgpu_canvas_interop.h" @@ -25,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -1779,6 +1781,51 @@ webscene_scene_acquire_status acquire_scene_v3(webscene_engine* engine, } catch (const std::bad_alloc&) { return WEBSCENE_SCENE_ACQUIRE_OUT_OF_MEMORY; } catch (...) { return WEBSCENE_SCENE_ACQUIRE_INTERNAL_ERROR; } } + +uint32_t webscene_profile_clear_data_v1( + const char* storage_directory, + size_t storage_directory_length, + const char* storage_partition_key, + size_t storage_partition_key_length, + uint64_t storage_quota_bytes, + uint32_t flags) +{ + if (storage_directory == nullptr || storage_directory_length == 0U + || storage_partition_key == nullptr || storage_partition_key_length == 0U + || (flags & (WEBSCENE_PROFILE_CLEAR_COOKIES_V1 + | WEBSCENE_PROFILE_CLEAR_LOCAL_STORAGE_V1 + | WEBSCENE_PROFILE_CLEAR_ALL_SITE_DATA_V1)) == 0U) { + return WEBSCENE_PROFILE_STATUS_INVALID_ARGUMENT_V1; + } + try { + const auto result = webscene_native::browser_profile_storage::clear_partition_sync( + std::filesystem::path(std::string(storage_directory, storage_directory_length)), + std::string(storage_partition_key, storage_partition_key_length), + storage_quota_bytes, + ((flags & WEBSCENE_PROFILE_CLEAR_COOKIES_V1) != 0U + ? webscene_native::profile_clear_cookies : 0U) + | ((flags & WEBSCENE_PROFILE_CLEAR_LOCAL_STORAGE_V1) != 0U + ? webscene_native::profile_clear_local_storage : 0U) + | ((flags & WEBSCENE_PROFILE_CLEAR_ALL_SITE_DATA_V1) != 0U + ? webscene_native::profile_clear_all_site_data : 0U)); + switch (result.status) { + case webscene_native::profile_storage_status::ok: + case webscene_native::profile_storage_status::not_found: + return WEBSCENE_PROFILE_STATUS_OK_V1; + case webscene_native::profile_storage_status::busy: + return WEBSCENE_PROFILE_STATUS_BUSY_V1; + case webscene_native::profile_storage_status::quota_exceeded: + return WEBSCENE_PROFILE_STATUS_QUOTA_EXCEEDED_V1; + case webscene_native::profile_storage_status::corrupt: + return WEBSCENE_PROFILE_STATUS_CORRUPT_V1; + case webscene_native::profile_storage_status::unavailable: + case webscene_native::profile_storage_status::io_error: + return WEBSCENE_PROFILE_STATUS_IO_ERROR_V1; + } + } catch (...) { + } + return WEBSCENE_PROFILE_STATUS_IO_ERROR_V1; +} } webscene_scene_acquire_status webscene_engine_acquire_latest_scene_v3(webscene_engine* engine, const webscene_scene_acquire_options_v3* options,const webscene_scene_view_v3** result) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports index cc71b61ed..254166771 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports @@ -15,6 +15,7 @@ _webscene_engine_copy_runtime_failure _webscene_engine_copy_scene_diagnostics _webscene_engine_create _webscene_engine_create_with_options +_webscene_profile_clear_data_v1 _webscene_engine_destroy _webscene_engine_enqueue _webscene_engine_enqueue_resize_frame diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 38a719b7a..eca2bb868 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -929,6 +929,35 @@ typedef struct webscene_engine_options { uint64_t storage_quota_bytes; } webscene_engine_options; +/* + * Profile data is local application data, protected by owner-only filesystem + * permissions but not encrypted by WebScene. Hosts should select an OS-backed + * protected location and may layer platform credential/encryption facilities. + * Clear only while no engine has the partition open; BUSY is returned instead + * of racing a live writer. The partition key is hashed below storage_directory, + * so it is never interpreted as a path and cannot broaden the deletion scope. + */ +enum { + WEBSCENE_PROFILE_CLEAR_COOKIES_V1 = 1U << 0U, + WEBSCENE_PROFILE_CLEAR_LOCAL_STORAGE_V1 = 1U << 1U, + WEBSCENE_PROFILE_CLEAR_ALL_SITE_DATA_V1 = 1U << 2U +}; +enum { + WEBSCENE_PROFILE_STATUS_OK_V1 = 0U, + WEBSCENE_PROFILE_STATUS_INVALID_ARGUMENT_V1 = 1U, + WEBSCENE_PROFILE_STATUS_BUSY_V1 = 2U, + WEBSCENE_PROFILE_STATUS_IO_ERROR_V1 = 3U, + WEBSCENE_PROFILE_STATUS_CORRUPT_V1 = 4U, + WEBSCENE_PROFILE_STATUS_QUOTA_EXCEEDED_V1 = 5U +}; +WEBSCENE_API uint32_t webscene_profile_clear_data_v1( + const char* storage_directory, + size_t storage_directory_length, + const char* storage_partition_key, + size_t storage_partition_key_length, + uint64_t storage_quota_bytes, + uint32_t flags); + enum { WEBSCENE_DOCUMENT_SCRIPT_ALL_FRAMES = 1U << 0U }; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.cpp new file mode 100644 index 000000000..ea78bf7de --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.cpp @@ -0,0 +1,599 @@ +#include "webscene_profile_storage.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#else +#include +#include +#include +#include +#endif + +namespace webscene_native { +namespace { + +constexpr std::array profile_magic{'W', 'S', 'P', 'R', 'O', 'F', '0', '1'}; +constexpr uint32_t profile_schema = 1U; +constexpr size_t maximum_identity_bytes = 4096U; +constexpr size_t maximum_cookie_count = 256U; +constexpr size_t maximum_origin_count = 4096U; +constexpr size_t maximum_entry_count = 65536U; +constexpr size_t maximum_cookie_bytes = 4096U; +constexpr size_t maximum_storage_string_bytes = 16U * 1024U * 1024U; +constexpr uint64_t minimum_quota_bytes = 1024U * 1024U; +constexpr uint64_t default_quota_bytes = 256U * 1024U * 1024U; + +uint64_t hash_bytes(const void* data, size_t length) noexcept +{ + auto value = UINT64_C(14695981039346656037); + const auto* bytes = static_cast(data); + for (size_t index = 0; index < length; ++index) { + value ^= bytes[index]; + value *= UINT64_C(1099511628211); + } + return value; +} + +std::string digest_name(const std::string& value) +{ + constexpr char hex[] = "0123456789abcdef"; + const auto first = hash_bytes(value.data(), value.size()); + auto second = hash_bytes(&first, sizeof(first)); + second ^= static_cast(value.size()) * UINT64_C(0x9e3779b97f4a7c15); + std::string result(32U, '0'); + for (size_t index = 0; index < 16U; ++index) { + const auto shift = static_cast((15U - index) * 4U); + const auto source = index < 8U ? first : second; + result[index] = hex[(source >> shift) & 0xfU]; + result[index + 16U] = hex[(source >> ((index * 4U) & 63U)) & 0xfU]; + } + return result; +} + +template +bool append_scalar(std::vector& output, const Value& value) +{ + if (output.size() > std::numeric_limits::max() - sizeof(value)) return false; + const auto offset = output.size(); + output.resize(offset + sizeof(value)); + std::memcpy(output.data() + offset, &value, sizeof(value)); + return true; +} + +bool append_string( + std::vector& output, + const std::string& value, + size_t maximum = maximum_storage_string_bytes) +{ + if (value.size() > maximum || value.size() > UINT32_MAX) return false; + const auto length = static_cast(value.size()); + if (!append_scalar(output, length) + || output.size() > std::numeric_limits::max() - value.size()) return false; + output.insert(output.end(), value.begin(), value.end()); + return true; +} + +template +bool read_scalar(const std::vector& input, size_t& cursor, Value& value) +{ + if (cursor > input.size() || input.size() - cursor < sizeof(value)) return false; + std::memcpy(&value, input.data() + cursor, sizeof(value)); + cursor += sizeof(value); + return true; +} + +bool read_string( + const std::vector& input, + size_t& cursor, + std::string& value, + size_t maximum = maximum_storage_string_bytes) +{ + uint32_t length = 0U; + if (!read_scalar(input, cursor, length) || length > maximum + || cursor > input.size() || input.size() - cursor < length) return false; + value.assign(reinterpret_cast(input.data() + cursor), length); + cursor += length; + return true; +} + +bool flush_file(const std::filesystem::path& path) +{ +#if defined(_WIN32) + const auto handle = CreateFileW( + path.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle == INVALID_HANDLE_VALUE) return false; + const auto flushed = FlushFileBuffers(handle) != 0; + CloseHandle(handle); + return flushed; +#else + const auto descriptor = ::open(path.c_str(), O_RDONLY); + if (descriptor < 0) return false; + const auto flushed = ::fsync(descriptor) == 0; + ::close(descriptor); + return flushed; +#endif +} + +bool replace_file( + const std::filesystem::path& temporary, + const std::filesystem::path& destination) +{ +#if defined(_WIN32) + return MoveFileExW( + temporary.c_str(), destination.c_str(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; +#else + std::error_code error; + std::filesystem::rename(temporary, destination, error); + if (error) return false; + const auto descriptor = ::open(destination.parent_path().c_str(), O_RDONLY); + if (descriptor >= 0) { + static_cast(::fsync(descriptor)); + ::close(descriptor); + } + return true; +#endif +} + +bool write_profile_payload( + const browser_profile_state& state, + std::vector& payload) +{ + if (state.cookies.size() > maximum_cookie_count + || state.local_storage.size() > maximum_origin_count) return false; + if (!append_scalar(payload, static_cast(state.cookies.size()))) return false; + for (const auto& cookie : state.cookies) { + const auto flags = static_cast( + (cookie.host_only ? 1U : 0U) + | (cookie.secure ? 2U : 0U) + | (cookie.http_only ? 4U : 0U)); + const auto same_site = static_cast(cookie.same_site); + if (!append_string(payload, cookie.name, maximum_cookie_bytes) + || !append_string(payload, cookie.value, maximum_cookie_bytes) + || !append_string(payload, cookie.domain, maximum_cookie_bytes) + || !append_string(payload, cookie.path, maximum_cookie_bytes) + || !append_scalar(payload, cookie.expiry_unix_seconds) + || !append_scalar(payload, cookie.creation_order) + || !append_scalar(payload, flags) + || !append_scalar(payload, same_site)) return false; + } + std::vector origins; + origins.reserve(state.local_storage.size()); + for (const auto& [origin, storage] : state.local_storage) { + static_cast(storage); + origins.push_back(origin); + } + std::sort(origins.begin(), origins.end()); + if (!append_scalar(payload, static_cast(origins.size()))) return false; + for (const auto& origin : origins) { + const auto& storage = state.local_storage.at(origin); + if (storage.keys.size() > maximum_entry_count + || !append_string(payload, origin, maximum_identity_bytes) + || !append_scalar(payload, static_cast(storage.keys.size()))) return false; + for (const auto& key : storage.keys) { + const auto value = storage.values.find(key); + if (value == storage.values.end() + || !append_string(payload, key) + || !append_string(payload, value->second)) return false; + } + } + return true; +} + +bool read_profile_payload( + const std::vector& payload, + browser_profile_state& state) +{ + size_t cursor = 0U; + uint32_t cookie_count = 0U; + if (!read_scalar(payload, cursor, cookie_count) + || cookie_count > maximum_cookie_count) return false; + state.cookies.reserve(cookie_count); + for (uint32_t index = 0U; index < cookie_count; ++index) { + profile_cookie cookie; + uint8_t flags = 0U, same_site = 0U; + if (!read_string(payload, cursor, cookie.name, maximum_cookie_bytes) + || !read_string(payload, cursor, cookie.value, maximum_cookie_bytes) + || !read_string(payload, cursor, cookie.domain, maximum_cookie_bytes) + || !read_string(payload, cursor, cookie.path, maximum_cookie_bytes) + || !read_scalar(payload, cursor, cookie.expiry_unix_seconds) + || !read_scalar(payload, cursor, cookie.creation_order) + || !read_scalar(payload, cursor, flags) + || !read_scalar(payload, cursor, same_site) + || same_site > static_cast(profile_cookie_same_site::none)) return false; + cookie.host_only = (flags & 1U) != 0U; + cookie.secure = (flags & 2U) != 0U; + cookie.http_only = (flags & 4U) != 0U; + cookie.same_site = static_cast(same_site); + state.cookies.push_back(std::move(cookie)); + } + uint32_t origin_count = 0U; + if (!read_scalar(payload, cursor, origin_count) + || origin_count > maximum_origin_count) return false; + for (uint32_t origin_index = 0U; origin_index < origin_count; ++origin_index) { + std::string origin; + uint32_t entry_count = 0U; + if (!read_string(payload, cursor, origin, maximum_identity_bytes) + || origin.empty() || origin == "null" + || !read_scalar(payload, cursor, entry_count) + || entry_count > maximum_entry_count) return false; + profile_local_storage storage; + storage.keys.reserve(entry_count); + for (uint32_t entry_index = 0U; entry_index < entry_count; ++entry_index) { + std::string key, value; + if (!read_string(payload, cursor, key) + || !read_string(payload, cursor, value) + || storage.values.contains(key)) return false; + storage.keys.push_back(key); + storage.values.emplace(std::move(key), std::move(value)); + } + if (!state.local_storage.emplace(std::move(origin), std::move(storage)).second) { + return false; + } + } + return cursor == payload.size(); +} + +} // namespace + +struct browser_profile_storage::lock_handle final { +#if defined(_WIN32) + HANDLE value{INVALID_HANDLE_VALUE}; + ~lock_handle() { if (value != INVALID_HANDLE_VALUE) CloseHandle(value); } +#else + int value{-1}; + ~lock_handle() { + if (value < 0) return; + static_cast(::flock(value, LOCK_UN)); + ::close(value); + } +#endif +}; + +browser_profile_storage::browser_profile_storage( + std::filesystem::path root, + std::string partition, + uint64_t quota_bytes) + : root_(std::move(root)) + , partition_(std::move(partition)) + , quota_bytes_(quota_bytes == 0U ? default_quota_bytes : quota_bytes) +{ + if (root_.empty() || partition_.empty() + || partition_.size() > maximum_identity_bytes + || quota_bytes_ < minimum_quota_bytes) return; + std::error_code error; + std::filesystem::create_directories(partition_path(), error); + if (error) { + open_status_ = profile_storage_status::io_error; + return; + } +#if !defined(_WIN32) + static_cast(::chmod(partition_path().c_str(), S_IRWXU)); +#endif + lock_ = std::make_unique(); + const auto lock_path = partition_path() / "browser-profile.lock"; +#if defined(_WIN32) + lock_->value = CreateFileW( + lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_ALWAYS, FILE_ATTRIBUTE_HIDDEN, nullptr); + if (lock_->value == INVALID_HANDLE_VALUE) { + open_status_ = GetLastError() == ERROR_SHARING_VIOLATION + ? profile_storage_status::busy : profile_storage_status::io_error; + lock_.reset(); + return; + } +#else + lock_->value = ::open(lock_path.c_str(), O_CREAT | O_RDWR, S_IRUSR | S_IWUSR); + if (lock_->value < 0 || ::flock(lock_->value, LOCK_EX | LOCK_NB) != 0) { + open_status_ = profile_storage_status::busy; + lock_.reset(); + return; + } + static_cast(::fchmod(lock_->value, S_IRUSR | S_IWUSR)); +#endif + open_status_ = profile_storage_status::ok; + const auto loaded = load_sync(); + if (loaded.status == profile_storage_status::ok) state_ = loaded.state; + else if (loaded.status != profile_storage_status::not_found + && loaded.status != profile_storage_status::corrupt) { + open_status_ = loaded.status; + lock_.reset(); + return; + } + worker_ = std::jthread([this](std::stop_token token) { run(token); }); +} + +browser_profile_storage::~browser_profile_storage() +{ + if (!worker_.joinable()) return; + { + std::lock_guard guard(mutex_); + if (admitted_generation_ != committed_generation_) write_pending_ = true; + } + worker_.request_stop(); + wake_.notify_all(); + worker_.join(); +} + +bool browser_profile_storage::available() const noexcept +{ + return open_status_ == profile_storage_status::ok && lock_ != nullptr; +} + +profile_storage_status browser_profile_storage::open_status() const noexcept +{ + return open_status_; +} + +std::filesystem::path browser_profile_storage::partition_path() const +{ + return root_ / digest_name(partition_); +} + +std::filesystem::path browser_profile_storage::profile_path() const +{ + return partition_path() / "browser-profile.wsprofile"; +} + +profile_storage_result browser_profile_storage::load_sync() +{ + if (!available()) return {open_status_, {}, "Browser profile is unavailable"}; + std::ifstream stream(profile_path(), std::ios::binary); + if (!stream) { + std::error_code error; + return std::filesystem::exists(profile_path(), error) + ? profile_storage_result{profile_storage_status::io_error, {}, + "Unable to open the browser profile"} + : profile_storage_result{profile_storage_status::not_found, {}, {}}; + } + std::array magic{}; + uint32_t schema = 0U; + uint64_t revision = 0U, payload_length = 0U, payload_hash = 0U; + uint32_t partition_length = 0U; + stream.read(magic.data(), static_cast(magic.size())); + if (!stream || magic != profile_magic + || !stream.read(reinterpret_cast(&schema), sizeof(schema)) + || schema != profile_schema + || !stream.read(reinterpret_cast(&revision), sizeof(revision)) + || !stream.read(reinterpret_cast(&partition_length), sizeof(partition_length)) + || partition_length > maximum_identity_bytes) { + return {profile_storage_status::corrupt, {}, + "Browser profile failed schema validation"}; + } + std::string stored_partition(partition_length, '\0'); + if (partition_length != 0U) stream.read(stored_partition.data(), partition_length); + if (!stream || stored_partition != partition_ + || !stream.read(reinterpret_cast(&payload_length), sizeof(payload_length)) + || !stream.read(reinterpret_cast(&payload_hash), sizeof(payload_hash)) + || payload_length > quota_bytes_ || payload_length > SIZE_MAX) { + return {profile_storage_status::corrupt, {}, + "Browser profile failed identity or size validation"}; + } + std::vector payload(static_cast(payload_length)); + if (!payload.empty()) stream.read( + reinterpret_cast(payload.data()), + static_cast(payload.size())); + browser_profile_state state; + state.revision = revision; + if (!stream || stream.peek() != std::ifstream::traits_type::eof() + || hash_bytes(payload.data(), payload.size()) != payload_hash + || !read_profile_payload(payload, state)) { + return {profile_storage_status::corrupt, {}, + "Browser profile failed content validation"}; + } + return {profile_storage_status::ok, std::move(state), {}}; +} + +profile_storage_result browser_profile_storage::store_sync( + const browser_profile_state& state) +{ + if (!available()) return {open_status_, {}, "Browser profile is unavailable"}; + std::vector payload; + if (!write_profile_payload(state, payload)) { + return {profile_storage_status::quota_exceeded, {}, + "Browser profile contains an oversized record"}; + } + const auto header_size = profile_magic.size() + sizeof(uint32_t) + + sizeof(uint64_t) * 3U + sizeof(uint32_t) + partition_.size(); + if (payload.size() > quota_bytes_ || header_size > quota_bytes_ - payload.size()) { + return {profile_storage_status::quota_exceeded, {}, + "Browser profile exceeds its configured quota"}; + } + std::error_code error; + std::filesystem::create_directories(partition_path(), error); + if (error) return {profile_storage_status::io_error, {}, + "Unable to create the browser profile directory"}; + static std::atomic sequence{0U}; + auto temporary = profile_path(); + temporary += "." +#if defined(_WIN32) + + std::to_string(GetCurrentProcessId()) +#else + + std::to_string(static_cast(::getpid())) +#endif + + "." + std::to_string(sequence.fetch_add(1U, std::memory_order_relaxed)) + + ".tmp"; + std::ofstream stream(temporary, std::ios::binary | std::ios::trunc); + const auto revision = state.revision + 1U; + const auto partition_length = static_cast(partition_.size()); + const auto payload_length = static_cast(payload.size()); + const auto payload_hash = hash_bytes(payload.data(), payload.size()); + stream.write(profile_magic.data(), profile_magic.size()); + stream.write(reinterpret_cast(&profile_schema), sizeof(profile_schema)); + stream.write(reinterpret_cast(&revision), sizeof(revision)); + stream.write(reinterpret_cast(&partition_length), sizeof(partition_length)); + stream.write(partition_.data(), static_cast(partition_.size())); + stream.write(reinterpret_cast(&payload_length), sizeof(payload_length)); + stream.write(reinterpret_cast(&payload_hash), sizeof(payload_hash)); + if (!payload.empty()) stream.write( + reinterpret_cast(payload.data()), + static_cast(payload.size())); + stream.close(); +#if !defined(_WIN32) + static_cast(::chmod(temporary.c_str(), S_IRUSR | S_IWUSR)); +#endif + if (!stream || !flush_file(temporary) || !replace_file(temporary, profile_path())) { + std::filesystem::remove(temporary, error); + return {profile_storage_status::io_error, {}, + "Unable to commit the browser profile atomically"}; + } + auto committed = state; + committed.revision = revision; + return {profile_storage_status::ok, std::move(committed), {}}; +} + +profile_storage_result browser_profile_storage::clear_sync(uint32_t flags) +{ + if (!available()) return {open_status_, {}, "Browser profile is unavailable"}; + browser_profile_state next; + { + std::lock_guard guard(mutex_); + next = state_; + } + if ((flags & profile_clear_all_site_data) != 0U) { + next.cookies.clear(); + next.local_storage.clear(); + std::error_code error; + std::vector empty_directory_candidates; + size_t visited = 0U; + std::filesystem::recursive_directory_iterator iterator(partition_path(), error); + const std::filesystem::recursive_directory_iterator end; + while (!error && iterator != end && visited < maximum_entry_count) { + ++visited; + const auto path = iterator->path(); + if (iterator->is_directory(error)) { + empty_directory_candidates.push_back(path); + } else if (iterator->is_regular_file(error) + && (path.extension() == ".wsidb" + || (path.extension() == ".tmp" + && path.filename() != "browser-profile.lock"))) { + std::filesystem::remove(path, error); + } + if (!error) iterator.increment(error); + } + if (error || iterator != end) { + return {profile_storage_status::io_error, {}, + "Unable to clear the bounded browser profile"}; + } + std::sort( + empty_directory_candidates.begin(), + empty_directory_candidates.end(), + [](const auto& left, const auto& right) { + return left.native().size() > right.native().size(); + }); + for (const auto& path : empty_directory_candidates) { + std::filesystem::remove(path, error); + if (error) { + error.clear(); + if (!std::filesystem::is_directory(path, error)) { + return {profile_storage_status::io_error, {}, + "Unable to clear the browser profile directory"}; + } + error.clear(); + } + } + } else { + if ((flags & profile_clear_cookies) != 0U) next.cookies.clear(); + if ((flags & profile_clear_local_storage) != 0U) next.local_storage.clear(); + } + auto result = store_sync(next); + if (result.status == profile_storage_status::ok) { + std::lock_guard guard(mutex_); + state_ = result.state; + committed_generation_ = admitted_generation_; + write_pending_ = false; + } + return result; +} + +void browser_profile_storage::schedule_write_locked() +{ + ++admitted_generation_; + write_pending_ = true; + wake_.notify_one(); +} + +void browser_profile_storage::replace_cookies(std::vector cookies) +{ + if (!available()) return; + std::lock_guard guard(mutex_); + state_.cookies = std::move(cookies); + schedule_write_locked(); +} + +void browser_profile_storage::replace_local_storage( + std::string origin, + profile_local_storage storage) +{ + if (!available() || origin.empty() || origin == "null") return; + std::lock_guard guard(mutex_); + state_.local_storage.insert_or_assign(std::move(origin), std::move(storage)); + schedule_write_locked(); +} + +void browser_profile_storage::clear_local_storage_origin(const std::string& origin) +{ + if (!available() || origin.empty() || origin == "null") return; + std::lock_guard guard(mutex_); + state_.local_storage.erase(origin); + schedule_write_locked(); +} + +browser_profile_state browser_profile_storage::snapshot() const +{ + std::lock_guard guard(mutex_); + return state_; +} + +profile_storage_result browser_profile_storage::clear_partition_sync( + const std::filesystem::path& root, + const std::string& partition, + uint64_t quota_bytes, + uint32_t flags) +{ + browser_profile_storage storage(root, partition, quota_bytes); + return storage.clear_sync(flags); +} + +void browser_profile_storage::run(std::stop_token token) +{ + for (;;) { + browser_profile_state pending; + uint64_t generation = 0U; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, token, [this] { return write_pending_; }); + if (!write_pending_ && token.stop_requested()) break; + pending = state_; + generation = admitted_generation_; + write_pending_ = false; + } + const auto result = store_sync(pending); + { + std::lock_guard guard(mutex_); + if (result.status == profile_storage_status::ok) { + state_.revision = std::max(state_.revision, result.state.revision); + committed_generation_ = std::max(committed_generation_, generation); + } + if (admitted_generation_ != generation) write_pending_ = true; + } + if (token.stop_requested()) { + std::lock_guard guard(mutex_); + if (!write_pending_) break; + } + } +} + +} // namespace webscene_native diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.h new file mode 100644 index 000000000..b71629292 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_profile_storage.h @@ -0,0 +1,115 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace webscene_native { + +enum class profile_storage_status : uint8_t { + ok, + not_found, + busy, + quota_exceeded, + unavailable, + corrupt, + io_error +}; + +enum class profile_cookie_same_site : uint8_t { lax, strict, none }; + +struct profile_cookie final { + std::string name; + std::string value; + std::string domain; + std::string path; + int64_t expiry_unix_seconds{0}; + uint64_t creation_order{0}; + bool host_only{true}; + bool secure{false}; + bool http_only{false}; + profile_cookie_same_site same_site{profile_cookie_same_site::lax}; +}; + +struct profile_local_storage final { + std::vector keys; + std::unordered_map values; +}; + +struct browser_profile_state final { + uint64_t revision{0}; + std::vector cookies; + std::unordered_map local_storage; +}; + +struct profile_storage_result final { + profile_storage_status status{profile_storage_status::io_error}; + browser_profile_state state; + std::string message; +}; + +enum : uint32_t { + profile_clear_cookies = 1U << 0U, + profile_clear_local_storage = 1U << 1U, + profile_clear_all_site_data = 1U << 2U +}; + +// One instance owns the non-blocking process lock for a host partition. The +// V8 thread updates the in-memory snapshot; one coalescing worker performs all +// writes. Destruction checkpoints the latest admitted snapshot before return. +class browser_profile_storage final { +public: + browser_profile_storage( + std::filesystem::path root, + std::string partition, + uint64_t quota_bytes); + ~browser_profile_storage(); + + browser_profile_storage(const browser_profile_storage&) = delete; + browser_profile_storage& operator=(const browser_profile_storage&) = delete; + + bool available() const noexcept; + profile_storage_status open_status() const noexcept; + profile_storage_result load_sync(); + profile_storage_result store_sync(const browser_profile_state& state); + profile_storage_result clear_sync(uint32_t flags); + + void replace_cookies(std::vector cookies); + void replace_local_storage(std::string origin, profile_local_storage storage); + void clear_local_storage_origin(const std::string& origin); + browser_profile_state snapshot() const; + + static profile_storage_result clear_partition_sync( + const std::filesystem::path& root, + const std::string& partition, + uint64_t quota_bytes, + uint32_t flags); + +private: + struct lock_handle; + void schedule_write_locked(); + void run(std::stop_token token); + std::filesystem::path partition_path() const; + std::filesystem::path profile_path() const; + + std::filesystem::path root_; + std::string partition_; + uint64_t quota_bytes_{0}; + profile_storage_status open_status_{profile_storage_status::unavailable}; + std::unique_ptr lock_; + mutable std::mutex mutex_; + std::condition_variable_any wake_; + browser_profile_state state_; + uint64_t admitted_generation_{0}; + uint64_t committed_generation_{0}; + bool write_pending_{false}; + std::jthread worker_; +}; + +} // namespace webscene_native diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index d716e7e6d..9485fc7f6 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -24,6 +24,7 @@ #include "webscene_performance_timeline_compatibility.h" #include "webscene_file_reader_compatibility.h" #include "webscene_indexeddb_storage.h" +#include "webscene_profile_storage.h" #include "webscene_indexeddb_compatibility.h" #include "webscene_stylesheet_cssom_compatibility.h" #include "webscene_secure_random.h" @@ -1147,6 +1148,130 @@ struct v8_dom_runtime::implementation final { return storage; } + static bool require_storage_access( + const v8::FunctionCallbackInfo& info, + session_storage_state* storage) + { + if (storage == nullptr) return false; + if (storage->accessible) return true; + throw_dom_exception( + info, + "Storage is unavailable for an opaque origin", + "SecurityError"); + return false; + } + + static uint64_t storage_usage( + const session_storage_state& storage, + const std::string* replacement_key = nullptr, + const std::string* replacement_value = nullptr) + { + uint64_t total = 0U; + for (const auto& key : storage.keys) { + const auto known = storage.values.find(key); + if (known == storage.values.end()) continue; + const auto value_size = replacement_key != nullptr && key == *replacement_key + ? replacement_value->size() : known->second.size(); + const auto addition = static_cast(key.size()) + value_size; + if (total > std::numeric_limits::max() - addition) { + return std::numeric_limits::max(); + } + total += addition; + } + if (replacement_key != nullptr && !storage.values.contains(*replacement_key)) { + const auto addition = static_cast(replacement_key->size()) + + replacement_value->size(); + if (total > std::numeric_limits::max() - addition) { + return std::numeric_limits::max(); + } + total += addition; + } + return total; + } + + static void persist_local_storage(const session_storage_state& storage) + { + if (!storage.local || storage.origin.empty() || storage.origin == "null") return; + const auto persistence = storage.persistence.lock(); + if (!persistence) return; + profile_local_storage durable; + durable.keys = storage.keys; + durable.values = storage.values; + persistence->replace_local_storage(storage.origin, std::move(durable)); + } + + void dispatch_storage_event( + session_storage_state* storage, + v8::Local source, + const std::optional& key, + const std::optional& old_value, + const std::optional& new_value) + { + if (storage == nullptr || !storage->local) return; + std::string source_url = storage->origin; + { + v8::Context::Scope source_scope(source); + v8::Local location; + v8::Local href; + v8::Local href_text; + if (source->Global()->Get( + source, js_string(isolate, "location")).ToLocal(&location) + && location->IsObject() + && location.As()->Get( + source, js_string(isolate, "href")).ToLocal(&href) + && href->ToString(source).ToLocal(&href_text)) { + v8::String::Utf8Value utf8(isolate, href_text); + if (*utf8 != nullptr) source_url.assign(*utf8, utf8.length()); + } + } + std::vector> recipients; + const auto admit = [&](v8::Local candidate) { + if (candidate.IsEmpty() || candidate == source) return; + v8::Context::Scope scope(candidate); + v8::Local area; + if (!candidate->Global()->Get( + candidate, js_string(isolate, "localStorage")).ToLocal(&area) + || !area->IsObject() + || unwrap_session_storage(area.As()) != storage) return; + recipients.push_back(candidate); + }; + if (!context.IsEmpty()) admit(context.Get(isolate)); + for (auto& [id, frame] : actual_frame_contexts) { + static_cast(id); + if (!frame.IsEmpty()) admit(frame.Get(isolate)); + } + for (const auto recipient : recipients) { + v8::Context::Scope scope(recipient); + auto global = recipient->Global(); + v8::Local area; + if (!global->Get( + recipient, js_string(isolate, "localStorage")).ToLocal(&area)) continue; + auto event = create_window_event(recipient, "storage"); + event->Set(recipient, js_string(isolate, "key"), key.has_value() + ? v8::Local(js_dom_string(isolate, *key)) + : v8::Local(v8::Null(isolate))).Check(); + event->Set(recipient, js_string(isolate, "oldValue"), old_value.has_value() + ? v8::Local(js_dom_string(isolate, *old_value)) + : v8::Local(v8::Null(isolate))).Check(); + event->Set(recipient, js_string(isolate, "newValue"), new_value.has_value() + ? v8::Local(js_dom_string(isolate, *new_value)) + : v8::Local(v8::Null(isolate))).Check(); + event->Set( + recipient, + js_string(isolate, "url"), + js_string(isolate, source_url.c_str())).Check(); + event->Set(recipient, js_string(isolate, "storageArea"), area).Check(); + v8::Local dispatcher; + if (!global->Get( + recipient, js_string(isolate, "dispatchEvent")).ToLocal(&dispatcher) + || !dispatcher->IsFunction()) continue; + v8::TryCatch ignored(isolate); + v8::Local arguments[]{event}; + static_cast(dispatcher.As()->Call( + recipient, global, 1, arguments)); + } + } + static bool storage_string( v8::Isolate* isolate, v8::Local value, @@ -1171,7 +1296,7 @@ struct v8_dom_runtime::implementation final { current(info.GetIsolate())->record_feature( "web-api", "Storage.getItem", "supported", {}, "web-api-binding"); auto* storage = require_session_storage(info); - if (storage == nullptr) return; + if (!require_storage_access(info, storage)) return; std::string key; if (!storage_string( info.GetIsolate(), @@ -1188,7 +1313,7 @@ struct v8_dom_runtime::implementation final { current(info.GetIsolate())->record_feature( "web-api", "Storage.setItem", "supported", {}, "web-api-binding"); auto* storage = require_session_storage(info); - if (storage == nullptr) return; + if (!require_storage_access(info, storage)) return; std::string key; std::string value; if (!storage_string( @@ -1199,8 +1324,28 @@ struct v8_dom_runtime::implementation final { info.GetIsolate(), info.Length() > 1 ? info[1] : v8::Undefined(info.GetIsolate()), value)) return; + if (storage_usage(*storage, &key, &value) > storage->quota_bytes) { + throw_dom_exception( + info, + "The Web Storage quota was exceeded", + "QuotaExceededError"); + return; + } + std::optional old_value; + if (const auto known = storage->values.find(key); + known != storage->values.end()) { + if (known->second == value) return; + old_value = known->second; + } if (!storage->values.contains(key)) storage->keys.push_back(key); storage->values[key] = value; + persist_local_storage(*storage); + current(info.GetIsolate())->dispatch_storage_event( + storage, + info.GetIsolate()->GetCurrentContext(), + key, + old_value, + value); } static void session_storage_remove_item(const v8::FunctionCallbackInfo& info) @@ -1208,14 +1353,24 @@ struct v8_dom_runtime::implementation final { current(info.GetIsolate())->record_feature( "web-api", "Storage.removeItem", "supported", {}, "web-api-binding"); auto* storage = require_session_storage(info); - if (storage == nullptr) return; + if (!require_storage_access(info, storage)) return; std::string key; if (!storage_string( info.GetIsolate(), info.Length() > 0 ? info[0] : v8::Undefined(info.GetIsolate()), key)) return; - if (storage->values.erase(key) == 0U) return; + const auto known = storage->values.find(key); + if (known == storage->values.end()) return; + const auto old_value = known->second; + storage->values.erase(known); std::erase(storage->keys, key); + persist_local_storage(*storage); + current(info.GetIsolate())->dispatch_storage_event( + storage, + info.GetIsolate()->GetCurrentContext(), + key, + old_value, + std::nullopt); } static void session_storage_clear(const v8::FunctionCallbackInfo& info) @@ -1223,9 +1378,17 @@ struct v8_dom_runtime::implementation final { current(info.GetIsolate())->record_feature( "web-api", "Storage.clear", "supported", {}, "web-api-binding"); auto* storage = require_session_storage(info); - if (storage == nullptr) return; + if (!require_storage_access(info, storage)) return; + if (storage->keys.empty()) return; storage->keys.clear(); storage->values.clear(); + persist_local_storage(*storage); + current(info.GetIsolate())->dispatch_storage_event( + storage, + info.GetIsolate()->GetCurrentContext(), + std::nullopt, + std::nullopt, + std::nullopt); } static void session_storage_key(const v8::FunctionCallbackInfo& info) @@ -1233,7 +1396,7 @@ struct v8_dom_runtime::implementation final { current(info.GetIsolate())->record_feature( "web-api", "Storage.key", "supported", {}, "web-api-binding"); auto* storage = require_session_storage(info); - if (storage == nullptr) return; + if (!require_storage_access(info, storage)) return; const auto context = info.GetIsolate()->GetCurrentContext(); const auto maybe_index = (info.Length() > 0 ? info[0] @@ -1253,11 +1416,17 @@ struct v8_dom_runtime::implementation final { current(info.GetIsolate())->record_feature( "web-api", "Storage.length", "supported", {}, "web-api-binding"); auto* storage = unwrap_session_storage(info.Holder()); - if (storage != nullptr) { - info.GetReturnValue().Set(v8::Integer::NewFromUnsigned( + if (storage == nullptr) return; + if (!storage->accessible) { + throw_dom_exception( info.GetIsolate(), - static_cast(storage->keys.size()))); + "Storage is unavailable for an opaque origin", + "SecurityError"); + return; } + info.GetReturnValue().Set(v8::Integer::NewFromUnsigned( + info.GetIsolate(), + static_cast(storage->keys.size()))); } v8::Local create_session_storage( diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc index 90db43a9c..d2ad518f8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc @@ -1209,7 +1209,18 @@ auto& frame_storage = frame_session_storage.try_emplace( frame.id, std::make_unique()).first->second; + auto& frame_local = frame_local_storage.try_emplace( + frame.id, + std::make_unique()).first->second; const auto same_origin_access = iframe_owner_allows_same_origin_access(frame); + frame_local->local = true; + frame_local->origin = same_origin_access + ? outer_storage_origin : resource_origin(frame_base_addresses[&frame_body]); + frame_local->quota_bytes = storage_quota_bytes == 0U + ? 5U * 1024U * 1024U + : std::min(storage_quota_bytes, 5U * 1024U * 1024U); + frame_local->accessible = !frame_local->origin.empty() + && frame_local->origin != "null"; global->Set( local_context, js_string(isolate, "sessionStorage"), @@ -1219,7 +1230,7 @@ js_string(isolate, "localStorage"), create_session_storage( local_context, - same_origin_access ? outer_local_storage : *frame_storage)).Check(); + same_origin_access ? outer_local_storage : *frame_local)).Check(); actual_frame_windows[frame.id].Reset(isolate, global); actual_frame_contexts[frame.id].Reset(isolate, local_context); actual_frame_documents[frame.id].Reset(isolate, frame_document_value); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 162936f3e..f7870ce61 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -70,11 +70,10 @@ } static void throw_dom_exception( - const v8::FunctionCallbackInfo& info, + v8::Isolate* isolate, const char* message, const char* name) { - auto* isolate = info.GetIsolate(); auto local_context = isolate->GetCurrentContext(); auto exception = v8::Object::New(isolate); exception->Set( @@ -105,6 +104,14 @@ isolate->ThrowException(exception); } + static void throw_dom_exception( + const v8::FunctionCallbackInfo& info, + const char* message, + const char* name) + { + throw_dom_exception(info.GetIsolate(), message, name); + } + static void attach_shadow(const v8::FunctionCallbackInfo& info) { auto* self = current(info.GetIsolate()); @@ -1265,6 +1272,7 @@ actual_frame_windows.erase(id); actual_frame_documents.erase(id); frame_session_storage.erase(id); + frame_local_storage.erase(id); frame_load_listeners.erase(id); frame_dom_content_loaded_listeners.erase(id); } @@ -1820,6 +1828,7 @@ frame_hydration_states.erase(id); actual_frame_documents.erase(id); frame_session_storage.erase(id); + frame_local_storage.erase(id); frame_load_listeners.erase(id); frame_dom_content_loaded_listeners.erase(id); window_selections.erase(id); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc index a564f42d1..313841dc8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc @@ -88,9 +88,59 @@ , profile_css(std::getenv("WEBSCENE_PROBE_PROFILE_CSS") != nullptr) #endif { + outer_local_storage.local = true; file_grant_releases = std::make_shared(); file_grant_releases->sink = file_grant_release_request_sink; if (!storage_directory.empty() && !storage_partition_key.empty()) { + profile_storage = std::make_shared( + std::filesystem::path(storage_directory), + storage_partition_key, + storage_quota_bytes); + if (profile_storage->available()) { + const auto loaded = profile_storage->snapshot(); + const auto now = std::chrono::system_clock::now(); + { + std::lock_guard lock(cookie_jar->mutex); + cookie_jar->persistence = profile_storage; + for (const auto& durable : loaded.cookies) { + const auto expiry = std::chrono::system_clock::from_time_t( + static_cast(durable.expiry_unix_seconds)); + if (expiry <= now) continue; + stored_cookie cookie; + cookie.name = durable.name; + cookie.value = durable.value; + cookie.domain = durable.domain; + cookie.path = durable.path; + cookie.expires = expiry; + cookie.creation_order = durable.creation_order; + cookie.persistent = true; + cookie.host_only = durable.host_only; + cookie.secure = durable.secure; + cookie.http_only = durable.http_only; + cookie.same_site = static_cast(durable.same_site); + cookie_jar->next_creation_order = std::max( + cookie_jar->next_creation_order, + cookie.creation_order + 1U); + cookie_jar->cookies.push_back(std::move(cookie)); + } + } + const auto local_quota = storage_quota_bytes == 0U + ? 5U * 1024U * 1024U + : std::min(storage_quota_bytes, 5U * 1024U * 1024U); + for (const auto& [origin, durable] : loaded.local_storage) { + session_storage_state storage; + storage.keys = durable.keys; + storage.values = durable.values; + storage.persistence = profile_storage; + storage.origin = origin; + storage.quota_bytes = local_quota; + storage.local = true; + storage.accessible = !origin.empty() && origin != "null"; + outer_local_storage_by_origin.emplace(origin, std::move(storage)); + } + } else { + profile_storage.reset(); + } indexeddb = std::make_unique( std::filesystem::path(storage_directory), storage_partition_key, @@ -112,6 +162,7 @@ stop_workers(); // The storage worker never touches V8. Join it before persistent // resolver handles and the isolate are released. + profile_storage.reset(); indexeddb.reset(); #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_MEDIA) clear_media_bindings(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index 6b6157e3e..c6e8ee670 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -57,6 +57,17 @@ outer_local_storage_by_origin.erase(known); } } + const auto local_quota = storage_quota_bytes == 0U + ? 5U * 1024U * 1024U + : std::min(storage_quota_bytes, 5U * 1024U * 1024U); + outer_local_storage.persistence = profile_storage; + outer_local_storage.origin = next_origin; + outer_local_storage.quota_bytes = local_quota; + outer_local_storage.local = true; + outer_local_storage.accessible = !next_origin.empty() && next_origin != "null"; + outer_session_storage.origin = next_origin; + outer_session_storage.local = false; + outer_session_storage.accessible = !next_origin.empty() && next_origin != "null"; outer_storage_origin = next_origin; } @@ -220,6 +231,7 @@ frame_base_addresses.clear(); provisional_frame_bodies.clear(); frame_session_storage.clear(); + frame_local_storage.clear(); canvas_contexts.clear(); canvas_states.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc index 9d13197da..4f4d04769 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_resources.inc @@ -240,6 +240,32 @@ return std::chrono::system_clock::from_time_t(seconds); } + void persist_cookie_jar_locked() + { + const auto persistence = cookie_jar->persistence.lock(); + if (!persistence) return; + const auto now = std::chrono::system_clock::now(); + std::vector durable; + durable.reserve(cookie_jar->cookies.size()); + for (const auto& cookie : cookie_jar->cookies) { + if (!cookie.persistent || cookie.expires <= now) continue; + profile_cookie value; + value.name = cookie.name; + value.value = cookie.value; + value.domain = cookie.domain; + value.path = cookie.path; + value.expiry_unix_seconds = static_cast( + std::chrono::system_clock::to_time_t(cookie.expires)); + value.creation_order = cookie.creation_order; + value.host_only = cookie.host_only; + value.secure = cookie.secure; + value.http_only = cookie.http_only; + value.same_site = static_cast(cookie.same_site); + durable.push_back(std::move(value)); + } + persistence->replace_cookies(std::move(durable)); + } + void store_cookie( const std::string& response_url, const std::string& line, @@ -335,6 +361,7 @@ if (!from_http && known != cookie_jar->cookies.end() && known->http_only) return; if (cookie.persistent && cookie.expires <= std::chrono::system_clock::now()) { if (known != cookie_jar->cookies.end()) cookie_jar->cookies.erase(known); + persist_cookie_jar_locked(); return; } if (known != cookie_jar->cookies.end()) { @@ -352,6 +379,7 @@ } cookie_jar->cookies.push_back(std::move(cookie)); } + persist_cookie_jar_locked(); } std::string cookie_header_for( @@ -374,9 +402,10 @@ std::vector matches; const auto now = std::chrono::system_clock::now(); std::lock_guard lock(cookie_jar->mutex); - std::erase_if(cookie_jar->cookies, [now](const auto& cookie) { + const auto expired = std::erase_if(cookie_jar->cookies, [now](const auto& cookie) { return cookie.persistent && cookie.expires <= now; }); + if (expired != 0U) persist_cookie_jar_locked(); for (const auto& cookie : cookie_jar->cookies) { if (script_visible && cookie.http_only) continue; if (cookie.secure && target.scheme != "https") continue; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 5dc331948..cea2c6b37 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -98,6 +98,7 @@ std::string storage_partition_key; uint64_t storage_quota_bytes{0}; std::unique_ptr indexeddb; + std::shared_ptr profile_storage; std::mutex indexeddb_completion_mutex; std::deque indexeddb_completions; std::unordered_map @@ -184,6 +185,7 @@ std::unordered_map outer_local_storage_by_origin; std::unordered_map> frame_session_storage; + std::unordered_map> frame_local_storage; std::vector current_document_start_scripts; std::unordered_map> frame_load_listeners; std::unordered_map>> diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc index 03a3c892a..0b88b48cf 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state_types.inc @@ -395,6 +395,11 @@ struct session_storage_state final { std::vector keys; std::unordered_map values; + std::weak_ptr persistence; + std::string origin; + uint64_t quota_bytes{5U * 1024U * 1024U}; + bool local{false}; + bool accessible{true}; }; struct canvas_path_segment final { @@ -686,6 +691,7 @@ std::mutex mutex; std::vector cookies; uint64_t next_creation_order{1U}; + std::weak_ptr persistence; }; struct pending_fetch_task final { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_frame_scheduling_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_frame_scheduling_tests.inc index 5b9c97a66..6034b7b97 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_frame_scheduling_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_frame_scheduling_tests.inc @@ -1277,6 +1277,11 @@ void test_session_storage_in_outer_and_frame_contexts(webscene_engine* engine) + 'outerValue: sessionStorage.getItem("view"),' + 'initialLength: sessionStorage.length,' + 'localTheme: localStorage.getItem("theme")};' + + 'globalThis.__storageEvent = null;' + + 'addEventListener("storage", event => {' + + 'globalThis.__storageEvent = {' + + 'key: event.key, oldValue: event.oldValue, newValue: event.newValue,' + + 'url: event.url, sameArea: event.storageArea === localStorage};});' + 'sessionStorage.setItem("go-to-date", "month");' + 'localStorage.setItem("layout", "compact");' + 'globalThis.__sessionStorageResult.value = sessionStorage.getItem("go-to-date");' @@ -1299,6 +1304,14 @@ void test_session_storage_in_outer_and_frame_contexts(webscene_engine* engine) "localStorage.getItem('layout')", "native-local-storage-frame-sharing.js") == R"("compact")", "same-origin localStorage did not remain shared between outer and frame contexts"); + const auto storage_event = evaluate(engine, R"JS((() => { + localStorage.setItem('event-key', 'before'); + localStorage.setItem('event-key', 'after'); + return globalThis.__sessionStorageFrame.contentWindow.__storageEvent; + })())JS", "native-local-storage-event.js"); + require( + storage_event == R"({"key":"event-key","oldValue":"before","newValue":"after","url":"http://127.0.0.1/","sameArea":true})", + "same-origin localStorage event delivery regressed: " + storage_event); const auto coercion = evaluate(engine, R"JS( (() => { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_response_cookie_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_response_cookie_tests.inc index 5fc38f0b1..c5b1a0b3a 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_response_cookie_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_response_cookie_tests.inc @@ -185,6 +185,171 @@ void test_response_header_cookie_contracts() webscene_engine_destroy(engine); } +struct durable_profile_server final { + std::atomic phase{1U}; + std::mutex mutex; + std::string navigation_cookie; +}; + +size_t load_durable_profile_resource_v4( + void* user_data, + uint32_t, + const char* url, + size_t url_length, + const char*, + size_t, + int64_t, + const webscene_resource_request_context_v4* context, + webscene_resource_response_v4* response, + char* destination, + size_t destination_capacity) +{ + auto& server = *static_cast(user_data); + const auto first = server.phase.load(std::memory_order_acquire) == 1U; + static constexpr std::string_view first_document = R"HTML( + )HTML"; + static constexpr std::string_view second_document = R"HTML( + )HTML"; + static constexpr char persistent_cookie[] = + "durable=secret; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600"; + static constexpr char session_cookie[] = + "session=discarded; Path=/; Secure; HttpOnly; SameSite=Lax"; + static const webscene_resource_header_v4 headers[]{ + {sizeof(webscene_resource_header_v4), "Set-Cookie", 10U, + persistent_cookie, sizeof(persistent_cookie) - 1U}, + {sizeof(webscene_resource_header_v4), "Set-Cookie", 10U, + session_cookie, sizeof(session_cookie) - 1U}}; + if (context != nullptr) { + std::lock_guard lock(server.mutex); + server.navigation_cookie.assign( + context->cookie == nullptr ? "" : context->cookie, + context->cookie_length); + } + if (response != nullptr) { + response->status = 200U; + response->status_text = "OK"; + response->status_text_length = 2U; + response->final_url = url; + response->final_url_length = url_length; + response->headers = first ? headers : nullptr; + response->header_count = first ? std::size(headers) : 0U; + } + const auto body = first ? first_document : second_document; + constexpr size_t header_size = 2U + sizeof(uint32_t) + sizeof(int64_t) * 2U; + const auto required = header_size + body.size(); + if (destination == nullptr || destination_capacity < required) return required; + std::memset(destination, 0, header_size); + destination[0] = 1; + std::copy(body.begin(), body.end(), destination + header_size); + return required; +} + +void test_durable_profile_restart_contract() +{ + const auto storage_directory = std::filesystem::temp_directory_path() + / ("webscene-durable-profile-test-" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(storage_directory); + const auto root = storage_directory.string(); + const std::string partition = "native-restart/default"; + durable_profile_server server; + webscene_engine_options options{}; + options.struct_size = sizeof(options); + options.resource_load_callback_v4 = load_durable_profile_resource_v4; + options.resource_load_v4_user_data = &server; + options.storage_directory = root.data(); + options.storage_directory_length = root.size(); + options.storage_partition_key = partition.data(); + options.storage_partition_key_length = partition.size(); + options.storage_quota_bytes = 4U * 1024U * 1024U; + constexpr std::string_view address{"https://profile.test/index.html"}; + auto* engine = webscene_engine_create_with_options(&options); + require(engine != nullptr, "durable profile writer engine creation failed"); + require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U, + "durable profile writer navigation failed"); + require(evaluate(engine, "globalThis.__durableProfile", "profile-writer.js") + == R"({"ready":true,"quota":true})", + "durable profile writer did not initialize state"); + webscene_engine_destroy(engine); + + server.phase.store(2U, std::memory_order_release); + { + std::lock_guard lock(server.mutex); + server.navigation_cookie.clear(); + } + engine = webscene_engine_create_with_options(&options); + require(engine != nullptr, "durable profile reader engine creation failed"); + require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U, + "durable profile reader navigation failed"); + const auto restored = evaluate( + engine, "globalThis.__durableProfile", "profile-reader.js"); + require(restored.find(R"("local":"retained")") != std::string::npos + && restored.find(R"("session":null)") != std::string::npos + && restored.find("visible=retained") != std::string::npos + && restored.find("temporary=discarded") == std::string::npos + && restored.find("doomed=discarded") == std::string::npos, + "durable/local versus session restart contract failed: " + restored); + { + std::lock_guard lock(server.mutex); + require(server.navigation_cookie.find("durable=secret") != std::string::npos + && server.navigation_cookie.find("visible=retained") != std::string::npos + && server.navigation_cookie.find("session=discarded") == std::string::npos + && server.navigation_cookie.find("temporary=discarded") == std::string::npos, + "persistent/session cookie restart contract failed"); + } + webscene_engine_destroy(engine); + + const std::string isolated_partition = "native-restart/isolated"; + options.storage_partition_key = isolated_partition.data(); + options.storage_partition_key_length = isolated_partition.size(); + { + std::lock_guard lock(server.mutex); + server.navigation_cookie.clear(); + } + engine = webscene_engine_create_with_options(&options); + require(engine != nullptr, "isolated profile engine creation failed"); + require(webscene_engine_load_url(engine, address.data(), address.size()) != 0U, + "isolated profile navigation failed"); + const auto isolated = evaluate( + engine, "globalThis.__durableProfile", "profile-isolated.js"); + require(isolated.find(R"("local":null)") != std::string::npos + && isolated.find(R"("session":null)") != std::string::npos, + "profile partition observed another partition's localStorage: " + isolated); + { + std::lock_guard lock(server.mutex); + require(server.navigation_cookie.empty(), + "profile partition observed another partition's cookies"); + } + webscene_engine_destroy(engine); + + require(webscene_profile_clear_data_v1( + root.data(), root.size(), partition.data(), partition.size(), + options.storage_quota_bytes, + WEBSCENE_PROFILE_CLEAR_ALL_SITE_DATA_V1) + == WEBSCENE_PROFILE_STATUS_OK_V1, + "durable profile clear-data contract failed"); + std::error_code cleanup_error; + std::filesystem::remove_all(storage_directory, cleanup_error); +} + struct single_flight_cookie_server final { std::atomic target_requests{0U}; }; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 275ceacf8..7f024a706 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -153,6 +153,14 @@ int main() test_indexeddb_runtime_contract(); return 0; } + if (selected == "web-storage") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "Web Storage focused engine creation failed"); + test_session_storage_in_outer_and_frame_contexts(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if (selected == "navigation-realm") { test_navigation_replaces_top_level_realm(); return 0; @@ -391,6 +399,7 @@ int main() if (selected == "resource-failure-diagnostics") { test_resource_failure_diagnostics(); return 0; } if (selected == "response-header-cookie") { test_response_header_cookie_contracts(); + test_durable_profile_restart_contract(); test_parallel_resource_prefetch(); test_fetch_carries_document_origin_to_resource_host(); test_tradingview_save_acknowledgement_uses_multipart_post(); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/profile_storage_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/profile_storage_tests.cpp new file mode 100644 index 000000000..f6c96f290 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/profile_storage_tests.cpp @@ -0,0 +1,322 @@ +#include "webscene_profile_storage.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#else +#include +#include +#endif + +namespace { + +using namespace webscene_native; + +void require(bool condition, const char* message) +{ + if (!condition) throw std::runtime_error(message); +} + +class temporary_directory final { +public: + temporary_directory() + : path_(std::filesystem::temp_directory_path() + / ("webscene-profile-" + std::to_string( + std::chrono::steady_clock::now().time_since_epoch().count()))) + { + std::filesystem::create_directories(path_); + } + ~temporary_directory() + { + std::error_code error; + std::filesystem::remove_all(path_, error); + } + const std::filesystem::path& path() const noexcept { return path_; } +private: + std::filesystem::path path_; +}; + +profile_cookie persistent_cookie(std::string value) +{ + profile_cookie cookie; + cookie.name = "sid"; + cookie.value = std::move(value); + cookie.domain = "example.test"; + cookie.path = "/"; + cookie.expiry_unix_seconds = 4102444800LL; + cookie.creation_order = 1U; + cookie.secure = true; + cookie.http_only = true; + cookie.same_site = profile_cookie_same_site::strict; + return cookie; +} + +void test_round_trip_and_partitioning() +{ + temporary_directory root; + { + browser_profile_storage storage(root.path(), "application/default", 4U * 1024U * 1024U); + require(storage.available(), "profile did not open"); + storage.replace_cookies({persistent_cookie("secret")}); + profile_local_storage local; + local.keys = {"theme", "empty"}; + local.values = {{"theme", "dark"}, {"empty", ""}}; + storage.replace_local_storage("https://example.test", std::move(local)); + } + { + browser_profile_storage storage(root.path(), "application/default", 4U * 1024U * 1024U); + const auto state = storage.snapshot(); + require(state.cookies.size() == 1U + && state.cookies.front().value == "secret" + && state.cookies.front().domain == "example.test" + && state.cookies.front().path == "/" + && state.cookies.front().expiry_unix_seconds == 4102444800LL + && state.cookies.front().creation_order == 1U + && state.cookies.front().host_only + && state.cookies.front().secure + && state.cookies.front().http_only + && state.cookies.front().same_site == profile_cookie_same_site::strict, + "persistent cookie metadata did not survive restart"); + const auto known = state.local_storage.find("https://example.test"); + require(known != state.local_storage.end() + && known->second.keys == std::vector({"theme", "empty"}) + && known->second.values.at("theme") == "dark", + "localStorage order or values did not survive restart"); + } + browser_profile_storage isolated(root.path(), "application/other", 4U * 1024U * 1024U); + require(isolated.snapshot().cookies.empty() + && isolated.snapshot().local_storage.empty(), + "another profile observed durable state"); +} + +void test_interrupted_temporary_write_is_ignored() +{ + temporary_directory root; + std::filesystem::path profile_file; + { + browser_profile_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + storage.replace_cookies({persistent_cookie("committed")}); + } + for (const auto& entry : std::filesystem::recursive_directory_iterator(root.path())) { + if (entry.path().extension() == ".wsprofile") profile_file = entry.path(); + } + require(!profile_file.empty(), "committed profile fixture was not created"); + std::ofstream(profile_file.string() + ".interrupted.tmp", std::ios::binary) + << "partial-next-generation"; + browser_profile_storage reopened(root.path(), "profile", 4U * 1024U * 1024U); + require(reopened.snapshot().cookies.size() == 1U + && reopened.snapshot().cookies.front().value == "committed", + "an interrupted temporary write displaced the last committed profile"); +} + +void test_concurrent_open_is_rejected() +{ + temporary_directory root; + browser_profile_storage first(root.path(), "profile", 4U * 1024U * 1024U); + browser_profile_storage second(root.path(), "profile", 4U * 1024U * 1024U); + require(first.available(), "first profile did not open"); + require(!second.available() + && second.open_status() == profile_storage_status::busy, + "concurrent profile open was not rejected"); +} + +void test_corruption_recovers_on_next_commit() +{ + temporary_directory root; + std::filesystem::path profile_file; + { + browser_profile_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + storage.replace_cookies({persistent_cookie("first")}); + } + for (const auto& entry : std::filesystem::recursive_directory_iterator(root.path())) { + if (entry.path().extension() == ".wsprofile") profile_file = entry.path(); + } + require(!profile_file.empty(), "profile file was not created"); + { + std::ofstream truncated(profile_file, std::ios::binary | std::ios::trunc); + truncated << "broken"; + } + { + browser_profile_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + require(storage.available() && storage.snapshot().cookies.empty(), + "corrupt profile did not recover to an empty in-memory state"); + storage.replace_cookies({persistent_cookie("recovered")}); + } + browser_profile_storage reopened(root.path(), "profile", 4U * 1024U * 1024U); + require(reopened.snapshot().cookies.size() == 1U + && reopened.snapshot().cookies.front().value == "recovered", + "atomic commit did not replace corrupt profile"); +} + +void test_scoped_clear() +{ + temporary_directory root; + { + browser_profile_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + storage.replace_cookies({persistent_cookie("secret")}); + profile_local_storage local; + local.keys = {"key"}; + local.values = {{"key", "value"}}; + storage.replace_local_storage("https://example.test", std::move(local)); + } + auto cleared = browser_profile_storage::clear_partition_sync( + root.path(), "profile", 4U * 1024U * 1024U, profile_clear_cookies); + require(cleared.status == profile_storage_status::ok, + "cookie-only profile clear failed"); + { + browser_profile_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + require(storage.snapshot().cookies.empty() + && storage.snapshot().local_storage.size() == 1U, + "cookie-only clear crossed the requested data boundary"); + } + cleared = browser_profile_storage::clear_partition_sync( + root.path(), "profile", 4U * 1024U * 1024U, + profile_clear_local_storage); + require(cleared.status == profile_storage_status::ok, + "localStorage-only profile clear failed"); + { + browser_profile_storage storage(root.path(), "profile", 4U * 1024U * 1024U); + require(storage.snapshot().cookies.empty() + && storage.snapshot().local_storage.empty(), + "localStorage-only clear crossed the requested data boundary"); + } + std::filesystem::path indexeddb_file; + for (const auto& entry : std::filesystem::directory_iterator(root.path())) { + if (!entry.is_directory()) continue; + indexeddb_file = entry.path() / "origin" / "database.wsidb"; + std::filesystem::create_directories(indexeddb_file.parent_path()); + std::ofstream(indexeddb_file, std::ios::binary) << "indexeddb"; + break; + } + require(!indexeddb_file.empty() && std::filesystem::exists(indexeddb_file), + "IndexedDB clear fixture was not created"); + cleared = browser_profile_storage::clear_partition_sync( + root.path(), "profile", 4U * 1024U * 1024U, + profile_clear_all_site_data); + require(cleared.status == profile_storage_status::ok + && !std::filesystem::exists(indexeddb_file), + "all-site-data clear did not remove IndexedDB data in the partition"); +} + +int run_child(const std::string& executable, const char* mode, const std::filesystem::path& root) +{ + const auto root_text = root.string(); +#if defined(_WIN32) + return static_cast(_spawnl( + _P_WAIT, executable.c_str(), executable.c_str(), mode, + root_text.c_str(), nullptr)); +#else + const auto process = ::fork(); + if (process == 0) { + ::execl(executable.c_str(), executable.c_str(), mode, + root_text.c_str(), static_cast(nullptr)); + _exit(127); + } + if (process < 0) return -1; + int status = 0; + return ::waitpid(process, &status, 0) == process && WIFEXITED(status) + ? WEXITSTATUS(status) : -1; +#endif +} + +void test_two_process_restart(const std::string& executable) +{ + temporary_directory root; + require(run_child(executable, "--write-child", root.path()) == 0, + "process A could not commit the browser profile"); + require(run_child(executable, "--read-child", root.path()) == 0, + "process B could not read the committed browser profile"); +} + +size_t descriptor_count() +{ +#if defined(_WIN32) + return 0U; +#else + std::error_code error; + const std::filesystem::path descriptors{"/dev/fd"}; + if (!std::filesystem::is_directory(descriptors, error)) return 0U; + return static_cast(std::distance( + std::filesystem::directory_iterator(descriptors, error), + std::filesystem::directory_iterator{})); +#endif +} + +void test_repeated_close_and_growth_bounds() +{ + temporary_directory root; + const auto descriptors_before = descriptor_count(); + const auto started = std::chrono::steady_clock::now(); + for (size_t cycle = 0U; cycle < 50U; ++cycle) { + browser_profile_storage storage(root.path(), "cycles/default", 4U * 1024U * 1024U); + require(storage.available(), "profile cycle could not acquire its lock"); + profile_local_storage local; + local.keys = {"cycle"}; + local.values = {{"cycle", std::to_string(cycle)}}; + storage.replace_local_storage("https://cycles.test", std::move(local)); + } + const auto elapsed = std::chrono::steady_clock::now() - started; + require(elapsed < std::chrono::seconds(5), + "repeated profile checkpoints exceeded the close-latency budget"); + const auto descriptors_after = descriptor_count(); + require(descriptors_before == 0U || descriptors_after <= descriptors_before + 2U, + "profile cycles leaked file descriptors"); + uint64_t stored_bytes = 0U; + size_t temporary_files = 0U; + for (const auto& entry : std::filesystem::recursive_directory_iterator(root.path())) { + if (!entry.is_regular_file()) continue; + stored_bytes += entry.file_size(); + if (entry.path().extension() == ".tmp") ++temporary_files; + } + require(stored_bytes < 4U * 1024U * 1024U && temporary_files == 0U, + "profile cycles leaked temporary files or exceeded the on-disk bound"); +} + +} // namespace + +int main(int argc, char** argv) +{ + try { + if (argc == 3 && std::string_view(argv[1]) == "--write-child") { + browser_profile_storage storage(argv[2], "two-process/default", 4U * 1024U * 1024U); + if (!storage.available()) return 2; + storage.replace_cookies({persistent_cookie("cross-process")}); + profile_local_storage local; + local.keys = {"project"}; + local.values = {{"project", "restored"}}; + storage.replace_local_storage("https://process.test", std::move(local)); + return 0; + } + if (argc == 3 && std::string_view(argv[1]) == "--read-child") { + browser_profile_storage storage(argv[2], "two-process/default", 4U * 1024U * 1024U); + const auto state = storage.snapshot(); + const auto origin = state.local_storage.find("https://process.test"); + return state.cookies.size() == 1U + && state.cookies.front().value == "cross-process" + && origin != state.local_storage.end() + && origin->second.values.at("project") == "restored" + ? 0 : 3; + } + test_round_trip_and_partitioning(); + test_concurrent_open_is_rejected(); + test_corruption_recovers_on_next_commit(); + test_interrupted_temporary_write_is_ignored(); + test_scoped_clear(); + test_two_process_restart(std::filesystem::absolute(argv[0]).string()); + test_repeated_close_and_growth_bounds(); + std::cout << "Browser profile storage contracts passed\n"; + return 0; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +}