mirror of
https://github.com/aphrody-labs/cef.git
synced 2026-10-13 21:07:07 +00:00
installer: Add bootstrap installer for Windows (see #4093)
Add a shared CEF installer that downloads, verifies, and manages CEF binary distributions on Windows. The installer is embedded in application bootstrap binaries and resolves the correct CEF version from a CDN manifest based on each application's version and ABI compatibility requirements. Key components: - CDN manifest parsing and version resolution - WinHTTP-based download with SHA-1/SHA-256 integrity verification - Authenticode catalog (.cat) signature verification - Parallel XZ decompression for fast archive extraction - Per-version metadata (cef_version.json) with full version tracking - Shared installation database for multi-app version management - Version pruning to reclaim disk space from unused versions - Revocation list support (compiled-in baseline + CDN delta) - Progress UI with WM_COPYDATA-based cancellation - Bundled CEF fallback for offline/CDN-failure scenarios
This commit is contained in:
1 parent
7324f016a3
commit
be20927a8d
202 files changed
+65465
-142
No files matched your search
@@ -333,6 +333,7 @@ group("cef") {
|
||||
":cefsimple_dll",
|
||||
":cefsimple_capi_dll",
|
||||
":ceftests_dll",
|
||||
"libcef_dll/bootstrap/installer:installer_tests",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -383,6 +384,7 @@ if (is_win) {
|
||||
|
||||
# Required by chrome_switches.cc
|
||||
"//chrome/common:buildflags",
|
||||
"//extensions/buildflags:buildflags",
|
||||
"//printing/buildflags:buildflags",
|
||||
"//ui/base:buildflags",
|
||||
|
||||
@@ -452,6 +454,33 @@ test("libcef_static_unittests") {
|
||||
]
|
||||
}
|
||||
|
||||
if (is_win) {
|
||||
# Certificate utilities used by installer_signature and bootstrap.
|
||||
# Separated to avoid duplicate symbol errors when both depend on these.
|
||||
source_set("cef_certificate_util") {
|
||||
sources = [
|
||||
"include/wrapper/cef_certificate_util_win.h",
|
||||
"include/wrapper/cef_util_win.h",
|
||||
"libcef_dll/wrapper/cef_certificate_util_win.cc",
|
||||
"libcef_dll/wrapper/cef_util_win.cc",
|
||||
]
|
||||
|
||||
deps = [ "//base" ]
|
||||
|
||||
libs = [
|
||||
"wintrust.lib",
|
||||
"crypt32.lib",
|
||||
]
|
||||
|
||||
defines = [ "CEF_BUILD_BOOTSTRAP" ]
|
||||
|
||||
configs += [
|
||||
"libcef/features:config",
|
||||
"//build/config:precompiled_headers",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Source files that are linked into libcef and cef_framework and implement
|
||||
# interfaces exposed to ceftests via the include/test directory. These sources
|
||||
# may access Chromium/CEF internals. This is defined as a separate target from
|
||||
@@ -1319,15 +1348,11 @@ if (is_mac) {
|
||||
if (is_win) {
|
||||
bootstrap_sources = includes_common +
|
||||
includes_win + [
|
||||
"include/wrapper/cef_certificate_util_win.h",
|
||||
"include/wrapper/cef_util_win.h",
|
||||
"libcef_dll/bootstrap/bootstrap_util_win.cc",
|
||||
"libcef_dll/bootstrap/bootstrap_util_win.h",
|
||||
"libcef_dll/bootstrap/bootstrap_win.cc",
|
||||
"libcef_dll/bootstrap/win/bootstrap.rc",
|
||||
"libcef_dll/bootstrap/win/resource.h",
|
||||
"libcef_dll/wrapper/cef_certificate_util_win.cc",
|
||||
"libcef_dll/wrapper/cef_util_win.cc",
|
||||
"libcef/browser/crashpad_runner.cc",
|
||||
"libcef/browser/crashpad_runner.h",
|
||||
"libcef/browser/preferred_stack_size_win.inc",
|
||||
@@ -1337,12 +1362,12 @@ if (is_win) {
|
||||
"//chrome/common/win/delay_load_failure_support.h",
|
||||
"//content/app/sandbox_helper_win.cc",
|
||||
"//content/public/app/sandbox_helper_win.h",
|
||||
|
||||
]
|
||||
|
||||
bootstrap_deps = [
|
||||
":make_api_versions_header",
|
||||
":make_config_header",
|
||||
":make_version_header",
|
||||
":cef_certificate_util",
|
||||
"libcef_dll/bootstrap/installer:cef_installer",
|
||||
"//base",
|
||||
"//build/win:default_exe_manifest",
|
||||
"//chrome/install_static:secondary_module",
|
||||
@@ -1373,7 +1398,12 @@ if (is_win) {
|
||||
testonly = true
|
||||
|
||||
sources = bootstrap_sources
|
||||
deps = bootstrap_deps
|
||||
deps = bootstrap_deps + [
|
||||
# Trigger generation of installer_config.json/rc so that the files exist
|
||||
# for distributions (automate-git.py builds bootstrap but not cefclient_dll).
|
||||
# The files are not embedded in this binary.
|
||||
"libcef_dll/bootstrap/installer:cef_installer_config"
|
||||
]
|
||||
libs = bootstrap_libs
|
||||
configs += bootstrap_configs
|
||||
|
||||
@@ -2550,10 +2580,15 @@ if (is_mac) {
|
||||
gypi_paths2.cefclient_sources_common +
|
||||
gypi_paths2.cefclient_sources_renderer +
|
||||
gypi_paths2.cefclient_sources_win +
|
||||
gypi_paths2.cefclient_sources_resources_win_rc
|
||||
gypi_paths2.cefclient_sources_resources_win_rc + [
|
||||
# Generated installer config resource (embedded in client DLL,
|
||||
# read by bootstrap.exe).
|
||||
"$target_gen_dir/installer_config.rc",
|
||||
]
|
||||
|
||||
deps = [
|
||||
":bootstrap",
|
||||
"libcef_dll/bootstrap/installer:cef_installer_config",
|
||||
":libcef",
|
||||
":libcef_dll_wrapper",
|
||||
]
|
||||
|
||||
@@ -368,6 +368,8 @@
|
||||
'tests/cefclient/browser/browser_window_osr_win.h',
|
||||
'tests/cefclient/browser/browser_window_std_win.cc',
|
||||
'tests/cefclient/browser/browser_window_std_win.h',
|
||||
'tests/cefclient/browser/client_update_win.cc',
|
||||
'tests/cefclient/browser/client_update_win.h',
|
||||
'tests/cefclient/browser/main_context_impl_win.cc',
|
||||
'tests/cefclient/browser/main_message_loop_multithreaded_win.cc',
|
||||
'tests/cefclient/browser/main_message_loop_multithreaded_win.h',
|
||||
|
||||
@@ -85,6 +85,52 @@ typedef struct _cef_version_info_t {
|
||||
///
|
||||
char sandbox_compat_hash[17];
|
||||
#endif
|
||||
|
||||
#if CEF_API_ADDED(CEF_NEXT)
|
||||
///
|
||||
/// Path to libcef.dll provided by the bootstrap installer (Windows only).
|
||||
/// Null if not using the installer or on other platforms.
|
||||
/// Owned by the bootstrap executable, valid for the lifetime of the process.
|
||||
/// Always nullptr when populated via cef_version_info_all().
|
||||
///
|
||||
const wchar_t* libcef_path;
|
||||
|
||||
///
|
||||
/// Non-zero if libcef_path points to a bundled CEF directory (shipped with
|
||||
/// the application) rather than a CDN-installed version. When non-zero,
|
||||
/// libcef.dll may be signed with the client application's certificate rather
|
||||
/// than the CEF distribution certificate.
|
||||
/// Always zero when populated via cef_version_info_all().
|
||||
///
|
||||
int libcef_is_bundled;
|
||||
|
||||
///
|
||||
/// Full version string from installer metadata (e.g.,
|
||||
/// "150.0.1+gabc1234+chromium-150.0.7871.4"). Used to verify that the
|
||||
/// loaded libcef.dll matches the version the installer resolved.
|
||||
/// Null if not using the installer or on other platforms.
|
||||
/// Owned by the bootstrap executable, valid for the lifetime of the process.
|
||||
/// Always nullptr when populated via cef_version_info_all().
|
||||
///
|
||||
const char* libcef_version_full;
|
||||
|
||||
///
|
||||
/// Structured installer startup error code. A resolved startup has a
|
||||
/// non-null libcef_path and value zero. Installer-not-configured has a null
|
||||
/// libcef_path and value zero. A recoverable resolution failure has a null
|
||||
/// libcef_path and a non-zero value with installer_error_message populated.
|
||||
/// Always zero when populated via cef_version_info_all().
|
||||
///
|
||||
int installer_error_code;
|
||||
|
||||
///
|
||||
/// UTF-8 diagnostic for a recoverable installer resolution failure, or null
|
||||
/// otherwise. Owned by bootstrap process-lifetime storage and must not be
|
||||
/// freed or retained beyond that lifetime. Always nullptr when populated via
|
||||
/// cef_version_info_all().
|
||||
///
|
||||
const char* installer_error_message;
|
||||
#endif
|
||||
} cef_version_info_t;
|
||||
|
||||
#if CEF_API_ADDED(14600)
|
||||
@@ -97,6 +143,23 @@ typedef struct _cef_version_info_t {
|
||||
sizeof(((cef_version_info_t*)0)->sandbox_compat_hash))
|
||||
#endif
|
||||
|
||||
#if CEF_API_ADDED(CEF_NEXT)
|
||||
///
|
||||
/// Minimum struct size required to contain the installer startup error fields.
|
||||
/// Used for version compatibility checks.
|
||||
///
|
||||
#define CEF_VERSION_INFO_SIZE_WITH_INSTALLER_ERROR \
|
||||
(offsetof(cef_version_info_t, installer_error_message) + \
|
||||
sizeof(((cef_version_info_t*)0)->installer_error_message))
|
||||
|
||||
///
|
||||
/// Return the full CEF version string for the libcef library (e.g.,
|
||||
/// "143.0.3+gcbafc77+chromium-143.0.7499.0"). The returned pointer is valid
|
||||
/// for the lifetime of the process.
|
||||
///
|
||||
CEF_EXPORT const char* cef_version_full(void);
|
||||
#endif
|
||||
|
||||
///
|
||||
/// Return all CEF version information for the libcef library.
|
||||
///
|
||||
@@ -132,6 +195,20 @@ typedef struct _cef_version_info_t {
|
||||
#define _CEF_POPULATE_SANDBOX_HASH(info)
|
||||
#endif
|
||||
|
||||
// Helper macro for setting bootstrap-only installer fields. By default all are
|
||||
// NULL/0. The bootstrap can override these after calling
|
||||
// CEF_POPULATE_VERSION_INFO.
|
||||
#if CEF_API_ADDED(CEF_NEXT)
|
||||
#define _CEF_POPULATE_LIBCEF_PATH(info) \
|
||||
(info)->libcef_path = NULL; \
|
||||
(info)->libcef_is_bundled = 0; \
|
||||
(info)->libcef_version_full = NULL; \
|
||||
(info)->installer_error_code = 0; \
|
||||
(info)->installer_error_message = NULL
|
||||
#else
|
||||
#define _CEF_POPULATE_LIBCEF_PATH(info)
|
||||
#endif
|
||||
|
||||
///
|
||||
/// Populate CEF version information for the client library.
|
||||
///
|
||||
@@ -145,7 +222,8 @@ typedef struct _cef_version_info_t {
|
||||
(info)->chrome_version_minor = CHROME_VERSION_MINOR; \
|
||||
(info)->chrome_version_build = CHROME_VERSION_BUILD; \
|
||||
(info)->chrome_version_patch = CHROME_VERSION_PATCH; \
|
||||
_CEF_POPULATE_SANDBOX_HASH(info)
|
||||
_CEF_POPULATE_SANDBOX_HASH(info); \
|
||||
_CEF_POPULATE_LIBCEF_PATH(info)
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -206,15 +206,22 @@ class CefScopedLibraryLoader final {
|
||||
|
||||
///
|
||||
/// Load the CEF library (libcef.dll) in the main process from the specified
|
||||
/// absolute path. If libcef.dll is code signed then all signatures must be
|
||||
/// valid. If |thumbprint| is a SHA1 hash (e.g. 40 character upper-case
|
||||
/// hex-encoded value) then the primary signature must match that thumbprint.
|
||||
/// If |allow_unsigned| is true and |thumbprint| is nullptr then libcef.dll
|
||||
/// may be unsigned, otherwise it must be validly signed. Failure of code
|
||||
/// signing requirements or DLL loading will result in a FATAL error and
|
||||
/// application termination. If |version_info| is specified then the
|
||||
/// libcef.dll version information must also match. Returns true if the load
|
||||
/// succeeds. Usage must be protected by cef::logging::ScopedEarlySupport.
|
||||
/// absolute path. The library is loaded using LOAD_WITH_ALTERED_SEARCH_PATH,
|
||||
/// which ensures that libcef.dll's dependencies are loaded from the same
|
||||
/// directory as libcef.dll rather than from the application directory. If
|
||||
/// libcef.dll is code signed then all signatures must be valid. If
|
||||
/// |thumbprint| is a SHA1 hash (e.g. 40 character upper-case hex-encoded
|
||||
/// value) then the primary signature must match that thumbprint. If
|
||||
/// |allow_unsigned| is true and |thumbprint| is nullptr then libcef.dll may
|
||||
/// be unsigned, otherwise it must be validly signed. Failure of code signing
|
||||
/// requirements or DLL loading will result in a FATAL error and application
|
||||
/// termination. If |version_info| is specified then the libcef.dll version
|
||||
/// information must also match. Returns true if the load succeeds. Usage must
|
||||
/// be protected by cef::logging::ScopedEarlySupport. Bootstrap clients
|
||||
/// should size-check and handle installer_error_code and
|
||||
/// installer_error_message before calling this method so they can select an
|
||||
/// adjacent fallback or present cancellation guidance instead of immediately
|
||||
/// terminating.
|
||||
///
|
||||
bool LoadInMainAssert(const wchar_t* dll_path,
|
||||
const char* thumbprint,
|
||||
|
||||
@@ -60,6 +60,14 @@ std::vector<std::wstring> ParseCommandLineArgs(const wchar_t* str);
|
||||
std::wstring GetCommandLineValue(const std::vector<std::wstring>& command_line,
|
||||
const std::wstring& name);
|
||||
|
||||
///
|
||||
/// Read binary data from a named RT_RCDATA resource.
|
||||
/// Returns true if successful and populates |data|, false otherwise.
|
||||
///
|
||||
bool ReadResourceData(HMODULE module,
|
||||
const wchar_t* resource_name,
|
||||
std::string* data);
|
||||
|
||||
} // namespace cef_util
|
||||
|
||||
#endif // CEF_INCLUDE_WRAPPER_CEF_UTIL_WIN_H_
|
||||
@@ -0,0 +1,84 @@
|
||||
# CEF Bootstrap Contributor Guidance
|
||||
|
||||
The Windows bootstrap is a process-integration layer. Changes under
|
||||
`installer/` must also follow [installer/AGENTS.md](installer/AGENTS.md) and
|
||||
the authoritative documents referenced there.
|
||||
|
||||
## Directory ownership
|
||||
|
||||
- `bootstrap_win.cc` owns executable startup, process classification, sandbox
|
||||
integration, module validation and loading, installer dispatch, and the
|
||||
final handoff to the client DLL.
|
||||
- `bootstrap_util_win.*` owns reusable module-name and module-path rules. It is
|
||||
compiled into both the bootstrap executables and `libcef`; changes to its
|
||||
switches, default names, or validation rules must remain compatible across
|
||||
those consumers.
|
||||
- `win/*` owns the bootstrap executable's Windows resources.
|
||||
- `installer/*` owns installer behavior and has additional contributor
|
||||
guidance in `installer/AGENTS.md`.
|
||||
|
||||
## Startup and security invariants
|
||||
|
||||
- Keep sandboxed subprocess startup separate from unsandboxed startup.
|
||||
Sandboxed processes may already be locked down and must not gain calls to
|
||||
Win32 APIs that are only safe before sandbox initialization.
|
||||
- Preserve the client-DLL loading sequence: load without executing code,
|
||||
validate the resolved path and signatures, allow installer inspection of
|
||||
trusted resources, unload, and only then perform the normal executable load.
|
||||
Never execute client DLL code from the untrusted load.
|
||||
- Preserve fail-closed module-path, executable, client-DLL, and
|
||||
`chrome_elf.dll` validation. Do not weaken these checks to support a new
|
||||
launch layout; extend the trusted layout rules explicitly.
|
||||
- Keep browser-process validation authoritative for sandboxed subprocesses.
|
||||
Subprocesses consume the validated module name propagated by the browser
|
||||
process and must not independently reinterpret untrusted path input.
|
||||
|
||||
## Compatibility and lifetime
|
||||
|
||||
- `bootstrap.exe` and `bootstrapc.exe` share `bootstrap_win.cc`. Keep their
|
||||
behavior aligned except for intentional GUI/console entry-point differences.
|
||||
- Changes to `bootstrap_util::switches::kModule` or the default bootstrap
|
||||
executable names require corresponding `libcef` compatibility and rebuild
|
||||
consideration.
|
||||
- Keep installer startup state, version strings, launch-health data, and any
|
||||
installed-version lease alive through the complete `RunWinMain` call.
|
||||
- Preserve size-gated compatibility for structures passed across the
|
||||
bootstrap/client DLL boundary. Update the corresponding version tests when
|
||||
fields or ownership rules change.
|
||||
|
||||
## Installer boundary
|
||||
|
||||
- `bootstrap_win.cc` owns executable startup, process integration, CEF launch
|
||||
orchestration, and calls through the public `cef_installer` interface.
|
||||
- `bootstrap_win.cc` must not reference `cef_installer::internal` or
|
||||
`cef_installer::test`. If it needs installer behavior, add or extend a
|
||||
narrowly scoped public `cef_installer` operation that owns the complete
|
||||
installer-specific decision.
|
||||
- Do not promote a low-level helper solely to bypass this boundary. Keep
|
||||
parsing, policy, validation, mutation, lifecycle, and testing-bypass
|
||||
decisions inside the installer library.
|
||||
- Bootstrap code may apply a test-only behavior only through a guarded public
|
||||
installer operation. It must not manipulate an installer test seam directly.
|
||||
- `bootstrap_win.cc` must not include an installer `*_test_support.h` header or
|
||||
call an installer `*ForTesting` API.
|
||||
|
||||
Before finishing a change to `bootstrap_win.cc`, this command must produce no
|
||||
matches:
|
||||
|
||||
```powershell
|
||||
rg -n "cef_installer::(internal|test)::|installer/.*_test_support\.h|cef_installer::.*ForTesting" `
|
||||
cef/libcef_dll/bootstrap/bootstrap_win.cc
|
||||
```
|
||||
|
||||
## Documentation and verification
|
||||
|
||||
- [docs/sandbox_setup.md](../../docs/sandbox_setup.md) is the public reference
|
||||
for bootstrap/client integration. Update it when client requirements,
|
||||
exported entry points, module selection, or deployment layout changes.
|
||||
- Build `cef`, `bootstrap`, and `bootstrapc` after changing shared bootstrap
|
||||
code. Let Ninja determine the affected objects.
|
||||
- Run `VersionTest.*` after changing bootstrap/client version information or
|
||||
size-gated structures.
|
||||
- Follow `installer/TESTING.md` for the required installer unit, integration,
|
||||
and E2E coverage when installer dispatch, resolution, lifecycle, or
|
||||
launch-health behavior changes.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -4,15 +4,18 @@
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
#include "base/auto_reset.h"
|
||||
#include "base/command_line.h"
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/format_macros.h"
|
||||
#include "base/functional/bind.h"
|
||||
#include "base/hash/sha1.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/no_destructor.h"
|
||||
@@ -31,6 +34,16 @@
|
||||
#include "cef/libcef/browser/crashpad_runner.h"
|
||||
#include "cef/libcef/browser/preferred_stack_size_win.inc"
|
||||
#include "cef/libcef_dll/bootstrap/bootstrap_util_win.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_bootstrap_helpers.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_controller.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_launch_state.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_lifecycle.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_progress_dialog.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_relaunch.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_signature.h"
|
||||
#include "cef/libcef_dll/bootstrap/win/resource.h"
|
||||
#include "chrome/app/delay_load_failure_hook_win.h"
|
||||
#include "chrome/chrome_elf/chrome_elf_main.h"
|
||||
@@ -262,6 +275,506 @@ struct DllLoadResult {
|
||||
base::FilePath used_path;
|
||||
};
|
||||
|
||||
// Creates a progress dialog and returns the progress callback that drives it.
|
||||
// The caller must keep the returned unique_ptr alive for the duration of the
|
||||
// operation. Returns nullptr dialog if show_progress_ui is false.
|
||||
//
|
||||
// The dialog is created but not shown until the progress callback receives a
|
||||
// step indicating real work (CDN download or later). This avoids a brief
|
||||
// dialog flash when a local/bundled CEF version resolves instantly.
|
||||
std::pair<std::unique_ptr<cef_installer::ProgressDialog>,
|
||||
cef_installer::ProgressCallback>
|
||||
CreateProgressUI(const cef_installer::ExtendedConfig& extended) {
|
||||
std::unique_ptr<cef_installer::ProgressDialog> dialog;
|
||||
cef_installer::ProgressCallback callback;
|
||||
|
||||
if (extended.show_progress_ui) {
|
||||
dialog =
|
||||
std::make_unique<cef_installer::ProgressDialog>(extended.parent_window);
|
||||
}
|
||||
|
||||
if (extended.show_progress_ui || extended.parent_window) {
|
||||
auto shown = std::make_shared<bool>(false);
|
||||
auto parent_cancel_pending = std::make_shared<bool>(false);
|
||||
callback = base::BindRepeating(
|
||||
[](cef_installer::ProgressDialog* dlg, HWND parent,
|
||||
const std::shared_ptr<bool>& shown,
|
||||
const std::shared_ptr<bool>& parent_cancel_pending,
|
||||
cef_installer::Step step, uint64_t bytes_done,
|
||||
uint64_t bytes_total) -> bool {
|
||||
const bool committing = step == cef_installer::kStepCommitting;
|
||||
if (dlg && !*shown && step >= cef_installer::kStepCdnResolve) {
|
||||
dlg->Show();
|
||||
*shown = true;
|
||||
}
|
||||
if (dlg) {
|
||||
dlg->SetCancellationDeferred(committing);
|
||||
dlg->SetStep(step);
|
||||
int percent = cef_installer::CalculateOverallProgress(
|
||||
step, bytes_done, bytes_total);
|
||||
dlg->SetProgress(percent);
|
||||
if (!committing && dlg->WasCancelled()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (parent) {
|
||||
const bool continue_requested = cef_installer::SendProgressToParent(
|
||||
parent, step, bytes_done, bytes_total);
|
||||
if (committing && !continue_requested) {
|
||||
*parent_cancel_pending = true;
|
||||
} else if (!committing &&
|
||||
(!continue_requested || *parent_cancel_pending)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
dialog.get(), extended.parent_window, shown, parent_cancel_pending);
|
||||
}
|
||||
|
||||
return {std::move(dialog), std::move(callback)};
|
||||
}
|
||||
|
||||
// Closes the progress dialog, showing an error dialog first on failure.
|
||||
void CloseProgressUI(cef_installer::ProgressDialog* dialog,
|
||||
const cef_installer::Result& result) {
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
if (!result.success &&
|
||||
result.error_code != cef_installer::kExitCodeCancelled) {
|
||||
dialog->ShowErrorDialog(result.error_code);
|
||||
}
|
||||
dialog->Close();
|
||||
}
|
||||
|
||||
bool IsSuccessOrNeutralExitCode(int exit_code) {
|
||||
switch (exit_code) {
|
||||
case CEF_RESULT_CODE_NORMAL_EXIT:
|
||||
case CEF_RESULT_CODE_PROFILE_IN_USE:
|
||||
case CEF_RESULT_CODE_NORMAL_EXIT_PROCESS_NOTIFIED:
|
||||
case CEF_RESULT_CODE_NORMAL_EXIT_PACK_EXTENSION_SUCCESS:
|
||||
case CEF_RESULT_CODE_NORMAL_EXIT_AUTO_DE_ELEVATED:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void WriteRetentionOutput(const cef_installer::Result& result,
|
||||
bool json_output) {
|
||||
std::string output =
|
||||
cef_installer::FormatRetentionOutput(result, json_output);
|
||||
HANDLE stdout_handle = ::GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
if (stdout_handle && stdout_handle != INVALID_HANDLE_VALUE &&
|
||||
!cef_installer::WriteAllToHandle(stdout_handle, output)) {
|
||||
PLOG(ERROR) << "Failed to write retention output";
|
||||
}
|
||||
}
|
||||
|
||||
// Run the installer for explicit commands (/cef-update, /cef-uninstall) or
|
||||
// auto-install. Returns:
|
||||
// - Exit code >= 0 if the process should exit (installer ran and completed)
|
||||
// - -1 if the process should continue normally (no installer action taken)
|
||||
// - Populates |startup_state| with configured/success/failure and launch
|
||||
// health state owned through RunWinMain.
|
||||
int MaybeRunInstaller(const base::CommandLine& command_line,
|
||||
const base::FilePath& exe_path,
|
||||
HMODULE client_dll_module,
|
||||
cef_installer::InstallerStartupState* startup_state) {
|
||||
const bool client_dll_exists = (client_dll_module != nullptr);
|
||||
|
||||
// Parse installer command from command line (/cef-update, /cef-uninstall)
|
||||
std::optional<cef_installer::Command> explicit_command =
|
||||
cef_installer::ParseInstallerCommand(command_line);
|
||||
if (cef_installer::HasConflictingInstallerCommands(command_line)) {
|
||||
LOG(ERROR) << "Conflicting explicit installer commands";
|
||||
return cef_installer::kExitCodeConfigError;
|
||||
}
|
||||
|
||||
// Establish the minimal trusted relaunch context before loading config so
|
||||
// controlled child-side config failures can still report a terminal result.
|
||||
auto uninstall_context = cef_installer::ResolveUninstallInvocationContext(
|
||||
command_line, exe_path, explicit_command);
|
||||
if (!uninstall_context) {
|
||||
LOG(ERROR) << "Invalid or untrusted uninstall relaunch context";
|
||||
return cef_installer::kExitCodeConfigError;
|
||||
}
|
||||
const cef_installer::UninstallInvocationState uninstall_invocation =
|
||||
uninstall_context->invocation;
|
||||
const cef_installer::UninstallRelaunchState& trusted_uninstall_state =
|
||||
uninstall_context->relaunch_state;
|
||||
const std::optional<cef_installer::UninstallLifecycleContext>&
|
||||
lifecycle_context = trusted_uninstall_state.lifecycle_context;
|
||||
cef_installer::Config config;
|
||||
const cef_installer::ConfigLoadResult config_load =
|
||||
cef_installer::TryLoadInstallerConfig(
|
||||
client_dll_module, &config, nullptr,
|
||||
uninstall_invocation ==
|
||||
cef_installer::UninstallInvocationState::kRelaunched);
|
||||
*startup_state =
|
||||
cef_installer::MakeInstallerStartupState(config_load, config);
|
||||
const bool retention_command =
|
||||
explicit_command == cef_installer::Command::kRetentionDryRun ||
|
||||
explicit_command == cef_installer::Command::kRetentionApply;
|
||||
if (cef_installer::HasMisappliedRetentionOptions(command_line,
|
||||
explicit_command)) {
|
||||
LOG(ERROR) << "cef-max-age-days is valid only with a retention command";
|
||||
return cef_installer::FinalizeControlledUninstallFailure(
|
||||
lifecycle_context,
|
||||
cef_installer::ControlledUninstallFailure::kInvalidRetentionOptions);
|
||||
}
|
||||
int retention_max_age_days = cef_installer::kDefaultRetentionMaxAgeDays;
|
||||
if (retention_command && !cef_installer::ParseRetentionMaxAgeDays(
|
||||
command_line, &retention_max_age_days)) {
|
||||
LOG(ERROR) << "cef-max-age-days must be an integer from 90 through 3650";
|
||||
return cef_installer::FinalizeControlledUninstallFailure(
|
||||
lifecycle_context,
|
||||
cef_installer::ControlledUninstallFailure::kInvalidRetentionAge);
|
||||
}
|
||||
|
||||
// All installer paths require a config. Return early if none is available.
|
||||
if (config_load.status == cef_installer::ConfigLoadStatus::kError) {
|
||||
LOG(ERROR) << startup_state->error_message;
|
||||
return cef_installer::FinalizeControlledUninstallFailure(
|
||||
lifecycle_context,
|
||||
cef_installer::ControlledUninstallFailure::kConfigLoad);
|
||||
}
|
||||
if (config_load.status == cef_installer::ConfigLoadStatus::kNotFound) {
|
||||
if (explicit_command.has_value()) {
|
||||
LOG(ERROR) << "Installer command requested but config not found";
|
||||
return cef_installer::FinalizeControlledUninstallFailure(
|
||||
lifecycle_context,
|
||||
cef_installer::ControlledUninstallFailure::kConfigNotFound);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (explicit_command.has_value()) {
|
||||
VLOG(1) << "Explicit installer command detected";
|
||||
|
||||
// Block explicit commands when enable_explicit_modes is not set.
|
||||
// In non-official builds, allow explicit commands for development/testing.
|
||||
#if defined(OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
if (!config.enable_explicit_modes) {
|
||||
LOG(ERROR) << "Explicit installer commands require enable_explicit_modes";
|
||||
return cef_installer::FinalizeControlledUninstallFailure(
|
||||
lifecycle_context,
|
||||
cef_installer::ControlledUninstallFailure::kExplicitModeDisabled);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Set lower process priority for background mode (standalone installer
|
||||
// only)
|
||||
if (command_line.HasSwitch(cef_installer::kSwitchBackground)) {
|
||||
SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve relative paths in config against the client DLL's directory.
|
||||
// These fields are only parsed from the client DLL's embedded resource,
|
||||
// so they will be empty in standalone mode (no client DLL).
|
||||
if (client_dll_module &&
|
||||
(!config.unchecked_cef_path.empty() || !config.bundled_cef_path.empty() ||
|
||||
!config.install_path.empty())) {
|
||||
wchar_t module_path_buf[MAX_PATH];
|
||||
DWORD len =
|
||||
::GetModuleFileNameW(client_dll_module, module_path_buf, MAX_PATH);
|
||||
base::FilePath client_dll_dir;
|
||||
if (len > 0 && len < MAX_PATH) {
|
||||
client_dll_dir = base::FilePath(module_path_buf).DirName();
|
||||
}
|
||||
|
||||
if (!config.unchecked_cef_path.empty()) {
|
||||
if (client_dll_dir.empty()) {
|
||||
config.unchecked_cef_path.clear();
|
||||
} else {
|
||||
base::FilePath resolved = cef_installer::ResolvePathRelativeTo(
|
||||
config.unchecked_cef_path, client_dll_dir);
|
||||
if (resolved.empty()) {
|
||||
config.unchecked_cef_path.clear();
|
||||
} else {
|
||||
// Validate the resolved path for reparse points. Use the client
|
||||
// DLL's directory as trust anchor when the path is under it (the
|
||||
// common case, e.g., "."). For absolute paths outside the client
|
||||
// DLL's directory, use the resolved directory's parent so that at
|
||||
// minimum the directory and libcef.dll are checked.
|
||||
base::FilePath libcef_candidate =
|
||||
resolved.Append(cef_installer::kLibcefFilename);
|
||||
base::FilePath trusted_root =
|
||||
client_dll_dir.IsParent(libcef_candidate) ? client_dll_dir
|
||||
: resolved.DirName();
|
||||
if (base::PathExists(libcef_candidate) &&
|
||||
!cef_installer::IsPathSafeForLoading(trusted_root,
|
||||
libcef_candidate)) {
|
||||
LOG(ERROR) << "Reparse point detected in unchecked_cef_path: "
|
||||
<< libcef_candidate.value();
|
||||
config.unchecked_cef_path.clear();
|
||||
} else {
|
||||
config.unchecked_cef_path = resolved.AsUTF8Unsafe();
|
||||
VLOG(1) << "Resolved unchecked_cef_path to "
|
||||
<< config.unchecked_cef_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.bundled_cef_path.empty()) {
|
||||
if (client_dll_dir.empty()) {
|
||||
config.bundled_cef_path.clear();
|
||||
} else {
|
||||
base::FilePath resolved = cef_installer::ResolvePathRelativeTo(
|
||||
config.bundled_cef_path, client_dll_dir);
|
||||
if (resolved.empty()) {
|
||||
config.bundled_cef_path.clear();
|
||||
} else {
|
||||
config.bundled_cef_path = resolved.AsUTF8Unsafe();
|
||||
VLOG(1) << "Resolved bundled_cef_path to " << config.bundled_cef_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cef_installer::ResolveClientInstallPath(client_dll_module, &config)) {
|
||||
LOG(ERROR) << "Failed to resolve client-resource install_path";
|
||||
} else if (!config.install_path.empty()) {
|
||||
VLOG(1) << "Resolved install_path to " << config.install_path;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse extended config once, shared by all command paths below.
|
||||
cef_installer::ExtendedConfig extended;
|
||||
cef_installer::ParseExtendedConfigFromCommandLine(command_line, &extended);
|
||||
extended.retention_max_age_days = retention_max_age_days;
|
||||
|
||||
// The selected application config supplies CDN URLs only when no
|
||||
// operation-specific list or local mirror was provided.
|
||||
if (extended.cdn_urls.empty() && extended.local_download_path.empty() &&
|
||||
!config.cdn_urls.empty()) {
|
||||
extended.cdn_urls = config.cdn_urls;
|
||||
}
|
||||
|
||||
// Propagate bundled_cef_path from Config (client DLL resource) into
|
||||
// ExtendedConfig so the controller's version selection considers it.
|
||||
if (extended.bundled_cef_path.empty() && !config.bundled_cef_path.empty()) {
|
||||
extended.bundled_cef_path = config.bundled_cef_path;
|
||||
}
|
||||
if (extended.install_path.empty() && !config.install_path.empty()) {
|
||||
extended.install_path = config.install_path;
|
||||
}
|
||||
|
||||
if (uninstall_invocation ==
|
||||
cef_installer::UninstallInvocationState::kRelaunched) {
|
||||
extended.install_path = trusted_uninstall_state.install_path;
|
||||
}
|
||||
|
||||
cef_installer::ApplyConfigThumbprintOverride(config, &extended);
|
||||
|
||||
if (explicit_command == cef_installer::Command::kRetentionDryRun ||
|
||||
explicit_command == cef_installer::Command::kRetentionApply) {
|
||||
// Retention is terminal maintenance. It never registers the invoking app
|
||||
// and never continues into resolved client-DLL execution.
|
||||
extended.show_progress_ui = false;
|
||||
cef_installer::Controller controller;
|
||||
cef_installer::Result result =
|
||||
controller.Run(*explicit_command, config, extended);
|
||||
WriteRetentionOutput(
|
||||
result, command_line.HasSwitch(cef_installer::kSwitchHeadless));
|
||||
return cef_installer::ResultToExitCode(result);
|
||||
}
|
||||
|
||||
if (explicit_command == cef_installer::Command::kUninstall) {
|
||||
auto run_prepared_uninstall =
|
||||
[&](const cef_installer::PreparedUninstall& prepared) {
|
||||
auto [dialog, progress] = CreateProgressUI(extended);
|
||||
cef_installer::Controller controller;
|
||||
cef_installer::Result result = controller.RunPreparedUninstall(
|
||||
config, extended, prepared, progress);
|
||||
LOG(INFO) << "Uninstall completed: outcome="
|
||||
<< cef_installer::OutcomeToString(result.outcome)
|
||||
<< " success=" << result.success;
|
||||
for (const auto& warning : result.warnings) {
|
||||
LOG(WARNING) << "Uninstall warning: " << warning;
|
||||
}
|
||||
return cef_installer::FinalizeUninstallLifecycleAfterProgressUi(
|
||||
lifecycle_context, result,
|
||||
base::BindOnce(
|
||||
[](cef_installer::ProgressDialog* dialog,
|
||||
const cef_installer::Result* result) {
|
||||
CloseProgressUI(dialog, *result);
|
||||
},
|
||||
base::Unretained(dialog.get()), base::Unretained(&result)));
|
||||
};
|
||||
|
||||
// A trusted relaunched child always prepares a fresh controller snapshot
|
||||
// and runs terminally in-process. It can never launch another child.
|
||||
if (uninstall_invocation ==
|
||||
cef_installer::UninstallInvocationState::kRelaunched) {
|
||||
VLOG(1) << "Running relaunched uninstall from temp directory";
|
||||
auto preflight = cef_installer::PrepareUninstall(
|
||||
config, extended, exe_path, uninstall_invocation);
|
||||
if (!preflight.prepared ||
|
||||
preflight.decision.execution !=
|
||||
cef_installer::UninstallExecution::kInProcess) {
|
||||
LOG(ERROR) << "Relaunched uninstall preflight was rejected";
|
||||
return cef_installer::FinalizeControlledUninstallFailure(
|
||||
lifecycle_context,
|
||||
cef_installer::ControlledUninstallFailure::kPreflightRejected,
|
||||
preflight.decision.error_code);
|
||||
}
|
||||
return run_prepared_uninstall(*preflight.prepared);
|
||||
}
|
||||
|
||||
// The original process resolves the mutation target once before creating
|
||||
// UI, copying files, acquiring a writer lock, or dispatching a controller.
|
||||
auto preflight = cef_installer::PrepareUninstall(config, extended, exe_path,
|
||||
uninstall_invocation);
|
||||
if (!preflight.prepared || preflight.decision.execution ==
|
||||
cef_installer::UninstallExecution::kReject) {
|
||||
LOG(ERROR) << "Uninstall preflight failed";
|
||||
return preflight.decision.error_code;
|
||||
}
|
||||
if (preflight.decision.execution ==
|
||||
cef_installer::UninstallExecution::kRelaunch) {
|
||||
VLOG(1) << "Relaunching uninstall from a temporary directory";
|
||||
const std::wstring relaunch_args =
|
||||
command_line.GetArgumentsString() + L" /" +
|
||||
base::UTF8ToWide(cef_installer::kSwitchUninstallRelaunched);
|
||||
const cef_installer::UninstallRelaunchResult relaunch =
|
||||
cef_installer::CopySelfToTempAndRelaunch(
|
||||
exe_path,
|
||||
config_load.source ==
|
||||
cef_installer::ConfigLoadSource::kClientResource
|
||||
? client_dll_module
|
||||
: nullptr,
|
||||
relaunch_args, extended.install_path, extended.parent_window);
|
||||
if (!relaunch.started()) {
|
||||
LOG(ERROR) << "Required uninstall relaunch failed";
|
||||
}
|
||||
return cef_installer::UninstallRelaunchExitCode(relaunch.status);
|
||||
}
|
||||
return run_prepared_uninstall(*preflight.prepared);
|
||||
}
|
||||
|
||||
// Handle explicit /cef-update
|
||||
if (explicit_command == cef_installer::Command::kUpdate) {
|
||||
VLOG(1) << "Running explicit update command";
|
||||
|
||||
auto [dialog, progress] = CreateProgressUI(extended);
|
||||
cef_installer::Controller controller;
|
||||
cef_installer::Result result = controller.Run(
|
||||
cef_installer::Command::kUpdate, config, extended, progress);
|
||||
CloseProgressUI(dialog.get(), result);
|
||||
cef_installer::ApplyInstallerResultToStartupState(result, config, extended,
|
||||
false, startup_state);
|
||||
|
||||
if (result.error_code == cef_installer::kExitCodePolicyError) {
|
||||
LOG(ERROR) << result.error_message;
|
||||
return result.error_code;
|
||||
}
|
||||
|
||||
if (result.success && !result.libcef_path.empty()) {
|
||||
VLOG(1) << "Update successful, libcef_path="
|
||||
<< result.libcef_path.value();
|
||||
} else {
|
||||
VLOG(1) << "Update completed: success=" << result.success;
|
||||
}
|
||||
|
||||
// When a client DLL is present, fall through to load it with the resolved
|
||||
// path (or empty path on failure — the client DLL handles fallback).
|
||||
// In standalone mode, exit with the installer result code.
|
||||
if (client_dll_exists) {
|
||||
return -1;
|
||||
}
|
||||
return cef_installer::ResultToExitCode(result);
|
||||
}
|
||||
|
||||
// No explicit command — resolve the installed CEF version.
|
||||
// When a client DLL is present, always resolve so the path can be passed
|
||||
// via version_info. In standalone mode (no client DLL), this is auto-install
|
||||
// — gated by enable_explicit_modes in official builds to prevent a renamed
|
||||
// binary from silently downloading CEF.
|
||||
bool should_resolve = client_dll_exists;
|
||||
if (!should_resolve) {
|
||||
#if defined(OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
should_resolve = config.enable_explicit_modes;
|
||||
#else
|
||||
should_resolve = true;
|
||||
#endif
|
||||
}
|
||||
|
||||
if (should_resolve) {
|
||||
VLOG(1) << "Resolving CEF version (client_dll_exists=" << client_dll_exists
|
||||
<< ")";
|
||||
|
||||
auto [dialog, progress] = CreateProgressUI(extended);
|
||||
cef_installer::Controller controller;
|
||||
cef_installer::Result result = controller.Run(
|
||||
cef_installer::Command::kInstall, config, extended, progress,
|
||||
cef_installer::ExecutionContext::kAutomaticStartup);
|
||||
CloseProgressUI(dialog.get(), result);
|
||||
cef_installer::ApplyInstallerResultToStartupState(result, config, extended,
|
||||
true, startup_state);
|
||||
|
||||
if (result.error_code == cef_installer::kExitCodePolicyError) {
|
||||
LOG(ERROR) << result.error_message;
|
||||
return result.error_code;
|
||||
}
|
||||
|
||||
if (result.success && !result.libcef_path.empty()) {
|
||||
VLOG(1) << "CEF resolved, libcef_path=" << result.libcef_path.value();
|
||||
} else if (!client_dll_exists) {
|
||||
// Standalone install failed — exit with error.
|
||||
LOG(ERROR) << "Auto-install failed: " << result.error_message;
|
||||
return cef_installer::ResultToExitCode(result);
|
||||
} else {
|
||||
// Client DLL present but resolution failed — continue anyway.
|
||||
// The client DLL can fall back to its own CEF discovery.
|
||||
VLOG(1) << "CEF resolution failed, client DLL will handle fallback";
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
VLOG(2) << "No installer action needed";
|
||||
return -1;
|
||||
}
|
||||
|
||||
void SetInstallerCrashAnnotations(
|
||||
const cef_installer::InstallerStartupState& state) {
|
||||
static crashpad::StringAnnotation<16> error("bs-install-error");
|
||||
static crashpad::StringAnnotation<1024> message("bs-install-msg");
|
||||
static crashpad::StringAnnotation<64> vmin("bs-install-vmin");
|
||||
static crashpad::StringAnnotation<64> vmax("bs-install-vmax");
|
||||
static crashpad::StringAnnotation<4> policy_denied(
|
||||
"bs-install-policy-denied");
|
||||
static crashpad::StringAnnotation<4> cancelled("bs-install-cancelled");
|
||||
|
||||
const auto values = cef_installer::BuildInstallerCrashAnnotationValues(state);
|
||||
if (!values.has_failure) {
|
||||
error.Clear();
|
||||
message.Clear();
|
||||
vmin.Clear();
|
||||
vmax.Clear();
|
||||
policy_denied.Clear();
|
||||
cancelled.Clear();
|
||||
return;
|
||||
}
|
||||
error.Set(values.error);
|
||||
message.Set(values.message);
|
||||
vmin.Set(values.vmin);
|
||||
vmax.Set(values.vmax);
|
||||
if (values.policy_denied.empty()) {
|
||||
policy_denied.Clear();
|
||||
} else {
|
||||
policy_denied.Set(values.policy_denied);
|
||||
}
|
||||
if (values.cancelled.empty()) {
|
||||
cancelled.Clear();
|
||||
} else {
|
||||
cancelled.Set(values.cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
constexpr DWORD kNormalLoad = 0;
|
||||
|
||||
template <DWORD LoadFlags>
|
||||
@@ -297,14 +810,14 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
// those in the Windows thread pool, and others) whose stack size can only be
|
||||
// controlled via the linker flag.
|
||||
#if defined(CEF_BUILD_BOOTSTRAP_CONSOLE)
|
||||
int exit_code = CefRunMainWithPreferredStackSize(main, argc, argv);
|
||||
int fiber_exit_code = CefRunMainWithPreferredStackSize(main, argc, argv);
|
||||
#else
|
||||
int exit_code = CefRunWinMainWithPreferredStackSize(wWinMain, hInstance,
|
||||
lpCmdLine, nCmdShow);
|
||||
int fiber_exit_code = CefRunWinMainWithPreferredStackSize(
|
||||
wWinMain, hInstance, lpCmdLine, nCmdShow);
|
||||
#endif
|
||||
if (exit_code >= 0) {
|
||||
if (fiber_exit_code >= 0) {
|
||||
// The fiber has completed so return here.
|
||||
return exit_code;
|
||||
return fiber_exit_code;
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -333,6 +846,18 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
return CEF_RESULT_CODE_BAD_PROCESS_TYPE;
|
||||
}
|
||||
|
||||
// Enable VLOG output based on --v flag. The bootstrap doesn't call
|
||||
// CommandLine::Init(), so the logging system can't read verbosity from the
|
||||
// global singleton. Apply it manually.
|
||||
if (command_line.HasSwitch("v")) {
|
||||
int verbose_level = 0;
|
||||
if (base::StringToInt(command_line.GetSwitchValueASCII("v"),
|
||||
&verbose_level) &&
|
||||
verbose_level > 0) {
|
||||
logging::SetMinLogLevel(-verbose_level);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the crashpad handler now instead of waiting for libcef to load.
|
||||
constexpr char kCrashpadHandler[] = "crashpad-handler";
|
||||
if (process_type == kCrashpadHandler) {
|
||||
@@ -356,6 +881,8 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
std::wstring dll_name;
|
||||
base::FilePath exe_path;
|
||||
cef_certificate_util::ThumbprintsInfo exe_thumbprints;
|
||||
// Owns all installer strings and launch-health data through RunWinMain.
|
||||
cef_installer::InstallerStartupState installer_state;
|
||||
|
||||
if (is_sandboxed) {
|
||||
// Running as a sandboxed sub-process. May already be locked down, so we
|
||||
@@ -386,7 +913,15 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
#endif
|
||||
LOG(FATAL) << "Missing module name";
|
||||
}
|
||||
}
|
||||
|
||||
// Subprocesses skip certificate validation, installer logic, and the
|
||||
// untrusted DLL pre-load. The browser process already verified EXE and
|
||||
// DLL signatures before launching child processes, and propagated the
|
||||
// validated module name via AppendExtraCommandLineSwitches. Repeating
|
||||
// three WinVerifyTrust calls per subprocess is redundant and expensive
|
||||
// (~30-300 ms).
|
||||
if (!is_subprocess) {
|
||||
cef_certificate_util::GetClientThumbprints(
|
||||
exe_path.value(), /*verify_binary=*/true, exe_thumbprints);
|
||||
|
||||
@@ -437,10 +972,13 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
// only option that doesn't execute DllMain while still allowing us
|
||||
// retrieve the path using GetModuleFileName. No execution of the DLL
|
||||
// should be attempted while loaded in this mode.
|
||||
const auto result =
|
||||
const auto untrusted_result =
|
||||
LoadClientDll<DONT_RESOLVE_DLL_REFERENCES>(dll_name, exe_path);
|
||||
if (result.module) {
|
||||
const auto& dll_path = bootstrap_util::GetModulePath(result.module);
|
||||
const bool client_dll_exists = (untrusted_result.module != nullptr);
|
||||
|
||||
if (client_dll_exists) {
|
||||
const auto& dll_path =
|
||||
bootstrap_util::GetModulePath(untrusted_result.module);
|
||||
|
||||
if (!bootstrap_util::IsModulePathAllowed(dll_path, exe_path)) {
|
||||
#if DCHECK_IS_ON()
|
||||
@@ -452,10 +990,32 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
}
|
||||
|
||||
CheckDllCodeSigning(dll_path, exe_thumbprints);
|
||||
}
|
||||
|
||||
FreeLibrary(result.module);
|
||||
} else {
|
||||
// Save the last error before calling any other function
|
||||
// Check if installer should run (explicit command or auto-install).
|
||||
// Pass the untrusted module handle so config can be loaded from client DLL.
|
||||
int installer_exit = MaybeRunInstaller(
|
||||
command_line, exe_path, untrusted_result.module, &installer_state);
|
||||
SetInstallerCrashAnnotations(installer_state);
|
||||
|
||||
// Now we can free the untrusted module.
|
||||
if (untrusted_result.module) {
|
||||
FreeLibrary(untrusted_result.module);
|
||||
}
|
||||
|
||||
if (installer_exit >= 0) {
|
||||
// Installer ran and process should exit.
|
||||
return installer_exit;
|
||||
}
|
||||
|
||||
// In standalone mode (no client DLL), exit after installer completes.
|
||||
// There is no client DLL to hand off to.
|
||||
if (!client_dll_exists && !installer_state.libcef_path.empty()) {
|
||||
return CEF_RESULT_CODE_NORMAL_EXIT;
|
||||
}
|
||||
|
||||
// If client DLL doesn't exist and installer didn't provide a path, fail.
|
||||
if (!client_dll_exists && installer_state.libcef_path.empty()) {
|
||||
const DWORD load_error = ::GetLastError();
|
||||
#if DCHECK_IS_ON()
|
||||
const auto subst = std::to_array<std::u16string>(
|
||||
@@ -463,12 +1023,13 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
base::WideToUTF16(cef_util::GetLastErrorAsString())});
|
||||
ShowError(FormatErrorString(IDS_ERROR_LOAD_FAILED, subst));
|
||||
#endif
|
||||
const auto sha1 = CalculateFileSHA1(result.used_path);
|
||||
const auto sha1 = CalculateFileSHA1(untrusted_result.used_path);
|
||||
if (!sha1.has_value()) {
|
||||
LOG(FATAL) << "Failed to read file: " << result.used_path.value()
|
||||
LOG(FATAL) << "Failed to read file: "
|
||||
<< untrusted_result.used_path.value()
|
||||
<< " with error: " << load_error;
|
||||
} else {
|
||||
LOG(FATAL) << "Failed to load " << result.used_path.value()
|
||||
LOG(FATAL) << "Failed to load " << untrusted_result.used_path.value()
|
||||
<< " with error: " << load_error
|
||||
<< " SHA1: " << sha1.value();
|
||||
}
|
||||
@@ -502,17 +1063,135 @@ int APIENTRY wWinMain(HINSTANCE hInstance,
|
||||
cef_version_info_t version_info = {};
|
||||
CEF_POPULATE_VERSION_INFO(&version_info);
|
||||
|
||||
// Return immediately without calling FreeLibrary() to avoid an illegal
|
||||
// access during shutdown. The sandbox broker owns objects created inside
|
||||
// libcef.dll (SandboxWin::InitBrokerServices) and cleanup is triggered
|
||||
// via an _onexit handler (SingletonBase::OnExit) called after wWinMain
|
||||
// exits.
|
||||
cef_installer::PopulateInstallerVersionInfo(installer_state,
|
||||
&version_info);
|
||||
|
||||
cef_installer::LaunchState sentinel;
|
||||
|
||||
// Canonical retention liveness is independent of launch-health mode.
|
||||
// Publish it before the health sentinel and before client execution.
|
||||
if (!installer_state.liveness_path.empty() &&
|
||||
!cef_installer::RefreshLivenessPath(
|
||||
installer_state.liveness_path, installer_state.appid,
|
||||
cef_installer::GetCurrentPlatform(),
|
||||
cef_installer::GetCurrentWallTime())) {
|
||||
LOG(WARNING) << "Failed to refresh installer liveness record: "
|
||||
<< installer_state.liveness_path.value();
|
||||
}
|
||||
|
||||
// Write launch health sentinel before RunWinMain (main process only).
|
||||
if (!installer_state.launch_state_path.empty()) {
|
||||
sentinel.appid = installer_state.appid;
|
||||
sentinel.pid = GetCurrentProcessId();
|
||||
sentinel.pid_start_time = cef_installer::GetCurrentPidStartTime();
|
||||
sentinel.consecutive_failures =
|
||||
installer_state.launch_consecutive_failures;
|
||||
sentinel.running = true;
|
||||
sentinel.confirmed = false;
|
||||
sentinel.version = installer_state.launch_version;
|
||||
sentinel.platform = installer_state.launch_platform;
|
||||
|
||||
if (cef_installer::WriteLaunchStatePath(
|
||||
installer_state.launch_state_path, sentinel)) {
|
||||
// Publish the active sentinel path so an in-process
|
||||
// RunInstaller("launch_success") call (HandleLaunchSuccess) can
|
||||
// resolve and confirm this launch.
|
||||
cef_installer::SetActiveLaunchStatePath(
|
||||
installer_state.launch_state_path);
|
||||
} else {
|
||||
LOG(ERROR) << "Launch-health sentinel write failed; launching "
|
||||
"untracked (appid="
|
||||
<< installer_state.appid
|
||||
<< ", path=" << installer_state.launch_state_path.value()
|
||||
<< ", mode="
|
||||
<< cef_installer::LaunchHealthModeToString(
|
||||
installer_state.launch_health)
|
||||
<< ")";
|
||||
installer_state.launch_state_path.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Call RunWinMain and capture the exit code.
|
||||
// Don't call FreeLibrary() to avoid an illegal access during shutdown.
|
||||
// The sandbox broker owns objects created inside libcef.dll
|
||||
// (SandboxWin::InitBrokerServices) and cleanup is triggered via an
|
||||
// _onexit handler (SingletonBase::OnExit) called after wWinMain exits.
|
||||
#if defined(CEF_BUILD_BOOTSTRAP_CONSOLE)
|
||||
return pFunc(argc, argv, &sandbox_info, &version_info);
|
||||
int exit_code = pFunc(argc, argv, &sandbox_info, &version_info);
|
||||
#else
|
||||
return pFunc(hInstance, lpCmdLine, nCmdShow, &sandbox_info,
|
||||
&version_info);
|
||||
int exit_code =
|
||||
pFunc(hInstance, lpCmdLine, nCmdShow, &sandbox_info, &version_info);
|
||||
#endif
|
||||
|
||||
// Client loading/execution has completed. Release the shared-store lease
|
||||
// before any post-exit pruning so deferred removal can now proceed.
|
||||
installer_state.version_lease.reset();
|
||||
|
||||
// Post-RunWinMain: update launch state based on exit code.
|
||||
if (!installer_state.launch_state_path.empty()) {
|
||||
auto ls = cef_installer::ReadLaunchStatePath(
|
||||
installer_state.launch_state_path);
|
||||
bool is_ours = ls && ls->pid == sentinel.pid &&
|
||||
ls->pid_start_time == sentinel.pid_start_time;
|
||||
|
||||
if (is_ours) {
|
||||
// Compose with kLaunchSuccess: if the client already confirmed this
|
||||
// launch (running==false), the sentinel is already durable — skip
|
||||
// the write entirely (no-op compose path).
|
||||
sentinel = *ls;
|
||||
const bool normal_exit = exit_code == CEF_RESULT_CODE_NORMAL_EXIT;
|
||||
const bool neutral_exit =
|
||||
!normal_exit && IsSuccessOrNeutralExitCode(exit_code);
|
||||
if (cef_installer::ApplyLaunchExit(installer_state.launch_health,
|
||||
normal_exit, neutral_exit,
|
||||
&sentinel)) {
|
||||
if (!cef_installer::WriteLaunchStatePath(
|
||||
installer_state.launch_state_path, sentinel)) {
|
||||
LOG(ERROR) << "Failed to persist launch-health exit state (path="
|
||||
<< installer_state.launch_state_path.value()
|
||||
<< ", mode="
|
||||
<< cef_installer::LaunchHealthModeToString(
|
||||
installer_state.launch_health)
|
||||
<< ")";
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup and pruning run only on a clean exit (code 0). Neutral
|
||||
// exits (notably "profile in use") can imply a concurrent live
|
||||
// instance with libcef.dll loaded, or an imminent relaunch — both
|
||||
// violate the "no in-use file conflicts" premise that justifies
|
||||
// post-exit pruning. The next clean exit (or a standalone
|
||||
// /cef-update) still prunes.
|
||||
if (exit_code == CEF_RESULT_CODE_NORMAL_EXIT) {
|
||||
for (const auto& path : installer_state.launch_cleanup_paths) {
|
||||
base::DeleteFile(path);
|
||||
}
|
||||
|
||||
// Post-exit pruning: run with short timeout to avoid blocking exit.
|
||||
cef_installer::ExtendedConfig prune_ext = installer_state.extended;
|
||||
prune_ext.lock_timeout_ms = 1;
|
||||
prune_ext.show_progress_ui = false;
|
||||
cef_installer::Controller prune_controller;
|
||||
prune_controller.Run(cef_installer::Command::kPrune,
|
||||
installer_state.config, prune_ext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Off mode has no sentinel, but clean-exit pruning remains active.
|
||||
if (installer_state.launch_health ==
|
||||
cef_installer::LaunchHealthMode::kOff &&
|
||||
exit_code == CEF_RESULT_CODE_NORMAL_EXIT &&
|
||||
installer_state.configured) {
|
||||
cef_installer::ExtendedConfig prune_ext = installer_state.extended;
|
||||
prune_ext.lock_timeout_ms = 1;
|
||||
prune_ext.show_progress_ui = false;
|
||||
cef_installer::Controller prune_controller;
|
||||
prune_controller.Run(cef_installer::Command::kPrune,
|
||||
installer_state.config, prune_ext);
|
||||
}
|
||||
|
||||
return exit_code;
|
||||
} else {
|
||||
// Save the last error before calling any other function
|
||||
const DWORD proc_error = ::GetLastError();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
# CEF Installer Enterprise Policy
|
||||
|
||||
This document is the normative reference for CEF shared-installer enterprise
|
||||
policy. `README.md` provides an overview and `SECURITY.md` defines trust
|
||||
boundaries; registry deployment and operational procedures are owned here.
|
||||
|
||||
## Registry schema and architecture
|
||||
|
||||
Configure machine-wide values under `HKLM\SOFTWARE\Policies\CEF`:
|
||||
|
||||
| Value | Type | Default | Meaning |
|
||||
|-------|------|---------|---------|
|
||||
| `AllowSharedUserStore` | `REG_DWORD` (`0` or `1`) | `1` | `0` excludes the standard `%LocalAppData%\CEF` store from reads and writes. |
|
||||
| `CdnUrls` | `REG_MULTI_SZ` | absent | One through three HTTPS base URLs, tried in order, each at most 2048 bytes. |
|
||||
| `DownloadPath` | `REG_SZ` | absent | Absolute local or UNC root containing the CDN mirror layout. |
|
||||
| `DisableDownloads` | `REG_DWORD` (`0` or `1`) | `0` | `1` disables every network and mirror fetch. |
|
||||
|
||||
`CdnUrls`, `DownloadPath`, and `DisableDownloads=1` are mutually exclusive.
|
||||
Wrong types, invalid values, unreadable known values, and conflicts return
|
||||
119/`POLICY_ERROR`; they are never silently ignored. Unknown names are ignored
|
||||
for forward compatibility.
|
||||
|
||||
On 64-bit Windows, policy and `HKLM\SOFTWARE\CEF\InstallLocation` are read from
|
||||
the 64-bit machine registry view by x86, x64, and ARM64 processes. On 32-bit
|
||||
Windows the native view is used. There is no redirected-view fallback or
|
||||
migration. Deployment tools must explicitly target the shared view, for example:
|
||||
|
||||
`InstallLocation` is not a value under the policy key and does not mandate an
|
||||
exclusive policy directory. It supplies the highest-priority standard
|
||||
directory candidate; normal source-derived role, read, and mutation rules
|
||||
still apply.
|
||||
|
||||
```powershell
|
||||
reg.exe add "HKLM\SOFTWARE\Policies\CEF" /v AllowSharedUserStore `
|
||||
/t REG_DWORD /d 0 /f /reg:64
|
||||
reg.exe add "HKLM\SOFTWARE\Policies\CEF" /v DisableDownloads `
|
||||
/t REG_DWORD /d 1 /f /reg:64
|
||||
```
|
||||
|
||||
To select ordered CDN URLs instead, use a separate, non-disabled source
|
||||
configuration:
|
||||
|
||||
```powershell
|
||||
$policy = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\CEF'
|
||||
New-Item -Path $policy -Force | Out-Null
|
||||
New-ItemProperty -Path $policy -Name DisableDownloads -PropertyType DWord `
|
||||
-Value 0 -Force
|
||||
New-ItemProperty -Path $policy -Name CdnUrls -PropertyType MultiString `
|
||||
-Value @('https://primary.example/cef/', 'https://backup.example/cef/') `
|
||||
-Force
|
||||
```
|
||||
|
||||
To switch that configuration to a mirror, first remove `CdnUrls` and keep
|
||||
downloads enabled:
|
||||
|
||||
```powershell
|
||||
# CdnUrls must be removed before selecting the mutually exclusive mirror.
|
||||
Remove-ItemProperty -Path $policy -Name CdnUrls
|
||||
Set-ItemProperty -Path $policy -Name DisableDownloads -Value 0
|
||||
New-ItemProperty -Path $policy -Name DownloadPath -PropertyType String `
|
||||
-Value '\\fileserver\cef-mirror' -Force
|
||||
```
|
||||
|
||||
Each operation loads policy once into an immutable snapshot. Concurrent
|
||||
multi-value registry edits are not transactionally atomic; deployment tooling
|
||||
should stage changes so it never relies on an intermediate combination being
|
||||
valid.
|
||||
|
||||
## Behavior and precedence
|
||||
|
||||
An effective policy download source has highest precedence and replaces rather
|
||||
than merges with operation and application sources. With no download-source
|
||||
policy, precedence is the operation-local mirror, the operation `cdn_urls`,
|
||||
the selected application config's `cdn_urls`, and finally the hardcoded CEF
|
||||
CDN.
|
||||
|
||||
For policy `CdnUrls`, revocation and manifest requests independently try each
|
||||
origin in order. After selecting a version, the installer requests its hash
|
||||
sidecar and archive from the same configured origin and tries every origin for
|
||||
that version before considering version fallback. If the selected archive is
|
||||
missing from every origin or from the effective mirror, or candidate-specific
|
||||
validation fails after allowed recovery, the installer may try at most one
|
||||
next-best compatible version from the same manifest. Cancellation, an outage
|
||||
across all origins, policy denial, and local write or publication failures are
|
||||
terminal for the operation. See
|
||||
[Download Source Precedence and Robustness](README.md#download-source-precedence-and-robustness)
|
||||
for the complete behavior.
|
||||
|
||||
Validated revocation, manifest, and complete archive cache entries are
|
||||
source-neutral. Resumable archive partials are bound to their configured and
|
||||
final origins and are discarded when either origin changes.
|
||||
Installer-owned complete archives and valid partials expire after seven days.
|
||||
Use `force_check` (`/cef-forcecheck`) when a newly selected source must be
|
||||
contacted immediately.
|
||||
|
||||
`AllowSharedUserStore=0` immediately hides existing shared LocalAppData
|
||||
versions. HKLM and Program Files provisioning stores remain readable, and a
|
||||
dedicated explicit-mode executable may mutate an admin store when elevated.
|
||||
Bundled and unchecked app-shipped CEF remain eligible because the shared
|
||||
installer is not a security boundary against the application itself.
|
||||
|
||||
A distinct `install_path` is application-owned and remains fully functional,
|
||||
including normal install, update, registration, repair, and pruning. This is
|
||||
operationally equivalent to app-shipped content. An `install_path` that is
|
||||
textually or canonically equal to the resolved HKLM, Program Files, or
|
||||
LocalAppData standard candidate is always configuration error 100, whether or
|
||||
not policy exists. It never selects that standard store or falls through;
|
||||
vendors must omit `install_path` to consume a standard location.
|
||||
|
||||
`DisableDownloads=1` permits offline selection of provisioned, installed, or
|
||||
bundled content, but prevents all network and mirror reads. An explicit
|
||||
`update` still requires its version check and therefore returns policy denial
|
||||
113. Elevation is not a bypass. Compiled and integrity-valid cached revocations
|
||||
remain enforced; external `revoked.json` refresh and refresh/cache/backoff
|
||||
writes are disabled.
|
||||
|
||||
Managed applications should ship a bundled fallback. If the effective minimum
|
||||
version exceeds every provisioned version and no bundled fallback exists,
|
||||
startup is intentionally broken until an administrator provisions a matching
|
||||
version or changes policy.
|
||||
|
||||
## Provisioning and revocation curation
|
||||
|
||||
Use a dedicated installer binary whose trusted resource sets
|
||||
`enable_explicit_modes: true`; never elevate the application. A scheduled
|
||||
elevated `/cef-update` can maintain an HKLM or Program Files store while
|
||||
`AllowSharedUserStore=0`. Do not set `DisableDownloads=1` while that updater is
|
||||
expected to fetch.
|
||||
|
||||
Registrations in an administrator-owned store are durable provisioning pins,
|
||||
not a census of standard-user consumers. To provision exact version `X`, give
|
||||
the dedicated bootstrap a unique provisioning `appid` and set `vmin = vmax =
|
||||
X`, then run `/cef-update`. Give each independent pin its own appid. Run
|
||||
`/cef-uninstall` with the same config to remove that pin; pruning reclaims only
|
||||
versions not required by another pin. Revocation remains authoritative over
|
||||
every pin.
|
||||
|
||||
Embed the provisioning config in the dedicated bootstrap for a one-file
|
||||
artifact. Both an unsigned bootstrap and unsigned `chrome_elf.dll`, or both
|
||||
binaries signed by the same certificate, are supported. A signed executable
|
||||
paired with a mismatched `chrome_elf.dll` is rejected. No separate provisioning
|
||||
command is required.
|
||||
|
||||
Standard-user registrations in `%LocalAppData%` never protect an admin-store
|
||||
version, and admin pins never protect a per-user-store version. Admin-role pins
|
||||
are not eligible for user-liveness retention. Use distinct custom paths when
|
||||
applications require incompatible signer policies. This provides operational
|
||||
isolation, not a security boundary against the owner of a user-writable store.
|
||||
|
||||
For an offline fleet, maintain a complete trusted mirror including curated
|
||||
`revoked.json`:
|
||||
|
||||
1. Temporarily clear or set `DisableDownloads` to `0`.
|
||||
2. Set the mutually exclusive policy `DownloadPath` to the mirror.
|
||||
3. Run the dedicated updater with `/cef-update /cef-forcecheck` (elevated only
|
||||
when the target is an admin store).
|
||||
4. Remove `DownloadPath` and restore `DisableDownloads=1`.
|
||||
|
||||
The forced check prevents a fresh source-neutral cache or matching refresh
|
||||
backoff from suppressing mirror ingestion. The run may also install versions;
|
||||
there is no revocation-only ingestion command. Command-line
|
||||
`/cef-download-path` and API `local_download_path` deliberately do not persist
|
||||
revocations. Never hand-edit integrity-protected `revocation_cache.json`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Code | Name | Meaning and remediation |
|
||||
|------|------|-------------------------|
|
||||
| 100 | `CONFIG_ERROR` | Application configuration is invalid, including a standard-location `install_path` alias. Fix the vendor configuration. |
|
||||
| 113 | `POLICY_DENIED` | Valid policy blocked required shared-store mutation or downloading. Review effective vmin and provisioned versions, then contact the administrator. |
|
||||
| 119 | `POLICY_ERROR` | A known HKLM policy value is unreadable, malformed, wrong-type, or conflicts with another source state. Correct the registry deployment. |
|
||||
|
||||
Policy denial is available through structured startup/API results, crash
|
||||
annotations, normal logs, and standalone output. CEF does not register or
|
||||
produce Windows Event Log events for these failures.
|
||||
@@ -0,0 +1,58 @@
|
||||
# CEF Installer Contributor Guidance
|
||||
|
||||
This file is a lightweight guide for changes under this directory. Do not
|
||||
duplicate installer contracts here; use the following documents as the
|
||||
authoritative references:
|
||||
|
||||
- [README.md](README.md) describes installer behavior, client integration,
|
||||
configuration, commands, result contracts, and build integration.
|
||||
- [SECURITY.md](SECURITY.md) defines trust boundaries, security invariants,
|
||||
required validation, and guards for test-only behavior.
|
||||
- [TESTING.md](TESTING.md) describes unit, integration, E2E, fuzz, manual, and
|
||||
coverage testing.
|
||||
- [ADMIN_POLICY.md](ADMIN_POLICY.md) is the normative reference for enterprise
|
||||
policy, provisioning, precedence, and policy errors.
|
||||
|
||||
When behavior changes, update the owning document and its tests in the same
|
||||
change. Security-sensitive changes must be checked against `SECURITY.md`,
|
||||
including its testing-mode guard requirements.
|
||||
|
||||
## Code organization
|
||||
|
||||
- Production installer code belongs in `cef_installer`. Keep the interface
|
||||
exposed outside this directory small and task-oriented.
|
||||
- `cef_installer::internal` is for implementation details and narrowly scoped
|
||||
test seams. It is not an API boundary. Use it only within installer
|
||||
implementation files and installer tests.
|
||||
- `cef_installer::test` is exclusively for test helpers, fixtures, fakes, and
|
||||
reference implementations. Its files belong in test-only targets. Production
|
||||
code and production targets must not include, call, or depend on it.
|
||||
- Do not put production behavior in `*_test_support.*`, and do not put test
|
||||
infrastructure in production files merely to make it reachable by tests.
|
||||
Keep production seams minimal; place the fake implementation and assertions
|
||||
in test-only code.
|
||||
- A function named `*ForTesting` is still production code when it is compiled
|
||||
into the installer target. Put such a seam in `internal`, apply the official
|
||||
build guards required by `SECURITY.md`, and avoid using it from normal
|
||||
production control flow.
|
||||
- Do not add environment-variable, command-line, registry, filesystem, timing,
|
||||
network, or process-launch bypasses ad hoc. Centralize typed test controls,
|
||||
fail closed in official builds, and document new bypasses in the Testing Mode
|
||||
Guards table in `SECURITY.md`.
|
||||
- Prefer testing through public behavior. Expose an `internal` helper only when
|
||||
direct coverage is materially clearer than exercising the owning public
|
||||
operation.
|
||||
|
||||
Before adding a source or symbol, classify its ownership:
|
||||
|
||||
- Production behavior belongs in a normal installer source and in
|
||||
`cef_installer` or `cef_installer::internal` according to its consumers.
|
||||
- A seam consulted by production code belongs in `internal`, uses the required
|
||||
official-build guard, and exposes only the minimum `*ForTesting` control.
|
||||
- A helper, fake, fixture, or reference implementation used only by tests
|
||||
belongs in `cef_installer::test`, a clearly named test-support source, and a
|
||||
test-only target.
|
||||
|
||||
Check `BUILD.gn` ownership whenever adding or moving test support. A filename or
|
||||
preprocessor guard does not make a source test-only: production targets must
|
||||
not list `*_test_support.*` or depend on `cef_installer::test`.
|
||||
@@ -0,0 +1,284 @@
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
import("//cef/libcef/features/features.gni")
|
||||
import("//testing/libfuzzer/fuzzer_test.gni")
|
||||
import("//testing/test.gni")
|
||||
|
||||
if (is_win) {
|
||||
action("cef_installer_config") {
|
||||
script = "//cef/tools/make_installer_config.py"
|
||||
|
||||
inputs = [
|
||||
"//cef/cef_api_versions.json",
|
||||
"//cef/tests/cefclient/win/installer_config.json.in",
|
||||
]
|
||||
outputs = [
|
||||
"$root_gen_dir/cef/installer_config.json",
|
||||
"$root_gen_dir/cef/installer_config.rc",
|
||||
]
|
||||
|
||||
args = [
|
||||
"--template",
|
||||
rebase_path("//cef/tests/cefclient/win/installer_config.json.in",
|
||||
root_build_dir),
|
||||
"--api-versions",
|
||||
rebase_path("//cef/cef_api_versions.json", root_build_dir),
|
||||
"--output-json",
|
||||
rebase_path(outputs[0], root_build_dir),
|
||||
"--output-rc",
|
||||
rebase_path(outputs[1], root_build_dir),
|
||||
"--resource-name",
|
||||
"CEF_INSTALLER_CONFIG",
|
||||
]
|
||||
|
||||
if (cef_api_version != "") {
|
||||
args += [
|
||||
"--api-version",
|
||||
cef_api_version,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
action("installer_revocation_resource") {
|
||||
script = "//cef/tools/make_revocation_resource.py"
|
||||
|
||||
inputs = [
|
||||
"//cef/cef_api_versions.json",
|
||||
"//cef/tools/cefbuilds/revoked.json",
|
||||
]
|
||||
outputs = [
|
||||
"$root_gen_dir/cef/installer/revocation_list.json",
|
||||
"$root_gen_dir/cef/installer/revocation_list.rc",
|
||||
]
|
||||
|
||||
args = [
|
||||
"--revoked-json",
|
||||
rebase_path("//cef/tools/cefbuilds/revoked.json", root_build_dir),
|
||||
"--api-versions",
|
||||
rebase_path("//cef/cef_api_versions.json", root_build_dir),
|
||||
"--output-json",
|
||||
rebase_path(outputs[0], root_build_dir),
|
||||
"--output-rc",
|
||||
rebase_path(outputs[1], root_build_dir),
|
||||
"--resource-name",
|
||||
"CEF_REVOCATION_LIST",
|
||||
]
|
||||
|
||||
if (cef_api_version != "") {
|
||||
args += [
|
||||
"--api-version",
|
||||
cef_api_version,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
source_set("cef_installer") {
|
||||
sources = [
|
||||
"$root_gen_dir/cef/installer/revocation_list.rc",
|
||||
"installer_archive.cc",
|
||||
"installer_archive.h",
|
||||
"installer_bootstrap_helpers.cc",
|
||||
"installer_bootstrap_helpers.h",
|
||||
"installer_cdn_manifest.cc",
|
||||
"installer_cdn_manifest.h",
|
||||
"installer_config.cc",
|
||||
"installer_config.h",
|
||||
"installer_constants.h",
|
||||
"installer_controller.cc",
|
||||
"installer_controller.h",
|
||||
"installer_crc.cc",
|
||||
"installer_crc.h",
|
||||
"installer_database.cc",
|
||||
"installer_database.h",
|
||||
"installer_download.cc",
|
||||
"installer_download.h",
|
||||
"installer_e2e_config.cc",
|
||||
"installer_e2e_config.h",
|
||||
"installer_file_integrity.cc",
|
||||
"installer_file_integrity.h",
|
||||
"installer_file_ops.cc",
|
||||
"installer_file_ops.h",
|
||||
"installer_launch_state.cc",
|
||||
"installer_launch_state.h",
|
||||
"installer_lock.cc",
|
||||
"installer_lock.h",
|
||||
"installer_lifecycle.cc",
|
||||
"installer_lifecycle.h",
|
||||
"installer_logger.cc",
|
||||
"installer_logger.h",
|
||||
"installer_parallel_xz.cc",
|
||||
"installer_parallel_xz.h",
|
||||
"installer_paths.cc",
|
||||
"installer_paths.h",
|
||||
"installer_policy.cc",
|
||||
"installer_policy.h",
|
||||
"installer_progress_dialog.cc",
|
||||
"installer_progress_dialog.h",
|
||||
"installer_registry.cc",
|
||||
"installer_registry.h",
|
||||
"installer_relaunch.cc",
|
||||
"installer_relaunch.h",
|
||||
"installer_result_json.cc",
|
||||
"installer_result_json.h",
|
||||
"installer_retention.cc",
|
||||
"installer_retention.h",
|
||||
"installer_revocation.cc",
|
||||
"installer_revocation.h",
|
||||
"installer_scoped_thread_pool.cc",
|
||||
"installer_scoped_thread_pool.h",
|
||||
"installer_signature.cc",
|
||||
"installer_signature.h",
|
||||
"installer_validation.cc",
|
||||
"installer_validation.h",
|
||||
"installer_version.cc",
|
||||
"installer_version.h",
|
||||
"installer_version_metadata.cc",
|
||||
"installer_version_metadata.h",
|
||||
"installer_version_resolver.cc",
|
||||
"installer_version_resolver.h",
|
||||
"installer_xz_index.cc",
|
||||
"installer_xz_index.h",
|
||||
]
|
||||
|
||||
deps = [
|
||||
":installer_revocation_resource",
|
||||
"//base",
|
||||
"//cef:cef_certificate_util",
|
||||
"//crypto",
|
||||
]
|
||||
|
||||
libs = [
|
||||
"advapi32.lib",
|
||||
"comctl32.lib",
|
||||
"user32.lib",
|
||||
]
|
||||
|
||||
public_deps = [
|
||||
"//cef:make_api_versions_header",
|
||||
"//cef:make_config_header",
|
||||
"//cef:make_version_header",
|
||||
"//cef/libcef/features:buildflags",
|
||||
"//third_party/lzma_sdk",
|
||||
"//third_party/lzma_sdk:lzma_sdk_xz",
|
||||
]
|
||||
|
||||
configs += [
|
||||
"//build/config:precompiled_headers",
|
||||
"//cef/libcef/features:config",
|
||||
]
|
||||
}
|
||||
|
||||
test("cef_installer_unittests") {
|
||||
testonly = true
|
||||
|
||||
sources = [
|
||||
"installer_archive_unittest.cc",
|
||||
"installer_bootstrap_helpers_unittest.cc",
|
||||
"installer_cdn_manifest_unittest.cc",
|
||||
"installer_config_unittest.cc",
|
||||
"installer_controller_unittest.cc",
|
||||
"installer_database_unittest.cc",
|
||||
"installer_download_unittest.cc",
|
||||
"installer_e2e_config_unittest.cc",
|
||||
"installer_file_integrity_unittest.cc",
|
||||
"installer_file_ops_unittest.cc",
|
||||
"installer_integration_test.cc",
|
||||
"installer_launch_state_unittest.cc",
|
||||
"installer_lock_unittest.cc",
|
||||
"installer_lifecycle_test_support.cc",
|
||||
"installer_lifecycle_test_support.h",
|
||||
"installer_lifecycle_unittest.cc",
|
||||
"installer_logger_unittest.cc",
|
||||
"installer_parallel_xz_unittest.cc",
|
||||
"installer_paths_unittest.cc",
|
||||
"installer_policy_unittest.cc",
|
||||
"installer_progress_dialog_unittest.cc",
|
||||
"installer_progress_ui_integration_unittest.cc",
|
||||
"installer_registry_unittest.cc",
|
||||
"installer_relaunch_unittest.cc",
|
||||
"installer_resources.rc",
|
||||
"installer_retention_unittest.cc",
|
||||
"installer_revocation_unittest.cc",
|
||||
"installer_signature_unittest.cc",
|
||||
"installer_test_archive.cc",
|
||||
"installer_test_archive.h",
|
||||
"installer_test_catalog.cc",
|
||||
"installer_test_catalog.h",
|
||||
"installer_test_helpers.cc",
|
||||
"installer_test_helpers.h",
|
||||
"installer_test_resources.h",
|
||||
"installer_test_resources.rc",
|
||||
"installer_validation_unittest.cc",
|
||||
"installer_version_metadata_unittest.cc",
|
||||
"installer_version_resolver_unittest.cc",
|
||||
"installer_version_unittest.cc",
|
||||
"installer_xz_index_unittest.cc",
|
||||
]
|
||||
|
||||
data = [
|
||||
"testdata/loopback_tls_cert.pem",
|
||||
"testdata/loopback_tls_key.pem",
|
||||
"testdata/multi_block_4.tar.xz",
|
||||
"testdata/multi_block_large.tar.xz",
|
||||
"testdata/single_block.tar.xz",
|
||||
"testdata/small_single.tar.xz",
|
||||
"testdata/test_signing.cer",
|
||||
"testdata/test_signing.pfx",
|
||||
"testdata/test_thumbprint.txt",
|
||||
]
|
||||
|
||||
deps = [
|
||||
":cef_installer",
|
||||
"e2e/config_test_dlls",
|
||||
"//base",
|
||||
"//base/test:run_all_unittests",
|
||||
"//net:test_support",
|
||||
"//testing/gtest",
|
||||
]
|
||||
|
||||
configs += [
|
||||
"//build/config:precompiled_headers",
|
||||
"//cef/libcef/features:config",
|
||||
]
|
||||
}
|
||||
|
||||
group("installer_tests") {
|
||||
testonly = true
|
||||
deps = [
|
||||
":cef_installer_unittests",
|
||||
"e2e",
|
||||
]
|
||||
}
|
||||
|
||||
fuzzer_test("installer_xz_index_fuzzer") {
|
||||
sources = [ "installer_xz_index_fuzzer.cc" ]
|
||||
deps = [
|
||||
":cef_installer",
|
||||
"//base",
|
||||
"//third_party/lzma_sdk",
|
||||
]
|
||||
seed_corpus = "testdata"
|
||||
}
|
||||
|
||||
fuzzer_test("installer_tar_fuzzer") {
|
||||
sources = [ "installer_tar_fuzzer.cc" ]
|
||||
deps = [
|
||||
":cef_installer",
|
||||
"//base",
|
||||
]
|
||||
libfuzzer_options = [ "max_len=65536" ]
|
||||
}
|
||||
|
||||
fuzzer_test("installer_xz_block_fuzzer") {
|
||||
sources = [ "installer_xz_block_fuzzer.cc" ]
|
||||
deps = [
|
||||
":cef_installer",
|
||||
"//base",
|
||||
"//third_party/lzma_sdk",
|
||||
"//third_party/lzma_sdk:lzma_sdk_xz",
|
||||
]
|
||||
seed_corpus = "testdata"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,509 @@
|
||||
# CEF Installer Security Model
|
||||
|
||||
This document defines the security invariants, trust boundaries, and defensive
|
||||
controls for the CEF installer library. It is the authoritative reference for
|
||||
developers modifying this code.
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Document
|
||||
|
||||
### When writing implementation plans
|
||||
|
||||
Reference this document in the plan and include a security section that maps
|
||||
planned changes to affected invariants. For each new code path, check every
|
||||
"If you change" callout in the relevant invariant sections and add
|
||||
corresponding exit criteria. Example from the parallel XZ extraction plan:
|
||||
|
||||
> **Security (S1):** `ExtractTarFromBuffer()` enforces `kMaxFileSize`,
|
||||
> `kMaxTotalExtractionSize`, `kMaxEntryCount`, and skips symlinks
|
||||
> *(from Invariant 3: Archive Extraction)*
|
||||
|
||||
### When reviewing plans or code
|
||||
|
||||
Use the invariant tables as a checklist. For each file touched by the change,
|
||||
find the matching invariant section and verify every row still holds. Pay
|
||||
special attention to:
|
||||
- New data paths that bypass existing validation
|
||||
- New testing bypasses that need `OFFICIAL_BUILD && NDEBUG` guards
|
||||
- New check-then-use patterns that should be added to the TOCTOU table
|
||||
|
||||
### When updating this document
|
||||
|
||||
Update invariant tables when:
|
||||
- A new security control is added (new row)
|
||||
- An existing control moves to a different file or function (update location)
|
||||
- A new TOCTOU race or testing bypass is introduced (add to relevant table)
|
||||
|
||||
Do NOT add review progress, findings, or task-specific notes here. Those
|
||||
belong in review plans or issue trackers.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Trusted | Untrusted |
|
||||
|----------|---------|-----------|
|
||||
| CDN HTTPS | Installer process | CDN server responses (manifests, archives, revocation list) |
|
||||
| Disk (admin dirs) | Administrator-owned ACL boundary | Standard users and lower-integrity processes |
|
||||
| Disk (`%LocalAppData%`/user custom dirs) | User-integrity applications share the user's authority | Network input and accidental corruption; not arbitrary native code running as that user |
|
||||
| Application CDN selection | Client and bootstrap resources execute at the owning user's integrity | Not an administrator boundary; another native process running as that user has equivalent authority |
|
||||
| IPC (WM_COPYDATA) | Installer mutation/result state and nonce-bound relaunch context | Parent window and all messages/return values; public lifecycle IDs provide correlation, not authentication |
|
||||
| Config sources | Client DLL and bootstrap embedded resources (authoritative when selected) | Command-line args; `RunInstaller` JSON is caller-controlled operation input |
|
||||
| Archive contents | N/A (always untrusted) | tar.xz entries: filenames, sizes, types, compressed data |
|
||||
| Revocation cache | Compiled baseline (immutable RT_RCDATA) | Disk-cached CDN delta (writable by install-dir owner) |
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
- HTTPS/TLS termination is sound (system cert store is trusted).
|
||||
- The code signing certificate private key is not compromised.
|
||||
- The compiled-in revocation baseline cannot be tampered with at runtime.
|
||||
- Chromium `base::` primitives (file I/O, crypto, threading) are sound.
|
||||
|
||||
---
|
||||
|
||||
## Security Invariants
|
||||
|
||||
### 1. Signature Verification
|
||||
|
||||
Every downloaded CEF distribution MUST be verified against a pinned
|
||||
certificate thumbprint before publication. The default user-writable store is
|
||||
not an ongoing authenticity boundary against arbitrary native code running as
|
||||
that user. Client-side runtime signer checks are defense in depth and do not
|
||||
reauthenticate every catalog-covered dependency/resource.
|
||||
|
||||
Bundled CEF is a different trust path. `CheckBundledCef()` requires
|
||||
`catalog.cat` to exist as a regular non-reparse file, but that is only a layout
|
||||
check; startup does not verify its signature or catalog members. Bundled
|
||||
integrity rests on the application's signed package.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| All installed files are verified against a signed catalog (`catalog.cat`) | `VerifyWithCatalog()` | `installer_signature.cc` |
|
||||
| Catalog signature is verified BEFORE file hashes are checked | `VerifyWithCatalog()` call order | `installer_signature.cc` |
|
||||
| Certificate thumbprint is always pinned (never empty) in production paths | `kCefCertificateThumbprint` default, traced from `DownloadAndInstall()` | `installer_constants.h`, `installer_controller.cc` |
|
||||
| Bundled `catalog.cat` presence is a non-reparse layout check, not runtime authentication | `CheckBundledCef()` | `installer_controller.cc` |
|
||||
| `SetSignatureTestingMode()` is a no-op in official builds | `#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))` guard | `installer_signature.cc` |
|
||||
| Authenticode revocation checking is disabled (no network dependency) | `WTD_REVOKE_NONE` flag | `installer_signature.cc` |
|
||||
| `GetSignatureThumbprint()` only returns thumbprints from valid signatures | Return-empty-on-failure pattern | `installer_signature.cc` |
|
||||
|
||||
A trusted `RunInstaller` caller may replace the default CEF thumbprint with
|
||||
another non-empty expected thumbprint. This still requires a Windows-valid
|
||||
catalog signature matching that certificate; it does not permit unsigned
|
||||
publication in official builds. Alternative-signer applications should use
|
||||
distinct `install_path` namespaces to prevent compatibility collisions. This
|
||||
separation is not a boundary against the user who owns a user-writable store.
|
||||
The cefclient reference additionally pins CDN-installed `libcef.dll` at load
|
||||
time as defense in depth, but does not reauthenticate every catalog member.
|
||||
|
||||
**If you change:** A new downloaded-install publication path MUST call
|
||||
`VerifyWithCatalog()` with a non-empty `expected_thumbprint` before
|
||||
publication. A new bundled/load path must document and test its source-
|
||||
appropriate trust behavior instead of implying ongoing catalog authentication.
|
||||
|
||||
### 2. Download Security
|
||||
|
||||
All network downloads MUST use HTTPS and enforce size limits.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Only HTTPS URLs accepted in production | `IsValidDownloadUrl()` | `installer_download.cc` |
|
||||
| `allow_http_for_testing` compiled out in official builds | `#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))` | `installer_download.cc` |
|
||||
| Downloads capped at `kDefaultMaxDownloadSize` (500 MB); revocation list capped at 1 MB | `DownloadOptions::max_download_size` check after download | `installer_download.cc`, `installer_controller.cc` |
|
||||
| File deleted on size-limit excess | Post-download check in `DownloadFile()` | `installer_download.cc` |
|
||||
| SHA256 hashing uses streaming (no full-file memory load) | `ComputeFileSha256()` via `crypto::SecureHash` | `installer_download.cc` |
|
||||
| Hash mismatch causes file deletion | Error path in `DownloadFile()` | `installer_download.cc` |
|
||||
| Generic downloads clean temporary files; resumable archives retain only safe origin-bound partials after interruption/cancellation and prune them after seven days | `DownloadFile()`, `PruneCacheDirectory()` | `installer_download.cc` |
|
||||
| HKLM policy URL lists require HTTPS and forbid userinfo, query, and fragment components | `ValidateEnterprisePolicySnapshot()` | `installer_policy.cc` |
|
||||
| Policy mirrors retain mandatory hash/catalog/signature validation | Effective source selects transport only | `installer_policy.cc`, `installer_controller.cc` |
|
||||
| Application `cdn_urls` are one-to-three bounded HTTPS prefixes without userinfo/query/fragment | Shared validation before source resolution | `installer_config.cc`, `installer_policy.cc`, `installer_controller.cc` |
|
||||
| Resumable archive state is bound to configured and post-redirect final-origin digests | Deterministic partial naming and pre-body origin comparison | `installer_download.cc` |
|
||||
| A partial appends only after a strict single-range `206`; `200` restarts from zero | Range state machine and `Content-Range` parser | `installer_download.cc` |
|
||||
| Complete cache publication requires final length and SHA256/SHA1 validation | Hash-before-move promotion | `installer_download.cc` |
|
||||
|
||||
HKLM enterprise policy is administrator authority over shared-installer
|
||||
behavior. Policy URL and mirror sources override application/operation
|
||||
transport inputs, but do not weaken post-download verification. A policy
|
||||
mirror may persist revocation deltas so read-only managed users consume the
|
||||
administrator-curated view; command-line and API local paths cannot. See
|
||||
[ADMIN_POLICY.md](ADMIN_POLICY.md) for registry and operational procedures.
|
||||
|
||||
`DisableDownloads` accepts the availability tradeoff that revocation freshness
|
||||
becomes administrator-curated. It stops external refreshes but never disables
|
||||
compiled or integrity-valid cached revocation enforcement. Policy does not
|
||||
restrict bundled or unchecked CEF shipped by the application itself; the
|
||||
shared installer is not a security boundary against same-user application
|
||||
code.
|
||||
|
||||
Application source configuration in a client DLL or bootstrap resource
|
||||
likewise operates at user integrity. HTTPS,
|
||||
transport hashes, catalog/signature verification, the compiled revocation
|
||||
baseline, the vmin clamp, platform checks, and ABI matching protect source
|
||||
authenticity, corruption detection, and compatibility. They do not create a
|
||||
security boundary against arbitrary native code already running as that user.
|
||||
|
||||
A stale or misconfigured application/policy mirror can reduce availability,
|
||||
serve an older still-valid build, omit a newer revocation delta, or consume
|
||||
cache space with failed downloads. Per-download limits, the additive compiled
|
||||
revocation baseline, mandatory signature/hash checks, and seven-day complete/
|
||||
partial archive pruning bound those effects; there is intentionally no
|
||||
aggregate cache quota.
|
||||
|
||||
Completed archives and installed distributions do not retain origin
|
||||
provenance because their hashes and signed catalogs establish content
|
||||
identity. Transient partials are different: their filenames carry only bounded
|
||||
digests of the normalized configured and post-redirect final origins. A change
|
||||
to either identity discards the partial before response bytes can be appended,
|
||||
so one archive body is never assembled across origins. Raw URLs, paths,
|
||||
queries, fragments, and credentials are not stored in partial names or
|
||||
metadata.
|
||||
|
||||
**If you change:** Adding a new download path? It MUST go through
|
||||
`DownloadFile()` or `DownloadToString()` — never call WinHTTP functions
|
||||
directly.
|
||||
|
||||
### 3. Archive Extraction
|
||||
|
||||
All archive extraction paths MUST enforce resource limits and path safety.
|
||||
These limits exist in `installer_archive.cc` and MUST be replicated in any
|
||||
new extraction path (e.g., parallel extraction, buffer-based extraction).
|
||||
|
||||
| Limit | Constant | Value | Purpose |
|
||||
|-------|----------|-------|---------|
|
||||
| Max decompressed buffer | `kMaxDecompressedSize` | 512 MB | Prevents decompression bomb in streaming buffer |
|
||||
| Max file size | `kMaxFileSize` | 4 GB | Per-file extraction limit |
|
||||
| Max total extraction | `kMaxTotalExtractionSize` | 10 GB | Aggregate across all entries |
|
||||
| Max path length | `kMaxPathLength` | 4096 | Prevents excessive path construction |
|
||||
| Max entry count | `kMaxEntryCount` | 100,000 | Prevents entry-count DoS |
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Every entry path passes through `IsPathSafe()` | `TarReader::IsPathSafe()` (both string and FilePath overloads) | `installer_archive.cc` |
|
||||
| `IsPathSafe()` rejects: `../`, absolute paths, UNC paths, Windows reserved names, null bytes, oversized paths | Explicit checks in `IsPathSafe()` | `installer_archive.cc` |
|
||||
| PAX path overrides pass through `IsPathSafe()` | Check after `ParsePaxPath()` | `installer_archive.cc` |
|
||||
| GNU long name paths pass through `IsPathSafe()` | Check after long-name parsing | `installer_archive.cc` |
|
||||
| Symlinks are skipped (never extracted) | Type check in extraction loop | `installer_archive.cc` |
|
||||
| Hard links, device nodes, FIFOs are rejected | Type check → `kOther` | `installer_archive.cc` |
|
||||
| Size arithmetic uses `base::CheckedNumeric` | Checked accumulation in extraction loop | `installer_archive.cc` |
|
||||
| Tar header checksum is validated | `ValidateChecksum()` | `installer_archive.cc` |
|
||||
|
||||
**If you change:** Adding a new extraction path (e.g., `ExtractTarFromBuffer`,
|
||||
parallel block extraction)? It MUST enforce ALL of the above limits. Copy the
|
||||
security checks from the existing streaming extraction loop, and add or extend
|
||||
a fuzz target as described in [Fuzz Testing](TESTING.md#fuzz-testing).
|
||||
|
||||
### 4. File System & Path Security
|
||||
|
||||
All file operations on install directories MUST check for reparse points
|
||||
(symlinks/junctions) and validate paths.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Readable dirs never include lower-priority locations than the writable dir | `ResolveInstallDirectories()` stops at first writable | `installer_paths.cc` |
|
||||
| Install/uninstall source dirs checked for reparse points | `IsDirectorySafe()` → `IsReparsePoint()` | `installer_file_ops.cc` |
|
||||
| All discovered install paths checked for reparse points | `IsReadableDirectory()` → `IsReparsePoint()` | `installer_paths.cc` |
|
||||
| `Versions/` parent directory checked for reparse points | `IsReparsePoint(versions_dir)` before enumerating or installing | `installer_file_ops.cc`, `installer_paths.cc`, `installer_version_metadata.cc` |
|
||||
| Version directories checked during scanning | `ScanInstalledVersions()` reparse check | `installer_paths.cc` |
|
||||
| Pre-operation reparse point cleanup (staging, cache, archive paths) | `VerifySafeFilePath()` / `VerifySafeDirectoryPath()` — removes reparse point then optionally deletes | `installer_file_ops.cc`, `installer_controller.cc` |
|
||||
| Cache directory checked before read/write/enumerate | `IsReparsePoint()` in `DownloadWithCache()`, `PruneCacheDirectory()` | `installer_download.cc` |
|
||||
| Cache files checked before read (via `IsCacheValid()`) and before write | `IsReparsePoint()` in `IsCacheValid()`, `DownloadWithCache()` | `installer_download.cc` |
|
||||
| Cache, revocation, and database files have CRC32 integrity footer | `WriteFileWithIntegrity()` / `ReadFileWithIntegrity()` — doom on mismatch | `installer_file_integrity.cc` |
|
||||
| Database uses atomic write (temp file + rename) | `Database::Save()` via `base::ReplaceFile()` | `installer_database.cc` |
|
||||
| Database corruption suspends pruning for 7-day grace period | `SuspendPruning()` / `CanPrune()` with persisted `pruning_suspended_until` timestamp | `installer_database.cc`, `installer_controller.cc` |
|
||||
| Revocation cache file checked before read and write | `IsReparsePoint()` on dir and file in both `WriteRevocationCache()` and `LoadRevocationCache()` | `installer_revocation.cc` |
|
||||
| Temp directory checked after creation (relaunch) | `IsReparsePoint()` after `CreateDirectory()` | `installer_relaunch.cc` |
|
||||
| Registry paths come from HKLM only (not HKCU) | `HKEY_LOCAL_MACHINE` in `ResolveRegistryPath()` | `installer_paths.cc` |
|
||||
| Registry paths validated as absolute, no parent refs | `IsAbsolute()`, `!ReferencesParent()` | `installer_paths.cc` |
|
||||
| Custom `install_path` is exclusive (fail hard, no fallback) | Early return in `ResolveInstallDirectories()` | `installer_paths.cc` |
|
||||
| Custom `install_path` remains readable when safe but non-writable; query does not create or probe it | operation-aware custom resolution | `installer_paths.cc` |
|
||||
| Directory roles come from candidate source, not ACLs/path prefixes | role-tagged candidate construction and stable deduplication | `installer_paths.cc` |
|
||||
| Official automatic startup cannot mutate admin-default roles without the trusted bootstrap gate | `IsAdminMutationAllowed()` before candidate write probing | `installer_paths.cc`, `installer_controller.cc` |
|
||||
| All returned libcef.dll paths validated for reparse points before use | `MakeValidatedLibcefResult()` → `IsPathSafeForLoading()` | `installer_controller.cc`, `installer_file_ops.cc` |
|
||||
| Launch state read/write/scan check `.launch/` dir for reparse points | `IsReparsePoint()` guard in `ReadLaunchStatePath()`, `WriteLaunchStatePath()`, and `ScanLaunchStates()`; writer-locked controller paths repair unsafe directories | `installer_launch_state.cc`, `installer_controller.cc` |
|
||||
| Launch records require a valid CRC footer and bounded current-schema JSON; health `last_update` and liveness `last_launch` must be nonzero | `ReadJsonWithIntegrity()`, `ParseLaunchStateDict()`, `ParseLivenessDict()` | `installer_launch_state.cc` |
|
||||
| Launch-record GC trusts only CRC-verified content time, ignores filesystem timestamps, preserves future timestamps and unavailable time, and expires at the exact 90-day boundary | `ClassifyLaunchStateGcAge()`, `PruneUnusedVersions()` | `installer_launch_state.cc`, `installer_controller.cc` |
|
||||
| GC observes one bounded no-follow publication with its resolved parent and conditionally deletes only the identical content at that location; invalid repair uses exact raw bytes, an opened-object oversized check, or same-object reparse removal | `ReadLaunchRecordSnapshot()`, `DeleteFileWithIntegrityIfMatching()`, `DeleteFileRawIfMatching()`, `DeleteFileIfOversized()`, `DeleteFileIfReparsePoint()` | `installer_launch_state.cc`, `installer_controller.cc`, `installer_file_integrity.cc` |
|
||||
| Read-only launch-state paths preserve corrupt, footerless, old-schema, noncanonical, and indeterminate files; writer-locked prune conditionally repairs only conclusive observations | preserve-on-mismatch readers and `PruneUnusedVersions()` repair snapshots | `installer_launch_state.cc`, `installer_controller.cc` |
|
||||
| Orphaned and below-vmin launch records never protect executable versions, including while age-retained or when time is future/unavailable | GC eligibility before confirmed protection in `PruneUnusedVersions()` | `installer_controller.cc` |
|
||||
| Empty install roots never produce relative `.launch` paths | Launch-health evaluation returns early and launch-state result paths require a non-empty install directory | `installer_launch_state.cc`, `installer_controller.cc` |
|
||||
| Atomic rename via `MoveFileExW` with `MOVEFILE_WRITE_THROUGH` | `InstallVersion()` | `installer_file_ops.cc` |
|
||||
| Staging under install dir guarantees same-volume atomic rename | `<install_dir>/.staging/` in `DownloadAndInstall()` | `installer_controller.cc` |
|
||||
| Trash directory uses cryptographic random suffix | `base::RandUint64()` in `GenerateTrashPath()` | `installer_file_ops.cc` |
|
||||
| Once verified staging exists, every safe regular-file or directory publication target is moved whole to reparse-safe `.trash/` without reading its contents; verified staging is then renamed into place, and reclamation deletes nested reparse points without traversing them | `InstallVersion()` | `installer_file_ops.cc` |
|
||||
| Mandatory target replacement is limited to verified install/update publication under the writer lock; missing/damaged-index recovery, query/read-only selection, prune, and retention scans remain unchanged | Controller call graph | `installer_controller.cc` |
|
||||
|
||||
Launch health is heuristic local compatibility data, not authentication,
|
||||
security policy, or fleet-wide revocation. It is disabled by default and keyed
|
||||
by appid, platform, and version. Opted-in classification uses a boot boundary
|
||||
and PID creation time to reduce stale-process attribution, but task termination,
|
||||
OOM, and logoff remain ambiguous. Integrity writes use same-directory atomic
|
||||
replacement; CRC detects corruption but does not authenticate content.
|
||||
Launch-record age is availability-oriented cleanup metadata, not a trust
|
||||
boundary: writers own health `last_update`, zero is invalid, future values and
|
||||
an unavailable clock preserve state, and filesystem times are never evidence.
|
||||
|
||||
**Priority search and privilege separation:** `ResolveInstallDirectories()`
|
||||
walks candidates in priority order (registry → ProgramFiles → LocalAppData),
|
||||
collecting readable directories and stopping at the first permitted writable
|
||||
one. Roles are source-derived. Elevated processes never continue to
|
||||
LocalAppData. In official automatic startup, a closed trusted explicit-mode
|
||||
gate makes HKLM and ProgramFiles read-only even if their ACLs permit writes.
|
||||
Custom paths are deployment-owned and require ACLs appropriate to the
|
||||
application's execution level.
|
||||
|
||||
**If you change:** Adding a new path discovery source or file operation? For
|
||||
read-only checks (enumeration, validation), use `IsReparsePoint()` or
|
||||
`IsReadableDirectory()`. For paths that need reparse-point removal before use
|
||||
(e.g., staging or cache directories the installer is about to create or write
|
||||
to), use `VerifySafeFilePath()` or `VerifySafeDirectoryPath()`. Be aware of
|
||||
TOCTOU — minimize the window between the check and the operation.
|
||||
|
||||
### 5. Config & Input Parsing
|
||||
|
||||
All parsed data from untrusted sources MUST be length-bounded and validated.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Client resources win when valid; only absence permits bootstrap-resource fallback; standalone mode reads only the bootstrap resource | `TryLoadInstallerConfig()` tri-state result and source provenance | `installer_bootstrap_helpers.cc` |
|
||||
| Malformed or invalid selected resources fail with config error 100 and never fall through | `TryLoadInstallerConfig()` tri-state result | `installer_bootstrap_helpers.cc` |
|
||||
| `install_path` is accepted from the client DLL resource only, resolved relative to that DLL, and ignored in bootstrap resources | parse option plus `ResolveClientInstallPath()` | `installer_config.cc`, `installer_bootstrap_helpers.cc` |
|
||||
| Unrecognized application-config keys warn individually but remain non-fatal for forward compatibility | recognized-key audit in `ParseConfigFromJson()` | `installer_config.cc` |
|
||||
| Application `cdn_urls` accepted from the selected client/bootstrap resource; operation `cdn_urls` accepted from `RunInstaller` JSON | Shared strict one-to-three URL-list parser; selected application lists never merge | `installer_config.cc`, `installer_controller.cc`, `installer_policy.cc` |
|
||||
| HTTPS enforced for every parsed `cdn_urls` entry | `ValidateAndNormalizeCdnUrls()` before source resolution and `IsValidDownloadUrl()` before requests | `installer_policy.cc`, `installer_download.cc` |
|
||||
| CDN manifest filenames validated | `IsValidArchiveFilename()` before URL construction | `installer_cdn_manifest.cc` |
|
||||
| JSON string fields bounded by `kMax*Length` constants in CDN manifest, database, and metadata parsers | `ParseBuildEntry()`, `Database::Load()`, `ReadVersionMetadata()` | `installer_cdn_manifest.cc`, `installer_database.cc`, `installer_version_metadata.cc` |
|
||||
| Log messages sanitized against injection | `SanitizeForLog()` escapes control characters | `installer_logger.cc` |
|
||||
| Parent HWND treated as untrusted (no ownership, only positioning) | `GetWindowRect()` only, with `IsWindow()` check | `installer_progress_dialog.cc` |
|
||||
| `parent_window` parses a decimal string or exactly represented numeric value with `uintptr_t` bounds; truncation and rounding are rejected | `ParseExtendedConfigFromJson()`, `ParseExtendedConfigFromCommandLine()` | `installer_controller.cc`, `installer_bootstrap_helpers.cc` |
|
||||
| Official builds read `enable_explicit_modes` only from the bootstrap's embedded resource; a missing or false gate blocks explicit commands and standalone auto-install | trusted bootstrap provenance and explicit-mode checks | `installer_bootstrap_helpers.cc`, `bootstrap_win.cc` |
|
||||
| Structured installer error messages are diagnostic data only and are never interpreted as commands or paths | Startup result and JSON serialization only | `installer_bootstrap_helpers.cc`, `installer_controller.cc` |
|
||||
| Known HKLM policy values are exact-type validated in one immutable operation snapshot; malformed/conflicting policy returns 119 | `LoadEnterprisePolicy()`, `ValidateEnterprisePolicySnapshot()` | `installer_policy.cc`, `installer_registry.cc` |
|
||||
| `WM_COPYDATA` progress and lifecycle messages contain no credentials, nonce, or filesystem paths | Bounded progress fields and normalized path-free lifecycle results | `installer_controller.cc`, `installer_lifecycle.cc` |
|
||||
|
||||
See [Config Security](README.md#config-security) for the integrator-facing
|
||||
configuration source priority and fallback behavior.
|
||||
|
||||
The explicit-mode gate prevents a trusted, code-signed bootstrap from being
|
||||
repurposed through renaming or copying. Signature verification prevents
|
||||
arbitrary downloaded code, but without this gate the bootstrap could still be
|
||||
abused for network access, disk consumption, or installer-state poisoning. See
|
||||
[Standalone Mode Security](README.md#standalone-mode-security) for the
|
||||
integrator-facing behavior matrix.
|
||||
|
||||
The parent HWND may belong to a lower-integrity process while the installer is
|
||||
elevated. The installer therefore never establishes window ownership, changes
|
||||
the parent window's state, or grants it control over another process. It uses
|
||||
the HWND only for positioning and bounded progress/cancellation messages. See
|
||||
[Parent Window Handle](README.md#parent-window-handle-cross-process-security)
|
||||
for the integration contract.
|
||||
|
||||
**Field length limits** (from `installer_constants.h`):
|
||||
|
||||
| Constant | Limit | Used for |
|
||||
|----------|-------|----------|
|
||||
| `kMaxVersionLength` | 64 | Version strings |
|
||||
| `kMaxUuidLength` | 256 | Application UUIDs |
|
||||
| `kMaxAbiHashLength` | 256 | ABI compatibility hashes |
|
||||
| `kMaxFilenameLength` | 256 | Archive filenames |
|
||||
| `kMaxSha1Length` | 40 | SHA1 hex strings |
|
||||
| `kMaxSha256Length` | 64 | SHA256 hex strings |
|
||||
| `kMaxTimestampLength` | 64 | ISO 8601 timestamps |
|
||||
| `kMaxVersionFullLength` | 256 | Full version strings |
|
||||
| `kMaxPlatformLength` | 32 | Platform strings |
|
||||
| `kMaxReasonLength` | 1024 | Revocation reason strings |
|
||||
| `kMaxRevocationDownloadSize` | 1 MB | CDN revocation list download cap |
|
||||
| `kMaxRevocationCacheFileSize` | 2 MB | On-disk revocation cache file size cap |
|
||||
| `kMaxRevocationEntryCount` | 10,000 | Revocation list entry count cap (prevents CPU exhaustion during parse/merge) |
|
||||
|
||||
**If you change:** Adding a new JSON parser or data source? It MUST enforce
|
||||
length limits from `installer_constants.h` on all string fields. If you need
|
||||
a new limit, add it to that file with a comment explaining the rationale.
|
||||
|
||||
### 6. Version Revocation
|
||||
|
||||
Revoked versions MUST be excluded from installation and use.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Compiled baseline loaded from immutable `RT_RCDATA` resource | `LoadCompiledRevocationList()` | `installer_revocation.cc` |
|
||||
| `MergeRevocationLists()` is additive-only (CDN cannot shrink baseline) | Union merge logic | `installer_revocation.cc` |
|
||||
| Revoked installed versions excluded from selection | `FindBestVersion()` filters against revocation list | `installer_version_resolver.cc` |
|
||||
| Revoked CDN versions blocked from download | `IsVersionRevoked()` check before download | `installer_controller.cc` |
|
||||
| Revoked bundled versions demoted | Priority adjustment | `installer_controller.cc` |
|
||||
| Effective revocations merge the compiled baseline with valid caches in every readable directory | `LoadEffectiveRevocationList()` seed passed through selection and prune | `installer_revocation.cc`, `installer_controller.cc` |
|
||||
| Revocation is authoritative over registration and confirmed prune protection | `GetRequiredVersionSet()`, `GetPrunableVersions()` | `installer_version_resolver.cc` |
|
||||
|
||||
**Accepted deviation:** The CDN revocation list is not cryptographically
|
||||
signed. Residual risk is mitigated by the compiled baseline (cannot be
|
||||
removed) and by the fact that adding fake entries only causes DoS (revokes
|
||||
legitimate versions), not code execution.
|
||||
|
||||
**If you change:** Adding a new version source (e.g., local cache, peer
|
||||
discovery)? It MUST be filtered against the merged revocation list before use.
|
||||
|
||||
### 7. Concurrency & Locking
|
||||
|
||||
Only one installer instance may modify a given install directory at a time.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Global named mutex with `Global\` prefix | `SingletonLock::Acquire()` | `installer_lock.cc` |
|
||||
| Lock scoped per install directory (hash-based name) | Name derivation in `SingletonLock` | `installer_lock.cc` |
|
||||
| Kernel abandonment grants exactly one waiter ownership; same-thread recursion is rejected and release asserts owner thread | `WAIT_ABANDONED` handling and owner tracking | `installer_lock.cc` |
|
||||
| Historical semaphore/new mutex object-type collision fails closed | `CreateMutexW()` failure on existing semaphore name | `installer_lock.cc` |
|
||||
| RAII release (destructor releases mutex) | `~SingletonLock()` | `installer_lock.cc` |
|
||||
| Authoritative database writes occur under the mutex; startup registration is zero-timeout best effort | `Controller::Run()`, `TryRegisterStartup()` | `installer_controller.cc` |
|
||||
| Registration, index publication, reconciliation, and prune ownership are scoped to one selected writable directory; cross-store registrations are not aggregated | controller passes only `install_dir` to authoritative operations | `installer_controller.cc` |
|
||||
| Query and automatic-startup offline selection do not acquire the writer mutex | pre-lock dispatch and `ResolveStartupOffline()` | `installer_controller.cc` |
|
||||
| Automatic-startup emergency scanning is limited to confirmed missing/corrupt indexes; non-dooming reads fail closed on generic I/O errors and valid indexes remain authoritative | per-root index status plus bounded scan in `ResolveStartupOffline()` | `installer_controller.cc`, `installer_version_metadata.cc` |
|
||||
| Installed startup result holds a no-delete `libcef.dll` lease through client execution | `AcquireVersionLease()`, `InstallerStartupState` | `installer_file_ops.cc`, `bootstrap_win.cc` |
|
||||
| Checked add publication is directory -> expanded index -> registration | `DownloadAndInstall()`, `PublishRegistration()` | `installer_controller.cc` |
|
||||
| Checked removal publication is registration -> reduced index -> whole-directory trash move | `UpdateDatabase()`, `PruneUnusedVersions()` | `installer_controller.cc` |
|
||||
| Valid indexes are authoritative during mutation recovery; missing/corrupt indexes are conservatively rebuilt before orphan classification | `ReconcileInstallState()` | `installer_controller.cc` |
|
||||
| Query/read-only selection never invokes reconciliation, repair, prune, network, or cache mutation | command dispatch and `LoadQueryRevocations()` | `installer_controller.cc` |
|
||||
| Only an integrity-valid, parseable fresh revocation cache means zero HTTP; stale/invalid launch refresh uses a single-attempt asynchronous request canceled at an absolute deadline and integrity-protected failure backoff | cache freshness/backoff gate and asynchronous request cancellation | `installer_revocation.cc`, `installer_download.cc`, `installer_controller.cc` |
|
||||
|
||||
**If you change:** Adding a new operation that modifies the install directory
|
||||
or database? It MUST run while `SingletonLock` is held.
|
||||
|
||||
### 8. Process Launch (Uninstall Relaunch)
|
||||
|
||||
Self-copy-and-relaunch for uninstall MUST not introduce privilege escalation
|
||||
or path injection vectors. Routing MUST select a mutation-permitted target
|
||||
before authorizing mutation and MUST fail closed when physical containment
|
||||
cannot be established. Once relaunch is required, failure to prepare or start
|
||||
the child MUST NOT fall back to direct mutation.
|
||||
|
||||
| Invariant | Enforced by | File |
|
||||
|-----------|-------------|------|
|
||||
| Original uninstall resolves enterprise policy and one mutation-permitted writable target before routing; no writable target cannot trigger relaunch | `PrepareUninstall()`, `ResolveInstallDirectories()` | `installer_bootstrap_helpers.cc`, `installer_paths.cc`, `installer_policy.cc` |
|
||||
| Bootstrap CLI direct dispatch requires a prepared snapshot bound to uninstall, configuration, install path, and the `kInProcess` decision; `RunInstaller("uninstall")` resolves its own state through `Controller::Run()` | `MaybeRunInstaller()`, `Controller::RunPreparedUninstall()` validation | `bootstrap_win.cc`, `installer_controller.cc` |
|
||||
| Physical containment is tri-state over handle-resolved identities; an indeterminate ordinary-uninstall result returns 105 before mutation or relaunch | `GetPhysicalPathContainment()`, `DecideUninstallExecution()` | `installer_paths.cc`, `installer_bootstrap_helpers.cc`, `bootstrap_win.cc` |
|
||||
| The internal relaunch marker and state are accepted only as a complete pair for explicit uninstall; the child executable must be physically contained by the installer temp directory and state must pass reparse, size, JSON, nonce, and absolute-path validation | `ResolveUninstallInvocationContext()`, `IsRunningFromTempDirectory()`, `GetPhysicalPathContainment()`, `ReadUninstallRelaunchState()` | `installer_bootstrap_helpers.cc`, `installer_paths.cc`, `installer_relaunch.cc` |
|
||||
| Temp directory uses 64-bit cryptographic random suffix | `base::RandUint64()` | `installer_relaunch.cc` |
|
||||
| Temp directory checked for reparse points after creation | `IsReparsePoint()` post-`CreateDirectory()` | `installer_relaunch.cc` |
|
||||
| `CreateProcessW` uses full path (no PATH search) | Explicit path construction | `installer_relaunch.cc` |
|
||||
| Exe path is quoted in command line | Quoting in command-line construction | `installer_relaunch.cc` |
|
||||
| Relaunched process inherits same privilege level | No elevation request | `installer_relaunch.cc` |
|
||||
| Failure to prepare or start a required relaunch returns 105 and never falls back to direct mutation | terminal relaunch branch in `MaybeRunInstaller()` | `bootstrap_win.cc` |
|
||||
| Optional lifecycle state binds one public operation ID and canonical decimal parent HWND to the private nonce state; partial, malformed, overflowed, or command-mismatched values fail closed | `WriteUninstallRelaunchState()`, `ReadUninstallRelaunchState()` | `installer_relaunch.cc` |
|
||||
| Lifecycle handoff occurs only after successful child creation; terminal results occur only after valid state acceptance and controlled child finalization | `CopySelfToTempAndRelaunch()`, `FinalizeUninstallLifecycle()` | `installer_relaunch.cc`, `bootstrap_win.cc`, `installer_lifecycle.cc` |
|
||||
| Lifecycle sends are one-shot and bounded to 500 ms; receiver results cannot cancel or alter commit/exit status | `SendInstallerLifecycleMessage()` | `installer_lifecycle.cc` |
|
||||
| Lifecycle payloads are valid UTF-8 with one NUL, 32 KiB total, bounded diagnostics, and path-free normalized messages | serializers/reference parsers and result producers | `installer_lifecycle.cc`, `installer_lifecycle_test_support.cc`, `installer_result_json.cc`, `installer_controller.cc` |
|
||||
| Child terminal failures accept only explicit stable installer error codes; exit 109 and arbitrary `UNKNOWN_ERROR` integers are rejected | lifecycle serializer and reference parsers | `installer_lifecycle.cc`, `installer_lifecycle_test_support.cc`, `e2e/lifecycle_receiver.py` |
|
||||
| Reference correlation state is capped at 256 operations, expires after 30 idle minutes, prioritizes orphan-terminal eviction, and exposes consume/remove operations | `InstallerLifecycleCorrelator`, `Correlator` | `installer_lifecycle_test_support.cc`, `e2e/lifecycle_receiver.py` |
|
||||
|
||||
The private relaunch nonce authorizes trusted-state consumption. The public
|
||||
128-bit operation ID only correlates a handoff with a terminal result; it does
|
||||
not authenticate the sender or receiver. Another process at the same user and
|
||||
integrity level may spoof window messages, and HWND destruction/reuse can
|
||||
redirect a later best-effort send. No receiver response carries a command or
|
||||
can change installer work.
|
||||
|
||||
Windows UIPI may block delivery from a lower-integrity child to a
|
||||
higher-integrity receiver. Such a receiver controls any opt-in by calling
|
||||
`ChangeWindowMessageFilterEx(WM_COPYDATA, MSGFLT_ALLOW)` on its own window. The
|
||||
installer does not weaken message filters. A blocked, timed-out, destroyed, or
|
||||
hung destination is an indeterminate transport outcome, never a reason to
|
||||
retry or roll back the uninstall.
|
||||
|
||||
---
|
||||
|
||||
## Registration Retention Availability Tradeoff
|
||||
|
||||
Registration retention is explicit maintenance, not an uninstall detector or
|
||||
a security boundary. It uses durable registration identity and canonical,
|
||||
integrity-valid launch evidence; it never infers ownership or abandonment from
|
||||
executable paths, ACLs, writability, or elevation. Missing, malformed,
|
||||
noncanonical, mismatched, zero, or future-dated evidence fails closed as
|
||||
unknown/protected.
|
||||
|
||||
Only source-derived per-user-default and custom `install_path` stores
|
||||
are eligible. HKLM and Program Files roles are provisioning-owned and
|
||||
ineligible even when their paths or permissions look user-writable. Apply
|
||||
recomputes under the writer lock and performs one authoritative final evidence
|
||||
collection. Each registration's direct canonical-liveness observation, rather
|
||||
than completion of the sequential store scan, is its launch-intent cutoff.
|
||||
Apply then offers a final cancellation checkpoint and enters the `committing`
|
||||
state. Logical database/index publication cannot be cancelled after that
|
||||
transition, but the built-in UI and parent-window protocol continue to accept
|
||||
one cancellation request and defer it to the next safe checkpoint before
|
||||
physical version cleanup. Registration removal is committed before checked
|
||||
reduced-index publication, and partial logical commits are reported for retry.
|
||||
Startup evidence writers never wait for retention-specific locks. Evidence
|
||||
published after its observation is
|
||||
preserved by exact, same-file-object compare-before-delete cleanup. Cleanup also
|
||||
requires the opened file's handle-resolved parent to match the observed
|
||||
`.launch` directory, so a later intermediate junction or symlink cannot
|
||||
redirect deletion. Later evidence does not roll back an already published
|
||||
database.
|
||||
Operators accept the availability risk that reclaimed dormant applications may
|
||||
later require a download or fail permanently under offline/no-download policy.
|
||||
|
||||
## Known TOCTOU Races
|
||||
|
||||
These check-then-use patterns have inherent race windows. They are mitigated
|
||||
but not eliminated. Document any new ones here.
|
||||
|
||||
| Check | Use | Location | Mitigation |
|
||||
|-------|-----|----------|------------|
|
||||
| `IsReparsePoint()` | `MoveFileExW()` (install) | `installer_file_ops.cc` `InstallVersion()` | Same-volume atomic rename minimizes window |
|
||||
| `IsReparsePoint()` | `MoveFileExW()` (uninstall) | `installer_file_ops.cc` `UninstallVersion()` | Same-volume atomic rename |
|
||||
| `IsReparsePoint()` | `base::CopyFile()` (relaunch) | `installer_relaunch.cc` | 64-bit random makes prediction impractical |
|
||||
| `PathExists(catalog)` | `VerifyCatalogSignature()` | `installer_signature.cc` `VerifyWithCatalog()` | Catalog from just-extracted temp dir under installer control |
|
||||
| `GetFileSize()` | `VerifyFileHash()` | `installer_download.cc` `DownloadFile()` | Low risk: file just written by this process in temp dir |
|
||||
| `TryUseDirectory()` write test | Actual file writes | `installer_paths.cc` | Write test only checks capability; real writes are under lock. Explicit uninstall captures the selected mutation target in its prepared snapshot. |
|
||||
| `GetPhysicalPathContainment()` | Choose direct mutation or temporary relaunch | `installer_paths.cc`, `installer_bootstrap_helpers.cc`, `bootstrap_win.cc` | Both identities are handle-resolved and indeterminate results fail closed. The immutable preflight binds the selected target; downstream reparse checks and the writer lock limit later mutation races. |
|
||||
| `IsReparsePoint(cache_dir)` | `WriteFile()` / `ReadFileToString()` | `installer_download.cc` `DownloadWithCache()` | Best effort — attacker already has user-level access; code signing is the hard boundary |
|
||||
| `IsReparsePoint(cache_path)` | `WriteFile()` | `installer_download.cc` `DownloadWithCache()` | Content is CDN-sourced (HTTPS), not attacker-controlled |
|
||||
| `IsReparsePoint(path)` | `WriteFile()` / `ReadFileToString()` | `installer_revocation.cc` `WriteRevocationCache()`, `LoadRevocationCache()` | Additive-only merge means injected entries cause DoS, not code execution |
|
||||
| `IsReparsePoint(versions_dir)` | `FileEnumerator` / `MoveFileExW` | `installer_file_ops.cc`, `installer_paths.cc`, `installer_version_metadata.cc` | Same-privilege; attacker must replace Versions/ dir between check and use |
|
||||
| `IsDirectorySafe()` | `MoveFileExW()` | `installer_file_ops.cc` `InstallVersion()` | Lock prevents concurrent modification by other installer instances |
|
||||
| `VerifySafeDirectoryPath()` | `CreateDirectory()` (staging, cache) | `installer_controller.cc` `DownloadAndInstall()` | Same-privilege; lock prevents concurrent installer instances |
|
||||
| `VerifySafeFilePath()` | `DownloadFile()` (archive) | `installer_controller.cc` `DownloadAndInstall()` | Content is verified post-download (signature + hash) |
|
||||
| `IsPathSafeForLoading()` | `LoadLibrary()` by client | `installer_file_ops.cc` / `installer_controller.cc` `MakeValidatedLibcefResult()` | Accepted at the owning user's trust level for user-writable stores. The cefclient reference loader's optional load-time `libcef.dll` signer check narrows this race as defense-in-depth; arbitrary clients must opt in, and catalog-covered dependencies/resources remain inside the same-user boundary. |
|
||||
|
||||
---
|
||||
|
||||
## Testing Mode Guards
|
||||
|
||||
These features exist for testing but MUST be compiled out of official builds
|
||||
or reject requested test state in official builds:
|
||||
|
||||
| Feature | Effect | Guard | File |
|
||||
|---------|--------|-------|------|
|
||||
| `SetTestingMode()` / `IsTestingMode()` | Master toggle; enables HTTP downloads, ignores TLS certificate errors, suppresses error dialogs | `OFFICIAL_BUILD && NDEBUG` | `installer_controller.cc` |
|
||||
| `SetStringResourceLoaderForTesting()` / `SetMessageBoxRunnerForTesting()` | Replaces localized UI strings or error-dialog display | `OFFICIAL_BUILD && NDEBUG` (callbacks and setters are compiled out) | `installer_progress_dialog.cc` |
|
||||
| `SetUninstallRelaunchCallbacksForTesting()` | Replaces process launch, lifecycle delivery, and authenticated client-module copying for deterministic relaunch tests | `OFFICIAL_BUILD && NDEBUG` (callbacks and setter are compiled out) | `installer_relaunch.cc` |
|
||||
| `SetEmergencyRecoveryScanLimitsForTesting()` | Overrides bounded automatic-startup recovery root, entry, and time limits | `OFFICIAL_BUILD && NDEBUG` (override state and method are compiled out) | `installer_controller.cc` |
|
||||
| `SetSignatureTestingMode()` | Accepts self-signed catalogs, skips thumbprint matching | `OFFICIAL_BUILD && NDEBUG` | `installer_signature.cc` |
|
||||
| `allow_http_for_testing` in `DownloadOptions` | Allows HTTP URLs (set by `IsTestingMode()`) | `OFFICIAL_BUILD && NDEBUG` | `installer_download.cc` |
|
||||
| `ignore_certificate_errors_for_testing` in `DownloadOptions` / `CEF_INSTALLER_IGNORE_CERTIFICATE_ERRORS_FOR_TESTING` | Ignores TLS certificate errors; set automatically by `IsTestingMode()` or explicitly by the environment variable for loopback process tests | `OFFICIAL_BUILD && NDEBUG` | `installer_controller.cc`, `installer_download.cc` |
|
||||
| `OverrideInstallDirectoriesForTesting()` | Overrides readable/writable directory discovery | `OFFICIAL_BUILD && NDEBUG` | `installer_paths.cc` |
|
||||
| `ResolveInstallDirectories()` override path | Returns test dirs instead of registry/filesystem | `OFFICIAL_BUILD && NDEBUG` | `installer_paths.cc` |
|
||||
| `InstallerE2EConfig` | Parses all `CEF_INSTALLER_TEST_*` subprocess inputs once into typed, process-wide settings; product components never read those environment variables directly | `OFFICIAL_BUILD && NDEBUG` (fault settings are inert; requested directory overrides are rejected) | `installer_e2e_config.cc` |
|
||||
| `CEF_INSTALLER_TEST_DIRECTORY_SCENARIO=admin_mutation_denied` with `CEF_INSTALLER_TEST_DIRECTORY_ROOT` | For original-uninstall E2E coverage, replaces directory candidates, reports elevation, and closes the admin mutation gate during uninstall preflight | `OFFICIAL_BUILD && NDEBUG` (requested test state is rejected) | `installer_e2e_config.cc`, `installer_bootstrap_helpers.cc`, `installer_paths.cc` |
|
||||
| `CEF_INSTALLER_TEST_RELAUNCH_FAILURE=1` | Forces required uninstall relaunch to fail before publication or child creation | `OFFICIAL_BUILD && NDEBUG` (setting is inert) | `installer_e2e_config.cc`, `installer_relaunch.cc` |
|
||||
| `CEF_INSTALLER_TEST_CHILD_CONFIG_FAILURE=1` | Fails a marked child after trusted state acceptance at the config-loading boundary | `OFFICIAL_BUILD && NDEBUG` (setting is inert) | `installer_e2e_config.cc`, `installer_bootstrap_helpers.cc` |
|
||||
| `CEF_INSTALLER_TEST_CHILD_STATE_BARRIER=<path>` | Signals a fixture-owned marker immediately after trusted-state acceptance and waits up to 30 seconds for deterministic termination testing | `OFFICIAL_BUILD && NDEBUG` (setting is inert) | `installer_e2e_config.cc`, `installer_bootstrap_helpers.cc` |
|
||||
| `CEF_INSTALLER_TEST_DATABASE_SAVE_FAILURE`, `CEF_INSTALLER_TEST_INDEX_FAULT`, `CEF_INSTALLER_TEST_FILE_OPS_FAULT` | Injects typed persistence and mutation-boundary failures in subprocess/E2E tests | `OFFICIAL_BUILD && NDEBUG` (settings are inert) | `installer_e2e_config.cc`, `installer_database.cc`, `installer_version_metadata.cc`, `installer_file_ops.cc` |
|
||||
| `certificate_thumbprint` in an embedded test resource / `Config` | Overrides the certificate thumbprint for archive verification; auto-enables `SetSignatureTestingMode` when the thumbprint differs from production default | `OFFICIAL_BUILD && NDEBUG` | `installer_config.cc`, `bootstrap_win.cc` |
|
||||
| `/cef-install-path` CLI flag | Overrides the CEF installation directory; could redirect installs to attacker-controlled paths | `OFFICIAL_BUILD && NDEBUG` | `installer_bootstrap_helpers.cc` |
|
||||
| `/cef-download-path` CLI flag, `local_download_path` in `DownloadOptions` and `ExtendedConfig` | Reads manifests and archives from a local directory instead of CDN; bypasses network security (HTTPS, certificate pinning) for the transport layer. All post-download validation (hash, signature, size limits) still applies | **No guard (allowed in official builds)** — signature verification is the security boundary, not HTTPS transport | `installer_bootstrap_helpers.cc`, `installer_download.cc`, `installer_controller.h` |
|
||||
|
||||
`Logger::ResetForTesting()` and `ProgressDialog::FlushForTesting()` have no
|
||||
`OFFICIAL_BUILD` guard but are not security-sensitive (they only reset internal
|
||||
logging state or drain pending tasks on the internal dialog thread,
|
||||
respectively).
|
||||
|
||||
`ScopedThreadPool` intentionally uses `ThreadPoolInstance::JoinForTesting()`
|
||||
and `FeatureList::ResetEarlyFeatureAccessTrackerForTesting()` in official
|
||||
builds. The installer creates a temporary thread pool only when the embedding
|
||||
process has not initialized one, and must tear it down before that process may
|
||||
later initialize Chromium. These are lifecycle operations, not configurable
|
||||
testing bypasses.
|
||||
|
||||
If adding a new testing bypass, it MUST use the same `OFFICIAL_BUILD && NDEBUG`
|
||||
guard pattern and MUST be documented in this table.
|
||||
@@ -0,0 +1,906 @@
|
||||
# Installer Testing Guide
|
||||
|
||||
All commands below run from the `chromium/src` directory.
|
||||
|
||||
## Building Tests
|
||||
|
||||
The installer unit tests are defined in `//cef/BUILD.gn` as the
|
||||
`cef_installer_unittests` target. Replace `<build_dir>` with your build
|
||||
directory (e.g., `out/Debug_GN_x64`).
|
||||
|
||||
```bash
|
||||
# Unit + integration tests only
|
||||
autoninja -C <build_dir> cef_installer_unittests
|
||||
|
||||
# Everything (unit tests + E2E infrastructure + bootstrap binary)
|
||||
autoninja -C <build_dir> cef cef_installer_unittests
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
```bash
|
||||
# Run all installer tests
|
||||
<build_dir>/cef_installer_unittests.exe
|
||||
|
||||
# Run a specific test suite
|
||||
<build_dir>/cef_installer_unittests.exe --gtest_filter="InstallerConfig*"
|
||||
|
||||
# Run a specific test
|
||||
<build_dir>/cef_installer_unittests.exe --gtest_filter="InstallerArchiveTest.ExtractValidArchive"
|
||||
|
||||
# List all available tests
|
||||
<build_dir>/cef_installer_unittests.exe --gtest_list_tests
|
||||
```
|
||||
|
||||
## Directory roles and provisioning
|
||||
|
||||
`InstallerPathsTest` uses source-tagged candidate overrides, injected physical
|
||||
read/write state, and injected elevation so the HKLM, Program Files, per-user,
|
||||
and custom matrix does not depend on the runner's token or host ACLs. The
|
||||
controller tests additionally override the official admin-mutation decision to
|
||||
prove gated startup takes no writer lock, database/registration path, or
|
||||
per-user fallback. Focused filters are:
|
||||
|
||||
```bash
|
||||
<build_dir>/cef_installer_unittests.exe \
|
||||
--gtest_filter="InstallerPathsTest.*Role*:InstallerPathsTest.*Admin*:InstallerPathsTest.*Elevated*:Controller*.*AdminDirectory*"
|
||||
|
||||
<build_dir>/cef_installer_unittests.exe \
|
||||
--gtest_filter="InstallerConfigTest.*InstallPath*:ConfigPrecedenceTest.*InstallPath*"
|
||||
```
|
||||
|
||||
The resource fixture `cef_config_test_appid_a.dll` contains a relative
|
||||
`install_path`; helper tests verify client-only acceptance, DLL-relative
|
||||
resolution, and independent bootstrap provenance for `enable_explicit_modes`.
|
||||
Real Program Files/HKLM permission coverage requires an appropriately elevated
|
||||
Windows environment. Broad E2E role tests should use isolated roots and the
|
||||
non-official override; cleanup must be limited to paths/registry values created
|
||||
by that test.
|
||||
|
||||
Provisioning uses existing `/cef-update` and `/cef-uninstall` commands with a
|
||||
dedicated appid and exact `vmin = vmax` pin. Multiple pins use multiple appids.
|
||||
Inspect `installer.json`, `versions.json`, `.launch`, `.cache`, `.staging`, and
|
||||
`.trash` under only the selected test root when asserting side effects.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
End-to-end tests exercise the real `bootstrap.exe` binary as a subprocess,
|
||||
covering command-line parsing, config loading, exit codes, launcher mode, and
|
||||
process lifecycle.
|
||||
|
||||
### Building
|
||||
|
||||
E2E tests require the full `cef` target (includes `bootstrap.exe`, the mock
|
||||
client DLL, the progress helper, and the CDN builder):
|
||||
|
||||
```bash
|
||||
autoninja -C <build_dir> cef
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **`CEF_RESOURCE_HACKER_PATH`** (system environment variable) — Must point to
|
||||
`ResourceHacker.exe` for tests that add or replace embedded configuration
|
||||
resources, including E2E2 and the bootstrap/client-resource CDN failover
|
||||
tests. Those tests fail with a clear error if it is not set. Tests that do
|
||||
not modify embedded resources do not require ResourceHacker.
|
||||
- **Windows certificate-store access** — The E2E CDN builder creates signed
|
||||
test distributions, and the suite launches helper binaries. Sandboxed runs
|
||||
may fail with certificate or key-storage errors. Rerun the E2E command with
|
||||
the required elevated permission instead of changing product code.
|
||||
- **Loopback HTTPS assets** - The self-signed localhost certificate and its
|
||||
test-only private key are checked in under `testdata`; OpenSSL is not a test
|
||||
prerequisite. The private key is fixture data and must never be used outside
|
||||
the installer tests.
|
||||
- **Enterprise policy fixture access** - `test_enterprise_policy.py` creates
|
||||
one unique subkey below
|
||||
`HKCU\\SOFTWARE\\CEF\\InstallerPolicyTests` by default, explicitly in the
|
||||
shared 64-bit view, so ordinary non-administrator runs exercise real
|
||||
registry I/O. Non-official binaries accept only that test-root prefix and
|
||||
the `HKCU`/`HKLM` test-hive values; official builds ignore both seams. Set
|
||||
`CEF_INSTALLER_E2E_POLICY_HKLM=1` in an elevated environment to run the same
|
||||
cases below `HKLM` instead. The test removes only its UUID-named key and
|
||||
never replaces or deletes the real `HKLM\\SOFTWARE\\Policies\\CEF` key.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Run all E2E tests
|
||||
python3 cef/libcef_dll/bootstrap/installer/e2e/run_e2e_tests.py \
|
||||
--build-dir=<build_dir>
|
||||
|
||||
# Run a single E2E test
|
||||
python3 cef/libcef_dll/bootstrap/installer/e2e/run_e2e_tests.py \
|
||||
--build-dir=<build_dir> \
|
||||
-k test_install_flow.TestInstallFlow.test_fresh_install
|
||||
```
|
||||
|
||||
### Uninstall routing coverage
|
||||
|
||||
The uninstall unit and E2E tests use exact exit codes and inspect final logical
|
||||
state:
|
||||
|
||||
- An external executable with a writable target is synchronous (0 on success),
|
||||
including a client-resource relative custom path and the two-app prune case.
|
||||
- Database/index failures return their exact nonzero controller code; physical
|
||||
cleanup failure returns 0 after logical commit and leaves cleanup deferred.
|
||||
- Resolver/preflight unit tests cover read-only exclusive custom/admin targets:
|
||||
they return the normal no-writable error without a lower-priority fallback.
|
||||
A containment unit test applies a scoped empty DACL to a temporary file, and
|
||||
the CLI E2E applies a scoped write-deny DACL to an owned custom store. The E2E
|
||||
verifies the normal error, unchanged state, and no temporary child.
|
||||
- Physical containment uses handle-resolved `FilePath` equality/parent checks.
|
||||
A real case-sensitive directory fixture verifies that sibling `CEF` and
|
||||
`cef` components remain distinct.
|
||||
- With a writable target, the missing-executable ordinary-uninstall preflight
|
||||
test verifies an exact 105 decision before mutation or relaunch. A separate
|
||||
real ACL-inaccessible fixture verifies that the containment helper returns
|
||||
`kIndeterminate`.
|
||||
- A non-official, accepted-value-only directory-role E2E seam models an
|
||||
elevated admin store whose mutation gate is closed. The subprocess test
|
||||
verifies no per-user fallback, mutation, or temporary child.
|
||||
- An executable staged beneath the writable target, or an external executable
|
||||
using `/cef-background`, returns exactly 109. With a pumped `/cef-parent`,
|
||||
lifecycle E2E tests correlate the handoff and normalized terminal result.
|
||||
Filesystem polling remains only legacy test cleanup and is not a supported
|
||||
caller completion contract.
|
||||
- Prepared-controller tests verify that snapshots bound to relaunch or reject
|
||||
decisions cannot be passed to the direct controller entry point.
|
||||
- Forced relaunch failure returns 105 with state unchanged. A marker without
|
||||
valid trusted state, or relaunch transport on a non-uninstall command,
|
||||
returns 100 without mutation.
|
||||
|
||||
Use these focused commands from `//src`:
|
||||
|
||||
```powershell
|
||||
out\Debug_GN_x64\cef_installer_unittests.exe `
|
||||
--gtest_filter='BootstrapHelpersTest.*Uninstall*:InstallerPathsTest.*Contain*:InstallerRelaunchTest.*:Controller*.*Uninstall*:InstallerOutcomeTest.*'
|
||||
|
||||
python cef\libcef_dll\bootstrap\installer\e2e\run_e2e_tests.py `
|
||||
--build-dir out\Debug_GN_x64 `
|
||||
-k 'test_uninstall or exit_code_relaunched or install_path_relative_in_client_dll_resource or trash_reclamation_reports_cleanup_deferred' `
|
||||
--verbose
|
||||
|
||||
out\Debug_GN_x64\cef_installer_unittests.exe `
|
||||
--gtest_filter='InstallerLifecycleTest.*:ProgressUIIntegrationTest.*:InstallerRelaunchTest.*'
|
||||
|
||||
python cef\libcef_dll\bootstrap\installer\e2e\run_e2e_tests.py `
|
||||
--build-dir out\Debug_GN_x64 `
|
||||
-k 'lifecycle or progress or uninstall' `
|
||||
--verbose
|
||||
```
|
||||
|
||||
Run lifecycle race/correlation filters at least 20 times when changing the
|
||||
transport. Unit and E2E binaries require Windows certificate-store access;
|
||||
rerun outside a restricted sandbox when certificate/key-storage APIs are
|
||||
denied. Elevation-specific UIPI coverage is environment-dependent: a
|
||||
higher-integrity receiver must opt in to `WM_COPYDATA`; same-integrity delivery
|
||||
is always covered.
|
||||
|
||||
`/cef-uninstall-relaunched` is internal transport/test state, not a supported
|
||||
caller bypass. It is accepted only for explicit uninstall with the nonce-bound
|
||||
trusted state file created beside the temporary executable. The non-official
|
||||
`CEF_INSTALLER_TEST_RELAUNCH_FAILURE=1` environment seam deterministically
|
||||
exercises failure before temp publication or child creation.
|
||||
|
||||
The admin-gate E2E pairs
|
||||
`CEF_INSTALLER_TEST_DIRECTORY_SCENARIO=admin_mutation_denied` with an absolute
|
||||
`CEF_INSTALLER_TEST_DIRECTORY_ROOT` containing existing `Admin` and `PerUser`
|
||||
directories. It is accepted only for an original uninstall in non-official
|
||||
builds; missing, unknown, relative, or incomplete values return configuration
|
||||
error, and official release builds reject requested test state.
|
||||
|
||||
### E2E test infrastructure
|
||||
|
||||
- **`e2e_test_base.py`** — Base class with helpers for running the installer,
|
||||
building local CDN directories, reading the database, embedding config
|
||||
resources via ResourceHacker, and launch state assertions. Each test
|
||||
unconditionally restores its fixed-name executable from unmodified
|
||||
`bootstrap.exe` before resource edits; launcher helpers likewise restore the
|
||||
executable and client DLL they own. Resource-editing tests require
|
||||
`CEF_RESOURCE_HACKER_PATH`, while the missing-resource test launches the
|
||||
freshly restored executable without invoking Resource Hacker.
|
||||
- **`cef_e2e_mock_client.dll`** — Mock client DLL for launcher mode tests.
|
||||
Supports single-threaded mode (env: `CEF_E2E_CONFIG_JSON`) and multi-threaded
|
||||
mode (env: `CEF_E2E_THREADING_MODE=multi`, `CEF_E2E_CONFIG_JSON_A/B`).
|
||||
When `CEF_E2E_EXIT_CODE` is set, `RunWinMain` writes the marker file and
|
||||
returns the specified exit code without calling `RunInstaller` — this lets
|
||||
launch health tests cover default/missing `off`, `explicit`, and
|
||||
`exit_code` modes, including clean exits, crashes, neutral exits, prior-boot
|
||||
records, explicit confirmation, and atomic write failure behavior.
|
||||
Controller unit tests cover invalid-file GC and writer-locked reparse-point
|
||||
repair.
|
||||
When `CEF_E2E_LAUNCH_SUCCESS` is also set, the mock client calls
|
||||
`RunInstaller("launch_success", ...)` to confirm launch health *before*
|
||||
writing the marker file and returning the exit code. This lets launch health
|
||||
tests simulate explicit confirmation before clean exits or crashes.
|
||||
- **`cef_e2e_progress_helper.exe`** — Creates a message-only window. Its
|
||||
legacy invocation writes the existing progress JSON list. `--lifecycle`
|
||||
writes separate raw `progress` and `lifecycle` arrays plus a rejected-message
|
||||
count. `--lifecycle-return=N`, `--non-pumping`,
|
||||
`--close-after-handoff`, and `--allow-copydata` cover receiver return,
|
||||
timeout, destruction, and UIPI opt-in behavior. The window procedure only
|
||||
bounds and copies bytes; parsing occurs afterward.
|
||||
- **`lifecycle_receiver.py`** — Strict version-1 reference parser and
|
||||
operation-ID correlator. It buffers terminal-first delivery, leaves missing
|
||||
handoffs unattributed, rejects non-terminal/unknown exit codes, ignores
|
||||
malformed/future events, and preserves the first valid correlated terminal.
|
||||
State is capped at 256 operations, expires after 30 idle minutes, and can
|
||||
be consumed or explicitly removed.
|
||||
- **`cef_e2e_build_test_cdn.exe`** — Builds signed test distributions in CDN
|
||||
layout.
|
||||
|
||||
Marker JSON from `cef_e2e_mock_client.dll` records size-gated
|
||||
`installer_error_code` and `installer_error_message`; a null message remains
|
||||
JSON `null`.
|
||||
|
||||
### Test-only flags
|
||||
|
||||
See [Testing Mode Guards](SECURITY.md#testing-mode-guards) for the
|
||||
official-build requirements that prevent test seams from becoming production
|
||||
bypasses.
|
||||
|
||||
All `CEF_INSTALLER_TEST_*` environment variables are parsed centrally into
|
||||
typed `InstallerE2EConfig` state. The owning directory, relaunch, config,
|
||||
database, index, or file-operation component applies the requested fault;
|
||||
bootstrap orchestration does not interpret test scenarios.
|
||||
|
||||
- **`/cef-install-path=<path>`** — Override the CEF installation directory. Each
|
||||
E2E test uses its own temp directory for isolation. Available only in
|
||||
non-official builds.
|
||||
- **`/cef-log-level=<level>`** — Set minimum log level for `cef_installer.log`.
|
||||
Accepted values: `info`, `warning`, `error` (case-insensitive). Default:
|
||||
`warning`. Use `/cef-log-level=info` for verbose output when debugging.
|
||||
Available in all builds.
|
||||
- **`CEF_INSTALLER_TEST_CHILD_CONFIG_FAILURE=1`** — Fails only a marked child
|
||||
after trusted state is accepted at the config-loading boundary, producing a
|
||||
controlled config terminal.
|
||||
- **`CEF_INSTALLER_TEST_CHILD_STATE_BARRIER=<absolute path>`** — Writes a
|
||||
fixture-owned ready marker after state acceptance and waits for up to 30
|
||||
seconds while it exists, allowing deterministic forced termination.
|
||||
- **`certificate_thumbprint`** (embedded test resource, non-official builds
|
||||
only) — Override the certificate thumbprint used for archive verification.
|
||||
When set to the test cert thumbprint, the bootstrap auto-enables signature
|
||||
testing mode so self-signed archives pass verification.
|
||||
|
||||
Lifecycle teardown terminates only the exact helper/child processes started by
|
||||
the fixture, waits for them, and removes only fixture-owned markers and
|
||||
verified new relaunch directories. Missing terminal delivery is indeterminate;
|
||||
tests do not infer completion from database, index, or temporary-directory
|
||||
internals.
|
||||
|
||||
### Writing E2E tests — common pitfalls
|
||||
|
||||
**`CEF_E2E_LAUNCH_SUCCESS` and `CEF_E2E_EXIT_CODE` interact.** Setting both
|
||||
simulates an app that confirms CEF health then exits with the given code. The
|
||||
sentinel is confirmed (`running=false, failures=0`) during `RunWinMain`, so a
|
||||
subsequent crash exit does *not* count as a CEF failure (the post-exit handler
|
||||
doesn't run for a failure exit, and the confirmation is already durable).
|
||||
Without `CEF_E2E_LAUNCH_SUCCESS`, a non-zero (failure) exit leaves the sentinel
|
||||
as `running=true`, which is detected as a crash on the next launch. Use the
|
||||
`launch_success=True` parameter on `_run_launcher_with_exit_code` to set it.
|
||||
Note that confirming on a *clean* exit still triggers post-exit cleanup/prune,
|
||||
which can delete an older confirmed version's `.launch/` file (and the version)
|
||||
— confirm on a crash exit if you need an older fallback version to survive.
|
||||
|
||||
**`_standalone_install` has side effects.** Each standalone install runs
|
||||
`ComputeAndInstall` → `DownloadAndInstall` → `PruneUnusedVersions`. The
|
||||
prune age-checks valid orphaned and below-global-`vmin` `.launch/` files.
|
||||
Records less than 90 days old survive; records exactly 90 days old or older
|
||||
may be deleted. E2E fixtures simulate age with integrity-protected
|
||||
`last_update` (health) or `last_launch` (liveness) content. Do not use
|
||||
filesystem mtime to age a fixture.
|
||||
|
||||
**Global vmin is the minimum across ALL registered apps.** If you need a
|
||||
`.launch/` file to survive a standalone install that raises one app's vmin,
|
||||
register a second app with a lower vmin first (e.g., `appid2` with
|
||||
`vmin=v1`). This keeps the global minimum low.
|
||||
|
||||
**`_standalone_install(version, vmin=X)` won't install `version` if a
|
||||
version ≥ X is already installed.** The installer selects the best existing
|
||||
version that satisfies vmin. To force a specific version, use `vmin=version`
|
||||
(or `vmin=version, vmax=version` to pin).
|
||||
|
||||
**`_embed_launcher_config` writes to the exe, not the DLL.** The bootstrap
|
||||
reads config from the client DLL first; the exe resource is a fallback. In
|
||||
launcher mode with the mock client DLL (which has no embedded config), the
|
||||
exe resource IS used. But if you've embedded config into the DLL (e.g., for
|
||||
bundled path tests), the DLL config takes priority and the exe config is
|
||||
ignored.
|
||||
|
||||
**Cleanup vs GC — test the right mechanism.** On successful exit, the
|
||||
bootstrap deletes `.launch/` files via `cleanup_paths` (only confirmed older
|
||||
files for the current app). Separately, `PruneUnusedVersions` runs stale
|
||||
file GC for orphaned and below-global-vmin records after the 90-day content-age
|
||||
grace. If your test asserts a file was deleted, make sure it was deleted by the
|
||||
mechanism you intend to test, not the other one. Use the second-app trick above
|
||||
to neutralize GC when testing cleanup, or keep vmin constant.
|
||||
|
||||
**Launch GC has a dedicated unit-test clock.** Tests that call
|
||||
`SetLaunchStateGcTimeForTesting()` must reset it to `std::nullopt` in fixture
|
||||
teardown. Keep health `last_update` independent from retention's
|
||||
`pid_start_time`; liveness uses `last_launch` for both policies. Useful focused
|
||||
checks are:
|
||||
|
||||
```powershell
|
||||
out\Debug_GN_x64\cef_installer_unittests.exe `
|
||||
--gtest_filter="LaunchStateTest.*:ControllerTempDirTest.Pruning_*"
|
||||
python cef\libcef_dll\bootstrap\installer\e2e\run_e2e_tests.py `
|
||||
--build-dir out\Debug_GN_x64 -k launch_health --verbose
|
||||
```
|
||||
|
||||
**Pruning requires a version to not be the best match.** A version is only
|
||||
prunable if no registered app's best match points to it. Installing a newer
|
||||
version doesn't make the older one prunable if the older one is still the
|
||||
best match (e.g., because vmin hasn't changed). Unregistering the app or
|
||||
installing a strictly better version makes the old one prunable.
|
||||
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for the security invariants that tests must
|
||||
cover. When adding a new code path (e.g., a new extraction method), check the
|
||||
"If you change" sections in SECURITY.md for required test coverage.
|
||||
|
||||
## Test Conventions
|
||||
|
||||
Each production source file has a corresponding `*_unittest.cc` (e.g.,
|
||||
`installer_archive.cc` -> `installer_archive_unittest.cc`). Integration tests
|
||||
that require a mock CDN server use the `InstallerIntegrationTest` fixture in
|
||||
`installer_integration_test.cc` (full `Controller::Run` with embedded HTTP
|
||||
server). Config precedence unit tests (`TryLoadInstallerConfig`) use test DLLs
|
||||
with embedded resources (`cef_config_test_appid_a.dll`, etc.) loaded via
|
||||
`LoadLibraryEx(LOAD_LIBRARY_AS_DATAFILE)`.
|
||||
|
||||
The `cef_config_test_malformed.dll` and `cef_config_test_invalid.dll`
|
||||
fixtures verify that present-but-bad trusted resources do not fall back.
|
||||
`embed_raw_config_resource()` provides the equivalent E2E path.
|
||||
|
||||
The CDN-list resource fixtures also include valid client/bootstrap lists and
|
||||
invalid HTTP, wrong-shape, empty, over-count, and over-length lists. Keep these
|
||||
fixtures in the public config-loader matrix: application `cdn_urls` use normal
|
||||
client/bootstrap source selection and must never merge.
|
||||
|
||||
Failure and API contract focused validation:
|
||||
|
||||
```bash
|
||||
<build_dir>/cef_installer_unittests.exe --gtest_filter="InstallerConfigTest.*:ConfigPrecedenceTest.*:InstallerResultTest.*:InstallerExitCodeTest.*:InstallerOutcomeTest.*:ExtendedConfigTest.*:RunInstallerTest.*:InstallerStartupResultTest.*"
|
||||
python3 cef/libcef_dll/bootstrap/installer/e2e/run_e2e_tests.py \
|
||||
--build-dir=<build_dir> \
|
||||
-k "test_run_installer or test_launcher_mode or test_exit_codes"
|
||||
```
|
||||
|
||||
Resolver and transactional-correctness focused validation:
|
||||
|
||||
```bash
|
||||
<build_dir>/cef_installer_unittests.exe --gtest_filter="InstallerVersionResolverTest.*:VersionIndexTest.*:InstallerRevocationTest.*:InstallerFileOpsTest.*Repair*:ControllerTempDirTest.*Index*:ControllerTempDirTest.*Rebuild*:ControllerTempDirTest.*Orphan*:InstallerIntegrationTest.*Update*:InstallerIntegrationTest.*Repair*"
|
||||
```
|
||||
|
||||
Registration-retention focused validation:
|
||||
|
||||
```bash
|
||||
<build_dir>/cef_installer_unittests.exe \
|
||||
--gtest_filter="InstallerRetentionTest.*:LaunchStateTest.*Retention*:ControllerTempDirTest.Retention*:BootstrapHelpersTest.*Retention*:RetentionOutputTest.*:InstallerFileIntegrityTest.*Conditional*:ProgressDialogTest.*Cancel*"
|
||||
|
||||
python3 cef/libcef_dll/bootstrap/installer/e2e/run_e2e_tests.py \
|
||||
--build-dir=<build_dir> \
|
||||
-k test_retention
|
||||
```
|
||||
|
||||
CDN robustness focused validation:
|
||||
|
||||
```powershell
|
||||
out\Debug_GN_x64\cef_installer_unittests.exe `
|
||||
--gtest_filter='InstallerConfigTest.*Cdn*:ConfigPrecedenceTest.*Cdn*:ExtendedConfigTest.*Cdn*:CombinedConfigTest.*Cdn*:ExtendedConfigSecurityTest.*Cdn*:InstallerPolicyTest.*Source*:InstallerPolicyTest.*Url*:InstallerPolicyTest.*Identity*:InstallerDownloadTest.*ContentRange*:InstallerDownloadTest.*Partial*:InstallerDownloadTest.*Prune*Archive*:InstallerDownloadServerTest.*Range*:InstallerDownloadServerTest.*Partial*:InstallerDownloadServerTest.*Origin*:InstallerDownloadServerTest.*Promotion*:InstallerControllerServerTest.*CdnFailover*:InstallerControllerServerTest.NextBest*:InstallerControllerClassificationTest.NextBest*:InstallerControllerTest.NextBest*:InstallerIntegrationTest.*PolicyFailover*:InstallerIntegrationTest.*NextBest*:InstallerIntegrationTest.*ArchiveCleanup*:InstallerIntegrationTest.Background*:BootstrapHelpersTest.*Background*:InstallerParallelXzTest.*Background*:DetermineThreadCountTest.*Background*'
|
||||
|
||||
python cef\libcef_dll\bootstrap\installer\e2e\run_e2e_tests.py `
|
||||
--build-dir out\Debug_GN_x64 `
|
||||
--filter 'next_best_when_newest_archive_missing or explicit_background_update or authorized_application_cdn_https_failover or bootstrap_resource_cdn_https_failover or client_resource_cdn_https_failover'
|
||||
```
|
||||
|
||||
Range tests use deterministic HTTP handlers that inspect exact request headers
|
||||
and script `200`, `206`, `416`, duplicate/malformed `Content-Range`, length
|
||||
disagreement, short/overlong bodies, redirects, and request counts. They must
|
||||
assert that rejected responses are never promoted, interruption/cancellation
|
||||
retains only the matching origin-bound partial, progress begins at the retained
|
||||
size, and the protocol/final-origin/hash conditions share one clean-request
|
||||
budget. Small manifest, revocation, and sidecar downloads remain non-resumable.
|
||||
|
||||
The signed integration tests cover one next-best selection without manifest
|
||||
refetch, ordinary commit cleanup, archive retention after checked-index or
|
||||
registration failure, post-commit `cleanup_deferred`, and propagation-lag
|
||||
fallback. The local-CDN E2E builder can be called twice to advertise two
|
||||
compatible versions; deleting only the newer archive exercises the same
|
||||
fallback in a real bootstrap process.
|
||||
|
||||
Retention tests should verify the report as well as the durable state. Check
|
||||
`installer.json`, `versions.json`, canonical files under `.launch`, and
|
||||
`retention_pending.json`. Failure and cancellation cases must preserve the
|
||||
documented database-before-index ordering, retain an exact pending version
|
||||
scope when retry is required, and converge on an explicit retry.
|
||||
|
||||
`SetVersionIndexFaultForTesting()` deterministically injects checked index
|
||||
write, replace, reread, and intended-set validation failures. Recovery tests
|
||||
use explicit missing/corrupt/valid index snapshots plus staging, trash, and
|
||||
unindexed directories; assertions must cover the durable index, registration,
|
||||
directory membership, `outcome`, numeric `error_code`, symbolic `error_name`,
|
||||
and `warnings` for deferred physical cleanup. Run crash/fault filters more than
|
||||
once when changing publication order.
|
||||
|
||||
`SetFileOpsFaultForTesting()` covers quarantine/replacement moves and trash move
|
||||
and reclamation. A quarantine-move failure preserves the existing target and
|
||||
verified staging at their original paths. A replacement-move failure preserves
|
||||
verified staging, leaves the old target under `.trash/`, and leaves the
|
||||
canonical destination absent. Recovery must report post-index orphan cleanup
|
||||
failures as successful `cleanup_deferred`; collision reclamation is likewise
|
||||
`cleanup_deferred` only after directory and checked index publication succeed.
|
||||
|
||||
- Use `base::ScopedTempDir` for filesystem operations.
|
||||
- Use `Version::FromApiVersion(CEF_API_VERSION_LAST)` for version values to
|
||||
avoid vmin clamping.
|
||||
- Use `SetTestingMode(true)` in non-official tests to permit an already-parsed,
|
||||
internally injected HTTP source and suppress error dialogs. Public resource
|
||||
and `RunInstaller` `cdn_urls` parsing remains HTTPS-only.
|
||||
- `VersionMetadata::IsValid()` requires non-empty `version`, `abi_hash`, and
|
||||
`platform`.
|
||||
|
||||
## Manual Testing
|
||||
|
||||
There are three ways to manually test the bootstrap + installer, each
|
||||
exercising a different CEF loading path. All three require a Release build
|
||||
(see [Prerequisites](README.md#prerequisites)).
|
||||
|
||||
In the examples below, `<build_dir>` is your build output directory
|
||||
(e.g., `out/Release_GN_x64`) and `cefclient` is the client name.
|
||||
The bootstrap binary is always renamed to match the client DLL (e.g.,
|
||||
`bootstrap.exe` → `cefclient.exe`, which loads `cefclient.dll`).
|
||||
|
||||
**`enable_explicit_modes`:** Explicit commands (`/cef-update`,
|
||||
`/cef-uninstall`) and standalone auto-install require
|
||||
`"enable_explicit_modes": true` in the bootstrap exe's embedded
|
||||
`CEF_INSTALLER_CONFIG` resource (official builds only — non-official builds
|
||||
allow these unconditionally). See
|
||||
[Embedding Resources with ResourceHacker](#embedding-resources-with-resourcehacker)
|
||||
for how to add this post-build.
|
||||
|
||||
### Mode 1: Unchecked CEF Path (all binaries co-located)
|
||||
|
||||
The simplest setup — all binaries live in one directory. This is the default
|
||||
when `cefclient.dll` has an `unchecked_cef_path` config (typically `"."`) or
|
||||
no `CEF_INSTALLER_CONFIG` resource at all.
|
||||
|
||||
**Setup:** No extra steps needed. The build output already has `cefclient.exe`,
|
||||
`cefclient.dll`, `chrome_elf.dll`, `libcef.dll`, and all CEF resources in the
|
||||
same directory.
|
||||
|
||||
```
|
||||
<build_dir>/
|
||||
cefclient.exe # bootstrap.exe, renamed
|
||||
cefclient.dll
|
||||
chrome_elf.dll # Must match exe signing (same cert, or both unsigned)
|
||||
libcef.dll
|
||||
*.pak, locales/, ... # CEF resources
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```
|
||||
<build_dir>/cefclient.exe
|
||||
```
|
||||
|
||||
The bootstrap finds `libcef.dll` adjacent to the client DLL via
|
||||
`unchecked_cef_path` and skips the installer entirely — no version checks,
|
||||
no signature verification, no CDN access.
|
||||
|
||||
### Mode 2: Bundled CEF Path (CEF in a subdirectory)
|
||||
|
||||
Tests the bundled version selection path. The bootstrap, client DLL, and
|
||||
`chrome_elf.dll` are in one directory; the CEF distribution is pre-extracted
|
||||
into a subdirectory.
|
||||
|
||||
**Setup:** Create a directory with the bootstrap files, and extract a signed
|
||||
CEF distribution into a subdirectory (e.g., `cef/`):
|
||||
|
||||
```
|
||||
test_dir/
|
||||
cefclient.exe # Copy of bootstrap.exe, renamed
|
||||
cefclient.dll # Client DLL with bundled_cef_path config
|
||||
chrome_elf.dll # Must match exe signing (same cert, or both unsigned)
|
||||
cef/ # Extracted CEF distribution
|
||||
cef_version.json
|
||||
catalog.cat
|
||||
Release/
|
||||
libcef.dll
|
||||
chrome_elf.dll
|
||||
*.pak, locales/, ...
|
||||
```
|
||||
|
||||
The client DLL's `CEF_INSTALLER_CONFIG` resource should include:
|
||||
```json
|
||||
{
|
||||
"appid": "...",
|
||||
"vmin": "...",
|
||||
"abi_hash": "...",
|
||||
"bundled_cef_path": "cef"
|
||||
}
|
||||
```
|
||||
|
||||
The `bundled_cef_path` is resolved relative to the client DLL's directory.
|
||||
|
||||
**Run:**
|
||||
```
|
||||
test_dir/cefclient.exe
|
||||
```
|
||||
|
||||
The bootstrap finds the bundled version via `bundled_cef_path`, validates it
|
||||
against version/ABI constraints, and loads it in-place without any CDN access.
|
||||
|
||||
### Mode 3: Local Download Path (install from archive)
|
||||
|
||||
Tests the full CDN download + install flow using a local directory instead of
|
||||
the network. The `/cef-download-path` switch redirects all CDN requests to
|
||||
local file reads. All post-download validation (hash verification, signature
|
||||
checking, archive extraction, version filtering) runs normally.
|
||||
|
||||
**Setup:** Create a local directory simulating the CDN layout. The installer
|
||||
derives filenames from the URLs it would normally fetch:
|
||||
|
||||
```
|
||||
cdn_dir/
|
||||
{abi_hash}_{platform}.json # Manifest (e.g., 1671cc913eeb4ecf_windows64.json)
|
||||
cef_binary_151.1.0_windows64_signed.tar.xz # Signed archive
|
||||
cef_binary_151.1.0_windows64_signed.tar.xz.sha256 # SHA256 hash (hex string, no filename)
|
||||
```
|
||||
|
||||
**Manifest filename:** When `abi_hash` is set in the config, the manifest
|
||||
filename is `{abi_hash}_{platform}.json`. Without `abi_hash`, it is
|
||||
`{milestone}_{platform}.json` (e.g., `151_windows64.json`).
|
||||
|
||||
The manifest is a JSON array of build entries. The example uses the current
|
||||
CEF 151.1 sandbox compatibility hash; replace the archive digests and timestamp
|
||||
with values from the actual test artifact.
|
||||
```json
|
||||
[
|
||||
{
|
||||
"version": "151.1.0",
|
||||
"file": "cef_binary_151.1.0_windows64_signed.tar.xz",
|
||||
"sha1": "70e10aff7a7b346ed24c3eb24c04b503b3f61f29",
|
||||
"last_modified": "2026-07-15T00:00:00Z",
|
||||
"abi_hash": "1671cc913eeb4ecf"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
The `.sha256` file contains just the hex hash (no filename):
|
||||
```
|
||||
2a4b43a1936e0d56511b98cf1ae497e8439710a2412e57379560561ab3f86124
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
# With a client DLL (installer resolves CEF before loading the client)
|
||||
cefclient.exe /cef-download-path=C:\tmp\cdn_dir
|
||||
|
||||
# Standalone mode (no client DLL, requires a bootstrap exe resource)
|
||||
cefclient.exe /cef-download-path=C:\tmp\cdn_dir
|
||||
|
||||
# With verbose logging (default log level is warning)
|
||||
cefclient.exe /cef-download-path=C:\tmp\cdn_dir /cef-log-level=info
|
||||
```
|
||||
|
||||
### Embedding Resources with ResourceHacker
|
||||
|
||||
You can add or update `CEF_INSTALLER_CONFIG` resources post-build using
|
||||
[ResourceHacker](http://www.angusj.com/resourcehacker/):
|
||||
|
||||
1. Create `config.json`. Use the same `appid`, `vmin`, and `abi_hash` as the
|
||||
client DLL's config so the exe registers/unregisters the same application.
|
||||
`appid` and `vmin` are required — the config parse fails without them.
|
||||
```json
|
||||
{
|
||||
"appid": "...",
|
||||
"vmin": "...",
|
||||
"abi_hash": "...",
|
||||
"enable_explicit_modes": true
|
||||
}
|
||||
```
|
||||
|
||||
2. Create `config.rc`:
|
||||
```rc
|
||||
CEF_INSTALLER_CONFIG RCDATA "config.json"
|
||||
```
|
||||
|
||||
3. Compile and embed (run from the directory containing both files):
|
||||
```bash
|
||||
ResourceHacker.exe -open config.rc -save config.res -action compile
|
||||
ResourceHacker.exe -open cefclient.exe -save cefclient.exe -action addoverwrite -res config.res -mask RCDATA,CEF_INSTALLER_CONFIG,
|
||||
```
|
||||
|
||||
The `CEF_RESOURCE_HACKER_PATH` environment variable should point to
|
||||
`ResourceHacker.exe` (also used by E2E tests).
|
||||
|
||||
### Testing Explicit Commands
|
||||
|
||||
```bash
|
||||
# Install CEF from a local directory first (see Mode 3 above)
|
||||
cefclient.exe /cef-download-path=C:\tmp\cdn_dir
|
||||
|
||||
# Uninstall (unregisters the app and prunes unused versions)
|
||||
cefclient.exe /cef-uninstall
|
||||
|
||||
# Explicit update check (checks even when a local version qualifies, and
|
||||
# downloads only a newer compatible non-revoked version)
|
||||
cefclient.exe /cef-update /cef-download-path=C:\tmp\cdn_dir
|
||||
```
|
||||
|
||||
Registration retention is a separate, explicit maintenance operation. Start
|
||||
with dry-run, review the reported registrations and version impact, and apply
|
||||
only after confirming that dormant applications may need to download CEF again:
|
||||
|
||||
```powershell
|
||||
# Deterministic text report; makes no installer-state changes.
|
||||
cefclient.exe /cef-retention-dry-run /cef-max-age-days=180
|
||||
|
||||
# JSON report for scripts and automated verification.
|
||||
cefclient.exe /cef-retention-dry-run /cef-max-age-days=180 /cef-headless
|
||||
|
||||
# Recomputes under the writer lock before committing removals.
|
||||
cefclient.exe /cef-retention-apply /cef-max-age-days=180
|
||||
```
|
||||
|
||||
Retention commands require `enable_explicit_modes` in the bootstrap's embedded
|
||||
configuration. Verify that dry-run leaves `installer.json`, `versions.json`,
|
||||
`.launch`, version directories, and `retention_pending.json` unchanged. After
|
||||
apply, inspect `retry_required`, `registrations_committed`, `versions_pruned`,
|
||||
and `warnings`; rerun apply when `retry_required` is true.
|
||||
|
||||
To test revocation, add a `revoked.json` to the local CDN directory:
|
||||
```json
|
||||
{
|
||||
"revoked_versions": [
|
||||
{ "version": "151.1.0", "reason": "testing revocation" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Then uninstall any previously installed version and attempt a fresh install —
|
||||
it should fail with "CDN version is revoked":
|
||||
```bash
|
||||
cefclient.exe /cef-uninstall
|
||||
cefclient.exe /cef-download-path=C:\tmp\cdn_dir
|
||||
```
|
||||
|
||||
Note: revocation does not block bundled installs (Mode 2). A revoked bundled
|
||||
version is demoted but still used as a last resort when no other version is
|
||||
available. See [Version Selection](README.md#version-selection) for details.
|
||||
|
||||
Check `cef_installer.log` in the install directory (e.g.,
|
||||
`%LocalAppData%\CEF\cef_installer.log`) for detailed output. The install
|
||||
directory also contains `installer.json` (registered apps) and
|
||||
`versions.json` (installed versions) which can be inspected directly.
|
||||
|
||||
### Lock-free launch and concurrency
|
||||
|
||||
Focused unit coverage:
|
||||
|
||||
```powershell
|
||||
out\Debug_GN_x64\cef_installer_unittests.exe `
|
||||
--gtest_filter='InstallerLockTest.*:InstallerFileOpsTest.*VersionLease*:ControllerTempDirTest.*Query*:ControllerTempDirTest.AutomaticStartup*:InstallerControllerServerTest.AutomaticStartup*:LaunchStateTest.*Liveness*:InstallerRevocationTest.*Backoff*'
|
||||
```
|
||||
|
||||
The tests use deterministic events rather than sleeps to pause a startup at the
|
||||
writer transition. They cover query/local startup while the mutex is held,
|
||||
post-lock index re-resolution with zero duplicate requests, contention timeout,
|
||||
one abandoned handoff across three waiters, lease-blocked directory move,
|
||||
fresh-cache zero requests, corrupt-cache refresh, stale refresh affecting the
|
||||
current selection, post-writer refresh gating, strict failure-backoff
|
||||
integrity, asynchronous cancellation while response headers are withheld,
|
||||
asynchronous cancellation while a response body remains open, and liveness
|
||||
refresh/clock-skew rules. Mutex coverage includes consecutive abandonment and
|
||||
wrong-thread release rejection. The lease rename behavior is Windows-specific
|
||||
and must be rerun for every locally available supported architecture; absence
|
||||
of a 32-bit output directory is a validation limitation, not 32-bit coverage.
|
||||
|
||||
Emergency recovery unit tests use missing, corrupt, and valid-empty index
|
||||
fixtures. They assert the selected lease, unchanged index state, query
|
||||
exclusion, bad-CRC preservation, generic-read-error fail-closed behavior,
|
||||
post-eligibility duplicate handling, held-mutex behavior, candidate rejection,
|
||||
and each scan bound. `SetEmergencyRecoveryScanLimitsForTesting()` reduces
|
||||
individual limits to zero for deterministic stopping tests. The launcher E2E
|
||||
case `test_bootstrap_recovers_missing_and_corrupt_version_index` exercises both
|
||||
damaged-index states in a real bootstrap/client process. See
|
||||
[Version Selection](README.md#version-selection) for the production contract
|
||||
and limits.
|
||||
|
||||
## Fuzz Testing
|
||||
|
||||
Archive extraction code processes untrusted data from the network. Three
|
||||
libFuzzer targets provide continuous coverage-guided testing of the parsing
|
||||
and decompression layers.
|
||||
|
||||
| Fuzzer | Target code | Attack surface |
|
||||
|--------|-------------|----------------|
|
||||
| `installer_xz_index_fuzzer` | `ParseXzIndex()`, `ReadMultiByteInt()` | XZ footer/index binary parsing, integer overflow, offset arithmetic |
|
||||
| `installer_tar_fuzzer` | `ExtractTarFromBuffer()`, `TarReader::ParseHeader()`, `ParsePaxPath()` | Path traversal, PAX/GNU extensions, octal parsing, file writes |
|
||||
| `installer_xz_block_fuzzer` | `DecompressXzBlock()` | LZMA2 block header parsing, decompression, CRC verification |
|
||||
|
||||
### Building Fuzzers
|
||||
|
||||
Create a fuzzer build directory by copying the existing debug config:
|
||||
|
||||
```bash
|
||||
mkdir out/libfuzzer
|
||||
cp out/Debug_GN_x64/args.gn out/libfuzzer/args.gn
|
||||
```
|
||||
|
||||
Edit `out/libfuzzer/args.gn` and add or modify these settings:
|
||||
|
||||
```gn
|
||||
# Changed from Debug_GN_x64:
|
||||
is_component_build = false # was true; libfuzzer requires static linking
|
||||
is_debug = false # was true; release build for fuzzer performance
|
||||
|
||||
# Added for fuzzing:
|
||||
use_libfuzzer = true # enable libFuzzer instrumentation
|
||||
is_asan = true # enable AddressSanitizer (catches buffer overruns, use-after-free)
|
||||
is_ubsan_security = true # enable UBSan security checks (integer overflow, shift UB)
|
||||
is_ubsan_vptr = false # vptr check unsupported on Windows
|
||||
```
|
||||
|
||||
Then generate the build files:
|
||||
|
||||
```bash
|
||||
gn gen out/libfuzzer
|
||||
```
|
||||
|
||||
Keep all other settings from `Debug_GN_x64/args.gn` unchanged, such as
|
||||
`target_cpu`, CDM keys, and codec flags. Only the six values above need to
|
||||
change. UBSan's `vptr` check requires RTTI, which is disabled on Windows
|
||||
(`/GR-`), so `is_ubsan_vptr` must be explicitly false.
|
||||
|
||||
Build the fuzzer targets:
|
||||
|
||||
```bash
|
||||
autoninja -C out/libfuzzer installer_xz_index_fuzzer installer_tar_fuzzer installer_xz_block_fuzzer
|
||||
```
|
||||
|
||||
### Running Fuzzers
|
||||
|
||||
```bash
|
||||
# Run with seed corpus (recommended for the first run):
|
||||
out/libfuzzer/installer_xz_index_fuzzer.exe cef/libcef_dll/bootstrap/installer/testdata/
|
||||
|
||||
# Run from an empty corpus:
|
||||
out/libfuzzer/installer_tar_fuzzer.exe
|
||||
|
||||
# Run for a fixed duration:
|
||||
out/libfuzzer/installer_xz_block_fuzzer.exe -max_total_time=300
|
||||
|
||||
# Reproduce a crash:
|
||||
out/libfuzzer/installer_tar_fuzzer.exe crash-abc123...
|
||||
```
|
||||
|
||||
Output lines containing `NEW` indicate that the fuzzer discovered inputs
|
||||
covering previously unseen code edges. A crash writes the input to
|
||||
`crash-<hash>` in the current directory.
|
||||
|
||||
### Design Notes
|
||||
|
||||
- The XZ index fuzzer neuters the CRC table, following the pattern in
|
||||
`third_party/lzma_sdk/google/seven_zip_reader_fuzzer.cc`, so mutations are
|
||||
not rejected at the CRC check before reaching deeper parsing logic.
|
||||
- The tar fuzzer does not neuter checksums. Tar header checksums are simple sums
|
||||
that the fuzzer discovers organically, and `ValidateChecksum()` is itself
|
||||
worth fuzzing.
|
||||
- The tar fuzzer caps input at 64 KB (`max_len=65536`) to keep iterations fast
|
||||
because `ExtractTarFromBuffer` performs file I/O.
|
||||
|
||||
When adding a code path that parses untrusted archive data, add a corresponding
|
||||
fuzzer target or extend an existing one to cover the new parsing logic. The
|
||||
authoritative extraction invariants are in
|
||||
[Archive Extraction](SECURITY.md#3-archive-extraction).
|
||||
|
||||
## Code Coverage
|
||||
|
||||
### Prerequisites
|
||||
|
||||
See [Coverage Setup Instructions](../../../tools/claude/CLAUDE_COVERAGE_INSTRUCTIONS.md)
|
||||
for one-time setup (enabling coverage tools in `.gclient`, creating a coverage
|
||||
build directory).
|
||||
|
||||
### Generating a Coverage Report
|
||||
|
||||
Coverage requires a separate build directory (`out/coverage`) with coverage
|
||||
instrumentation enabled. Build the tests in that directory first:
|
||||
|
||||
```bash
|
||||
autoninja -C out/coverage cef_installer_unittests
|
||||
```
|
||||
|
||||
Then generate the report:
|
||||
|
||||
```bash
|
||||
python3 tools/code_coverage/coverage.py \
|
||||
cef_installer_unittests \
|
||||
-b out/coverage \
|
||||
-o out/report \
|
||||
-c out/coverage/cef_installer_unittests.exe \
|
||||
-f cef/libcef_dll/bootstrap/installer \
|
||||
--coverage-tools-dir=third_party/llvm-build/Release+Asserts/bin \
|
||||
--no-component-view
|
||||
```
|
||||
|
||||
This produces:
|
||||
- `out/report/win/summary.json` -- machine-readable summary
|
||||
- `out/report/win/coverage.profdata` -- raw profile data
|
||||
|
||||
### Analyzing Coverage
|
||||
|
||||
The analysis commands below use the `analyze_coverage_output.py` script. The
|
||||
`--profdata`, `--binary`, and `--llvm-cov` flags are needed for detailed
|
||||
analysis (uncovered function names and line ranges); omit them for a quick
|
||||
summary.
|
||||
|
||||
```bash
|
||||
# Quick summary (filtered to installer files)
|
||||
python3 cef/tools/claude/analyze_coverage_output.py \
|
||||
out/report/win/summary.json \
|
||||
--filter cef/libcef_dll/bootstrap/installer
|
||||
|
||||
# Detailed analysis (includes uncovered function names)
|
||||
python3 cef/tools/claude/analyze_coverage_output.py \
|
||||
out/report/win/summary.json \
|
||||
--filter cef/libcef_dll/bootstrap/installer \
|
||||
--profdata out/report/win/coverage.profdata \
|
||||
--binary out/coverage/cef_installer_unittests.exe \
|
||||
--llvm-cov third_party/llvm-build/Release+Asserts/bin/llvm-cov.exe
|
||||
|
||||
# Show uncovered line ranges for a specific file
|
||||
python3 cef/tools/claude/analyze_coverage_output.py \
|
||||
out/report/win/summary.json \
|
||||
--profdata out/report/win/coverage.profdata \
|
||||
--binary out/coverage/cef_installer_unittests.exe \
|
||||
--llvm-cov third_party/llvm-build/Release+Asserts/bin/llvm-cov.exe \
|
||||
--show-uncovered-lines cef/libcef_dll/bootstrap/installer/installer_controller.cc
|
||||
|
||||
# Show uncovered lines for all installer source files
|
||||
python3 cef/tools/claude/analyze_coverage_output.py \
|
||||
out/report/win/summary.json \
|
||||
--profdata out/report/win/coverage.profdata \
|
||||
--binary out/coverage/cef_installer_unittests.exe \
|
||||
--llvm-cov third_party/llvm-build/Release+Asserts/bin/llvm-cov.exe \
|
||||
--show-uncovered-lines "cef/libcef_dll/bootstrap/installer/*.cc"
|
||||
```
|
||||
|
||||
For details on how `--show-uncovered-lines` computes coverage from LLVM regions,
|
||||
see [How `--show-uncovered-lines` Works](../../../tools/claude/CLAUDE_COVERAGE_INSTRUCTIONS.md#how---show-uncovered-lines-works).
|
||||
|
||||
### Coverage Expectations
|
||||
|
||||
**Function coverage:** All production files should have 100% function coverage.
|
||||
|
||||
**Line coverage:** Overall line coverage should stay at or above 90%. Files that
|
||||
currently have 100% line coverage (per `llvm-cov report`) must not regress. When
|
||||
adding new code, add corresponding tests to maintain coverage.
|
||||
|
||||
**Acceptable exceptions from coverage:**
|
||||
|
||||
- **Singleton destructors** (e.g., `Logger::~Logger`) -- intentionally never
|
||||
called due to `base::NoDestructor`.
|
||||
- **WinHTTP internals** (e.g., `ScopedWinHttpHandle` default constructor,
|
||||
`DownloadSink::OnError` base class method) -- trivial internal plumbing
|
||||
exercised implicitly through download calls.
|
||||
- **Interactive message-box backend** (`MessageBoxExW`) -- requires user
|
||||
interaction. `ShowErrorDialog` and `ShowErrorMessageBox` are covered through
|
||||
the test message-box runner.
|
||||
- **Process creation** (e.g., `CopyFileIfExists` in installer_relaunch.cc)
|
||||
-- requires real process spawning that cannot be mocked in unit tests.
|
||||
- **DLL export function** (`RunInstaller`) -- thin C ABI wrapper around
|
||||
`Controller::Run()`; tested indirectly through the controller.
|
||||
- **Registry path validation** (`IsRegistryPathValid` in installer_paths.cc)
|
||||
-- requires HKLM registry entries that unit tests cannot create without
|
||||
admin privileges.
|
||||
- **`TryLoadInstallerConfig` official-build branch** -- the
|
||||
`enable_explicit_modes` command gate in official builds is unreachable in
|
||||
non-official test builds. Resource provenance and bootstrap-only policy
|
||||
parsing are covered by `ConfigPrecedenceTest`.
|
||||
- **Unreachable switch fallthroughs** -- default cases in exhaustive switch
|
||||
statements that exist for compiler warnings but can never execute.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
if (is_win) {
|
||||
# Mock client DLL for E2E launcher/RunInstaller tests.
|
||||
shared_library("cef_e2e_mock_client") {
|
||||
testonly = true
|
||||
sources = [ "mock_client_dll/cef_mock_client.cc" ]
|
||||
deps = [
|
||||
"//cef:make_api_versions_header",
|
||||
"//cef:make_config_header",
|
||||
"//cef:make_version_header",
|
||||
"//cef/libcef/features:buildflags",
|
||||
]
|
||||
libs = [ "kernel32.lib" ]
|
||||
configs += [ "//cef/libcef/features:config" ]
|
||||
}
|
||||
|
||||
# Helper binary that receives WM_COPYDATA progress notifications.
|
||||
executable("cef_e2e_progress_helper") {
|
||||
testonly = true
|
||||
sources = [ "progress_helper/progress_helper.cc" ]
|
||||
libs = [ "user32.lib" ]
|
||||
}
|
||||
|
||||
# Helper tool that builds signed test distributions in CDN layout for E2E.
|
||||
executable("cef_e2e_build_test_cdn") {
|
||||
testonly = true
|
||||
|
||||
sources = [
|
||||
"../installer_test_archive.cc",
|
||||
"../installer_test_archive.h",
|
||||
"../installer_test_catalog.cc",
|
||||
"../installer_test_catalog.h",
|
||||
"../installer_test_helpers.cc",
|
||||
"../installer_test_helpers.h",
|
||||
"build_test_cdn.cc",
|
||||
]
|
||||
|
||||
data = [ "../testdata/test_signing.pfx" ]
|
||||
|
||||
deps = [
|
||||
"//base",
|
||||
"//cef/libcef_dll/bootstrap/installer:cef_installer",
|
||||
]
|
||||
|
||||
configs += [
|
||||
"//build/config:precompiled_headers",
|
||||
"//cef/libcef/features:config",
|
||||
]
|
||||
}
|
||||
|
||||
group("e2e") {
|
||||
testonly = true
|
||||
deps = [
|
||||
":cef_e2e_build_test_cdn",
|
||||
":cef_e2e_mock_client",
|
||||
":cef_e2e_progress_helper",
|
||||
]
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Build a local CDN directory for E2E tests.
|
||||
|
||||
Delegates to the C++ cef_e2e_build_test_cdn helper which wraps
|
||||
test::BuildTestDistribution() for signing and archive creation.
|
||||
|
||||
When --version/--abi-hash are omitted, reads vmin and abi_hash from
|
||||
gen/cef/installer_config.json in the build directory (derived from
|
||||
cef_api_versions.json at build time) and constructs a default version
|
||||
as <milestone>.1.
|
||||
|
||||
Usage:
|
||||
# Auto-detect version from build config:
|
||||
python3 build_local_cdn.py --build-dir=out/Debug_GN_x64 \
|
||||
--output-dir=/tmp/cdn
|
||||
|
||||
# Explicit version:
|
||||
python3 build_local_cdn.py --build-dir=out/Debug_GN_x64 \
|
||||
--output-dir=/tmp/cdn --version=151.1 --abi-hash=a1b2c3d4e5f6
|
||||
|
||||
# Multi-version:
|
||||
python3 build_local_cdn.py --build-dir=out/Debug_GN_x64 \
|
||||
--output-dir=/tmp/cdn \
|
||||
--version=151.1 --abi-hash=a1b2c3d4e5f6 \
|
||||
--version=151.1.1 --abi-hash=a1b2c3d4e5f6
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from e2e_config import get_test_versions
|
||||
|
||||
|
||||
def build_cdn(build_dir, output_dir, versions):
|
||||
"""Build a local CDN directory with the given versions.
|
||||
|
||||
Args:
|
||||
build_dir: Path to the build directory containing cef_e2e_build_test_cdn.
|
||||
output_dir: Directory to create CDN files in.
|
||||
versions: List of (version, abi_hash) tuples.
|
||||
|
||||
Returns:
|
||||
True on success.
|
||||
"""
|
||||
helper = os.path.join(build_dir, 'cef_e2e_build_test_cdn.exe')
|
||||
if not os.path.isfile(helper):
|
||||
print(f'Error: cef_e2e_build_test_cdn.exe not found at {helper}',
|
||||
file=sys.stderr)
|
||||
print('Build it with: autoninja -C <build_dir> cef_e2e_build_test_cdn',
|
||||
file=sys.stderr)
|
||||
return False
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
for version, abi_hash in versions:
|
||||
cmd = [
|
||||
helper, f'--output-dir={output_dir}', f'--version={version}',
|
||||
f'--abi-hash={abi_hash}'
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
print(f'Error building CDN for {version}: {result.stderr}',
|
||||
file=sys.stderr)
|
||||
return False
|
||||
|
||||
# Write empty revoked.json if not present.
|
||||
revoked_path = os.path.join(output_dir, 'revoked.json')
|
||||
if not os.path.isfile(revoked_path):
|
||||
with open(revoked_path, 'w') as f:
|
||||
json.dump({'revoked_versions': []}, f)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Build a local CDN directory for E2E tests')
|
||||
parser.add_argument('--build-dir', required=True)
|
||||
parser.add_argument('--output-dir', required=True)
|
||||
parser.add_argument('--version', action='append')
|
||||
parser.add_argument('--abi-hash', action='append')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version or args.abi_hash:
|
||||
if not args.version or not args.abi_hash:
|
||||
print('Error: --version and --abi-hash must both be provided',
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
if len(args.version) != len(args.abi_hash):
|
||||
print('Error: --version and --abi-hash must be paired', file=sys.stderr)
|
||||
return 1
|
||||
versions = list(zip(args.version, args.abi_hash))
|
||||
else:
|
||||
result = get_test_versions(args.build_dir)
|
||||
if result is None:
|
||||
print(
|
||||
'Error: could not read installer_config.json from '
|
||||
f'{args.build_dir}/gen/cef/',
|
||||
file=sys.stderr)
|
||||
print('Build with: autoninja -C <build_dir> cef_installer_config',
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
version, _, _, abi_hash = result
|
||||
versions = [(version, abi_hash)]
|
||||
|
||||
if build_cdn(args.build_dir, args.output_dir, versions):
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// Helper tool that wraps test::BuildTestDistribution() to create a local CDN
|
||||
// directory for E2E tests. Produces the same signed .tar.xz archives and
|
||||
// manifests that the integration tests use, but writes them to disk in the
|
||||
// CDN directory layout that the installer expects with /cef-download-path.
|
||||
//
|
||||
// Usage:
|
||||
// cef_e2e_build_test_cdn --output-dir=C:\tmp\cdn --version=150.1
|
||||
// --abi-hash=a1b2c3d4e5f6
|
||||
|
||||
#include <iostream>
|
||||
|
||||
#include "base/at_exit.h"
|
||||
#include "base/command_line.h"
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_download.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_test_archive.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_test_helpers.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kSwitchOutputDir[] = "output-dir";
|
||||
constexpr char kSwitchVersion[] = "version";
|
||||
constexpr char kSwitchAbiHash[] = "abi-hash";
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
base::AtExitManager at_exit;
|
||||
base::CommandLine::Init(argc, argv);
|
||||
const base::CommandLine& cmd = *base::CommandLine::ForCurrentProcess();
|
||||
|
||||
std::string output_dir_str = cmd.GetSwitchValueASCII(kSwitchOutputDir);
|
||||
std::string version = cmd.GetSwitchValueASCII(kSwitchVersion);
|
||||
std::string abi_hash = cmd.GetSwitchValueASCII(kSwitchAbiHash);
|
||||
|
||||
if (output_dir_str.empty() || version.empty()) {
|
||||
std::cerr << "Usage: cef_e2e_build_test_cdn --output-dir=<path> "
|
||||
<< "--version=<ver> --abi-hash=<hash>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (abi_hash.empty()) {
|
||||
abi_hash = "a1b2c3d4e5f6";
|
||||
}
|
||||
|
||||
base::FilePath output_dir = base::FilePath::FromUTF8Unsafe(output_dir_str);
|
||||
base::CreateDirectory(output_dir);
|
||||
|
||||
base::FilePath pfx_path =
|
||||
cef_installer::test::GetTestDataPath().AppendASCII("test_signing.pfx");
|
||||
|
||||
auto dist = cef_installer::test::BuildTestDistribution(
|
||||
version, abi_hash, output_dir, pfx_path, "test");
|
||||
if (!dist.has_value()) {
|
||||
std::cerr << "Failed to build test distribution for " << version
|
||||
<< std::endl;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Rename archive to match CDN naming convention.
|
||||
std::string cdn_filename =
|
||||
"cef_" + version + "_" + dist->platform + ".tar.xz";
|
||||
base::FilePath cdn_archive = output_dir.AppendASCII(cdn_filename);
|
||||
if (cdn_archive != dist->archive_path) {
|
||||
base::Move(dist->archive_path, cdn_archive);
|
||||
}
|
||||
|
||||
// Write SHA256 sidecar.
|
||||
std::string sha256 = cef_installer::ComputeFileSha256(cdn_archive);
|
||||
base::FilePath sha256_path = output_dir.AppendASCII(cdn_filename + ".sha256");
|
||||
base::WriteFile(sha256_path, sha256);
|
||||
|
||||
// Write manifest JSON.
|
||||
std::string manifest =
|
||||
R"([{"version": ")" + version + R"(", "file": ")" + cdn_filename +
|
||||
R"(", "sha1": ")" + dist->archive_sha1 +
|
||||
R"(", "last_modified": "2026-01-01T00:00:00Z", "abi_hash": ")" +
|
||||
abi_hash + R"("}])";
|
||||
|
||||
// Write manifest under both naming conventions:
|
||||
// - milestone-based: 150_windows64.json (used when no abi_hash in config)
|
||||
// - abi_hash-based: a1b2c3d4e5f6_windows64.json (used when abi_hash set)
|
||||
std::string milestone = version.substr(0, version.find('.'));
|
||||
std::vector<std::string> manifest_names = {
|
||||
milestone + "_" + dist->platform + ".json",
|
||||
abi_hash + "_" + dist->platform + ".json",
|
||||
};
|
||||
|
||||
for (const auto& name : manifest_names) {
|
||||
base::FilePath manifest_path = output_dir.AppendASCII(name);
|
||||
|
||||
// If manifest already exists, merge (for multi-version).
|
||||
std::string existing;
|
||||
if (base::ReadFileToString(manifest_path, &existing) &&
|
||||
existing.size() > 2) {
|
||||
std::string merged = existing;
|
||||
merged.pop_back(); // Remove trailing ]
|
||||
merged += "," + manifest.substr(1); // Append new entry (skip leading [)
|
||||
base::WriteFile(manifest_path, merged);
|
||||
} else {
|
||||
base::WriteFile(manifest_path, manifest);
|
||||
}
|
||||
}
|
||||
|
||||
// Write empty revoked.json if not present.
|
||||
base::FilePath revoked_path = output_dir.AppendASCII("revoked.json");
|
||||
if (!base::PathExists(revoked_path)) {
|
||||
base::WriteFile(revoked_path, R"({"revoked_versions": []})");
|
||||
}
|
||||
|
||||
std::cout << "Built CDN distribution: " << cdn_filename << std::endl;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
|
||||
if (is_win) {
|
||||
# Test DLLs with embedded CEF_INSTALLER_CONFIG resources for config
|
||||
# precedence unit tests.
|
||||
shared_library("cef_config_test_appid_a") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_appid_a.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_appid_b") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_appid_b.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_explicit_modes") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_explicit_modes.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_cdn_wrong_shape") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_cdn_wrong_shape.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_cdn_empty") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_cdn_empty.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_cdn_overcount") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_cdn_overcount.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_cdn_overlength") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_cdn_overlength.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_no_config") {
|
||||
testonly = true
|
||||
sources = [ "dll_stub.cc" ]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_malformed") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_malformed.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_invalid") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_invalid.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
shared_library("cef_config_test_invalid_cdn") {
|
||||
testonly = true
|
||||
sources = [
|
||||
"config_invalid_cdn.rc",
|
||||
"dll_stub.cc",
|
||||
]
|
||||
}
|
||||
|
||||
group("config_test_dlls") {
|
||||
testonly = true
|
||||
deps = [
|
||||
":cef_config_test_appid_a",
|
||||
":cef_config_test_appid_b",
|
||||
":cef_config_test_cdn_empty",
|
||||
":cef_config_test_cdn_overcount",
|
||||
":cef_config_test_cdn_overlength",
|
||||
":cef_config_test_cdn_wrong_shape",
|
||||
":cef_config_test_explicit_modes",
|
||||
":cef_config_test_invalid",
|
||||
":cef_config_test_invalid_cdn",
|
||||
":cef_config_test_malformed",
|
||||
":cef_config_test_no_config",
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"aaaaaaaa-1111-1111-1111-111111111111","vmin":"1.0","install_path":"relative_store","cdn_urls":["https://client.example/prefix","https://client.example/prefix"]}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_appid_a.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"bbbbbbbb-2222-2222-2222-222222222222","vmin":"1.0","cdn_urls":["https://bootstrap.example/"]}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_appid_b.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"f2000000-6666-6666-6666-666666666666","vmin":"1.0","cdn_urls":[]}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_cdn_empty.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"f3000000-6666-6666-6666-666666666666","vmin":"1.0","cdn_urls":["https://1.example/","https://2.example/","https://3.example/","https://4.example/"]}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_cdn_overcount.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"f4000000-6666-6666-6666-666666666666","vmin":"1.0","cdn_urls":["https://example.com/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_cdn_overlength.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"f1000000-6666-6666-6666-666666666666","vmin":"1.0","cdn_urls":"https://one.example/"}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_cdn_wrong_shape.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"eeeeeeee-5555-5555-5555-555555555555","vmin":"1.0","enable_explicit_modes":true}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "config_explicit_modes.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"eeeeeeee-5555-5555-5555-555555555555"}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_invalid.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":"ffffffff-6666-6666-6666-666666666666","vmin":"1.0","cdn_urls":["http://insecure.example/"]}
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_invalid_cdn.json"
|
||||
@@ -0,0 +1 @@
|
||||
{"appid":
|
||||
@@ -0,0 +1 @@
|
||||
CEF_INSTALLER_CONFIG RCDATA "cef/libcef_dll/bootstrap/installer/e2e/config_test_dlls/config_malformed.json"
|
||||
@@ -0,0 +1,11 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule,
|
||||
DWORD ul_reason_for_call,
|
||||
LPVOID lpReserved) {
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Shared helpers for reading build-time installer configuration."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
def read_installer_config(build_dir):
|
||||
"""Read gen/cef/installer_config.json from the build directory.
|
||||
|
||||
Returns the parsed dict, or None if the file is missing.
|
||||
"""
|
||||
config_path = os.path.join(build_dir, 'gen', 'cef', 'installer_config.json')
|
||||
if not os.path.isfile(config_path):
|
||||
return None
|
||||
with open(config_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def get_test_versions(build_dir):
|
||||
"""Derive test version strings from the build's installer config.
|
||||
|
||||
Returns (test_version, test_version_update, test_version_higher, abi_hash)
|
||||
or None if the config cannot be read.
|
||||
|
||||
The versions are constructed from the milestone in vmin so they stay
|
||||
in sync when the API version changes:
|
||||
test_version: <milestone>.1
|
||||
test_version_update: <milestone>.1.1
|
||||
test_version_higher: <milestone>.2
|
||||
"""
|
||||
config = read_installer_config(build_dir)
|
||||
if config is None:
|
||||
return None
|
||||
vmin = config.get('vmin', '')
|
||||
if not vmin:
|
||||
return None
|
||||
milestone = vmin.split('.')[0]
|
||||
abi_hash = config.get('abi_hash', '')
|
||||
return (f'{milestone}.1', f'{milestone}.1.1', f'{milestone}.2', abi_hash)
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Test-selection helpers for the E2E runner.
|
||||
|
||||
`compile_filter` turns a pytest-style -k expression into a predicate over a
|
||||
test id; `flatten_suite` yields individual test cases from a nested suite.
|
||||
Kept separate from run_e2e_tests.py so it is unit-testable without a build.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
|
||||
|
||||
def _is_boolean_expr(node):
|
||||
"""True if `node` is built only from and/or/not over bare keywords.
|
||||
|
||||
Anything else -- a binary op (`a-b`, `a+b`), attribute access (`mod.Class`),
|
||||
a call, a comparison, a literal -- is not a keyword expression we evaluate;
|
||||
the caller treats the whole string as a literal substring instead.
|
||||
"""
|
||||
if isinstance(node, ast.Expression):
|
||||
return _is_boolean_expr(node.body)
|
||||
if isinstance(node, ast.BoolOp):
|
||||
return all(_is_boolean_expr(v) for v in node.values)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
||||
return _is_boolean_expr(node.operand)
|
||||
return isinstance(node, ast.Name)
|
||||
|
||||
|
||||
def compile_filter(expr):
|
||||
"""Compile a pytest-style -k expression into predicate(test_id) -> bool.
|
||||
|
||||
Supports `and`, `or`, `not`, parentheses, and bare keywords matched
|
||||
case-insensitively as substrings of the test id. Any expression that is not
|
||||
a pure boolean combination of bare keywords -- because it fails to parse
|
||||
(`a or`) or uses non-keyword syntax (`a-b`, `mod.Class.test_x`, `a + b`) --
|
||||
falls back to plain substring containment of the whole string, so any
|
||||
single-keyword usage works regardless of the characters it contains.
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(expr, mode='eval')
|
||||
except SyntaxError:
|
||||
tree = None
|
||||
|
||||
if tree is None or not _is_boolean_expr(tree):
|
||||
return lambda test_id: expr.lower() in test_id.lower()
|
||||
|
||||
def evaluate(node, test_id):
|
||||
if isinstance(node, ast.Expression):
|
||||
return evaluate(node.body, test_id)
|
||||
if isinstance(node, ast.BoolOp):
|
||||
results = [evaluate(v, test_id) for v in node.values]
|
||||
return all(results) if isinstance(node.op, ast.And) else any(results)
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
||||
return not evaluate(node.operand, test_id)
|
||||
# _is_boolean_expr guarantees this is an ast.Name.
|
||||
return node.id.lower() in test_id.lower()
|
||||
|
||||
return lambda test_id: evaluate(tree, test_id)
|
||||
|
||||
|
||||
def flatten_suite(suite):
|
||||
"""Yield individual test cases from an arbitrarily nested TestSuite."""
|
||||
for item in suite:
|
||||
if isinstance(item, unittest.TestSuite):
|
||||
yield from flatten_suite(item)
|
||||
else:
|
||||
yield item
|
||||
@@ -0,0 +1,819 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Base class for CEF installer E2E tests."""
|
||||
|
||||
import contextlib
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import json
|
||||
import http.server
|
||||
import os
|
||||
import shutil
|
||||
import ssl
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
from e2e_config import get_test_versions
|
||||
|
||||
# Resolved from CEF_E2E_BUILD_DIR environment variable.
|
||||
BUILD_DIR = None
|
||||
# Path to testdata/ directory containing certificates.
|
||||
TESTDATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..',
|
||||
'testdata')
|
||||
_INTEGRITY_FOOTER_MAGIC = 0xCEF09E1F4A3B2C1D
|
||||
|
||||
# Command-line flags (must match installer_bootstrap_helpers.h).
|
||||
FLAG_UPDATE = '/cef-update'
|
||||
FLAG_UNINSTALL = '/cef-uninstall'
|
||||
FLAG_UNINSTALL_RELAUNCHED = '/cef-uninstall-relaunched'
|
||||
FLAG_RETENTION_DRY_RUN = '/cef-retention-dry-run'
|
||||
FLAG_RETENTION_APPLY = '/cef-retention-apply'
|
||||
FLAG_RETENTION_MAX_AGE_DAYS = '/cef-max-age-days'
|
||||
FLAG_HEADLESS = '/cef-headless'
|
||||
FLAG_FORCECHECK = '/cef-forcecheck'
|
||||
FLAG_BACKGROUND = '/cef-background'
|
||||
FLAG_PARENT = '/cef-parent'
|
||||
FLAG_INSTALL_PATH = '--cef-install-path'
|
||||
FLAG_DOWNLOAD_PATH = '--cef-download-path'
|
||||
|
||||
# Exit codes (must match installer_constants.h).
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_CONFIG_ERROR = 100
|
||||
EXIT_NETWORK_ERROR = 101
|
||||
EXIT_SIGNATURE_ERROR = 102
|
||||
EXIT_NO_MATCHING_VERSION = 103
|
||||
EXIT_EXTRACTION_ERROR = 104
|
||||
EXIT_INSTALL_ERROR = 105
|
||||
EXIT_DATABASE_ERROR = 106
|
||||
EXIT_LOCK_TIMEOUT = 107
|
||||
EXIT_CANCELLED = 108
|
||||
EXIT_RELAUNCHED = 109
|
||||
EXIT_NO_SENTINEL = 110
|
||||
EXIT_SENTINEL_READ_ERROR = 111
|
||||
EXIT_SENTINEL_OWNER_MISMATCH = 112
|
||||
EXIT_POLICY_DENIED = 113
|
||||
EXIT_INDEX_ERROR = 114
|
||||
EXIT_POLICY_ERROR = 119
|
||||
|
||||
|
||||
class _TrusteeW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('multiple_trustee', ctypes.c_void_p),
|
||||
('multiple_trustee_operation', wintypes.DWORD),
|
||||
('trustee_form', wintypes.DWORD),
|
||||
('trustee_type', wintypes.DWORD),
|
||||
('name', ctypes.c_void_p),
|
||||
]
|
||||
|
||||
|
||||
class _ExplicitAccessW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
('access_permissions', wintypes.DWORD),
|
||||
('access_mode', wintypes.DWORD),
|
||||
('inheritance', wintypes.DWORD),
|
||||
('trustee', _TrusteeW),
|
||||
]
|
||||
|
||||
|
||||
class _ScopedDirectoryWriteDeny:
|
||||
"""Temporarily deny directory-entry creation while preserving DACL repair."""
|
||||
|
||||
_SE_FILE_OBJECT = 1
|
||||
_DACL_SECURITY_INFORMATION = 0x00000004
|
||||
_WIN_WORLD_SID = 1
|
||||
_SECURITY_MAX_SID_SIZE = 68
|
||||
_DENY_ACCESS = 3
|
||||
_TRUSTEE_IS_SID = 0
|
||||
_TRUSTEE_IS_WELL_KNOWN_GROUP = 5
|
||||
# FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_WRITE_EA |
|
||||
# FILE_WRITE_ATTRIBUTES. WRITE_DAC and deletion remain available so cleanup
|
||||
# can always restore the original descriptor and remove the fixture.
|
||||
_DIRECTORY_WRITE_MASK = 0x00000116
|
||||
|
||||
def __init__(self, path):
|
||||
self._path = os.path.abspath(path)
|
||||
self._security_descriptor = ctypes.c_void_p()
|
||||
self._original_dacl = ctypes.c_void_p()
|
||||
self._new_dacl = ctypes.c_void_p()
|
||||
self._applied = False
|
||||
self._advapi32 = ctypes.WinDLL('advapi32', use_last_error=True)
|
||||
self._kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
self._configure_functions()
|
||||
|
||||
def _configure_functions(self):
|
||||
self._advapi32.GetNamedSecurityInfoW.argtypes = [
|
||||
wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p),
|
||||
ctypes.POINTER(ctypes.c_void_p)
|
||||
]
|
||||
self._advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD
|
||||
self._advapi32.CreateWellKnownSid.argtypes = [
|
||||
wintypes.DWORD, ctypes.c_void_p, ctypes.c_void_p,
|
||||
ctypes.POINTER(wintypes.DWORD)
|
||||
]
|
||||
self._advapi32.CreateWellKnownSid.restype = wintypes.BOOL
|
||||
self._advapi32.SetEntriesInAclW.argtypes = [
|
||||
wintypes.ULONG,
|
||||
ctypes.POINTER(_ExplicitAccessW), ctypes.c_void_p,
|
||||
ctypes.POINTER(ctypes.c_void_p)
|
||||
]
|
||||
self._advapi32.SetEntriesInAclW.restype = wintypes.DWORD
|
||||
self._advapi32.SetNamedSecurityInfoW.argtypes = [
|
||||
wintypes.LPWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p,
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p
|
||||
]
|
||||
self._advapi32.SetNamedSecurityInfoW.restype = wintypes.DWORD
|
||||
self._kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
||||
self._kernel32.LocalFree.restype = ctypes.c_void_p
|
||||
|
||||
@staticmethod
|
||||
def _raise_security_error(result, operation):
|
||||
raise OSError(result, f'{operation} failed with Windows error {result}')
|
||||
|
||||
def apply(self):
|
||||
path_buffer = ctypes.create_unicode_buffer(self._path)
|
||||
result = self._advapi32.GetNamedSecurityInfoW(
|
||||
path_buffer, self._SE_FILE_OBJECT, self._DACL_SECURITY_INFORMATION,
|
||||
None, None, ctypes.byref(self._original_dacl), None,
|
||||
ctypes.byref(self._security_descriptor))
|
||||
if result:
|
||||
self._raise_security_error(result, 'GetNamedSecurityInfoW')
|
||||
|
||||
sid_size = wintypes.DWORD(self._SECURITY_MAX_SID_SIZE)
|
||||
world_sid = ctypes.create_string_buffer(sid_size.value)
|
||||
if not self._advapi32.CreateWellKnownSid(self._WIN_WORLD_SID, None,
|
||||
world_sid, ctypes.byref(sid_size)):
|
||||
error = ctypes.get_last_error()
|
||||
self._release()
|
||||
raise ctypes.WinError(error)
|
||||
|
||||
access = _ExplicitAccessW()
|
||||
access.access_permissions = self._DIRECTORY_WRITE_MASK
|
||||
access.access_mode = self._DENY_ACCESS
|
||||
access.inheritance = 0
|
||||
access.trustee.multiple_trustee = None
|
||||
access.trustee.multiple_trustee_operation = 0
|
||||
access.trustee.trustee_form = self._TRUSTEE_IS_SID
|
||||
access.trustee.trustee_type = self._TRUSTEE_IS_WELL_KNOWN_GROUP
|
||||
access.trustee.name = ctypes.cast(world_sid, ctypes.c_void_p)
|
||||
result = self._advapi32.SetEntriesInAclW(1, ctypes.byref(access),
|
||||
self._original_dacl,
|
||||
ctypes.byref(self._new_dacl))
|
||||
if result:
|
||||
self._release()
|
||||
self._raise_security_error(result, 'SetEntriesInAclW')
|
||||
|
||||
result = self._advapi32.SetNamedSecurityInfoW(
|
||||
path_buffer, self._SE_FILE_OBJECT, self._DACL_SECURITY_INFORMATION,
|
||||
None, None, self._new_dacl, None)
|
||||
if result:
|
||||
self._release()
|
||||
self._raise_security_error(result, 'SetNamedSecurityInfoW')
|
||||
self._applied = True
|
||||
|
||||
def restore(self):
|
||||
if not self._applied:
|
||||
return
|
||||
path_buffer = ctypes.create_unicode_buffer(self._path)
|
||||
result = self._advapi32.SetNamedSecurityInfoW(
|
||||
path_buffer, self._SE_FILE_OBJECT, self._DACL_SECURITY_INFORMATION,
|
||||
None, None, self._original_dacl, None)
|
||||
if result:
|
||||
self._raise_security_error(result, 'DACL restoration')
|
||||
self._applied = False
|
||||
self._release()
|
||||
|
||||
def _release(self):
|
||||
if self._new_dacl.value:
|
||||
self._kernel32.LocalFree(self._new_dacl)
|
||||
self._new_dacl = ctypes.c_void_p()
|
||||
if self._security_descriptor.value:
|
||||
self._kernel32.LocalFree(self._security_descriptor)
|
||||
self._security_descriptor = ctypes.c_void_p()
|
||||
|
||||
|
||||
def _get_build_dir():
|
||||
global BUILD_DIR
|
||||
if BUILD_DIR is None:
|
||||
BUILD_DIR = os.environ.get('CEF_E2E_BUILD_DIR', '')
|
||||
return BUILD_DIR
|
||||
|
||||
|
||||
class E2ETestBase(unittest.TestCase):
|
||||
"""Base class for CEF installer end-to-end tests.
|
||||
|
||||
The exe lives in the build directory (component build requires all DLLs
|
||||
to be co-located). We create CefTestApp.exe as a copy of bootstrap.exe
|
||||
inside the build dir and embed each test's application config in that
|
||||
private copy. Test isolation comes from --cef-install-path and
|
||||
--cef-download-path pointing to per-test temp dirs.
|
||||
"""
|
||||
|
||||
def tearDown(self):
|
||||
# Print installer log on test failure to aid debugging.
|
||||
if hasattr(self, '_outcome'):
|
||||
# Python 3.4+
|
||||
result = self._outcome.result
|
||||
errors = (result.failures + result.errors) if result else []
|
||||
if any(test is self for test, _ in errors):
|
||||
log_path = os.path.join(self.install_dir, 'cef_installer.log')
|
||||
if os.path.isfile(log_path):
|
||||
with open(log_path) as f:
|
||||
print(f'\n--- cef_installer.log ---\n{f.read()}---')
|
||||
|
||||
@contextlib.contextmanager
|
||||
def scoped_read_only_directory(self, path):
|
||||
"""Deny write probes for an owned temp directory, then restore its DACL."""
|
||||
fixture = _ScopedDirectoryWriteDeny(path)
|
||||
try:
|
||||
fixture.apply()
|
||||
except OSError as error:
|
||||
self.skipTest(f'NTFS DACL fixture is unavailable: {error}')
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fixture.restore()
|
||||
|
||||
def setUp(self):
|
||||
build_dir = _get_build_dir()
|
||||
if not build_dir or not os.path.isdir(build_dir):
|
||||
self.skipTest(f'Build directory not found: {build_dir}')
|
||||
|
||||
self.build_dir = build_dir
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.temp_dir, ignore_errors=True)
|
||||
|
||||
self.install_dir = os.path.join(self.temp_dir, 'CEF')
|
||||
self.cdn_dir = os.path.join(self.temp_dir, 'cdn')
|
||||
|
||||
# Derive test versions from the build's installer_config.json so they
|
||||
# stay in sync when the API version changes.
|
||||
versions = get_test_versions(build_dir)
|
||||
if versions is None:
|
||||
self.skipTest(f'installer_config.json not found in {build_dir}/gen/cef/')
|
||||
(self.test_version, self.test_version_update, self.test_version_higher,
|
||||
_) = versions
|
||||
|
||||
# The exe must live in the build directory so it can find component
|
||||
# DLLs. Restore bootstrap.exe -> CefTestApp.exe for every test before any
|
||||
# resource edits.
|
||||
# The bootstrap fatals if the exe name is "bootstrap" (expects to be
|
||||
# renamed).
|
||||
src_exe = os.path.join(build_dir, 'bootstrap.exe')
|
||||
if not os.path.isfile(src_exe):
|
||||
self.skipTest(f'bootstrap.exe not found at {src_exe}')
|
||||
self.app_name = 'CefTestApp'
|
||||
self.bootstrap_exe = os.path.join(build_dir, f'{self.app_name}.exe')
|
||||
self._restore_bootstrap_executable(self.bootstrap_exe)
|
||||
|
||||
self.mock_client_dll_src = os.path.join(build_dir,
|
||||
'cef_e2e_mock_client.dll')
|
||||
|
||||
# Read the test certificate thumbprint.
|
||||
thumbprint_path = os.path.join(TESTDATA_DIR, 'test_thumbprint.txt')
|
||||
if os.path.isfile(thumbprint_path):
|
||||
with open(thumbprint_path) as f:
|
||||
self.test_thumbprint = f.read().strip()
|
||||
else:
|
||||
self.test_thumbprint = ''
|
||||
|
||||
def _restore_bootstrap_executable(self, destination):
|
||||
"""Unconditionally restore an unmodified bootstrap executable."""
|
||||
source = os.path.join(self.build_dir, 'bootstrap.exe')
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
except PermissionError as error:
|
||||
self.fail('Cannot restore bootstrap fixture while a prior test process '
|
||||
f'is using it: {destination}: {error}')
|
||||
|
||||
def setup_launcher(self, app_name='TestApp'):
|
||||
"""Copy bootstrap.exe and mock client DLL with matching names.
|
||||
|
||||
The bootstrap derives the DLL name from the exe name
|
||||
(GetDefaultModuleValue), so TestApp.exe loads TestApp.dll.
|
||||
Both must be in the build directory for component builds.
|
||||
"""
|
||||
app_exe = os.path.join(self.build_dir, f'{app_name}.exe')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
self._restore_bootstrap_executable(app_exe)
|
||||
if os.path.isfile(self.mock_client_dll_src):
|
||||
shutil.copy2(self.mock_client_dll_src, app_dll)
|
||||
else:
|
||||
self.skipTest('cef_e2e_mock_client.dll not found')
|
||||
# Clean up launcher files after test.
|
||||
self.addCleanup(lambda: os.remove(app_dll)
|
||||
if os.path.isfile(app_dll) else None)
|
||||
self.addCleanup(lambda: os.remove(app_exe)
|
||||
if os.path.isfile(app_exe) else None)
|
||||
return app_exe, app_name
|
||||
|
||||
def embed_test_config(self,
|
||||
appid,
|
||||
vmin,
|
||||
abi_hash='',
|
||||
vmax='',
|
||||
exe=None,
|
||||
**extra):
|
||||
"""Embed a complete runtime config in an already restored executable."""
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': vmin,
|
||||
'abi_hash': abi_hash,
|
||||
'enable_explicit_modes': True,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
if vmax:
|
||||
config['vmax'] = vmax
|
||||
config.update(extra)
|
||||
target = exe or self.bootstrap_exe
|
||||
self.embed_config_resource(target, config)
|
||||
return target
|
||||
|
||||
def run_installer(self,
|
||||
args=None,
|
||||
use_local_cdn=True,
|
||||
allow_configured_cdn=False,
|
||||
install_path=None,
|
||||
use_install_path_override=True,
|
||||
timeout=30,
|
||||
exe=None,
|
||||
env=None):
|
||||
"""Run the exe with args, return (exit_code, stdout, stderr)."""
|
||||
cmd = [exe or self.bootstrap_exe] + (args or [])
|
||||
# The bootstrap resolves CEF before loading a client DLL, which may
|
||||
# show a progress/error dialog. Ensure /cef-headless is always present
|
||||
# so E2E tests don't block on UI.
|
||||
if FLAG_HEADLESS not in cmd:
|
||||
cmd.append(FLAG_HEADLESS)
|
||||
# Pass --cef-download-path to prevent accidental real-CDN access. The one
|
||||
# explicit exception is allow_configured_cdn, used only with loopback HTTPS
|
||||
# URLs by the application-source process test below.
|
||||
if allow_configured_cdn:
|
||||
pass
|
||||
elif use_local_cdn and os.path.isdir(self.cdn_dir):
|
||||
cmd.append(f'{FLAG_DOWNLOAD_PATH}={self.cdn_dir}')
|
||||
elif not any(FLAG_DOWNLOAD_PATH in a for a in cmd):
|
||||
os.makedirs(self.cdn_dir, exist_ok=True)
|
||||
cmd.append(f'{FLAG_DOWNLOAD_PATH}={self.cdn_dir}')
|
||||
if use_install_path_override:
|
||||
path = install_path or self.install_dir
|
||||
cmd.append(f'{FLAG_INSTALL_PATH}={path}')
|
||||
run_env = os.environ.copy()
|
||||
if env:
|
||||
run_env.update(env)
|
||||
proc = subprocess.run(cmd,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
cwd=self.build_dir,
|
||||
env=run_env)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
def stage_bootstrap_in_install_root(self,
|
||||
install_root,
|
||||
app_name='ContainedCefTestApp'):
|
||||
"""Stage a runnable component-build bootstrap beneath an install root."""
|
||||
os.makedirs(install_root, exist_ok=True)
|
||||
staged_exe = os.path.join(install_root, f'{app_name}.exe')
|
||||
shutil.copy2(self.bootstrap_exe, staged_exe)
|
||||
shutil.copy2(os.path.join(self.build_dir, 'chrome_elf.dll'),
|
||||
os.path.join(install_root, 'chrome_elf.dll'))
|
||||
crash_config = os.path.join(self.build_dir, 'crash_reporter.cfg')
|
||||
if os.path.isfile(crash_config):
|
||||
shutil.copy2(crash_config, os.path.join(install_root,
|
||||
'crash_reporter.cfg'))
|
||||
return staged_exe
|
||||
|
||||
@staticmethod
|
||||
def snapshot_uninstall_relaunch_dirs():
|
||||
"""Return the existing installer-owned relaunch directories."""
|
||||
temp_root = tempfile.gettempdir()
|
||||
try:
|
||||
entries = os.scandir(temp_root)
|
||||
except OSError:
|
||||
return set()
|
||||
with entries:
|
||||
return {
|
||||
os.path.normcase(os.path.abspath(entry.path))
|
||||
for entry in entries
|
||||
if entry.is_dir(
|
||||
follow_symlinks=False) and entry.name.startswith('cef_uninstall_')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _database_contains_app(install_root, appid):
|
||||
db_path = os.path.join(install_root, 'installer.json')
|
||||
if not os.path.isfile(db_path):
|
||||
return False
|
||||
with open(db_path, 'rb') as db_file:
|
||||
text = db_file.read().decode('utf-8', errors='replace')
|
||||
start = text.find('{')
|
||||
if start < 0:
|
||||
return False
|
||||
try:
|
||||
value, _ = json.JSONDecoder().raw_decode(text, start)
|
||||
except json.JSONDecodeError:
|
||||
return True
|
||||
return any(app.get('uuid') == appid for app in value.get('apps', []))
|
||||
|
||||
@staticmethod
|
||||
def _read_version_index_versions(install_root):
|
||||
"""Return the integrity-checked version/platform keys, or None."""
|
||||
index_path = os.path.join(install_root, 'versions.json')
|
||||
try:
|
||||
with open(index_path, 'rb') as index_file:
|
||||
raw = index_file.read()
|
||||
except OSError:
|
||||
return None
|
||||
if len(raw) < 16:
|
||||
return None
|
||||
content = raw[:-16]
|
||||
expected_crc, reserved, magic = struct.unpack('<IIQ', raw[-16:])
|
||||
if (reserved != 0 or magic != _INTEGRITY_FOOTER_MAGIC or
|
||||
zlib.crc32(content) & 0xffffffff != expected_crc):
|
||||
return None
|
||||
try:
|
||||
value = json.loads(content.decode('utf-8'))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
versions = value.get('versions') if isinstance(value, dict) else None
|
||||
if not isinstance(versions, list):
|
||||
return None
|
||||
keys = set()
|
||||
for entry in versions:
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
version = entry.get('version')
|
||||
platform = entry.get('platform')
|
||||
if not isinstance(version, str) or not isinstance(platform, str):
|
||||
return None
|
||||
key = (version, platform)
|
||||
if key in keys:
|
||||
return None
|
||||
keys.add(key)
|
||||
return keys
|
||||
|
||||
def wait_for_relaunched_uninstall(self,
|
||||
appid,
|
||||
previous_dirs,
|
||||
expected_exe_name,
|
||||
expected_index_versions,
|
||||
install_root=None,
|
||||
timeout=15):
|
||||
"""Wait for a child to publish the expected commit and exit, then clean."""
|
||||
install_root = install_root or self.install_dir
|
||||
expected_index_versions = set(expected_index_versions)
|
||||
deadline = time.time() + timeout
|
||||
relaunch_dir = None
|
||||
while time.time() < deadline and relaunch_dir is None:
|
||||
candidates = self.snapshot_uninstall_relaunch_dirs() - previous_dirs
|
||||
matching = [
|
||||
path for path in candidates
|
||||
if os.path.isfile(os.path.join(path, expected_exe_name))
|
||||
]
|
||||
if len(matching) == 1:
|
||||
relaunch_dir = matching[0]
|
||||
self.addCleanup(shutil.rmtree, relaunch_dir, ignore_errors=True)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
self.assertIsNotNone(relaunch_dir,
|
||||
f'Expected one relaunched {expected_exe_name} child')
|
||||
|
||||
registration_removed = False
|
||||
index_versions = None
|
||||
while time.time() < deadline:
|
||||
registration_removed = not self._database_contains_app(
|
||||
install_root, appid)
|
||||
index_versions = self._read_version_index_versions(install_root)
|
||||
if (registration_removed and index_versions == expected_index_versions):
|
||||
try:
|
||||
shutil.rmtree(relaunch_dir)
|
||||
return relaunch_dir
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
self.fail('Relaunched uninstall did not publish its complete logical '
|
||||
f'commit and exit before timeout: registration_removed='
|
||||
f'{registration_removed}, index_versions={index_versions}')
|
||||
|
||||
def get_loopback_tls_certificate(self):
|
||||
"""Return the checked-in, test-only localhost TLS certificate and key."""
|
||||
cert_path = os.path.join(TESTDATA_DIR, 'loopback_tls_cert.pem')
|
||||
key_path = os.path.join(TESTDATA_DIR, 'loopback_tls_key.pem')
|
||||
self.assertTrue(os.path.isfile(cert_path),
|
||||
f'Loopback TLS certificate not found: {cert_path}')
|
||||
self.assertTrue(os.path.isfile(key_path),
|
||||
f'Loopback TLS key not found: {key_path}')
|
||||
return cert_path, key_path
|
||||
|
||||
def start_https_file_server(self, directory, cert_path, key_path):
|
||||
"""Serve a directory over loopback HTTPS and record request paths."""
|
||||
requests = []
|
||||
|
||||
class RecordingHandler(http.server.SimpleHTTPRequestHandler):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=directory, **kwargs)
|
||||
|
||||
def do_GET(self):
|
||||
requests.append(f'GET {self.path}')
|
||||
super().do_GET()
|
||||
|
||||
def do_HEAD(self):
|
||||
requests.append(f'HEAD {self.path}')
|
||||
super().do_HEAD()
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), RecordingHandler)
|
||||
server.daemon_threads = True
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(cert_path, key_path)
|
||||
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
def stop_server():
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
self.addCleanup(stop_server)
|
||||
return f'https://localhost:{server.server_port}/', requests
|
||||
|
||||
def assert_version_installed(self, version, platform='windows64'):
|
||||
"""Assert that a version directory exists with valid metadata."""
|
||||
version_dir = os.path.join(self.install_dir, 'Versions', version, platform)
|
||||
self.assertTrue(os.path.isdir(version_dir),
|
||||
f'Version directory not found: {version_dir}')
|
||||
metadata_path = os.path.join(version_dir, 'cef_version.json')
|
||||
self.assertTrue(os.path.isfile(metadata_path),
|
||||
f'Metadata not found: {metadata_path}')
|
||||
|
||||
def assert_version_not_installed(self, version, platform='windows64'):
|
||||
"""Assert that a version directory does not exist."""
|
||||
version_dir = os.path.join(self.install_dir, 'Versions', version, platform)
|
||||
self.assertFalse(os.path.exists(version_dir),
|
||||
f'Version directory still exists: {version_dir}')
|
||||
|
||||
def build_cdn(self, version=None, abi_hash='a1b2c3d4e5f6'):
|
||||
"""Build a local CDN directory with the given version.
|
||||
|
||||
Can be called multiple times to add versions (manifests merge).
|
||||
"""
|
||||
if version is None:
|
||||
version = self.test_version
|
||||
os.makedirs(self.cdn_dir, exist_ok=True)
|
||||
helper = os.path.join(self.build_dir, 'cef_e2e_build_test_cdn.exe')
|
||||
if not os.path.isfile(helper):
|
||||
self.skipTest('cef_e2e_build_test_cdn.exe not found')
|
||||
result = subprocess.run([
|
||||
helper, f'--output-dir={self.cdn_dir}', f'--version={version}',
|
||||
f'--abi-hash={abi_hash}'
|
||||
],
|
||||
capture_output=True,
|
||||
encoding='utf-8',
|
||||
errors='replace')
|
||||
self.assertEqual(0, result.returncode, f'CDN build failed: {result.stderr}')
|
||||
return version, abi_hash
|
||||
|
||||
def embed_config_resource(self, exe_path, config_dict):
|
||||
"""Embed a CEF_INSTALLER_CONFIG resource into an exe using ResourceHacker.
|
||||
|
||||
Writes config JSON + .rc file to self.temp_dir, compiles with
|
||||
ResourceHacker, then replaces/adds the resource in the target exe.
|
||||
Fails the test (not skips) if CEF_RESOURCE_HACKER_PATH is not set.
|
||||
"""
|
||||
rh_path = os.environ.get('CEF_RESOURCE_HACKER_PATH', '')
|
||||
if not rh_path or not os.path.isfile(rh_path):
|
||||
self.fail('CEF_RESOURCE_HACKER_PATH must be set to the path of '
|
||||
'ResourceHacker.exe (required for embedded config tests). '
|
||||
f'Current value: {rh_path!r}')
|
||||
|
||||
self.embed_raw_config_resource(exe_path, json.dumps(config_dict))
|
||||
|
||||
def embed_raw_config_resource(self, exe_path, config_text):
|
||||
"""Embed raw CEF_INSTALLER_CONFIG text for malformed-config tests."""
|
||||
rh_path = os.environ.get('CEF_RESOURCE_HACKER_PATH', '')
|
||||
if not rh_path or not os.path.isfile(rh_path):
|
||||
self.fail('CEF_RESOURCE_HACKER_PATH must be set to the path of '
|
||||
'ResourceHacker.exe (required for embedded config tests). '
|
||||
f'Current value: {rh_path!r}')
|
||||
|
||||
config_json_path = os.path.join(self.temp_dir, 'config.json')
|
||||
with open(config_json_path, 'w') as f:
|
||||
f.write(config_text)
|
||||
|
||||
rc_path = os.path.join(self.temp_dir, 'config.rc')
|
||||
with open(rc_path, 'w') as f:
|
||||
f.write('CEF_INSTALLER_CONFIG RCDATA "config.json"\n')
|
||||
|
||||
res_path = os.path.join(self.temp_dir, 'config.res')
|
||||
|
||||
# Compile .rc -> .res (cwd must be temp_dir so relative "config.json" resolves).
|
||||
result = subprocess.run([
|
||||
rh_path, '-open', 'config.rc', '-save', 'config.res', '-action',
|
||||
'compile'
|
||||
],
|
||||
capture_output=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
cwd=self.temp_dir)
|
||||
self.assertEqual(0, result.returncode,
|
||||
f'ResourceHacker compile failed: {result.stderr}')
|
||||
|
||||
# Add/overwrite the resource in the exe.
|
||||
result = subprocess.run([
|
||||
rh_path, '-open', exe_path, '-save', exe_path, '-action',
|
||||
'addoverwrite', '-res', res_path, '-mask',
|
||||
'RCDATA,CEF_INSTALLER_CONFIG,'
|
||||
],
|
||||
capture_output=True,
|
||||
encoding='utf-8',
|
||||
errors='replace',
|
||||
cwd=self.temp_dir)
|
||||
self.assertEqual(0, result.returncode,
|
||||
f'ResourceHacker addoverwrite failed: {result.stderr}')
|
||||
|
||||
def get_appid_hash(self, appid):
|
||||
"""Compute SHA-1 of appid, return first 16 hex chars.
|
||||
|
||||
Must match C++ GetAppidHash logic.
|
||||
"""
|
||||
import hashlib
|
||||
h = hashlib.sha1(appid.encode('utf-8')).hexdigest()
|
||||
return h[:16].upper()
|
||||
|
||||
def get_launch_dir(self):
|
||||
"""Return path to the .launch/ directory under install_dir."""
|
||||
return os.path.join(self.install_dir, '.launch')
|
||||
|
||||
@staticmethod
|
||||
def filetime_now():
|
||||
"""Return current wall time as Windows FILETIME ticks."""
|
||||
return int((time.time() + 11644473600) * 10000000)
|
||||
|
||||
@staticmethod
|
||||
def write_integrity_json(path, value):
|
||||
"""Write compact JSON with the installer's CRC32 integrity footer."""
|
||||
content = json.dumps(value, separators=(',', ':')).encode('utf-8')
|
||||
footer = struct.pack('<IIQ',
|
||||
zlib.crc32(content) & 0xffffffff, 0,
|
||||
_INTEGRITY_FOOTER_MAGIC)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(content)
|
||||
f.write(footer)
|
||||
|
||||
def write_registration_database(self, apps):
|
||||
"""Write a valid schema-1 installer.json with supplied app entries."""
|
||||
path = os.path.join(self.install_dir, 'installer.json')
|
||||
self.write_integrity_json(path, {
|
||||
'schema_version': 1,
|
||||
'apps': apps,
|
||||
})
|
||||
return path
|
||||
|
||||
def write_liveness(self,
|
||||
appid,
|
||||
last_launch,
|
||||
platform='windows64',
|
||||
filename=None):
|
||||
"""Write an integrity-valid liveness-only record."""
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
name = filename or f'{appid_hash}_{platform}'
|
||||
path = os.path.join(self.get_launch_dir(), name)
|
||||
self.write_integrity_json(path, {
|
||||
'appid': appid,
|
||||
'platform': platform,
|
||||
'last_launch': str(last_launch),
|
||||
})
|
||||
return path
|
||||
|
||||
def write_health(self,
|
||||
appid,
|
||||
version,
|
||||
pid_start_time,
|
||||
platform='windows64',
|
||||
filename=None,
|
||||
last_update=None,
|
||||
running=False,
|
||||
confirmed=True,
|
||||
consecutive_failures=0):
|
||||
"""Write an integrity-valid health sentinel with controlled FILETIME."""
|
||||
if last_update is None:
|
||||
last_update = self.filetime_now()
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
name = filename or f'{appid_hash}_{version}_{platform}'
|
||||
path = os.path.join(self.get_launch_dir(), name)
|
||||
self.write_integrity_json(
|
||||
path, {
|
||||
'appid': appid,
|
||||
'pid': 0,
|
||||
'pid_start_time': str(pid_start_time),
|
||||
'consecutive_failures': consecutive_failures,
|
||||
'running': running,
|
||||
'confirmed': confirmed,
|
||||
'version': version,
|
||||
'platform': platform,
|
||||
'last_update': str(last_update),
|
||||
})
|
||||
return path
|
||||
|
||||
def read_launch_state(self, version, appid_hash, platform='windows64'):
|
||||
"""Read launch state from .launch/ directory.
|
||||
|
||||
Filename: <appid_hash>_<version>_<platform>
|
||||
Returns dict or None.
|
||||
"""
|
||||
launch_dir = self.get_launch_dir()
|
||||
path = os.path.join(launch_dir, f'{appid_hash}_{version}_{platform}')
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
with open(path, 'rb') as f:
|
||||
raw = f.read()
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
start = text.find('{')
|
||||
if start < 0:
|
||||
return None
|
||||
try:
|
||||
obj, _ = json.JSONDecoder().raw_decode(text, start)
|
||||
return obj
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def assert_launch_state(self,
|
||||
version,
|
||||
appid_hash,
|
||||
running,
|
||||
failures,
|
||||
platform='windows64'):
|
||||
"""Assert launch state in .launch/ dir with expected running and failures."""
|
||||
state = self.read_launch_state(version, appid_hash, platform)
|
||||
self.assertIsNotNone(state,
|
||||
f'Launch state not found for {version}/{appid_hash}')
|
||||
self.assertEqual(running, state.get('running'),
|
||||
f'Expected running={running}, got {state}')
|
||||
self.assertEqual(failures, state.get('consecutive_failures'),
|
||||
f'Expected failures={failures}, got {state}')
|
||||
last_update = state.get('last_update')
|
||||
self.assertIsInstance(last_update, str, f'Missing last_update: {state}')
|
||||
self.assertTrue(last_update.isdecimal(), f'Invalid last_update: {state}')
|
||||
self.assertGreater(int(last_update), 0, f'Zero last_update: {state}')
|
||||
|
||||
def assert_no_launch_state(self, version, appid_hash, platform='windows64'):
|
||||
"""Assert launch state file does not exist in .launch/ dir."""
|
||||
launch_dir = self.get_launch_dir()
|
||||
path = os.path.join(launch_dir, f'{appid_hash}_{version}_{platform}')
|
||||
self.assertFalse(os.path.exists(path),
|
||||
f'Launch state file still exists: {path}')
|
||||
|
||||
def read_liveness(self, appid_hash, platform='windows64'):
|
||||
"""Read a health-off liveness-only record from .launch/."""
|
||||
path = os.path.join(self.get_launch_dir(), f'{appid_hash}_{platform}')
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
with open(path, 'rb') as f:
|
||||
text = f.read().decode('utf-8', errors='replace')
|
||||
start = text.find('{')
|
||||
if start < 0:
|
||||
return None
|
||||
try:
|
||||
obj, _ = json.JSONDecoder().raw_decode(text, start)
|
||||
return obj
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
def list_launch_files(self):
|
||||
"""List all files in the .launch/ directory."""
|
||||
launch_dir = self.get_launch_dir()
|
||||
if not os.path.isdir(launch_dir):
|
||||
return []
|
||||
return os.listdir(launch_dir)
|
||||
|
||||
def read_database(self):
|
||||
"""Read and parse installer.json from the install directory.
|
||||
|
||||
The database file has a CRC32 integrity footer after the JSON.
|
||||
We use raw_decode to parse only the JSON prefix.
|
||||
"""
|
||||
db_path = os.path.join(self.install_dir, 'installer.json')
|
||||
with open(db_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
start = text.find('{')
|
||||
if start < 0:
|
||||
return {}
|
||||
obj, _ = json.JSONDecoder().raw_decode(text, start)
|
||||
return obj
|
||||
@@ -0,0 +1,249 @@
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Reference parser and correlator for uninstall lifecycle protocol v1."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
|
||||
MAX_PAYLOAD = 32 * 1024
|
||||
MAX_ERROR_MESSAGE = 4096
|
||||
MAX_WARNING = 1024
|
||||
MAX_WARNINGS = 32
|
||||
_OPERATION_ID = re.compile(r'^[0-9a-f]{32}$')
|
||||
_TERMINAL_ERROR_NAMES = {
|
||||
100: 'CONFIG_ERROR',
|
||||
101: 'NETWORK_ERROR',
|
||||
102: 'SIGNATURE_ERROR',
|
||||
103: 'NO_MATCHING_VERSION',
|
||||
104: 'EXTRACTION_ERROR',
|
||||
105: 'INSTALL_ERROR',
|
||||
106: 'DATABASE_ERROR',
|
||||
107: 'LOCK_TIMEOUT',
|
||||
108: 'CANCELLED',
|
||||
110: 'NO_SENTINEL',
|
||||
111: 'SENTINEL_READ_ERROR',
|
||||
112: 'SENTINEL_OWNER_MISMATCH',
|
||||
113: 'POLICY_DENIED',
|
||||
114: 'INDEX_ERROR',
|
||||
115: 'RECOVERY_ERROR',
|
||||
116: 'REPAIR_ERROR',
|
||||
117: 'QUARANTINE_ERROR',
|
||||
118: 'RETENTION_SNAPSHOT_CHANGED',
|
||||
119: 'POLICY_ERROR',
|
||||
199: 'UNKNOWN_ERROR',
|
||||
}
|
||||
|
||||
DEFAULT_CORRELATION_CAPACITY = 256
|
||||
DEFAULT_CORRELATION_EXPIRATION_SECONDS = 30 * 60
|
||||
|
||||
|
||||
def _integer(value, minimum=None, maximum=None):
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return False
|
||||
return ((minimum is None or value >= minimum) and
|
||||
(maximum is None or value <= maximum))
|
||||
|
||||
|
||||
def _uint32_number(value):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return False
|
||||
if isinstance(value, float):
|
||||
return (math.isfinite(value) and value.is_integer() and
|
||||
1 <= value <= 0xffffffff)
|
||||
return 1 <= value <= 0xffffffff
|
||||
|
||||
|
||||
def _bounded_utf8_string(value, maximum):
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
try:
|
||||
return len(value.encode('utf-8')) <= maximum
|
||||
except UnicodeEncodeError:
|
||||
return False
|
||||
|
||||
|
||||
def parse_payload(payload):
|
||||
"""Return (status, event), where status is valid, ignored, or malformed."""
|
||||
if not isinstance(payload, str) or '\0' in payload:
|
||||
return 'malformed', None
|
||||
try:
|
||||
encoded = payload.encode('utf-8')
|
||||
except UnicodeEncodeError:
|
||||
return 'malformed', None
|
||||
if not encoded or len(encoded) + 1 > MAX_PAYLOAD:
|
||||
return 'malformed', None
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except (TypeError, ValueError):
|
||||
return 'malformed', None
|
||||
if not isinstance(event, dict):
|
||||
return 'malformed', None
|
||||
version = event.get('protocol_version')
|
||||
name = event.get('event')
|
||||
if not _integer(version) or not isinstance(name, str):
|
||||
return 'malformed', None
|
||||
if version != 1 or name not in ('relaunch_started', 'operation_result'):
|
||||
return 'ignored', None
|
||||
operation_id = event.get('operation_id')
|
||||
if not isinstance(operation_id,
|
||||
str) or not _OPERATION_ID.fullmatch(operation_id):
|
||||
return 'malformed', None
|
||||
if name == 'relaunch_started':
|
||||
child_pid = event.get('child_pid')
|
||||
if (not _uint32_number(child_pid) or child_pid < 1 or
|
||||
child_pid > 0xffffffff):
|
||||
return 'malformed', None
|
||||
return 'valid', event
|
||||
|
||||
success = event.get('success')
|
||||
outcome = event.get('outcome')
|
||||
exit_code = event.get('exit_code')
|
||||
if (event.get('command') != 'uninstall' or not isinstance(success, bool) or
|
||||
outcome not in ('committed', 'cleanup_deferred', 'failed') or
|
||||
not _integer(exit_code)):
|
||||
return 'malformed', None
|
||||
error_fields = ('error_code', 'error_name', 'error_message')
|
||||
if success:
|
||||
if outcome == 'failed' or exit_code != 0 or any(
|
||||
k in event for k in error_fields):
|
||||
return 'malformed', None
|
||||
else:
|
||||
error_code = event.get('error_code')
|
||||
error_name = event.get('error_name')
|
||||
error_message = event.get('error_message')
|
||||
if (outcome != 'failed' or not _integer(error_code) or error_code == 0 or
|
||||
error_code != exit_code or error_code not in _TERMINAL_ERROR_NAMES or
|
||||
_TERMINAL_ERROR_NAMES[error_code] != error_name or
|
||||
not _bounded_utf8_string(error_message, MAX_ERROR_MESSAGE)):
|
||||
return 'malformed', None
|
||||
warnings = event.get('warnings', [])
|
||||
if (not isinstance(warnings, list) or len(warnings) > MAX_WARNINGS or
|
||||
any(not _bounded_utf8_string(item, MAX_WARNING) for item in warnings)):
|
||||
return 'malformed', None
|
||||
if ('diagnostics_truncated' in event and
|
||||
not isinstance(event['diagnostics_truncated'], bool)):
|
||||
return 'malformed', None
|
||||
return 'valid', event
|
||||
|
||||
|
||||
class Correlator:
|
||||
"""Bounded, expiring attribution of terminals to matching handoffs."""
|
||||
|
||||
def __init__(self,
|
||||
capacity=DEFAULT_CORRELATION_CAPACITY,
|
||||
expiration_seconds=DEFAULT_CORRELATION_EXPIRATION_SECONDS,
|
||||
clock=time.monotonic):
|
||||
if capacity < 1 or expiration_seconds <= 0:
|
||||
raise ValueError('correlation bounds must be positive')
|
||||
self._capacity = capacity
|
||||
self._expiration_seconds = expiration_seconds
|
||||
self._clock = clock
|
||||
self._last_activity = {}
|
||||
self.handoffs = {}
|
||||
self.pending = {}
|
||||
self.results = {}
|
||||
|
||||
def _discard(self, operation_id):
|
||||
existed = operation_id in self._last_activity
|
||||
self._last_activity.pop(operation_id, None)
|
||||
self.handoffs.pop(operation_id, None)
|
||||
self.pending.pop(operation_id, None)
|
||||
self.results.pop(operation_id, None)
|
||||
return existed
|
||||
|
||||
def purge_expired(self):
|
||||
now = self._clock()
|
||||
expired = [
|
||||
operation_id
|
||||
for operation_id, last_activity in self._last_activity.items()
|
||||
if now - last_activity >= self._expiration_seconds
|
||||
]
|
||||
for operation_id in expired:
|
||||
self._discard(operation_id)
|
||||
return len(expired)
|
||||
|
||||
def _make_room(self, terminal_first):
|
||||
if len(self._last_activity) < self._capacity:
|
||||
return True
|
||||
candidates = [
|
||||
operation_id for operation_id in self.pending
|
||||
if operation_id not in self.handoffs
|
||||
]
|
||||
if not candidates:
|
||||
if terminal_first:
|
||||
return False
|
||||
candidates = list(self._last_activity)
|
||||
oldest = min(candidates, key=self._last_activity.__getitem__)
|
||||
self._discard(oldest)
|
||||
return True
|
||||
|
||||
def accept_payload(self, payload):
|
||||
status, event = parse_payload(payload)
|
||||
if status != 'valid':
|
||||
return status
|
||||
self.purge_expired()
|
||||
now = self._clock()
|
||||
operation_id = event['operation_id']
|
||||
if event['event'] == 'relaunch_started':
|
||||
if operation_id in self.handoffs:
|
||||
return 'duplicate'
|
||||
if (operation_id not in self._last_activity and
|
||||
not self._make_room(terminal_first=False)):
|
||||
return 'ignored'
|
||||
self.handoffs[operation_id] = event
|
||||
self._last_activity[operation_id] = now
|
||||
if operation_id in self.pending:
|
||||
self.results[operation_id] = self.pending.pop(operation_id)
|
||||
return 'correlated'
|
||||
return 'handoff'
|
||||
if operation_id in self.pending or operation_id in self.results:
|
||||
return 'duplicate'
|
||||
if operation_id not in self.handoffs:
|
||||
if (operation_id not in self._last_activity and
|
||||
not self._make_room(terminal_first=True)):
|
||||
return 'ignored'
|
||||
self.pending[operation_id] = event
|
||||
self._last_activity[operation_id] = now
|
||||
return 'buffered'
|
||||
self.results[operation_id] = event
|
||||
self._last_activity[operation_id] = now
|
||||
return 'correlated'
|
||||
|
||||
def consume_result(self, operation_id):
|
||||
self.purge_expired()
|
||||
result = self.results.get(operation_id)
|
||||
if result is not None:
|
||||
self._discard(operation_id)
|
||||
return result
|
||||
|
||||
def remove(self, operation_id):
|
||||
self.purge_expired()
|
||||
return self._discard(operation_id)
|
||||
|
||||
def tracked_operation_count(self):
|
||||
self.purge_expired()
|
||||
return len(self._last_activity)
|
||||
|
||||
def snapshot(self):
|
||||
self.purge_expired()
|
||||
return {
|
||||
'handoffs': dict(self.handoffs),
|
||||
'pending': dict(self.pending),
|
||||
'results': dict(self.results),
|
||||
}
|
||||
|
||||
|
||||
def correlate_helper_output(output):
|
||||
"""Parse the structured helper output and return receiver public state."""
|
||||
correlator = Correlator()
|
||||
statuses = []
|
||||
for payload in output.get('lifecycle', []):
|
||||
statuses.append(correlator.accept_payload(payload))
|
||||
state = correlator.snapshot()
|
||||
return {
|
||||
'statuses': statuses,
|
||||
**state,
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
// Mock client DLL for E2E tests. Loaded by bootstrap.exe in launcher mode.
|
||||
// Calls RunInstaller (exported by bootstrap.exe) and writes a marker file
|
||||
// with the result.
|
||||
//
|
||||
// Environment variables used:
|
||||
// CEF_E2E_MARKER_PATH - Path to write the result marker JSON file
|
||||
// CEF_E2E_CONFIG_JSON - Config JSON to pass to RunInstaller
|
||||
// CEF_E2E_COMMAND - Command string (default: "install")
|
||||
// CEF_E2E_THREADING_MODE - "multi" to spawn two threads calling
|
||||
// RunInstaller sequentially on separate threads
|
||||
// - "parallel" to spawn two threads calling
|
||||
// RunInstaller concurrently
|
||||
// CEF_E2E_CONFIG_JSON_A - Config JSON for thread A (multi/parallel mode)
|
||||
// CEF_E2E_CONFIG_JSON_B - Config JSON for thread B (multi/parallel mode)
|
||||
// CEF_E2E_LAUNCH_SUCCESS - When set (with CEF_E2E_EXIT_CODE), call
|
||||
// RunInstaller("launch_success") to confirm
|
||||
// launch health before returning the exit code
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "include/cef_version_info.h"
|
||||
|
||||
typedef const char* (*RunInstallerFn)(const char* command,
|
||||
const char* config_json);
|
||||
|
||||
static RunInstallerFn GetRunInstaller() {
|
||||
HMODULE exe = ::GetModuleHandleW(nullptr);
|
||||
return reinterpret_cast<RunInstallerFn>(
|
||||
::GetProcAddress(exe, "RunInstaller"));
|
||||
}
|
||||
|
||||
static std::string GetEnvVar(const char* name) {
|
||||
char buf[8192] = {};
|
||||
DWORD len = ::GetEnvironmentVariableA(name, buf, sizeof(buf));
|
||||
return len > 0 ? std::string(buf, len) : std::string();
|
||||
}
|
||||
|
||||
static std::string WideToJsonEscaped(const wchar_t* wide) {
|
||||
if (!wide) {
|
||||
return "";
|
||||
}
|
||||
int len =
|
||||
::WideCharToMultiByte(CP_UTF8, 0, wide, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (len <= 0) {
|
||||
return "";
|
||||
}
|
||||
std::string utf8(len - 1, '\0');
|
||||
::WideCharToMultiByte(CP_UTF8, 0, wide, -1, &utf8[0], len, nullptr, nullptr);
|
||||
std::string escaped;
|
||||
escaped.reserve(utf8.size());
|
||||
for (char c : utf8) {
|
||||
if (c == '\\') {
|
||||
escaped += "\\\\";
|
||||
} else if (c == '"') {
|
||||
escaped += "\\\"";
|
||||
} else {
|
||||
escaped += c;
|
||||
}
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
static std::string Utf8ToJsonEscaped(const char* value) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
std::string escaped;
|
||||
for (const char* current = value; *current; ++current) {
|
||||
const unsigned char c = static_cast<unsigned char>(*current);
|
||||
switch (c) {
|
||||
case '\\':
|
||||
escaped += "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
escaped += "\\\"";
|
||||
break;
|
||||
case '\b':
|
||||
escaped += "\\b";
|
||||
break;
|
||||
case '\f':
|
||||
escaped += "\\f";
|
||||
break;
|
||||
case '\n':
|
||||
escaped += "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
escaped += "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
escaped += "\\t";
|
||||
break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
constexpr char kHex[] = "0123456789abcdef";
|
||||
escaped += "\\u00";
|
||||
escaped += kHex[c >> 4];
|
||||
escaped += kHex[c & 0x0F];
|
||||
} else {
|
||||
escaped += static_cast<char>(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
static void WriteMarkerFile(const std::string& path,
|
||||
const char* result_json,
|
||||
const cef_version_info_t* vi) {
|
||||
FILE* f = fopen(path.c_str(), "w");
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"result\": %s,\n", result_json ? result_json : "null");
|
||||
|
||||
if (vi && vi->size >= CEF_VERSION_INFO_SIZE_WITH_INSTALLER_ERROR) {
|
||||
std::string escaped_path = WideToJsonEscaped(vi->libcef_path);
|
||||
fprintf(f, " \"libcef_path\": \"%s\",\n", escaped_path.c_str());
|
||||
fprintf(f, " \"libcef_is_bundled\": %d,\n", vi->libcef_is_bundled);
|
||||
fprintf(f, " \"libcef_version_full\": \"%s\",\n",
|
||||
vi->libcef_version_full ? vi->libcef_version_full : "");
|
||||
fprintf(f, " \"cef_version_major\": %d,\n", vi->cef_version_major);
|
||||
fprintf(f, " \"installer_error_code\": %d,\n", vi->installer_error_code);
|
||||
if (vi->installer_error_message) {
|
||||
const std::string escaped_message =
|
||||
Utf8ToJsonEscaped(vi->installer_error_message);
|
||||
fprintf(f, " \"installer_error_message\": \"%s\"\n",
|
||||
escaped_message.c_str());
|
||||
} else {
|
||||
fprintf(f, " \"installer_error_message\": null\n");
|
||||
}
|
||||
} else {
|
||||
fprintf(f, " \"version_info\": null\n");
|
||||
}
|
||||
|
||||
fprintf(f, "}\n");
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
// Per-thread context for multi-threaded RunInstaller calls.
|
||||
struct ThreadContext {
|
||||
RunInstallerFn run_installer;
|
||||
std::string command;
|
||||
std::string config_json;
|
||||
std::string result_json;
|
||||
bool success;
|
||||
};
|
||||
|
||||
static DWORD WINAPI RunInstallerThread(LPVOID param) {
|
||||
auto* ctx = static_cast<ThreadContext*>(param);
|
||||
const char* result =
|
||||
ctx->run_installer(ctx->command.c_str(), ctx->config_json.c_str());
|
||||
ctx->result_json = result ? result : "null";
|
||||
ctx->success = result && strstr(result, "\"success\":true");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void WriteMultiMarkerFile(const std::string& path,
|
||||
const ThreadContext& ctx_a,
|
||||
const ThreadContext& ctx_b,
|
||||
const cef_version_info_t* vi) {
|
||||
FILE* f = fopen(path.c_str(), "w");
|
||||
if (!f) {
|
||||
return;
|
||||
}
|
||||
fprintf(f, "{\n");
|
||||
fprintf(f, " \"result_a\": %s,\n", ctx_a.result_json.c_str());
|
||||
fprintf(f, " \"result_b\": %s,\n", ctx_b.result_json.c_str());
|
||||
if (vi) {
|
||||
fprintf(f, " \"cef_version_major\": %d\n", vi->cef_version_major);
|
||||
} else {
|
||||
fprintf(f, " \"version_info\": null\n");
|
||||
}
|
||||
fprintf(f, "}\n");
|
||||
fclose(f);
|
||||
}
|
||||
|
||||
static int RunMultiThreaded(const std::string& marker_path,
|
||||
const std::string& command,
|
||||
RunInstallerFn run_installer,
|
||||
const cef_version_info_t* vi,
|
||||
bool parallel) {
|
||||
std::string config_a = GetEnvVar("CEF_E2E_CONFIG_JSON_A");
|
||||
std::string config_b = GetEnvVar("CEF_E2E_CONFIG_JSON_B");
|
||||
if (config_a.empty() || config_b.empty()) {
|
||||
WriteMarkerFile(
|
||||
marker_path,
|
||||
"{\"success\":false,\"error_message\":\"missing config A or B\"}", vi);
|
||||
return 1;
|
||||
}
|
||||
|
||||
ThreadContext ctx_a = {run_installer, command, config_a, "null", false};
|
||||
ThreadContext ctx_b = {run_installer, command, config_b, "null", false};
|
||||
|
||||
HANDLE thread_a =
|
||||
::CreateThread(nullptr, 0, RunInstallerThread, &ctx_a, 0, nullptr);
|
||||
|
||||
if (parallel) {
|
||||
// Run both threads concurrently. Tests that ScopedThreadPool's refcount
|
||||
// keeps the pool alive while either thread is still using it.
|
||||
HANDLE thread_b =
|
||||
::CreateThread(nullptr, 0, RunInstallerThread, &ctx_b, 0, nullptr);
|
||||
HANDLE handles[] = {thread_a, thread_b};
|
||||
::WaitForMultipleObjects(2, handles, TRUE, 60000);
|
||||
::CloseHandle(thread_a);
|
||||
::CloseHandle(thread_b);
|
||||
} else {
|
||||
// Run sequentially on separate threads. The test goal is verifying
|
||||
// thread-local result strings don't cross-contaminate.
|
||||
::WaitForSingleObject(thread_a, 30000);
|
||||
::CloseHandle(thread_a);
|
||||
|
||||
HANDLE thread_b =
|
||||
::CreateThread(nullptr, 0, RunInstallerThread, &ctx_b, 0, nullptr);
|
||||
::WaitForSingleObject(thread_b, 30000);
|
||||
::CloseHandle(thread_b);
|
||||
}
|
||||
|
||||
WriteMultiMarkerFile(marker_path, ctx_a, ctx_b, vi);
|
||||
return (ctx_a.success && ctx_b.success) ? 0 : 3;
|
||||
}
|
||||
|
||||
extern "C" __declspec(dllexport) int RunWinMain(HINSTANCE hInstance,
|
||||
LPTSTR lpCmdLine,
|
||||
int nCmdShow,
|
||||
void* sandbox_info,
|
||||
cef_version_info_t* vi) {
|
||||
std::string marker_path = GetEnvVar("CEF_E2E_MARKER_PATH");
|
||||
std::string exit_code_str = GetEnvVar("CEF_E2E_EXIT_CODE");
|
||||
|
||||
// When CEF_E2E_EXIT_CODE is set, skip RunInstaller entirely. Write the
|
||||
// marker file with version_info from the bootstrap and return the
|
||||
// specified exit code. This lets E2E tests control the exit code to
|
||||
// simulate clean exits, crashes, and neutral exits.
|
||||
if (!exit_code_str.empty()) {
|
||||
int exit_code = atoi(exit_code_str.c_str());
|
||||
|
||||
// When CEF_E2E_LAUNCH_SUCCESS is set, confirm launch health via the
|
||||
// RunInstaller export before returning the exit code. This composes with
|
||||
// CEF_E2E_EXIT_CODE: a confirm-then-crash run leaves the sentinel
|
||||
// confirmed (running=false, consecutive_failures=0) before the crash exit,
|
||||
// so the crash does not penalize the CEF version. Mirrors cefclient's
|
||||
// ConfirmLaunchHealth — kLaunchSuccess ignores config (the sentinel path
|
||||
// is a process-global the bootstrap set before RunWinMain), so pass null.
|
||||
if (!GetEnvVar("CEF_E2E_LAUNCH_SUCCESS").empty()) {
|
||||
RunInstallerFn run_installer = GetRunInstaller();
|
||||
if (run_installer) {
|
||||
run_installer("launch_success", nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
if (!marker_path.empty()) {
|
||||
WriteMarkerFile(marker_path, "{\"success\":true}", vi);
|
||||
}
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
std::string config_json = GetEnvVar("CEF_E2E_CONFIG_JSON");
|
||||
std::string command = GetEnvVar("CEF_E2E_COMMAND");
|
||||
std::string threading_mode = GetEnvVar("CEF_E2E_THREADING_MODE");
|
||||
if (command.empty()) {
|
||||
command = "install";
|
||||
}
|
||||
|
||||
if (marker_path.empty()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
RunInstallerFn run_installer = GetRunInstaller();
|
||||
if (!run_installer) {
|
||||
WriteMarkerFile(
|
||||
marker_path,
|
||||
"{\"success\":false,\"error_message\":\"RunInstaller not found\"}", vi);
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (threading_mode == "multi" || threading_mode == "parallel") {
|
||||
return RunMultiThreaded(marker_path, command, run_installer, vi,
|
||||
threading_mode == "parallel");
|
||||
}
|
||||
|
||||
// Single-threaded mode (default).
|
||||
if (config_json.empty()) {
|
||||
WriteMarkerFile(
|
||||
marker_path,
|
||||
"{\"success\":false,\"error_message\":\"no config or marker path\"}",
|
||||
vi);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char* result = run_installer(command.c_str(), config_json.c_str());
|
||||
WriteMarkerFile(marker_path, result, vi);
|
||||
|
||||
if (result && strstr(result, "\"success\":true")) {
|
||||
return 0;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule,
|
||||
DWORD ul_reason_for_call,
|
||||
LPVOID lpReserved) {
|
||||
return TRUE;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr ULONG_PTR kProgress = 0x43454649;
|
||||
constexpr ULONG_PTR kLifecycle = 0x43454652;
|
||||
constexpr size_t kMaxPayload = 32 * 1024;
|
||||
|
||||
struct RawMessage {
|
||||
ULONG_PTR kind;
|
||||
std::string payload;
|
||||
};
|
||||
|
||||
std::vector<RawMessage>& Messages() {
|
||||
static auto* messages = new std::vector<RawMessage>();
|
||||
return *messages;
|
||||
}
|
||||
|
||||
bool g_cancel = false;
|
||||
bool g_lifecycle_mode = false;
|
||||
bool g_close_after_handoff = false;
|
||||
bool g_non_pumping = false;
|
||||
LRESULT g_lifecycle_result = 1;
|
||||
size_t g_rejected = 0;
|
||||
|
||||
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) {
|
||||
if (msg == WM_COPYDATA) {
|
||||
const auto* data = reinterpret_cast<const COPYDATASTRUCT*>(lparam);
|
||||
if (!data || !data->lpData || data->cbData < 2 ||
|
||||
data->cbData > kMaxPayload ||
|
||||
(data->dwData != kProgress && data->dwData != kLifecycle)) {
|
||||
++g_rejected;
|
||||
return 0;
|
||||
}
|
||||
const char* bytes = static_cast<const char*>(data->lpData);
|
||||
if (bytes[data->cbData - 1] != '\0' ||
|
||||
std::memchr(bytes, '\0', data->cbData - 1)) {
|
||||
++g_rejected;
|
||||
return 0;
|
||||
}
|
||||
Messages().push_back({data->dwData, std::string(bytes, data->cbData - 1)});
|
||||
return data->dwData == kProgress ? (g_cancel ? 2 : 1) : g_lifecycle_result;
|
||||
}
|
||||
if (msg == WM_CLOSE) {
|
||||
::PostQuitMessage(0);
|
||||
return 0;
|
||||
}
|
||||
return ::DefWindowProcW(hwnd, msg, wparam, lparam);
|
||||
}
|
||||
|
||||
void WriteEscaped(FILE* file, std::string_view value) {
|
||||
std::fputc('"', file);
|
||||
for (unsigned char c : value) {
|
||||
if (c == '"' || c == '\\') {
|
||||
std::fputc('\\', file);
|
||||
std::fputc(c, file);
|
||||
} else if (c == '\n') {
|
||||
std::fputs("\\n", file);
|
||||
} else if (c == '\r') {
|
||||
std::fputs("\\r", file);
|
||||
} else if (c == '\t') {
|
||||
std::fputs("\\t", file);
|
||||
} else if (c < 0x20) {
|
||||
std::fprintf(file, "\\u%04x", c);
|
||||
} else {
|
||||
std::fputc(c, file);
|
||||
}
|
||||
}
|
||||
std::fputc('"', file);
|
||||
}
|
||||
|
||||
void WriteArray(FILE* file, ULONG_PTR kind, bool raw_json) {
|
||||
bool first = true;
|
||||
std::fputc('[', file);
|
||||
for (const auto& message : Messages()) {
|
||||
if (message.kind != kind) {
|
||||
continue;
|
||||
}
|
||||
if (!first) {
|
||||
std::fputc(',', file);
|
||||
}
|
||||
if (raw_json) {
|
||||
std::fputs(message.payload.c_str(), file);
|
||||
} else {
|
||||
WriteEscaped(file, message.payload);
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
std::fputc(']', file);
|
||||
}
|
||||
|
||||
void WriteOutput(const char* path) {
|
||||
FILE* file = std::fopen(path, "w");
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!g_lifecycle_mode) {
|
||||
WriteArray(file, kProgress, true);
|
||||
} else {
|
||||
std::fputs("{\"progress\":", file);
|
||||
WriteArray(file, kProgress, false);
|
||||
std::fputs(",\"lifecycle\":", file);
|
||||
WriteArray(file, kLifecycle, false);
|
||||
std::fprintf(file, ",\"rejected\":%zu}", g_rejected);
|
||||
}
|
||||
std::fputc('\n', file);
|
||||
std::fclose(file);
|
||||
}
|
||||
|
||||
bool HasEvent(std::string_view event) {
|
||||
const std::string needle = "\"event\":\"" + std::string(event) + "\"";
|
||||
for (const auto& message : Messages()) {
|
||||
if (message.kind == kLifecycle &&
|
||||
message.payload.find(needle) != std::string::npos) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::fprintf(stderr, "Usage: %s <output_path> [options]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
bool allow_copydata = false;
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
const std::string_view arg(argv[i]);
|
||||
if (arg == "--cancel") {
|
||||
g_cancel = true;
|
||||
} else if (arg == "--lifecycle") {
|
||||
g_lifecycle_mode = true;
|
||||
} else if (arg == "--close-after-handoff") {
|
||||
g_close_after_handoff = true;
|
||||
} else if (arg == "--non-pumping") {
|
||||
g_non_pumping = true;
|
||||
} else if (arg == "--allow-copydata") {
|
||||
allow_copydata = true;
|
||||
} else if (arg.starts_with("--lifecycle-return=")) {
|
||||
g_lifecycle_result = static_cast<LRESULT>(std::strtoll(
|
||||
argv[i] + std::strlen("--lifecycle-return="), nullptr, 10));
|
||||
}
|
||||
}
|
||||
|
||||
WNDCLASSEXW wc = {};
|
||||
wc.cbSize = sizeof(wc);
|
||||
wc.lpfnWndProc = WndProc;
|
||||
wc.hInstance = ::GetModuleHandleW(nullptr);
|
||||
wc.lpszClassName = L"CefE2EProgressHelper";
|
||||
::RegisterClassExW(&wc);
|
||||
HWND hwnd = ::CreateWindowExW(0, wc.lpszClassName, L"", 0, 0, 0, 0, 0,
|
||||
HWND_MESSAGE, nullptr, wc.hInstance, nullptr);
|
||||
if (!hwnd) {
|
||||
return 1;
|
||||
}
|
||||
if (allow_copydata) {
|
||||
CHANGEFILTERSTRUCT filter = {sizeof(filter)};
|
||||
::ChangeWindowMessageFilterEx(hwnd, WM_COPYDATA, MSGFLT_ALLOW, &filter);
|
||||
}
|
||||
std::printf("%llu\n", static_cast<unsigned long long>(
|
||||
reinterpret_cast<uintptr_t>(hwnd)));
|
||||
std::fflush(stdout);
|
||||
|
||||
const ULONGLONG start = ::GetTickCount64();
|
||||
MSG msg;
|
||||
while (::GetTickCount64() - start < 30000) {
|
||||
if (g_non_pumping) {
|
||||
::Sleep(100);
|
||||
continue;
|
||||
}
|
||||
if (::PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||||
if (msg.message == WM_QUIT) {
|
||||
break;
|
||||
}
|
||||
::TranslateMessage(&msg);
|
||||
::DispatchMessageW(&msg);
|
||||
} else {
|
||||
::MsgWaitForMultipleObjects(0, nullptr, FALSE, 100, QS_ALLINPUT);
|
||||
}
|
||||
// Sent messages can be dispatched while PeekMessageW returns FALSE, so
|
||||
// inspect copied payloads after either branch and outside WndProc.
|
||||
if ((HasEvent("operation_result") && HasEvent("relaunch_started")) ||
|
||||
(g_close_after_handoff && HasEvent("relaunch_started"))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
::DestroyWindow(hwnd);
|
||||
WriteOutput(argv[1]);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Runner for CEF installer E2E tests.
|
||||
|
||||
Usage:
|
||||
python3 run_e2e_tests.py --build-dir=out/Debug_GN_x64
|
||||
python3 run_e2e_tests.py --build-dir=out/Debug_GN_x64 -k test_install_flow
|
||||
python3 run_e2e_tests.py --build-dir=out/Debug_GN_x64 \
|
||||
-k "test_launch_success and not clean_exit"
|
||||
python3 run_e2e_tests.py --build-dir=out/Debug_GN_x64 --verbose
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Run CEF installer E2E tests')
|
||||
parser.add_argument(
|
||||
'--build-dir',
|
||||
required=True,
|
||||
help='Path to the build directory (e.g., out/Debug_GN_x64)')
|
||||
parser.add_argument(
|
||||
'-k',
|
||||
'--filter',
|
||||
default=None,
|
||||
help='Filter tests by a pytest-style keyword expression (and/or/not, '
|
||||
'parentheses; bare keywords are case-insensitive substrings of the '
|
||||
'test id).')
|
||||
parser.add_argument('--verbose',
|
||||
'-v',
|
||||
action='store_true',
|
||||
help='Verbose test output')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve build dir relative to chromium/src.
|
||||
build_dir = os.path.abspath(args.build_dir)
|
||||
if not os.path.isdir(build_dir):
|
||||
print(f'Error: build directory not found: {build_dir}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Set environment variable for the test base to find build artifacts.
|
||||
os.environ['CEF_E2E_BUILD_DIR'] = build_dir
|
||||
|
||||
# Discover tests in the e2e directory.
|
||||
test_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
# Add test_dir to sys.path so `from e2e_test_base import ...` works.
|
||||
if test_dir not in sys.path:
|
||||
sys.path.insert(0, test_dir)
|
||||
loader = unittest.TestLoader()
|
||||
suite = loader.discover(test_dir, pattern='test_*.py')
|
||||
|
||||
if args.filter:
|
||||
# Import here: e2e_filter lives in test_dir, which was just added to
|
||||
# sys.path above.
|
||||
from e2e_filter import compile_filter, flatten_suite
|
||||
match = compile_filter(args.filter)
|
||||
suite = unittest.TestSuite(t for t in flatten_suite(suite) if match(str(t)))
|
||||
|
||||
verbosity = 2 if args.verbose else 1
|
||||
runner = unittest.TextTestRunner(verbosity=verbosity)
|
||||
result = runner.run(suite)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for the beta channel install path."""
|
||||
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_SUCCESS, FLAG_HEADLESS,
|
||||
FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestBetaInstall(E2ETestBase):
|
||||
|
||||
def _build_beta_cdn(self, version=None, abi_hash='a1b2c3d4e5f6'):
|
||||
"""Build a local CDN with beta-channel manifest naming.
|
||||
|
||||
Calls the standard build_cdn helper, then copies each platform/abi-hash
|
||||
manifest to the _beta variant (e.g., 150_windows64.json ->
|
||||
150_windows64_beta.json). The archive files are channel-agnostic.
|
||||
"""
|
||||
if version is None:
|
||||
version = self.test_version
|
||||
self.build_cdn(version=version, abi_hash=abi_hash)
|
||||
|
||||
for manifest in glob.glob(os.path.join(self.cdn_dir, '*.json')):
|
||||
name = os.path.basename(manifest)
|
||||
if name == 'revoked.json':
|
||||
continue
|
||||
base, ext = os.path.splitext(name)
|
||||
beta_name = base + '_beta' + ext
|
||||
shutil.copy2(manifest, os.path.join(self.cdn_dir, beta_name))
|
||||
|
||||
return version, abi_hash
|
||||
|
||||
def test_beta_install_uses_beta_manifest(self):
|
||||
"""Install with channel=beta fetches _beta manifest and succeeds."""
|
||||
version, abi_hash = self._build_beta_cdn()
|
||||
appid = 'e2e00030-3030-3030-3030-303030303030'
|
||||
|
||||
self.embed_test_config(appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
channel='beta')
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code, f'Beta install failed (exit {exit_code}): '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
db = self.read_database()
|
||||
found = any(a.get('uuid') == appid for a in db.get('apps', []))
|
||||
self.assertTrue(found, f'App not in database after beta install: {db}')
|
||||
|
||||
def test_beta_install_without_beta_manifest_fails(self):
|
||||
"""Install with channel=beta fails when only stable manifests exist."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00031-3131-3131-3131-313131313131'
|
||||
|
||||
self.embed_test_config(appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
channel='beta')
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
# Should fail because there's no _beta.json manifest.
|
||||
self.assertNotEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assert_version_not_installed(version)
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for concurrent installer processes."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_SUCCESS, FLAG_DOWNLOAD_PATH,
|
||||
FLAG_HEADLESS, FLAG_INSTALL_PATH, FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestConcurrent(E2ETestBase):
|
||||
|
||||
def test_two_processes_install_simultaneously(self):
|
||||
"""Two processes install simultaneously, both succeed."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_a = 'e2e00024-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
appid_b = 'e2e00024-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
# Create a second executable with an independent embedded resource.
|
||||
exe_b = os.path.join(self.build_dir, 'CefTestAppB.exe')
|
||||
self._restore_bootstrap_executable(exe_b)
|
||||
self.addCleanup(lambda: os.remove(exe_b) if os.path.isfile(exe_b) else None)
|
||||
|
||||
# Embed independent resources for both apps before either process starts.
|
||||
self.embed_test_config(appid=appid_a, vmin=version, abi_hash=abi_hash)
|
||||
self.embed_test_config(appid=appid_b,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
exe=exe_b)
|
||||
|
||||
results = [None, None]
|
||||
|
||||
def run_process(exe, idx):
|
||||
cmd = [
|
||||
exe, FLAG_UPDATE, FLAG_HEADLESS,
|
||||
f'{FLAG_DOWNLOAD_PATH}={self.cdn_dir}',
|
||||
f'{FLAG_INSTALL_PATH}={self.install_dir}'
|
||||
]
|
||||
proc = subprocess.run(cmd,
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
cwd=self.build_dir)
|
||||
results[idx] = proc.returncode
|
||||
|
||||
t_a = threading.Thread(target=run_process, args=(self.bootstrap_exe, 0))
|
||||
t_b = threading.Thread(target=run_process, args=(exe_b, 1))
|
||||
t_a.start()
|
||||
t_b.start()
|
||||
t_a.join(timeout=60)
|
||||
t_b.join(timeout=60)
|
||||
|
||||
# Both processes should succeed.
|
||||
self.assertEqual(EXIT_SUCCESS, results[0],
|
||||
f'Process A failed: exit {results[0]}')
|
||||
self.assertEqual(EXIT_SUCCESS, results[1],
|
||||
f'Process B failed: exit {results[1]}')
|
||||
|
||||
# One version directory, two apps in database.
|
||||
self.assert_version_installed(version)
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertIn(appid_a, uuids)
|
||||
self.assertIn(appid_b, uuids)
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Unit tests for e2e_filter (no build directory required)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from e2e_filter import compile_filter, flatten_suite
|
||||
|
||||
|
||||
class CompileFilterTest(unittest.TestCase):
|
||||
|
||||
def test_single_keyword_substring(self):
|
||||
m = compile_filter('launch')
|
||||
self.assertTrue(m('test_launch_success (mod.C.test_launch_success)'))
|
||||
self.assertFalse(m('test_install_flow (mod.C.test_install_flow)'))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertTrue(compile_filter('LAUNCH')('xx_launch_yy'))
|
||||
self.assertTrue(compile_filter('launch')('XX_LAUNCH_YY'))
|
||||
|
||||
def test_or(self):
|
||||
m = compile_filter('alpha or beta')
|
||||
self.assertTrue(m('zz_alpha_zz'))
|
||||
self.assertTrue(m('zz_beta_zz'))
|
||||
self.assertFalse(m('zz_gamma_zz'))
|
||||
|
||||
def test_and(self):
|
||||
m = compile_filter('alpha and beta')
|
||||
self.assertTrue(m('alpha_beta'))
|
||||
self.assertFalse(m('alpha_only'))
|
||||
|
||||
def test_not(self):
|
||||
m = compile_filter('not alpha')
|
||||
self.assertTrue(m('beta'))
|
||||
self.assertFalse(m('alpha'))
|
||||
|
||||
def test_parentheses_precedence(self):
|
||||
m = compile_filter('(alpha or beta) and not gamma')
|
||||
self.assertTrue(m('alpha_x'))
|
||||
self.assertTrue(m('beta_x'))
|
||||
self.assertFalse(m('alpha_gamma'))
|
||||
self.assertFalse(m('delta'))
|
||||
|
||||
def test_precedence_without_parens(self):
|
||||
# `and` binds tighter than `or`: 'a or (b and c)'.
|
||||
m = compile_filter('alpha or beta and gamma')
|
||||
self.assertTrue(m('alpha_only')) # alpha satisfies the or
|
||||
self.assertTrue(m('beta_gamma')) # beta and gamma together
|
||||
self.assertFalse(m('beta_only')) # beta without gamma -> false
|
||||
|
||||
def test_hyphenated_keyword_falls_back_to_substring(self):
|
||||
# Hyphen parses as subtraction (BinOp), not a keyword -> literal substring.
|
||||
m = compile_filter('a-b')
|
||||
self.assertTrue(m('zz_a-b_zz'))
|
||||
self.assertFalse(m('zz_ab_zz'))
|
||||
|
||||
def test_malformed_expression_falls_back_to_substring(self):
|
||||
# Trailing operator is a genuine SyntaxError -> literal substring match.
|
||||
m = compile_filter('alpha or')
|
||||
self.assertTrue(m('zz_alpha or_zz'))
|
||||
self.assertFalse(m('zz_alpha_zz'))
|
||||
|
||||
def test_python_keyword_falls_back_to_substring(self):
|
||||
# A bare Python keyword (`import`) is a SyntaxError in eval mode -> literal.
|
||||
m = compile_filter('import')
|
||||
self.assertTrue(m('test_import_flow'))
|
||||
self.assertFalse(m('test_export_flow'))
|
||||
|
||||
def test_empty_expression_matches_everything(self):
|
||||
# '' is a SyntaxError -> substring fallback, and '' is in every string.
|
||||
# The runner guards this with `if args.filter:`, but pin the module behavior.
|
||||
m = compile_filter('')
|
||||
self.assertTrue(m('test_anything'))
|
||||
self.assertTrue(m(''))
|
||||
|
||||
def test_no_match_returns_false(self):
|
||||
self.assertFalse(compile_filter('nonexistent_xyz')('test_anything'))
|
||||
|
||||
def test_non_boolean_expression_falls_back_to_substring(self):
|
||||
# Parseable but not a boolean expr (binary +) -> literal substring match.
|
||||
m = compile_filter('a + b')
|
||||
self.assertTrue(m('zz_a + b_zz'))
|
||||
self.assertFalse(m('zz_ab_zz'))
|
||||
|
||||
def test_dotted_keyword_falls_back_to_substring(self):
|
||||
# Qualified name parses as ast.Attribute -> literal substring match.
|
||||
m = compile_filter('mod.Class.test_x')
|
||||
self.assertTrue(m('test_x (mod.Class.test_x)'))
|
||||
self.assertFalse(m('test_y (mod.Class.test_y)'))
|
||||
|
||||
|
||||
class FlattenSuiteTest(unittest.TestCase):
|
||||
|
||||
class _Dummy(unittest.TestCase):
|
||||
|
||||
def test_noop(self):
|
||||
pass
|
||||
|
||||
def test_flattens_nested(self):
|
||||
inner = unittest.TestSuite([self._Dummy('test_noop')])
|
||||
outer = unittest.TestSuite([unittest.TestSuite([inner])])
|
||||
flat = list(flatten_suite(outer))
|
||||
self.assertEqual(1, len(flat))
|
||||
self.assertIsInstance(flat[0], self._Dummy)
|
||||
|
||||
def test_empty_suite_yields_nothing(self):
|
||||
self.assertEqual([], list(flatten_suite(unittest.TestSuite())))
|
||||
|
||||
def test_flat_suite(self):
|
||||
suite = unittest.TestSuite([self._Dummy('test_noop')])
|
||||
flat = list(flatten_suite(suite))
|
||||
self.assertEqual(1, len(flat))
|
||||
self.assertIsInstance(flat[0], self._Dummy)
|
||||
|
||||
def test_mixed_flat_and_nested_preserves_order(self):
|
||||
a = self._Dummy('test_noop')
|
||||
b = self._Dummy('test_noop')
|
||||
c = self._Dummy('test_noop')
|
||||
# A bare test, then a nested sub-suite, then another bare test.
|
||||
suite = unittest.TestSuite([a, unittest.TestSuite([b]), c])
|
||||
self.assertEqual([a, b, c], list(flatten_suite(suite)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""End-to-end coverage for the isolated enterprise policy registry seam.
|
||||
|
||||
Tests use HKCU by default so normal E2E runs exercise real registry I/O.
|
||||
Set CEF_INSTALLER_E2E_POLICY_HKLM=1 in an elevated environment to rerun the
|
||||
same coverage against the production hive.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import winreg
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_POLICY_DENIED, EXIT_POLICY_ERROR,
|
||||
EXIT_SUCCESS, FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestEnterprisePolicy(E2ETestBase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.policy_subkey = ('SOFTWARE\\CEF\\InstallerPolicyTests\\' +
|
||||
str(uuid.uuid4()))
|
||||
self.policy_hive_name = 'HKCU'
|
||||
self.policy_hive = winreg.HKEY_CURRENT_USER
|
||||
if os.environ.get('CEF_INSTALLER_E2E_POLICY_HKLM') == '1':
|
||||
self.policy_hive_name = 'HKLM'
|
||||
self.policy_hive = winreg.HKEY_LOCAL_MACHINE
|
||||
access = winreg.KEY_READ | winreg.KEY_WRITE | winreg.KEY_WOW64_64KEY
|
||||
try:
|
||||
self.policy_key = winreg.CreateKeyEx(self.policy_hive, self.policy_subkey,
|
||||
0, access)
|
||||
except PermissionError as error:
|
||||
self.skipTest(f'isolated {self.policy_hive_name} policy fixture '
|
||||
f'requires permission: {error}')
|
||||
self.addCleanup(self._cleanup_policy_key)
|
||||
|
||||
def _cleanup_policy_key(self):
|
||||
if getattr(self, 'policy_key', None):
|
||||
self.policy_key.Close()
|
||||
self.policy_key = None
|
||||
try:
|
||||
winreg.DeleteKeyEx(self.policy_hive, self.policy_subkey,
|
||||
winreg.KEY_WOW64_64KEY, 0)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def _policy_env(self):
|
||||
return {
|
||||
'CEF_INSTALLER_POLICY_TEST_KEY': self.policy_subkey,
|
||||
'CEF_INSTALLER_POLICY_TEST_HIVE': self.policy_hive_name,
|
||||
}
|
||||
|
||||
def _embed_config(self):
|
||||
self.embed_test_config('e2e00004-0000-0000-0000-000000000001',
|
||||
self.test_version,
|
||||
enable_explicit_modes=True)
|
||||
|
||||
def test_enterprise_policy_malformed_returns_119(self):
|
||||
self._embed_config()
|
||||
# CdnUrls must be REG_MULTI_SZ; a REG_SZ is a loud policy error.
|
||||
winreg.SetValueEx(self.policy_key, 'CdnUrls', 0, winreg.REG_SZ,
|
||||
'https://invalid-type.example/')
|
||||
code, _, _ = self.run_installer(args=[FLAG_UPDATE], env=self._policy_env())
|
||||
self.assertEqual(EXIT_POLICY_ERROR, code)
|
||||
self.assertFalse(os.path.exists(self.install_dir))
|
||||
|
||||
def test_enterprise_policy_disable_downloads_denies_update(self):
|
||||
self._embed_config()
|
||||
winreg.SetValueEx(self.policy_key, 'DisableDownloads', 0, winreg.REG_DWORD,
|
||||
1)
|
||||
code, _, _ = self.run_installer(args=[FLAG_UPDATE], env=self._policy_env())
|
||||
self.assertEqual(EXIT_POLICY_DENIED, code)
|
||||
self.assertFalse(os.path.exists(self.install_dir))
|
||||
|
||||
def test_enterprise_policy_mirror_overrides_operation_source(self):
|
||||
version, _ = self.build_cdn()
|
||||
self.embed_test_config('e2e00004-0000-0000-0000-000000000002',
|
||||
version,
|
||||
enable_explicit_modes=True)
|
||||
winreg.SetValueEx(self.policy_key, 'DownloadPath', 0, winreg.REG_SZ,
|
||||
self.cdn_dir)
|
||||
conflicting = os.path.join(self.temp_dir, 'conflicting-empty-mirror')
|
||||
os.makedirs(conflicting)
|
||||
code, _, stderr = self.run_installer(
|
||||
args=[FLAG_UPDATE, f'--cef-download-path={conflicting}'],
|
||||
use_local_cdn=False,
|
||||
env=self._policy_env(),
|
||||
timeout=60)
|
||||
self.assertEqual(EXIT_SUCCESS, code, stderr.decode(errors='replace'))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests verifying installer exit codes.
|
||||
|
||||
Parametrized test covering reachable exit codes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_CONFIG_ERROR, EXIT_NETWORK_ERROR,
|
||||
EXIT_NO_MATCHING_VERSION, EXIT_RELAUNCHED,
|
||||
EXIT_SUCCESS, FLAG_BACKGROUND, FLAG_DOWNLOAD_PATH,
|
||||
FLAG_HEADLESS, FLAG_UNINSTALL, FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestExitCodes(E2ETestBase):
|
||||
|
||||
def test_exit_code_success(self):
|
||||
"""Exit code 0: Successful install."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(
|
||||
appid='e2e00021-0000-0000-0000-000000000000',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
|
||||
def test_exit_code_config_error(self):
|
||||
"""Exit code 100: Invalid config."""
|
||||
self.embed_raw_config_resource(self.bootstrap_exe, 'NOT VALID JSON {{{')
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS],
|
||||
use_local_cdn=False)
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
|
||||
def test_exit_code_network_error(self):
|
||||
"""Exit code >= 100: Empty CDN causes download/resolve failure."""
|
||||
self.embed_test_config(
|
||||
appid='e2e00021-0101-0101-0101-010101010101',
|
||||
vmin=self.test_version,
|
||||
abi_hash='a1b2c3d4e5f6',
|
||||
)
|
||||
empty_cdn = os.path.join(self.temp_dir, 'empty_cdn')
|
||||
os.makedirs(empty_cdn)
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS, f'{FLAG_DOWNLOAD_PATH}={empty_cdn}'],
|
||||
use_local_cdn=False)
|
||||
# Local download mode with missing manifest reports either
|
||||
# kExitCodeNetworkError (101) or kExitCodeNoMatchingVersion (103)
|
||||
# depending on whether the manifest is missing vs empty.
|
||||
self.assertIn(exit_code, (EXIT_NETWORK_ERROR, EXIT_NO_MATCHING_VERSION))
|
||||
|
||||
def test_exit_code_no_matching_version(self):
|
||||
"""Exit code 103: No CDN version satisfies the config range."""
|
||||
abi_hash = 'a1b2c3d4e5f6'
|
||||
self.build_cdn(version=self.test_version, abi_hash=abi_hash)
|
||||
# vmin higher than what the CDN offers.
|
||||
self.embed_test_config(
|
||||
appid='e2e00021-0103-0103-0103-010301030103',
|
||||
vmin=self.test_version_higher,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_NO_MATCHING_VERSION, exit_code)
|
||||
|
||||
def test_exit_code_relaunched(self):
|
||||
"""Exit code 109: explicit background uninstall starts one child."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00021-0109-0109-0109-010901090109'
|
||||
self.embed_test_config(
|
||||
appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
# Install first.
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_BACKGROUND, FLAG_HEADLESS], env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code,
|
||||
stderr.decode(errors='replace'))
|
||||
self.wait_for_relaunched_uninstall(appid,
|
||||
relaunch_dirs,
|
||||
os.path.basename(self.bootstrap_exe),
|
||||
expected_index_versions=())
|
||||
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for the CEF installer install flow."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_CONFIG_ERROR,
|
||||
EXIT_NO_MATCHING_VERSION, EXIT_SUCCESS,
|
||||
FLAG_HEADLESS, FLAG_UNINSTALL, FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestInstallFlow(E2ETestBase):
|
||||
|
||||
def _start_https_cdn_failover(self):
|
||||
empty_primary = os.path.join(self.temp_dir, 'empty-primary')
|
||||
os.makedirs(empty_primary)
|
||||
cert_path, key_path = self.get_loopback_tls_certificate()
|
||||
primary_url, primary_requests = self.start_https_file_server(
|
||||
empty_primary, cert_path, key_path)
|
||||
fallback_url, fallback_requests = self.start_https_file_server(
|
||||
self.cdn_dir, cert_path, key_path)
|
||||
return (primary_url, primary_requests, fallback_url, fallback_requests)
|
||||
|
||||
def _cdn_resource_config(self, appid, version, abi_hash, cdn_urls):
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'cdn_urls': cdn_urls,
|
||||
'enable_explicit_modes': True,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
return config
|
||||
|
||||
def _assert_https_archive_failover(self, version, primary_requests,
|
||||
fallback_requests):
|
||||
self.assertTrue(primary_requests, 'Primary application CDN got no request')
|
||||
self.assertTrue(fallback_requests,
|
||||
'Fallback application CDN got no request')
|
||||
archive_name = f'cef_{version}_windows64.tar.xz'
|
||||
self.assertTrue(any(
|
||||
archive_name in request for request in primary_requests))
|
||||
self.assertTrue(
|
||||
any(archive_name in request for request in fallback_requests))
|
||||
|
||||
def test_fresh_install(self):
|
||||
"""Fresh install via /cef-update with a bootstrap resource."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(
|
||||
appid='e2e00001-0001-0001-0001-000000000001',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
|
||||
exit_code, stdout, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code, f'Install failed (exit {exit_code}): '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# Verify database was updated.
|
||||
db = self.read_database()
|
||||
self.assertIn('apps', db)
|
||||
found = any(
|
||||
a.get('uuid') == 'e2e00001-0001-0001-0001-000000000001'
|
||||
for a in db['apps'])
|
||||
self.assertTrue(found, f'App not in database: {db}')
|
||||
|
||||
def test_bootstrap_resource_cdn_https_failover(self):
|
||||
"""Bootstrap-resource URLs drive real process HTTPS failover."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
(primary_url, primary_requests, fallback_url,
|
||||
fallback_requests) = self._start_https_cdn_failover()
|
||||
app_name = 'E2ECdnBootstrapResource'
|
||||
app_exe = os.path.join(self.build_dir, f'{app_name}.exe')
|
||||
self._restore_bootstrap_executable(app_exe)
|
||||
self.addCleanup(lambda: os.remove(app_exe)
|
||||
if os.path.isfile(app_exe) else None)
|
||||
self.embed_config_resource(
|
||||
app_exe,
|
||||
self._cdn_resource_config('e2e00032-0032-0032-0032-000000000032',
|
||||
version, abi_hash,
|
||||
[primary_url, fallback_url]))
|
||||
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS],
|
||||
exe=app_exe,
|
||||
use_local_cdn=False,
|
||||
allow_configured_cdn=True,
|
||||
timeout=60,
|
||||
env={'CEF_INSTALLER_IGNORE_CERTIFICATE_ERRORS_FOR_TESTING': '1'})
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code,
|
||||
f'HTTPS bootstrap-resource CDN failover failed: '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
self._assert_https_archive_failover(version, primary_requests,
|
||||
fallback_requests)
|
||||
|
||||
def test_client_resource_cdn_https_failover(self):
|
||||
"""Client-resource URLs drive real bootstrap-process HTTPS failover."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
(primary_url, primary_requests, fallback_url,
|
||||
fallback_requests) = self._start_https_cdn_failover()
|
||||
app_exe, app_name = self.setup_launcher('E2ECdnClientResource')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
self.embed_config_resource(
|
||||
app_dll,
|
||||
self._cdn_resource_config('e2e00033-0033-0033-0033-000000000033',
|
||||
version, abi_hash,
|
||||
[primary_url, fallback_url]))
|
||||
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS],
|
||||
exe=app_exe,
|
||||
use_local_cdn=False,
|
||||
allow_configured_cdn=True,
|
||||
timeout=60,
|
||||
env={
|
||||
'CEF_E2E_EXIT_CODE': '0',
|
||||
'CEF_INSTALLER_IGNORE_CERTIFICATE_ERRORS_FOR_TESTING': '1',
|
||||
})
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code, f'HTTPS client-resource CDN failover failed: '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
self._assert_https_archive_failover(version, primary_requests,
|
||||
fallback_requests)
|
||||
|
||||
def test_install_idempotency(self):
|
||||
"""Running install twice succeeds without re-download."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(
|
||||
appid='e2e00003-0003-0003-0003-000000000003',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
|
||||
# First install.
|
||||
exit_code1, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code1)
|
||||
self.assert_version_installed(version)
|
||||
|
||||
exit_code2, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code2)
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_next_best_when_newest_archive_missing(self):
|
||||
"""Local mirror propagation lag falls back once."""
|
||||
abi_hash = 'a1b2c3d4e5f6'
|
||||
older = self.test_version
|
||||
newer = self.test_version_update
|
||||
self.build_cdn(version=older, abi_hash=abi_hash)
|
||||
self.build_cdn(version=newer, abi_hash=abi_hash)
|
||||
missing_archive = os.path.join(self.cdn_dir,
|
||||
f'cef_{newer}_windows64.tar.xz')
|
||||
self.assertTrue(os.path.isfile(missing_archive))
|
||||
os.remove(missing_archive)
|
||||
self.embed_test_config(
|
||||
appid='e2e00030-0030-0030-0030-000000000030',
|
||||
vmin=older,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code,
|
||||
f'Next-best install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(older)
|
||||
self.assert_version_not_installed(newer)
|
||||
|
||||
def test_standalone_auto_install_with_embedded_config(self):
|
||||
"""Standalone auto-install with embedded config resource."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00002-0002-0002-0002-000000000002'
|
||||
|
||||
# Create a fresh exe copy with a distinct name for this test.
|
||||
auto_exe_name = 'E2E2AutoApp'
|
||||
auto_exe = os.path.join(self.build_dir, f'{auto_exe_name}.exe')
|
||||
self._restore_bootstrap_executable(auto_exe)
|
||||
self.addCleanup(lambda: os.remove(auto_exe)
|
||||
if os.path.isfile(auto_exe) else None)
|
||||
|
||||
# Embed config resource into the exe.
|
||||
config_dict = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'enable_explicit_modes': True,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config_dict['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(auto_exe, config_dict)
|
||||
|
||||
# Run with /cef-headless only (no /cef-update) — auto-install triggers because
|
||||
# no client DLL + config loaded + no explicit command.
|
||||
exit_code, _, stderr = self.run_installer([FLAG_HEADLESS], exe=auto_exe)
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code, f'Auto-install failed (exit {exit_code}): '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
db = self.read_database()
|
||||
found = any(a.get('uuid') == appid for a in db.get('apps', []))
|
||||
self.assertTrue(found, f'App not in database: {db}')
|
||||
|
||||
|
||||
class TestErrorHandling(E2ETestBase):
|
||||
|
||||
def test_invalid_config(self):
|
||||
"""Invalid config resource produces config error 100."""
|
||||
self.embed_raw_config_resource(self.bootstrap_exe, 'NOT VALID JSON {{{')
|
||||
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS],
|
||||
use_local_cdn=False)
|
||||
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
|
||||
def test_missing_config(self):
|
||||
"""A freshly restored bootstrap has no config resource."""
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS],
|
||||
use_local_cdn=False)
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
|
||||
def test_revoked_version_only(self):
|
||||
"""Only available version is revoked, install fails."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
|
||||
# Overwrite revoked.json to revoke the only available version.
|
||||
revoked_path = os.path.join(self.cdn_dir, 'revoked.json')
|
||||
revoked = {
|
||||
'revoked_versions': [{
|
||||
'version': version,
|
||||
'reason': 'test',
|
||||
'revoked_at': '2026-01-01T00:00:00Z'
|
||||
}]
|
||||
}
|
||||
with open(revoked_path, 'w') as f:
|
||||
json.dump(revoked, f)
|
||||
|
||||
self.embed_test_config(
|
||||
appid='e2e00017-1717-1717-1717-171717171717',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
# kExitCodeNoMatchingVersion (only version is revoked).
|
||||
self.assertEqual(EXIT_NO_MATCHING_VERSION, exit_code)
|
||||
|
||||
|
||||
class TestMultiApp(E2ETestBase):
|
||||
|
||||
def test_sequential_install_two_apps(self):
|
||||
"""Sequential install of two apps, shared version."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
|
||||
# Install app A.
|
||||
self.embed_test_config(
|
||||
appid='e2e00018-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
exit_a, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_a)
|
||||
|
||||
# Install app B (different appid, same version range).
|
||||
self.embed_test_config(
|
||||
appid='e2e00018-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
exit_b, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_b)
|
||||
|
||||
# One version directory, two apps in database.
|
||||
self.assert_version_installed(version)
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertIn('e2e00018-aaaa-aaaa-aaaa-aaaaaaaaaaaa', uuids)
|
||||
self.assertIn('e2e00018-bbbb-bbbb-bbbb-bbbbbbbbbbbb', uuids)
|
||||
|
||||
def test_uninstall_one_of_two_apps(self):
|
||||
"""Uninstall one of two apps, version survives."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
|
||||
# Install both apps.
|
||||
for appid in [
|
||||
'e2e00019-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'e2e00019-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
]:
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
|
||||
# The external executable runs the uninstall synchronously.
|
||||
self.embed_test_config(
|
||||
appid='e2e00019-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
exit_u, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_u)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
|
||||
# Version should still exist (app B needs it).
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# Database should have only app B.
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertNotIn('e2e00019-aaaa-aaaa-aaaa-aaaaaaaaaaaa', uuids)
|
||||
self.assertIn('e2e00019-bbbb-bbbb-bbbb-bbbbbbbbbbbb', uuids)
|
||||
|
||||
def test_two_apps_different_milestones(self):
|
||||
"""Two apps with different milestones, uninstall one."""
|
||||
# Derive current version dynamically from build_cdn output.
|
||||
current_ver, hash_a = self.build_cdn(abi_hash='aaa111bbb222')
|
||||
|
||||
# Compute next-milestone version (e.g., 150.1 -> 151.0).
|
||||
parts = current_ver.split('.')
|
||||
next_milestone = str(int(parts[0]) + 1) + '.0'
|
||||
hash_b = 'ccc333ddd444'
|
||||
self.build_cdn(version=next_milestone, abi_hash=hash_b)
|
||||
|
||||
appid_a = 'e2e00020-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
appid_b = 'e2e00020-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
# Install App A (current milestone).
|
||||
self.embed_test_config(appid=appid_a, vmin=current_ver, abi_hash=hash_a)
|
||||
exit_a, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_a,
|
||||
f'App A install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(current_ver)
|
||||
|
||||
# Install App B (next milestone).
|
||||
self.embed_test_config(appid=appid_b, vmin=next_milestone, abi_hash=hash_b)
|
||||
exit_b, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_b,
|
||||
f'App B install failed: {stderr.decode(errors="replace")}')
|
||||
|
||||
# Both version directories should exist.
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertIn(appid_a, uuids)
|
||||
self.assertIn(appid_b, uuids)
|
||||
|
||||
# External default uninstall is synchronous.
|
||||
self.embed_test_config(appid=appid_a, vmin=current_ver, abi_hash=hash_a)
|
||||
exit_u, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_u)
|
||||
|
||||
# Current milestone pruned, next milestone intact.
|
||||
self.assert_version_not_installed(current_ver)
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertNotIn(appid_a, uuids)
|
||||
self.assertIn(appid_b, uuids)
|
||||
@@ -0,0 +1,879 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for launch health tracking and version rollback.
|
||||
|
||||
Uses launcher mode (bootstrap.exe + mock client DLL) with CEF_E2E_EXIT_CODE
|
||||
to control exit codes and test the sentinel lifecycle.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_SUCCESS, FLAG_HEADLESS,
|
||||
FLAG_UNINSTALL, FLAG_UPDATE, FLAG_FORCECHECK)
|
||||
|
||||
# Exit codes the bootstrap treats as success or neutral (see
|
||||
# IsSuccessOrNeutralExitCode): a launch ending in one of these leaves the
|
||||
# sentinel running=false. 0 = normal exit; 21 = CEF_RESULT_CODE_PROFILE_IN_USE.
|
||||
# Any other exit code, without an explicit launch_success confirmation, leaves
|
||||
# the sentinel running=true.
|
||||
_SUCCESS_OR_NEUTRAL_EXIT_CODES = frozenset({0, 21})
|
||||
|
||||
|
||||
class TestLaunchHealth(E2ETestBase):
|
||||
|
||||
def _embed_launcher_config(self,
|
||||
app_exe,
|
||||
appid,
|
||||
vmin,
|
||||
abi_hash='a1b2c3d4e5f6',
|
||||
launch_health='exit_code'):
|
||||
"""Embed CEF_INSTALLER_CONFIG resource into the launcher exe.
|
||||
|
||||
The mock client has no application-config resource, so selection falls
|
||||
back to this bootstrap resource. This activates the bootstrap's
|
||||
auto-install path, which is required for launch health sentinel writes.
|
||||
"""
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': vmin,
|
||||
'abi_hash': abi_hash,
|
||||
'launch_health': launch_health,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_exe, config)
|
||||
|
||||
def test_default_off_writes_no_health_sentinel(self):
|
||||
"""Missing launch_health selects normally without a health sentinel."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a00-0000-0000-0000-000000000000'
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, _ = self.setup_launcher('LH00App')
|
||||
|
||||
config = {'appid': appid, 'vmin': version, 'abi_hash': abi_hash}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_exe, config)
|
||||
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
self.assertEqual(1, rc)
|
||||
self.assertIsNotNone(marker)
|
||||
self.assert_no_launch_state(version, self.get_appid_hash(appid))
|
||||
|
||||
def test_explicit_ordinary_exit_is_neutral(self):
|
||||
"""Explicit mode requires launch_success even when RunWinMain returns 0."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a00-0000-0000-0000-000000000001'
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, _ = self.setup_launcher('LHE0App')
|
||||
self._embed_launcher_config(app_exe,
|
||||
appid,
|
||||
version,
|
||||
abi_hash,
|
||||
launch_health='explicit')
|
||||
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assert_launch_state(version,
|
||||
self.get_appid_hash(appid),
|
||||
running=False,
|
||||
failures=0)
|
||||
state = self.read_launch_state(version, self.get_appid_hash(appid))
|
||||
self.assertFalse(state.get('confirmed'))
|
||||
|
||||
def _run_launcher_with_exit_code(self,
|
||||
app_exe,
|
||||
exit_code,
|
||||
timeout=30,
|
||||
launch_success=False):
|
||||
"""Run launcher with CEF_E2E_EXIT_CODE to simulate a specific exit.
|
||||
|
||||
When launch_success=True, also sets CEF_E2E_LAUNCH_SUCCESS so the mock
|
||||
client calls RunInstaller("launch_success") to confirm launch health
|
||||
before returning the exit code (simulates a client that confirms CEF
|
||||
is healthy, possibly before a later crash).
|
||||
|
||||
If this launch leaves the sentinel running=true (an unconfirmed failure
|
||||
exit — not success/neutral, and no launch_success), this method sleeps
|
||||
briefly before returning. The next launch's scan calls IsProcessAlive()
|
||||
on this process's PID, and the sleep gives that next process a distinct
|
||||
creation time so a same-granularity-window PID reuse can't make a dead
|
||||
crasher look alive. Success/neutral exits and launch_success leave
|
||||
running=false and are never liveness-checked, so they don't sleep.
|
||||
"""
|
||||
marker_path = os.path.join(self.temp_dir, 'e2e_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_EXIT_CODE': str(exit_code),
|
||||
}
|
||||
if launch_success:
|
||||
env['CEF_E2E_LAUNCH_SUCCESS'] = '1'
|
||||
rc, stdout, stderr = self.run_installer(args=[],
|
||||
exe=app_exe,
|
||||
use_local_cdn=True,
|
||||
env=env,
|
||||
timeout=timeout)
|
||||
marker_data = None
|
||||
if os.path.isfile(marker_path):
|
||||
with open(marker_path) as f:
|
||||
marker_data = json.load(f)
|
||||
if exit_code not in _SUCCESS_OR_NEUTRAL_EXIT_CODES and not launch_success:
|
||||
time.sleep(0.5)
|
||||
return rc, marker_data
|
||||
|
||||
def _standalone_install(self, version, abi_hash, appid, vmin=None, vmax=''):
|
||||
"""Install a version using standalone /cef-update."""
|
||||
self.embed_test_config(appid=appid,
|
||||
vmin=vmin or version,
|
||||
abi_hash=abi_hash,
|
||||
vmax=vmax,
|
||||
launch_health='exit_code',
|
||||
enable_explicit_modes=True)
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS, FLAG_FORCECHECK])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_fresh_install_clean_exit(self):
|
||||
"""Install a version, run launcher with exit 0, verify confirmed state."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a01-0001-0001-0001-000000000001'
|
||||
|
||||
# Install version via standalone.
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
|
||||
# Set up launcher.
|
||||
app_exe, app_name = self.setup_launcher('LH01App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Run launcher with exit code 0.
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
|
||||
# Verify launch state is confirmed in .launch/ dir.
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(version, appid_hash, running=False, failures=0)
|
||||
|
||||
def test_three_crashes_trigger_rollback(self):
|
||||
"""Three crashes on newer version trigger rollback to older."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00a02-0002-0002-0002-000000000002'
|
||||
|
||||
# Install v1 via standalone with vmax pinning.
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
|
||||
# Set up launcher.
|
||||
app_exe, app_name = self.setup_launcher('LH02App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
|
||||
# Confirm v1 (exit 0).
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# Install v1_1 (both versions now available). Use vmin=v1_1 to force
|
||||
# the installer to install the newer version (v1 already satisfies
|
||||
# vmin=v1).
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
|
||||
# Re-embed config with vmin=v1 so both versions are eligible.
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
|
||||
# Crash v1_1 three times (exit code 1).
|
||||
for i in range(3):
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
|
||||
# Run launcher with exit 0 — should rollback to v1.
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIsNotNone(marker)
|
||||
|
||||
# Verify v1 is now confirmed in .launch/ dir.
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(v1, appid_hash, running=False, failures=0)
|
||||
|
||||
# v1_1 should still show running=true, failures=2 (sentinel from last crash).
|
||||
state = self.read_launch_state(v1_1, appid_hash)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertTrue(state.get('running'))
|
||||
self.assertEqual(2, state.get('consecutive_failures'))
|
||||
|
||||
def test_neutral_exit_preserves_history(self):
|
||||
"""Neutral exit preserves failure history without incrementing."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a03-0003-0003-0003-000000000003'
|
||||
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LH03App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# One crash.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
|
||||
# Neutral exit (code 21 = CEF_RESULT_CODE_PROFILE_IN_USE).
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 21)
|
||||
self.assertEqual(21, rc)
|
||||
|
||||
# Verify launch state in .launch/ dir: running=false, failures=1 (preserved).
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(version, appid_hash, running=False, failures=1)
|
||||
|
||||
# Run launcher with exit 0 — version still selected (under threshold).
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
def test_clean_exit_deletes_older_launch_files(self):
|
||||
"""Confirming a newer version deletes older versions' confirmed launch files."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00a04-0004-0004-0004-000000000004'
|
||||
|
||||
# Install v1 only first, so the launcher picks it (no other choice).
|
||||
# Use a second app with vmin=v1 to keep global vmin low so GC
|
||||
# doesn't delete v1's .launch/ file when v1_1 is installed later.
|
||||
appid2 = 'e2e00a04-0004-0004-0004-000000000002'
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
self.embed_test_config(appid=appid2,
|
||||
vmin=v1,
|
||||
vmax=v1,
|
||||
abi_hash=abi_hash,
|
||||
enable_explicit_modes=True)
|
||||
self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
# Confirm v1 via launcher (only version installed).
|
||||
app_exe, app_name = self.setup_launcher('LH04App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(v1, appid_hash, running=False, failures=0)
|
||||
|
||||
# Now install v1_1. The standalone install's prune won't GC v1's
|
||||
# file because appid2 keeps global vmin at v1.
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
|
||||
# Confirm v1_1 — cleanup should delete v1's confirmed file.
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# v1's confirmed launch file should be deleted by cleanup-on-confirm.
|
||||
self.assert_no_launch_state(v1, appid_hash)
|
||||
# v1_1's launch file should exist (confirmed).
|
||||
self.assert_launch_state(v1_1, appid_hash, running=False, failures=0)
|
||||
|
||||
def test_previously_confirmed_version_starts_crashing(self):
|
||||
"""A previously confirmed version can be disqualified if it starts crashing."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a08-0008-0008-0008-000000000008'
|
||||
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LH08App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Confirm the version.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(version, appid_hash, running=False, failures=0)
|
||||
|
||||
# Now crash 3 times.
|
||||
for _ in range(3):
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
|
||||
# The version is the only one so it should still be selected (last resort),
|
||||
# but the launch state should show it was crashing.
|
||||
state = self.read_launch_state(version, appid_hash)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertTrue(state.get('running'))
|
||||
self.assertEqual(2, state.get('consecutive_failures'))
|
||||
|
||||
def test_post_exit_pruning(self):
|
||||
"""Confirming a newer version allows older to be pruned post-exit."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00a05-0005-0005-0005-000000000005'
|
||||
|
||||
# Install and confirm v1.
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
app_exe, app_name = self.setup_launcher('LH05App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# v1 is confirmed — should be protected from pruning.
|
||||
self.assert_version_installed(v1)
|
||||
|
||||
# Install v1_1 and confirm it.
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# v1's launch file was deleted (cleanup on confirm). Post-exit pruning
|
||||
# should have pruned v1 since it's no longer protected.
|
||||
self.assert_version_not_installed(v1)
|
||||
self.assert_version_installed(v1_1)
|
||||
|
||||
def test_disqualified_version_stays_disqualified(self):
|
||||
"""A disqualified version stays disqualified across subsequent launches."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00a06-0006-0006-0006-000000000006'
|
||||
|
||||
# Install and confirm v1.
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
app_exe, app_name = self.setup_launcher('LH06App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# Install v1_1.
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
|
||||
# Crash v1_1 three times.
|
||||
for _ in range(3):
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
|
||||
# Record v1_1's launch state after disqualification.
|
||||
state_after_crashes = self.read_launch_state(v1_1, appid_hash)
|
||||
self.assertIsNotNone(state_after_crashes)
|
||||
|
||||
# Run launcher twice more with exit 0 — v1 should be selected both times.
|
||||
for _ in range(2):
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIsNotNone(marker)
|
||||
# Marker should show v1 (rollback), not v1_1.
|
||||
self.assertIn(v1, marker.get('libcef_version_full', ''))
|
||||
self.assertNotIn(v1_1, marker.get('libcef_version_full', ''))
|
||||
|
||||
# v1_1's launch state should be unchanged (still disqualified).
|
||||
state_now = self.read_launch_state(v1_1, appid_hash)
|
||||
self.assertEqual(state_after_crashes, state_now)
|
||||
|
||||
def test_bundled_preferred_over_disqualified(self):
|
||||
"""Non-revoked bundled version is selected over a disqualified installed."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a09-0009-0009-0009-000000000009'
|
||||
|
||||
# Install and confirm the version.
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LH09App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# Crash 3 times to disqualify.
|
||||
for _ in range(3):
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
state = self.read_launch_state(version, appid_hash)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertTrue(state.get('running'))
|
||||
self.assertEqual(2, state.get('consecutive_failures'))
|
||||
|
||||
# Create a bundled CEF directory with the same version.
|
||||
bundled_dir = os.path.join(self.temp_dir, 'bundled', 'windows64')
|
||||
release_dir = os.path.join(bundled_dir, 'Release')
|
||||
os.makedirs(release_dir, exist_ok=True)
|
||||
with open(os.path.join(bundled_dir, 'cef_version.json'), 'w') as f:
|
||||
json.dump(
|
||||
{
|
||||
'version': version,
|
||||
'abi_hash': abi_hash,
|
||||
'platform': 'windows64',
|
||||
}, f)
|
||||
with open(os.path.join(release_dir, 'libcef.dll'), 'wb') as f:
|
||||
f.write(b'fake bundled dll')
|
||||
with open(os.path.join(bundled_dir, 'catalog.cat'), 'wb') as f:
|
||||
f.write(b'fake bundled catalog')
|
||||
|
||||
# Embed config with bundled_cef_path into the client DLL. The bootstrap
|
||||
# only parses bundled_cef_path from the client DLL resource (not the exe
|
||||
# fallback), because allow_bundled_cef_path is only set for that source.
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'bundled_cef_path': bundled_dir,
|
||||
'launch_health': 'exit_code',
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_dll, config)
|
||||
|
||||
# Run launcher — should select bundled over disqualified installed.
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
self.assertEqual(1, marker.get('libcef_is_bundled'),
|
||||
f'Expected bundled version, got marker: {marker}')
|
||||
|
||||
def test_pid_guard_prevents_stale_update(self):
|
||||
"""PID guard prevents a foreign PID from being treated as a crash."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00a07-0007-0007-0007-000000000007'
|
||||
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LH07App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Confirm the version.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(version, appid_hash, running=False, failures=0)
|
||||
|
||||
# Overwrite launch state in .launch/ dir with a foreign PID.
|
||||
launch_dir = self.get_launch_dir()
|
||||
os.makedirs(launch_dir, exist_ok=True)
|
||||
ls_path = os.path.join(launch_dir, f'{appid_hash}_{version}_windows64')
|
||||
foreign_state = {
|
||||
'appid': appid,
|
||||
'pid': 99999,
|
||||
'pid_start_time': '999999999999999',
|
||||
'consecutive_failures': 2,
|
||||
'running': True,
|
||||
'confirmed': False,
|
||||
'version': version,
|
||||
'platform': 'windows64',
|
||||
'last_update': str(self.filetime_now()),
|
||||
}
|
||||
self.write_integrity_json(ls_path, foreign_state)
|
||||
|
||||
# Run launcher with exit 0. The controller should see the foreign PID
|
||||
# as a dead process (PID 99999 doesn't exist), compute projected=3,
|
||||
# and disqualify the version. But since it's the only version, it falls
|
||||
# back to it. The bootstrap writes a new sentinel with its own PID,
|
||||
# then confirms on exit 0.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# After clean exit, the state should be confirmed in .launch/ dir.
|
||||
state = self.read_launch_state(version, appid_hash)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertFalse(state.get('running'))
|
||||
self.assertEqual(0, state.get('consecutive_failures'))
|
||||
# PID should be different from the foreign one.
|
||||
self.assertNotEqual(99999, state.get('pid'))
|
||||
|
||||
# ===========================================================================
|
||||
# Doom loop prevention
|
||||
# ===========================================================================
|
||||
|
||||
def test_crash_history_survives_pruning(self):
|
||||
"""Crash history in .launch/ survives version directory pruning."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
v1_2 = self.test_version_higher
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
self.build_cdn(version=v1_2, abi_hash=abi_hash)
|
||||
appid = 'e2e00a10-0010-0010-0010-000000000010'
|
||||
|
||||
# Install v1 and v1_1.
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1, vmax=v1_1)
|
||||
|
||||
# Crash v1_1 (one crash, then neutral exit to get running=false).
|
||||
app_exe, app_name = self.setup_launcher('LH10App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 21)
|
||||
self.assertEqual(21, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
state_v1_1 = self.read_launch_state(v1_1, appid_hash)
|
||||
self.assertIsNotNone(state_v1_1)
|
||||
self.assertEqual(1, state_v1_1.get('consecutive_failures'))
|
||||
|
||||
# Register a second app with vmin=v1 to keep the global vmin low
|
||||
# (GC only deletes below the minimum vmin across ALL apps). Then
|
||||
# install v1_2 via the first app with vmin=v1_2.
|
||||
appid2 = 'e2e00a10-0010-0010-0010-000000000002'
|
||||
self.embed_test_config(appid=appid2,
|
||||
vmin=v1,
|
||||
vmax=v1,
|
||||
abi_hash=abi_hash,
|
||||
enable_explicit_modes=True)
|
||||
self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self._standalone_install(v1_2, abi_hash, appid, vmin=v1_2)
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# v1_1's directory should be pruned (not required, not confirmed).
|
||||
self.assert_version_not_installed(v1_1)
|
||||
|
||||
# But v1_1's .launch/ crash history should still exist.
|
||||
state_after = self.read_launch_state(v1_1, appid_hash)
|
||||
self.assertIsNotNone(state_after,
|
||||
'.launch/ crash history should survive pruning')
|
||||
|
||||
def test_cleanup_preserves_crash_history(self):
|
||||
"""Confirming a newer version preserves an older version's crash-history file."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00a11-0011-0011-0011-000000000011'
|
||||
|
||||
# Install v1 only. Use a second app with vmin=v1 to keep global
|
||||
# vmin low when v1_1 is installed later.
|
||||
appid2 = 'e2e00a11-0011-0011-0011-000000000002'
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
self.embed_test_config(appid=appid2,
|
||||
vmin=v1,
|
||||
vmax=v1,
|
||||
abi_hash=abi_hash,
|
||||
enable_explicit_modes=True)
|
||||
self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
# Crash v1 once (it's the only version), then neutral exit.
|
||||
app_exe, app_name = self.setup_launcher('LH11App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 21)
|
||||
self.assertEqual(21, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
state_v1 = self.read_launch_state(v1, appid_hash)
|
||||
self.assertIsNotNone(state_v1)
|
||||
self.assertEqual(1, state_v1.get('consecutive_failures'))
|
||||
|
||||
# Install v1_1.
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
|
||||
# Confirm v1_1 (launcher selects it as newest).
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# v1's crash-history file should be preserved (not deleted by cleanup).
|
||||
state_v1_after = self.read_launch_state(v1, appid_hash)
|
||||
self.assertIsNotNone(state_v1_after,
|
||||
'v1 crash history should survive cleanup')
|
||||
self.assertEqual(1, state_v1_after.get('consecutive_failures'))
|
||||
|
||||
# v1_1 should be confirmed.
|
||||
self.assert_launch_state(v1_1, appid_hash, running=False, failures=0)
|
||||
|
||||
def test_stale_launch_files_gc(self):
|
||||
"""Recent below-vmin history survives and explicitly old history expires."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
v1_2 = self.test_version_higher
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00a12-0012-0012-0012-000000000012'
|
||||
|
||||
# Install v1 and publish recent unconfirmed crash history.
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.write_health(appid,
|
||||
v1,
|
||||
self.filetime_now(),
|
||||
confirmed=False,
|
||||
consecutive_failures=1)
|
||||
|
||||
# Install v1_1 with vmin=v1_1 (moves the minimum forward).
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
self.assert_version_not_installed(v1)
|
||||
self.assert_launch_state(v1, appid_hash, running=False, failures=1)
|
||||
|
||||
# An already-old record is removed on the first eligible prune.
|
||||
old_time = self.filetime_now() - 91 * 24 * 60 * 60 * 10000000
|
||||
self.write_health(appid,
|
||||
v1,
|
||||
self.filetime_now(),
|
||||
last_update=old_time,
|
||||
confirmed=False,
|
||||
consecutive_failures=1)
|
||||
self.build_cdn(version=v1_2, abi_hash=abi_hash)
|
||||
self._standalone_install(v1_2, abi_hash, appid, vmin=v1_2)
|
||||
self.assert_no_launch_state(v1, appid_hash)
|
||||
|
||||
def test_uninstall_reinstall_retains_disqualification(self):
|
||||
"""Recent failure history prevents redownloading the same bad version."""
|
||||
good = self.test_version_update
|
||||
bad = self.test_version_higher
|
||||
_, abi_hash = self.build_cdn(version=bad)
|
||||
appid = 'e2e00a13-0013-0013-0013-000000000013'
|
||||
self._standalone_install(bad, abi_hash, appid, vmin=bad, vmax=bad)
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.write_health(appid,
|
||||
bad,
|
||||
self.filetime_now(),
|
||||
running=True,
|
||||
confirmed=False,
|
||||
consecutive_failures=2)
|
||||
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Uninstall failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_not_installed(bad)
|
||||
self.assert_launch_state(bad, appid_hash, running=True, failures=2)
|
||||
|
||||
self.build_cdn(version=good, abi_hash=abi_hash)
|
||||
cert_path, key_path = self.get_loopback_tls_certificate()
|
||||
cdn_url, requests = self.start_https_file_server(
|
||||
self.cdn_dir, cert_path, key_path)
|
||||
self.embed_test_config(appid=appid,
|
||||
vmin=good,
|
||||
vmax=bad,
|
||||
abi_hash=abi_hash,
|
||||
launch_health='exit_code',
|
||||
enable_explicit_modes=True,
|
||||
cdn_urls=[cdn_url])
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS, FLAG_FORCECHECK],
|
||||
use_local_cdn=False,
|
||||
allow_configured_cdn=True,
|
||||
timeout=60,
|
||||
env={'CEF_INSTALLER_IGNORE_CERTIFICATE_ERRORS_FOR_TESTING': '1'})
|
||||
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Reinstall failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(good)
|
||||
self.assert_version_not_installed(bad)
|
||||
good_archive = f'cef_{good}_windows64.tar.xz'
|
||||
bad_archive = f'cef_{bad}_windows64.tar.xz'
|
||||
self.assertTrue(any(good_archive in request for request in requests),
|
||||
f'Good archive was not requested: {requests}')
|
||||
self.assertFalse(any(bad_archive in request for request in requests),
|
||||
f'Disqualified archive was requested: {requests}')
|
||||
|
||||
# ===========================================================================
|
||||
# Explicit launch success confirmation (kLaunchSuccess)
|
||||
# ===========================================================================
|
||||
|
||||
def test_launch_success_prevents_crash_penalty(self):
|
||||
"""launch_success before a crash leaves the version confirmed."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00b01-0b01-0b01-0b01-00000000b001'
|
||||
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LS01App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# First leave a pre-launch running publication behind.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
self.assertEqual(1, rc)
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
before = self.read_launch_state(version, appid_hash)
|
||||
self.assertIsNotNone(before)
|
||||
before_update = int(before.get('last_update', '0'))
|
||||
|
||||
# Client confirms launch health, then the app crashes (exit 1). Because
|
||||
# the sentinel was confirmed during RunWinMain, the crash exit does not
|
||||
# revert it: the post-exit handler doesn't run for a failure exit, and the
|
||||
# sentinel is already running=false, failures=0.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1, launch_success=True)
|
||||
self.assertEqual(1, rc)
|
||||
|
||||
self.assert_launch_state(version, appid_hash, running=False, failures=0)
|
||||
confirmed = self.read_launch_state(version, appid_hash)
|
||||
self.assertGreater(int(confirmed.get('last_update', '0')), before_update)
|
||||
|
||||
# Next launch: version is confirmed (not disqualified) and selected again.
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIsNotNone(marker)
|
||||
self.assertIn(version, marker.get('libcef_version_full', ''))
|
||||
|
||||
def test_launch_success_clean_exit(self):
|
||||
"""launch_success + clean exit confirms and prunes/cleans up older files."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00b02-0b02-0b02-0b02-00000000b002'
|
||||
|
||||
# Install v1 only first; keep global vmin low with a second app so v1's
|
||||
# .launch file isn't GC'd when v1_1 is installed later.
|
||||
appid2 = 'e2e00b02-0b02-0b02-0b02-000000000002'
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
self.embed_test_config(appid=appid2,
|
||||
vmin=v1,
|
||||
vmax=v1,
|
||||
abi_hash=abi_hash,
|
||||
enable_explicit_modes=True)
|
||||
self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
app_exe, app_name = self.setup_launcher('LS02App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0, launch_success=True)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(v1, appid_hash, running=False, failures=0)
|
||||
|
||||
# Install v1_1, then confirm it via launch_success on a clean exit. The
|
||||
# post-exit handler (exit 0) deletes v1's older confirmed .launch file.
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0, launch_success=True)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
self.assert_launch_state(v1_1, appid_hash, running=False, failures=0)
|
||||
self.assert_no_launch_state(v1, appid_hash)
|
||||
|
||||
def test_without_launch_success_crash_still_counts(self):
|
||||
"""Without launch_success, an early crash counts against the version."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00b03-0b03-0b03-0b03-00000000b003'
|
||||
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LS03App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Crash without confirming: sentinel left running=true (detected next time).
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
self.assertEqual(1, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
state = self.read_launch_state(version, appid_hash)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertTrue(state.get('running'))
|
||||
|
||||
# A second crash launch: the prior crash is projected (failures=1) and
|
||||
# persisted into the new sentinel before RunWinMain.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
self.assertEqual(1, rc)
|
||||
self.assert_launch_state(version, appid_hash, running=True, failures=1)
|
||||
|
||||
def test_launch_success_resets_prior_crash_history(self):
|
||||
"""launch_success clears a projected failure from a prior crash."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00b04-0b04-0b04-0b04-00000000b004'
|
||||
|
||||
self._standalone_install(version, abi_hash, appid)
|
||||
app_exe, app_name = self.setup_launcher('LS04App')
|
||||
self._embed_launcher_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Crash once without confirming.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
self.assertEqual(1, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assertTrue(self.read_launch_state(version, appid_hash).get('running'))
|
||||
|
||||
# Next launch confirms via launch_success then exits cleanly. The
|
||||
# projected failure from the prior crash is reset to 0.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0, launch_success=True)
|
||||
self.assertEqual(0, rc)
|
||||
self.assert_launch_state(version, appid_hash, running=False, failures=0)
|
||||
|
||||
def test_launch_success_then_three_early_crashes_disqualify(self):
|
||||
"""Confirmation is per-launch: later early crashes still disqualify."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00b05-0b05-0b05-0b05-00000000b005'
|
||||
|
||||
# Install and confirm v1 (the known-good fallback).
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
app_exe, app_name = self.setup_launcher('LS05App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
# Install v1_1 and confirm it once via launch_success, then crash (exit 1).
|
||||
# The confirmation is durable (written during RunWinMain), and the failure
|
||||
# exit skips post-exit cleanup/prune so v1's protected .launch file — and
|
||||
# thus the v1 fallback — survives for the rollback assertion below.
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1, launch_success=True)
|
||||
self.assertEqual(1, rc)
|
||||
|
||||
appid_hash = self.get_appid_hash(appid)
|
||||
self.assert_launch_state(v1_1, appid_hash, running=False, failures=0)
|
||||
|
||||
# Now crash v1_1 three times WITHOUT confirming (early crashes). The prior
|
||||
# confirmation does not carry over — each launch starts fresh.
|
||||
for _ in range(3):
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 1)
|
||||
|
||||
# Next clean launch: v1_1 is disqualified (projected=3) → rollback to v1.
|
||||
rc, marker = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assertIsNotNone(marker)
|
||||
self.assertIn(v1, marker.get('libcef_version_full', ''))
|
||||
self.assertNotIn(v1_1, marker.get('libcef_version_full', ''))
|
||||
|
||||
def test_neutral_exit_does_not_prune(self):
|
||||
"""Neutral exit does not prune older unused versions; a clean exit does."""
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
_, abi_hash = self.build_cdn(version=v1)
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
appid = 'e2e00b06-0b06-0b06-0b06-00000000b006'
|
||||
|
||||
# Install and confirm v1.
|
||||
self._standalone_install(v1, abi_hash, appid, vmin=v1, vmax=v1)
|
||||
app_exe, app_name = self.setup_launcher('LS06App')
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assert_version_installed(v1)
|
||||
|
||||
# Keep the platform global vmin at v1 without requiring v1 once v1_1 is
|
||||
# installed. This isolates confirmed protection from below-vmin GC.
|
||||
appid2 = 'e2e00b06-0b06-0b06-0b06-000000000002'
|
||||
self.embed_test_config(appid=appid2,
|
||||
vmin=v1,
|
||||
vmax=v1_1,
|
||||
abi_hash=abi_hash,
|
||||
enable_explicit_modes=True)
|
||||
self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
|
||||
# Install v1_1 (now the best match; v1 becomes prunable).
|
||||
self._standalone_install(v1_1, abi_hash, appid, vmin=v1_1)
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
|
||||
# Run v1_1 with a neutral exit (21 = CEF_RESULT_CODE_PROFILE_IN_USE).
|
||||
# Pruning must NOT run on a neutral exit (a concurrent live instance may
|
||||
# hold libcef.dll), so v1 survives.
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 21)
|
||||
self.assertEqual(21, rc)
|
||||
self.assert_version_installed(v1)
|
||||
self.assert_version_installed(v1_1)
|
||||
|
||||
# A following clean exit DOES prune the now-unused v1.
|
||||
self._embed_launcher_config(app_exe, appid, v1, abi_hash)
|
||||
rc, _ = self._run_launcher_with_exit_code(app_exe, 0)
|
||||
self.assertEqual(0, rc)
|
||||
self.assert_version_not_installed(v1)
|
||||
self.assert_version_installed(v1_1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,704 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for the CEF installer launcher mode.
|
||||
|
||||
Launcher mode: bootstrap.exe is renamed, a client DLL is adjacent.
|
||||
The bootstrap loads the DLL and calls RunWinMain. The mock client DLL
|
||||
calls RunInstaller (exported by the bootstrap exe) to query or install CEF.
|
||||
|
||||
Tests also cover bootstrap-driven CEF resolution: when a client DLL is
|
||||
present and installer config is embedded in the bootstrap exe, the
|
||||
bootstrap resolves the CEF path before loading the client DLL and passes
|
||||
it via version_info->libcef_path.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_CONFIG_ERROR, EXIT_SUCCESS,
|
||||
FLAG_FORCECHECK, FLAG_HEADLESS, FLAG_UNINSTALL,
|
||||
FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestLauncherMode(E2ETestBase):
|
||||
|
||||
def _make_config_json(self,
|
||||
appid,
|
||||
vmin,
|
||||
abi_hash='a1b2c3d4e5f6',
|
||||
include_cdn=True,
|
||||
**extra):
|
||||
"""Build RunInstaller config JSON for the mock client DLL."""
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': vmin,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if include_cdn and os.path.isdir(self.cdn_dir):
|
||||
config['local_download_path'] = self.cdn_dir
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
config.update(extra)
|
||||
return json.dumps(config)
|
||||
|
||||
def _run_launcher(self, app_exe, config_json, command='install', timeout=30):
|
||||
"""Run a launcher exe with mock DLL environment variables.
|
||||
|
||||
Returns (exit_code, marker_data) where marker_data is parsed JSON
|
||||
from the marker file, or None if the file was not written.
|
||||
"""
|
||||
marker_path = os.path.join(self.temp_dir, 'e2e_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_CONFIG_JSON': config_json,
|
||||
'CEF_E2E_COMMAND': command,
|
||||
}
|
||||
exit_code, stdout, stderr = self.run_installer(args=[],
|
||||
exe=app_exe,
|
||||
use_local_cdn=False,
|
||||
env=env,
|
||||
timeout=timeout)
|
||||
|
||||
marker_data = None
|
||||
if os.path.isfile(marker_path):
|
||||
with open(marker_path) as f:
|
||||
marker_data = json.load(f)
|
||||
return exit_code, marker_data
|
||||
|
||||
def test_dll_finds_installed_version(self):
|
||||
"""Launcher DLL queries a pre-installed CEF version."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00011-1111-1111-1111-111111111111'
|
||||
|
||||
# Pre-install using standalone mode.
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# Set up launcher mode.
|
||||
app_exe, _ = self.setup_launcher('E2E11App')
|
||||
|
||||
# Query via mock DLL.
|
||||
config_json = self._make_config_json(appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
include_cdn=False)
|
||||
exit_code, marker = self._run_launcher(app_exe,
|
||||
config_json,
|
||||
command='query')
|
||||
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Launcher query failed (exit {exit_code})')
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
result = marker.get('result', {})
|
||||
self.assertTrue(result.get('success'), f'Query not successful: {result}')
|
||||
self.assertTrue(result.get('libcef_path', ''), 'No libcef_path in result')
|
||||
|
||||
def test_dll_installs_cef(self):
|
||||
"""Launcher DLL installs CEF via RunInstaller."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00012-1212-1212-1212-121212121212'
|
||||
|
||||
# Set up launcher mode (no pre-install).
|
||||
app_exe, _ = self.setup_launcher('E2E12App')
|
||||
|
||||
# Install via mock DLL.
|
||||
config_json = self._make_config_json(appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash)
|
||||
exit_code, marker = self._run_launcher(app_exe,
|
||||
config_json,
|
||||
command='install')
|
||||
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Launcher install failed (exit {exit_code})')
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
result = marker.get('result', {})
|
||||
self.assertTrue(result.get('success'), f'Install not successful: {result}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_dll_fails_to_install(self):
|
||||
"""Launcher DLL fails to install with no CDN available."""
|
||||
appid = 'e2e00013-1313-1313-1313-131313131313'
|
||||
|
||||
# Set up launcher mode with no pre-installed version and no CDN.
|
||||
app_exe, _ = self.setup_launcher('E2E13App')
|
||||
|
||||
empty_cdn = os.path.join(self.temp_dir, 'empty_cdn')
|
||||
os.makedirs(empty_cdn)
|
||||
config_json = self._make_config_json(appid=appid,
|
||||
vmin=self.test_version,
|
||||
include_cdn=False,
|
||||
local_download_path=empty_cdn)
|
||||
exit_code, marker = self._run_launcher(app_exe,
|
||||
config_json,
|
||||
command='install')
|
||||
|
||||
# Mock DLL returns 3 when RunInstaller reports failure.
|
||||
self.assertNotEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
result = marker.get('result', {})
|
||||
self.assertFalse(result.get('success', True))
|
||||
|
||||
def test_wrong_thumbprint_fails(self):
|
||||
"""Wrong certificate thumbprint causes signature error."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00016-1616-1616-1616-161616161616'
|
||||
|
||||
app_exe, _ = self.setup_launcher('E2E16App')
|
||||
|
||||
config_json = self._make_config_json(appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
certificate_thumbprint='a' * 40)
|
||||
exit_code, marker = self._run_launcher(app_exe,
|
||||
config_json,
|
||||
command='install')
|
||||
|
||||
self.assertNotEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
result = marker.get('result', {})
|
||||
self.assertEqual(102, result.get('error_code'))
|
||||
self.assertEqual('SIGNATURE_ERROR', result.get('error_name'),
|
||||
f'Expected SIGNATURE_ERROR, got: {result}')
|
||||
self.assertEqual('failed', result.get('outcome'))
|
||||
|
||||
def test_bundled_cef_fallback(self):
|
||||
"""Bundled CEF fallback when no CDN available."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_pre = 'e2e00004-0004-0004-0004-000000000004'
|
||||
|
||||
# Step 1: Standalone install to create version dir on disk.
|
||||
self.embed_test_config(appid=appid_pre, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# The version dir at install_dir/Versions/<ver>/windows64/ has
|
||||
# cef_version.json + Release/libcef.dll — CheckBundledCef structure.
|
||||
bundled_path = os.path.join(self.install_dir, 'Versions', version,
|
||||
'windows64')
|
||||
|
||||
# Step 2: Launcher mode with bundled_cef_path, empty CDN, fresh install.
|
||||
fresh_install = os.path.join(self.temp_dir, 'fresh_install')
|
||||
empty_cdn = os.path.join(self.temp_dir, 'empty_cdn')
|
||||
os.makedirs(empty_cdn)
|
||||
|
||||
appid = 'e2e00004-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
app_exe, _ = self.setup_launcher('E2E4App')
|
||||
|
||||
config_json = self._make_config_json(appid=appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
include_cdn=False,
|
||||
local_download_path=empty_cdn,
|
||||
install_path=fresh_install,
|
||||
bundled_cef_path=bundled_path)
|
||||
exit_code, marker = self._run_launcher(app_exe,
|
||||
config_json,
|
||||
command='install')
|
||||
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Bundled fallback failed (exit {exit_code})')
|
||||
self.assertIsNotNone(marker, 'Marker file not written')
|
||||
result = marker.get('result', {})
|
||||
self.assertTrue(result.get('success'), f'Not successful: {result}')
|
||||
self.assertTrue(result.get('libcef_path', ''), 'No libcef_path')
|
||||
|
||||
|
||||
class TestBootstrapCefResolution(E2ETestBase):
|
||||
"""Tests for bootstrap-driven CEF resolution with a client DLL present.
|
||||
|
||||
When installer config is embedded in the bootstrap exe, the bootstrap
|
||||
resolves the installed CEF path and passes it to the client DLL via
|
||||
version_info->libcef_path — before RunWinMain is called.
|
||||
"""
|
||||
|
||||
def _embed_bootstrap_config(self,
|
||||
app_exe,
|
||||
appid,
|
||||
vmin,
|
||||
abi_hash='',
|
||||
enable_explicit_modes=False):
|
||||
"""Embed a CEF_INSTALLER_CONFIG resource in the bootstrap exe."""
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': vmin,
|
||||
}
|
||||
if abi_hash:
|
||||
config['abi_hash'] = abi_hash
|
||||
if enable_explicit_modes:
|
||||
config['enable_explicit_modes'] = True
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_exe, config)
|
||||
|
||||
def _read_marker(self, marker_path):
|
||||
"""Read and parse the marker file written by the mock client DLL."""
|
||||
self.assertTrue(os.path.isfile(marker_path),
|
||||
f'Marker file not written: {marker_path}')
|
||||
with open(marker_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
def _run_launcher_with_marker(self,
|
||||
app_exe,
|
||||
args=None,
|
||||
env=None,
|
||||
use_local_cdn=False,
|
||||
install_path=None,
|
||||
use_install_path_override=True):
|
||||
"""Run launcher and return (exit_code, marker_data, stderr)."""
|
||||
marker_path = os.path.join(self.temp_dir, 'e2e_marker.json')
|
||||
run_env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
}
|
||||
if env:
|
||||
run_env.update(env)
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
args=args or [],
|
||||
exe=app_exe,
|
||||
use_local_cdn=use_local_cdn,
|
||||
install_path=install_path,
|
||||
use_install_path_override=use_install_path_override,
|
||||
env=run_env)
|
||||
marker = None
|
||||
if os.path.isfile(marker_path):
|
||||
with open(marker_path) as f:
|
||||
marker = json.load(f)
|
||||
return exit_code, marker, stderr
|
||||
|
||||
def test_bootstrap_resolves_preinstalled_cef(self):
|
||||
"""Bootstrap resolves a pre-installed CEF version and passes path via
|
||||
version_info to the client DLL."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0b001-b001-b001-b001-b00100000001'
|
||||
|
||||
# Pre-install using standalone mode.
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# Set up launcher mode with embedded config in bootstrap.
|
||||
app_exe, _ = self.setup_launcher('BootResolve1')
|
||||
self._embed_bootstrap_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Run with no explicit command and no RunInstaller config.
|
||||
# The bootstrap resolves CEF from embedded config and passes
|
||||
# version_info->libcef_path to the mock client DLL.
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(app_exe)
|
||||
|
||||
self.assertIsNotNone(marker, 'Mock client DLL was not loaded')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertTrue(libcef_path,
|
||||
f'Bootstrap did not resolve libcef_path: {marker}')
|
||||
self.assertIn('libcef.dll', libcef_path.lower(),
|
||||
f'Unexpected libcef_path: {libcef_path}')
|
||||
version_full = marker.get('libcef_version_full', '')
|
||||
self.assertIn(version, version_full,
|
||||
f'libcef_version_full does not contain version: {marker}')
|
||||
self.assertEqual(0, marker.get('installer_error_code'))
|
||||
self.assertIsNone(marker.get('installer_error_message'))
|
||||
liveness = self.read_liveness(self.get_appid_hash(appid))
|
||||
self.assertIsNotNone(liveness)
|
||||
self.assertEqual(appid, liveness.get('appid'))
|
||||
self.assertEqual('windows64', liveness.get('platform'))
|
||||
self.assertTrue(int(liveness.get('last_launch', '0')) > 0)
|
||||
|
||||
def test_bootstrap_local_hit_ignores_held_writer_mutex(self):
|
||||
"""A local automatic startup launches while another writer owns mutex."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0b010-b010-b010-b010-b01000000010'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
|
||||
app_exe, _ = self.setup_launcher('BootLockFree1')
|
||||
self._embed_bootstrap_config(app_exe, appid, version, abi_hash)
|
||||
normalized = self.install_dir.lower().replace('\\', '/')
|
||||
suffix = hashlib.sha1(normalized.encode('utf-8')).hexdigest()[:16].upper()
|
||||
mutex_name = f'Global\\CEF_Installer_{suffix}'
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel32.CreateMutexW.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p
|
||||
]
|
||||
kernel32.CreateMutexW.restype = ctypes.c_void_p
|
||||
kernel32.ReleaseMutex.argtypes = [ctypes.c_void_p]
|
||||
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
mutex = kernel32.CreateMutexW(None, True, mutex_name)
|
||||
self.assertTrue(mutex, f'CreateMutexW failed: {ctypes.get_last_error()}')
|
||||
try:
|
||||
start = time.monotonic()
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(app_exe)
|
||||
elapsed = time.monotonic() - start
|
||||
finally:
|
||||
kernel32.ReleaseMutex(mutex)
|
||||
kernel32.CloseHandle(mutex)
|
||||
|
||||
self.assertIsNotNone(marker)
|
||||
self.assertTrue(marker.get('libcef_path'),
|
||||
f'No resolved path (exit={exit_code}, marker={marker})')
|
||||
self.assertLess(elapsed, 3.0)
|
||||
|
||||
def test_bootstrap_recovers_missing_and_corrupt_version_index(self):
|
||||
"""Automatic startup can recover a valid distribution without an index."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
preinstall_appid = 'e2e0b011-b011-b011-b011-b01100000011'
|
||||
self.embed_test_config(appid=preinstall_appid,
|
||||
vmin=version,
|
||||
abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
|
||||
index_path = os.path.join(self.install_dir, 'versions.json')
|
||||
cases = [('missing', None, 'e2e0b012-b012-b012-b012-b01200000012'),
|
||||
('corrupt', b'not an index',
|
||||
'e2e0b013-b013-b013-b013-b01300000013')]
|
||||
for suffix, corrupt_contents, appid in cases:
|
||||
with self.subTest(index_state=suffix):
|
||||
if corrupt_contents is None:
|
||||
if os.path.exists(index_path):
|
||||
os.remove(index_path)
|
||||
else:
|
||||
with open(index_path, 'wb') as index_file:
|
||||
index_file.write(corrupt_contents)
|
||||
|
||||
app_exe, _ = self.setup_launcher(f'BootIndexRecovery{suffix}')
|
||||
self._embed_bootstrap_config(app_exe, appid, version, abi_hash)
|
||||
exit_code, marker, stderr = self._run_launcher_with_marker(app_exe)
|
||||
|
||||
self.assertIsNotNone(
|
||||
marker,
|
||||
f'Client DLL was not loaded (exit={exit_code}, stderr={stderr!r})')
|
||||
self.assertTrue(marker.get('libcef_path'), marker)
|
||||
self.assertIn(version, marker.get('libcef_version_full', ''))
|
||||
if corrupt_contents is None:
|
||||
self.assertFalse(os.path.exists(index_path))
|
||||
else:
|
||||
with open(index_path, 'rb') as index_file:
|
||||
self.assertEqual(corrupt_contents, index_file.read())
|
||||
|
||||
def test_bootstrap_installs_from_cdn_for_client_dll(self):
|
||||
"""Bootstrap downloads CEF from CDN when no local version exists,
|
||||
then passes the path to the client DLL via version_info."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0b002-b002-b002-b002-b00200000002'
|
||||
|
||||
# Set up launcher mode — no pre-install, CDN available.
|
||||
app_exe, _ = self.setup_launcher('BootResolve2')
|
||||
self._embed_bootstrap_config(app_exe, appid, version, abi_hash)
|
||||
|
||||
# Run with no explicit command. The bootstrap should auto-install
|
||||
# from CDN and pass the path via version_info.
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(app_exe,
|
||||
use_local_cdn=True)
|
||||
|
||||
self.assertIsNotNone(marker, 'Mock client DLL was not loaded')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertTrue(libcef_path,
|
||||
f'Bootstrap did not resolve libcef_path: {marker}')
|
||||
version_full = marker.get('libcef_version_full', '')
|
||||
self.assertIn(version, version_full,
|
||||
f'libcef_version_full does not contain version: {marker}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_update_falls_through_to_client_dll(self):
|
||||
"""Explicit /cef-update with a client DLL runs the update and then
|
||||
loads the client DLL (instead of exiting)."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0b003-b003-b003-b003-b00300000003'
|
||||
|
||||
# Set up launcher mode with embedded config and CDN.
|
||||
app_exe, _ = self.setup_launcher('BootResolve3')
|
||||
self._embed_bootstrap_config(app_exe,
|
||||
appid,
|
||||
version,
|
||||
abi_hash,
|
||||
enable_explicit_modes=True)
|
||||
|
||||
# Run with /cef-update. Previously this would exit without loading
|
||||
# the client DLL. Now it should run the update, then load the DLL.
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(
|
||||
app_exe, args=[FLAG_UPDATE, FLAG_HEADLESS], use_local_cdn=True)
|
||||
|
||||
self.assertIsNotNone(marker, 'Client DLL not loaded after /cef-update')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertTrue(libcef_path, f'No libcef_path after /cef-update: {marker}')
|
||||
self.assert_version_installed(version)
|
||||
self.assert_no_launch_state(version, self.get_appid_hash(appid))
|
||||
|
||||
def test_resolution_failure_falls_through(self):
|
||||
"""When CEF resolution fails (no CDN, no installed version), the
|
||||
bootstrap still loads the client DLL with empty libcef_path."""
|
||||
appid = 'e2e0b004-b004-b004-b004-b00400000004'
|
||||
|
||||
# Set up launcher mode with NO CDN and NO pre-installed version.
|
||||
app_exe, _ = self.setup_launcher('BootResolve4')
|
||||
self._embed_bootstrap_config(app_exe, appid, self.test_version)
|
||||
|
||||
empty_cdn = os.path.join(self.temp_dir, 'empty_cdn')
|
||||
os.makedirs(empty_cdn)
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(app_exe)
|
||||
|
||||
# Client DLL should still be loaded even though resolution failed.
|
||||
self.assertIsNotNone(marker,
|
||||
'Client DLL not loaded after resolution failure')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertEqual('', libcef_path,
|
||||
f'Expected empty libcef_path on failure: {marker}')
|
||||
version_full = marker.get('libcef_version_full', '')
|
||||
self.assertEqual(
|
||||
'', version_full,
|
||||
f'Expected empty libcef_version_full on failure: {marker}')
|
||||
self.assertIsInstance(marker.get('installer_error_code'), int)
|
||||
self.assertNotEqual(0, marker.get('installer_error_code'))
|
||||
self.assertTrue(marker.get('installer_error_message'))
|
||||
|
||||
def test_installer_not_configured_clears_error(self):
|
||||
app_exe, _ = self.setup_launcher('BootNotConfigured')
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(
|
||||
app_exe, env={'CEF_E2E_EXIT_CODE': '0'})
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertIsNotNone(marker)
|
||||
self.assertEqual('', marker.get('libcef_path', ''))
|
||||
self.assertEqual(0, marker.get('installer_error_code'))
|
||||
self.assertIsNone(marker.get('installer_error_message'))
|
||||
|
||||
def test_malformed_client_resource_exits_config_error(self):
|
||||
app_exe, app_name = self.setup_launcher('BootMalformedClient')
|
||||
self._embed_bootstrap_config(app_exe,
|
||||
'e2e0b008-b008-b008-b008-b00800000008',
|
||||
self.test_version)
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
self.embed_raw_config_resource(app_dll, '{"appid":')
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(
|
||||
app_exe, env={'CEF_E2E_EXIT_CODE': '0'})
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertIsNone(marker, 'Malformed trusted config loaded the client DLL')
|
||||
|
||||
def test_post_clamp_empty_range_exits_config_error(self):
|
||||
app_exe, app_name = self.setup_launcher('BootEmptyRange')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
self.embed_raw_config_resource(
|
||||
app_dll, '{"appid":"e2e0b009-b009-b009-b009-b00900000009",'
|
||||
'"vmin":"1.0","vmax":"2.0"}')
|
||||
exit_code, marker, stderr = self._run_launcher_with_marker(
|
||||
app_exe, env={'CEF_E2E_EXIT_CODE': '0'})
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertIsNone(marker)
|
||||
diagnostic = stderr.decode(errors='replace')
|
||||
self.assertIn('Configured vmin 1.0', diagnostic)
|
||||
self.assertIn('effective vmin', diagnostic)
|
||||
self.assertIn('configured vmax 2.0', diagnostic)
|
||||
|
||||
def test_unchecked_cef_path(self):
|
||||
"""Bootstrap uses unchecked_cef_path from client DLL resource to resolve
|
||||
libcef.dll directly, skipping installer version checks."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_pre = 'e2e0b005-b005-b005-b005-b00500000005'
|
||||
|
||||
# Step 1: Standalone install to create a Release dir with libcef.dll.
|
||||
self.embed_test_config(appid=appid_pre, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# The Release dir has libcef.dll — use it as unchecked_cef_path.
|
||||
unchecked_dir = os.path.join(self.install_dir, 'Versions', version,
|
||||
'windows64', 'Release')
|
||||
self.assertTrue(os.path.isfile(os.path.join(unchecked_dir, 'libcef.dll')))
|
||||
|
||||
# Step 2: Set up launcher with unchecked_cef_path embedded in the
|
||||
# client DLL resource. No RunInstaller config — the bootstrap reads
|
||||
# the DLL resource, resolves the path, and passes it via version_info.
|
||||
appid = 'e2e0b005-cccc-cccc-cccc-cccccccccccc'
|
||||
app_exe, app_name = self.setup_launcher('BootResolve5')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
|
||||
dll_config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'unchecked_cef_path': unchecked_dir,
|
||||
}
|
||||
self.embed_config_resource(app_dll, dll_config)
|
||||
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(app_exe)
|
||||
|
||||
self.assertIsNotNone(marker, 'Mock client DLL was not loaded')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertTrue(libcef_path,
|
||||
f'Bootstrap did not resolve libcef_path: {marker}')
|
||||
self.assertIn('libcef.dll', libcef_path.lower(),
|
||||
f'Unexpected libcef_path: {libcef_path}')
|
||||
|
||||
def test_install_path_relative_in_client_dll_resource(self):
|
||||
"""A client-resource install_path is DLL-relative and exclusive."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
app_exe, app_name = self.setup_launcher('BootInstallPath1')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
# Only a default-named bootstrap accepts --module. Use the console variant
|
||||
# so a regression reports on stderr instead of displaying a message box.
|
||||
# The E2E base's self.bootstrap_exe is a renamed CefTestApp.exe and
|
||||
# intentionally ignores that switch.
|
||||
host_exe = os.path.join(self.build_dir, 'bootstrapc.exe')
|
||||
custom_store = os.path.join(self.build_dir, f'{app_name}_relative_store')
|
||||
shutil.rmtree(custom_store, ignore_errors=True)
|
||||
self.addCleanup(lambda: shutil.rmtree(custom_store, ignore_errors=True))
|
||||
relative_store = os.path.basename(custom_store)
|
||||
appid = 'e2e0b008-c008-c008-c008-c00800000008'
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': relative_store,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_dll, config)
|
||||
|
||||
exit_code, marker, stderr = self._run_launcher_with_marker(
|
||||
app_exe, use_local_cdn=True, use_install_path_override=False)
|
||||
self.assertIsNotNone(marker, stderr.decode(errors='replace'))
|
||||
self.assertEqual(0, marker.get('installer_error_code'),
|
||||
stderr.decode(errors='replace'))
|
||||
libcef_path = os.path.normcase(os.path.abspath(marker['libcef_path']))
|
||||
self.assertTrue(
|
||||
libcef_path.startswith(os.path.normcase(os.path.abspath(custom_store))))
|
||||
self.assertTrue(os.path.isfile(os.path.join(custom_store,
|
||||
'installer.json')))
|
||||
self.assertTrue(os.path.isfile(os.path.join(custom_store, 'versions.json')))
|
||||
self.assertFalse(os.path.isdir(os.path.join(self.install_dir, 'Versions')))
|
||||
|
||||
# The host executable is outside the DLL-relative custom store, so default
|
||||
# uninstall is synchronous and keeps the already-resolved absolute target.
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
args=[f'--module={app_name}', FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
exe=host_exe,
|
||||
use_local_cdn=False,
|
||||
use_install_path_override=False,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code, stderr.decode(errors='replace'))
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertFalse(
|
||||
os.path.isdir(os.path.join(custom_store, 'Versions', version)))
|
||||
|
||||
def test_bundled_cef_path_in_client_dll_resource(self):
|
||||
"""Bootstrap resolves bundled_cef_path from client DLL resource, selects
|
||||
the bundled version when no installed version exists."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_pre = 'e2e0b006-b006-b006-b006-b00600000006'
|
||||
|
||||
# Step 1: Standalone install to create a version dir with valid metadata.
|
||||
self.embed_test_config(appid=appid_pre, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# The version dir has cef_version.json + Release/libcef.dll —
|
||||
# valid for CheckBundledCef.
|
||||
bundled_path = os.path.join(self.install_dir, 'Versions', version,
|
||||
'windows64')
|
||||
|
||||
# Step 2: Set up launcher with bundled_cef_path embedded in the client
|
||||
# DLL resource (not RunInstaller config). Use a fresh install dir so
|
||||
# there are no pre-installed versions.
|
||||
fresh_install = os.path.join(self.temp_dir, 'fresh_install')
|
||||
os.makedirs(fresh_install)
|
||||
|
||||
appid = 'e2e0b006-cccc-cccc-cccc-cccccccccccc'
|
||||
app_exe, app_name = self.setup_launcher('BootBundled1')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
|
||||
dll_config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'bundled_cef_path': bundled_path,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
dll_config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_dll, dll_config)
|
||||
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(
|
||||
app_exe, install_path=fresh_install)
|
||||
|
||||
self.assertIsNotNone(marker, 'Mock client DLL was not loaded')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertTrue(libcef_path,
|
||||
f'Bootstrap did not resolve libcef_path: {marker}')
|
||||
self.assertIn('libcef.dll', libcef_path.lower(),
|
||||
f'Unexpected libcef_path: {libcef_path}')
|
||||
# libcef_path should point into the bundled directory, not fresh_install.
|
||||
norm = lambda p: p.replace('\\', '/').lower()
|
||||
self.assertIn(norm(bundled_path), norm(libcef_path),
|
||||
f'libcef_path not from bundled dir: {libcef_path}')
|
||||
|
||||
def test_bundled_cef_path_relative_in_client_dll_resource(self):
|
||||
"""Bootstrap resolves a relative bundled_cef_path against the client
|
||||
DLL directory."""
|
||||
import shutil
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_pre = 'e2e0b007-b007-b007-b007-b00700000007'
|
||||
|
||||
# Step 1: Standalone install to create version dir.
|
||||
self.embed_test_config(appid=appid_pre, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Pre-install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# Copy the bundled version adjacent to the DLL so a relative path works.
|
||||
app_exe, app_name = self.setup_launcher('BootBundled2')
|
||||
app_dll = os.path.join(self.build_dir, f'{app_name}.dll')
|
||||
bundled_rel_dir = os.path.join(self.build_dir, 'bundled_cef')
|
||||
src_version_dir = os.path.join(self.install_dir, 'Versions', version,
|
||||
'windows64')
|
||||
if os.path.isdir(bundled_rel_dir):
|
||||
shutil.rmtree(bundled_rel_dir)
|
||||
shutil.copytree(src_version_dir, bundled_rel_dir)
|
||||
self.addCleanup(lambda: shutil.rmtree(bundled_rel_dir, ignore_errors=True))
|
||||
|
||||
# Step 2: Embed a relative bundled_cef_path in the client DLL resource.
|
||||
fresh_install = os.path.join(self.temp_dir, 'fresh_install2')
|
||||
os.makedirs(fresh_install)
|
||||
|
||||
appid = 'e2e0b007-cccc-cccc-cccc-cccccccccccc'
|
||||
dll_config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'bundled_cef_path': 'bundled_cef',
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
dll_config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(app_dll, dll_config)
|
||||
|
||||
exit_code, marker, _ = self._run_launcher_with_marker(
|
||||
app_exe, install_path=fresh_install)
|
||||
|
||||
self.assertIsNotNone(marker, 'Mock client DLL was not loaded')
|
||||
libcef_path = marker.get('libcef_path', '')
|
||||
self.assertTrue(libcef_path,
|
||||
f'Bootstrap did not resolve libcef_path: {marker}')
|
||||
self.assertIn('libcef.dll', libcef_path.lower(),
|
||||
f'Unexpected libcef_path: {libcef_path}')
|
||||
@@ -0,0 +1,158 @@
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""Unit tests for the lifecycle reference receiver."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from lifecycle_receiver import Correlator, parse_payload
|
||||
|
||||
ID_A = '0123456789abcdef0123456789abcdef'
|
||||
ID_B = 'fedcba9876543210fedcba9876543210'
|
||||
ID_C = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
|
||||
|
||||
def handoff(operation_id=ID_A, pid=123):
|
||||
return json.dumps(
|
||||
{
|
||||
'protocol_version': 1,
|
||||
'event': 'relaunch_started',
|
||||
'operation_id': operation_id,
|
||||
'child_pid': pid,
|
||||
},
|
||||
separators=(',', ':'))
|
||||
|
||||
|
||||
def terminal(operation_id=ID_A, success=True):
|
||||
value = {
|
||||
'protocol_version': 1,
|
||||
'event': 'operation_result',
|
||||
'operation_id': operation_id,
|
||||
'command': 'uninstall',
|
||||
'success': success,
|
||||
'outcome': 'committed' if success else 'failed',
|
||||
'exit_code': 0 if success else 106,
|
||||
}
|
||||
if not success:
|
||||
value.update({
|
||||
'error_code': 106,
|
||||
'error_name': 'DATABASE_ERROR',
|
||||
'error_message': 'Database save failed',
|
||||
})
|
||||
return json.dumps(value, separators=(',', ':'))
|
||||
|
||||
|
||||
class TestLifecycleReceiver(unittest.TestCase):
|
||||
|
||||
def test_parse_valid_and_unknown(self):
|
||||
self.assertEqual('valid', parse_payload(handoff())[0])
|
||||
self.assertEqual('valid', parse_payload(terminal(success=False))[0])
|
||||
future = json.loads(handoff())
|
||||
future['protocol_version'] = 2
|
||||
self.assertEqual('ignored', parse_payload(json.dumps(future))[0])
|
||||
future['protocol_version'] = 1
|
||||
future['event'] = 'future'
|
||||
self.assertEqual('ignored', parse_payload(json.dumps(future))[0])
|
||||
|
||||
def test_parse_rejects_malformed_and_bounds(self):
|
||||
malformed = [
|
||||
'', '[]', '{',
|
||||
handoff(pid=0),
|
||||
handoff(pid=True),
|
||||
handoff(operation_id=ID_A.upper()),
|
||||
terminal().replace('"exit_code":0', '"exit_code":1'),
|
||||
terminal() + '\0'
|
||||
]
|
||||
for payload in malformed:
|
||||
self.assertEqual('malformed', parse_payload(payload)[0], payload)
|
||||
value = json.loads(terminal(success=False))
|
||||
value['error_message'] = 'x' * 4097
|
||||
self.assertEqual('malformed', parse_payload(json.dumps(value))[0])
|
||||
value = json.loads(terminal())
|
||||
value['diagnostics_truncated'] = 1
|
||||
self.assertEqual('malformed', parse_payload(json.dumps(value))[0])
|
||||
value = json.loads(terminal(success=False))
|
||||
value['error_message'] = '\ud800'
|
||||
self.assertEqual('malformed', parse_payload(json.dumps(value))[0])
|
||||
value['error_message'] = 'safe'
|
||||
value['warnings'] = ['\ud800']
|
||||
self.assertEqual('malformed', parse_payload(json.dumps(value))[0])
|
||||
|
||||
def test_parse_rejects_impossible_and_unknown_terminal_codes(self):
|
||||
for error_code, error_name in ((109, 'RELAUNCHED'), (120, 'UNKNOWN_ERROR'),
|
||||
(9999, 'UNKNOWN_ERROR')):
|
||||
value = json.loads(terminal(success=False))
|
||||
value['exit_code'] = error_code
|
||||
value['error_code'] = error_code
|
||||
value['error_name'] = error_name
|
||||
self.assertEqual('malformed', parse_payload(json.dumps(value))[0], value)
|
||||
|
||||
value = json.loads(terminal(success=False))
|
||||
value['exit_code'] = 199
|
||||
value['error_code'] = 199
|
||||
value['error_name'] = 'UNKNOWN_ERROR'
|
||||
self.assertEqual('valid', parse_payload(json.dumps(value))[0])
|
||||
|
||||
def test_terminal_first_duplicate_and_missing_handoff(self):
|
||||
receiver = Correlator()
|
||||
self.assertEqual('buffered', receiver.accept_payload(terminal()))
|
||||
self.assertNotIn(ID_A, receiver.results)
|
||||
self.assertEqual('duplicate', receiver.accept_payload(terminal()))
|
||||
self.assertEqual('correlated', receiver.accept_payload(handoff()))
|
||||
self.assertIn(ID_A, receiver.results)
|
||||
self.assertEqual('duplicate', receiver.accept_payload(terminal()))
|
||||
self.assertEqual('duplicate', receiver.accept_payload(handoff()))
|
||||
|
||||
self.assertEqual('buffered', receiver.accept_payload(terminal(ID_B)))
|
||||
self.assertNotIn(ID_B, receiver.results)
|
||||
|
||||
def test_concurrent_operation_ids_do_not_cross_correlate(self):
|
||||
receiver = Correlator()
|
||||
self.assertEqual('handoff', receiver.accept_payload(handoff(ID_A, 1)))
|
||||
self.assertEqual('handoff', receiver.accept_payload(handoff(ID_B, 2)))
|
||||
self.assertEqual('correlated', receiver.accept_payload(terminal(ID_B)))
|
||||
self.assertNotIn(ID_A, receiver.results)
|
||||
self.assertEqual('correlated', receiver.accept_payload(terminal(ID_A)))
|
||||
self.assertEqual(ID_A, receiver.results[ID_A]['operation_id'])
|
||||
self.assertEqual(ID_B, receiver.results[ID_B]['operation_id'])
|
||||
|
||||
def test_correlation_is_bounded_expiring_and_consumable(self):
|
||||
now = [100.0]
|
||||
receiver = Correlator(capacity=2,
|
||||
expiration_seconds=10,
|
||||
clock=lambda: now[0])
|
||||
self.assertEqual('buffered', receiver.accept_payload(terminal(ID_A)))
|
||||
now[0] += 1
|
||||
self.assertEqual('handoff', receiver.accept_payload(handoff(ID_B, 2)))
|
||||
now[0] += 1
|
||||
self.assertEqual('buffered', receiver.accept_payload(terminal(ID_C)))
|
||||
self.assertEqual(2, receiver.tracked_operation_count())
|
||||
|
||||
# Prefer evicting the oldest orphan terminal over an active handoff.
|
||||
self.assertEqual('handoff', receiver.accept_payload(handoff(ID_A, 1)))
|
||||
self.assertNotIn(ID_A, receiver.results)
|
||||
self.assertNotIn(ID_C, receiver.pending)
|
||||
self.assertIn(ID_B, receiver.handoffs)
|
||||
self.assertEqual('ignored', receiver.accept_payload(terminal(ID_C)))
|
||||
self.assertEqual(2, receiver.tracked_operation_count())
|
||||
|
||||
self.assertEqual('correlated', receiver.accept_payload(terminal(ID_A)))
|
||||
self.assertEqual(ID_A, receiver.consume_result(ID_A)['operation_id'])
|
||||
self.assertNotIn(ID_A, receiver.handoffs)
|
||||
self.assertNotIn(ID_A, receiver.results)
|
||||
self.assertTrue(receiver.remove(ID_B))
|
||||
self.assertFalse(receiver.remove(ID_B))
|
||||
|
||||
self.assertEqual('buffered', receiver.accept_payload(terminal(ID_C)))
|
||||
self.assertTrue(receiver.remove(ID_C))
|
||||
self.assertEqual(0, receiver.tracked_operation_count())
|
||||
self.assertEqual('buffered', receiver.accept_payload(terminal(ID_C)))
|
||||
now[0] += 10
|
||||
self.assertEqual(1, receiver.purge_expired())
|
||||
self.assertEqual(0, receiver.tracked_operation_count())
|
||||
self.assertEqual('handoff', receiver.accept_payload(handoff(ID_C, 3)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for WM_COPYDATA progress notification."""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_SUCCESS, FLAG_HEADLESS,
|
||||
FLAG_PARENT, FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestProgress(E2ETestBase):
|
||||
|
||||
def test_parent_receives_progress(self):
|
||||
"""Parent window receives WM_COPYDATA progress updates."""
|
||||
helper_exe = os.path.join(self.build_dir, 'cef_e2e_progress_helper.exe')
|
||||
if not os.path.isfile(helper_exe):
|
||||
self.skipTest('cef_e2e_progress_helper.exe not found')
|
||||
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00025-2525-2525-2525-252525252525'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
|
||||
messages_path = os.path.join(self.temp_dir, 'progress_messages.json')
|
||||
|
||||
# Start the progress helper — it prints HWND to stdout then pumps.
|
||||
helper_proc = subprocess.Popen([helper_exe, messages_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=self.build_dir)
|
||||
|
||||
try:
|
||||
# Read HWND from first line of stdout.
|
||||
hwnd_line = helper_proc.stdout.readline().decode().strip()
|
||||
self.assertTrue(hwnd_line, 'No HWND from progress helper')
|
||||
hwnd = int(hwnd_line)
|
||||
|
||||
# Run installer with parent window handle.
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Install failed: {stderr.decode(errors="replace")}')
|
||||
finally:
|
||||
# Send WM_CLOSE for clean shutdown (helper writes output on exit).
|
||||
WM_CLOSE = 0x0010
|
||||
ctypes.windll.user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)
|
||||
helper_proc.wait(timeout=10)
|
||||
|
||||
# Read collected messages.
|
||||
self.assertTrue(os.path.isfile(messages_path),
|
||||
'Progress messages file not written')
|
||||
with open(messages_path) as f:
|
||||
messages = json.load(f)
|
||||
|
||||
self.assertIsInstance(messages, list)
|
||||
self.assertGreater(len(messages), 0, 'No progress messages received')
|
||||
|
||||
# Verify message structure.
|
||||
for msg in messages:
|
||||
self.assertIn('step_name', msg, f'Missing step_name: {msg}')
|
||||
self.assertIn('step', msg, f'Missing step: {msg}')
|
||||
self.assertIn('total_steps', msg, f'Missing total_steps: {msg}')
|
||||
self.assertIn('bytes_done', msg, f'Missing bytes_done: {msg}')
|
||||
self.assertIn('bytes_total', msg, f'Missing bytes_total: {msg}')
|
||||
self.assertIn('overall_percent', msg, f'Missing overall_percent: {msg}')
|
||||
self.assertGreaterEqual(msg['overall_percent'], 0)
|
||||
self.assertLessEqual(msg['overall_percent'], 100)
|
||||
self.assertIn('message', msg, f'Missing message field: {msg}')
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for explicit registration-retention maintenance."""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_CONFIG_ERROR, EXIT_SUCCESS,
|
||||
FLAG_RETENTION_APPLY, FLAG_RETENTION_DRY_RUN,
|
||||
FLAG_RETENTION_MAX_AGE_DAYS)
|
||||
|
||||
|
||||
class TestRetention(E2ETestBase):
|
||||
|
||||
def _run_export(self, app_exe, config, command, marker_name):
|
||||
marker_path = os.path.join(self.temp_dir, marker_name)
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_CONFIG_JSON': json.dumps(config),
|
||||
'CEF_E2E_COMMAND': command,
|
||||
}
|
||||
self.run_installer(exe=app_exe, use_local_cdn=False, env=env, timeout=60)
|
||||
self.assertTrue(os.path.isfile(marker_path), 'Marker file not written')
|
||||
with open(marker_path) as f:
|
||||
return json.load(f)['result']
|
||||
|
||||
def _install(self, app_exe, appid, version, abi_hash, marker):
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
result = self._run_export(app_exe, config, 'install', marker)
|
||||
self.assertTrue(result.get('success'), result)
|
||||
|
||||
def _retention(self, app_exe, command, marker, days=180):
|
||||
return self._run_export(app_exe, {
|
||||
'install_path': self.install_dir,
|
||||
'max_age_days': days,
|
||||
}, command, marker)
|
||||
|
||||
def _store_snapshot(self):
|
||||
snapshot = {}
|
||||
if not os.path.isdir(self.install_dir):
|
||||
return snapshot
|
||||
for root, _, files in os.walk(self.install_dir):
|
||||
for name in files:
|
||||
if name == 'cef_installer.log':
|
||||
continue
|
||||
path = os.path.join(root, name)
|
||||
with open(path, 'rb') as f:
|
||||
snapshot[os.path.relpath(path, self.install_dir)] = f.read()
|
||||
return snapshot
|
||||
|
||||
def test_dry_run_and_apply(self):
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'a1000000-0000-4000-8000-000000000001'
|
||||
app_exe, _ = self.setup_launcher('RetentionE2EApply')
|
||||
self._install(app_exe, appid, version, abi_hash, 'install.json')
|
||||
evidence_path = self.write_liveness(
|
||||
appid,
|
||||
self.filetime_now() - 181 * 24 * 60 * 60 * 10000000)
|
||||
before = self._store_snapshot()
|
||||
|
||||
dry_run = self._retention(app_exe, 'retention_dry_run', 'dry_run.json')
|
||||
|
||||
self.assertTrue(dry_run.get('success'), dry_run)
|
||||
self.assertEqual('reclaim', dry_run['registrations'][0]['decision'])
|
||||
self.assertTrue(dry_run['versions'][0]['expected_removal'])
|
||||
self.assertEqual(before, self._store_snapshot())
|
||||
|
||||
apply = self._retention(app_exe, 'retention_apply', 'apply.json')
|
||||
|
||||
self.assertTrue(apply.get('success'), apply)
|
||||
self.assertTrue(apply.get('registrations_committed'))
|
||||
self.assertTrue(apply.get('versions_pruned'))
|
||||
self.assertEqual([], self.read_database().get('apps'))
|
||||
self.assertFalse(os.path.exists(evidence_path))
|
||||
self.assert_version_not_installed(version)
|
||||
|
||||
def test_unknown_evidence_preserves_shared_version(self):
|
||||
version, abi_hash = self.build_cdn()
|
||||
old_appid = 'a1000000-0000-4000-8000-000000000002'
|
||||
unknown_appid = 'a1000000-0000-4000-8000-000000000003'
|
||||
app_exe, _ = self.setup_launcher('RetentionE2EShared')
|
||||
self._install(app_exe, old_appid, version, abi_hash, 'install.json')
|
||||
self.write_registration_database([{
|
||||
'uuid': old_appid,
|
||||
'platform': 'windows64',
|
||||
'vmin': version,
|
||||
'vmax': '',
|
||||
'abi_hash': abi_hash,
|
||||
}, {
|
||||
'uuid': unknown_appid,
|
||||
'platform': 'windows64',
|
||||
'vmin': version,
|
||||
'vmax': '',
|
||||
'abi_hash': abi_hash,
|
||||
}])
|
||||
self.write_liveness(old_appid,
|
||||
self.filetime_now() - 181 * 24 * 60 * 60 * 10000000)
|
||||
|
||||
result = self._retention(app_exe, 'retention_apply', 'apply.json')
|
||||
|
||||
self.assertTrue(result.get('success'), result)
|
||||
apps = self.read_database().get('apps')
|
||||
self.assertEqual([unknown_appid], [app['uuid'] for app in apps])
|
||||
self.assert_version_installed(version)
|
||||
decisions = {
|
||||
item['appid']: item['decision'] for item in result['registrations']
|
||||
}
|
||||
self.assertEqual('reclaim', decisions[old_appid])
|
||||
self.assertEqual('protected', decisions[unknown_appid])
|
||||
|
||||
def test_fresh_evidence_between_dry_run_and_apply_rescues(self):
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'a1000000-0000-4000-8000-000000000004'
|
||||
app_exe, _ = self.setup_launcher('RetentionE2ERescue')
|
||||
self._install(app_exe, appid, version, abi_hash, 'install.json')
|
||||
self.write_liveness(appid,
|
||||
self.filetime_now() - 181 * 24 * 60 * 60 * 10000000)
|
||||
dry_run = self._retention(app_exe, 'retention_dry_run', 'dry_run.json')
|
||||
self.assertEqual('reclaim', dry_run['registrations'][0]['decision'])
|
||||
self.write_liveness(appid, self.filetime_now())
|
||||
|
||||
apply = self._retention(app_exe, 'retention_apply', 'apply.json')
|
||||
|
||||
self.assertTrue(apply.get('success'), apply)
|
||||
self.assertEqual([], [
|
||||
item for item in apply['registrations'] if item['decision'] == 'reclaim'
|
||||
])
|
||||
self.assertEqual(1, len(self.read_database().get('apps')))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_cli_custom_store_and_threshold_validation(self):
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'a1000000-0000-4000-8000-000000000005'
|
||||
self.install_dir = os.path.join(self.temp_dir, 'Program Files', 'CEF')
|
||||
app_exe, _ = self.setup_launcher('RetentionE2ECliInstall')
|
||||
self._install(app_exe, appid, version, abi_hash, 'install.json')
|
||||
self.write_liveness(appid,
|
||||
self.filetime_now() - 91 * 24 * 60 * 60 * 10000000)
|
||||
self.embed_test_config(appid, version, abi_hash)
|
||||
|
||||
code, stdout, _ = self.run_installer(
|
||||
args=[FLAG_RETENTION_DRY_RUN, f'{FLAG_RETENTION_MAX_AGE_DAYS}=90'])
|
||||
|
||||
self.assertEqual(EXIT_SUCCESS, code)
|
||||
report = json.loads(stdout.decode('utf-8'))
|
||||
self.assertEqual('reclaim', report['registrations'][0]['decision'])
|
||||
|
||||
before = self._store_snapshot()
|
||||
code, _, _ = self.run_installer(
|
||||
args=[FLAG_RETENTION_APPLY, f'{FLAG_RETENTION_MAX_AGE_DAYS}=89'])
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, code)
|
||||
self.assertEqual(before, self._store_snapshot())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for the RunInstaller DLL export."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import E2ETestBase, EXIT_SUCCESS
|
||||
|
||||
|
||||
class TestRunInstaller(E2ETestBase):
|
||||
|
||||
def _run_export(self, app_exe, config, command, marker_name, extra_env=None):
|
||||
marker_path = os.path.join(self.temp_dir, marker_name)
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_CONFIG_JSON': json.dumps(config),
|
||||
'CEF_E2E_COMMAND': command,
|
||||
}
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
self.run_installer(exe=app_exe, use_local_cdn=False, env=env, timeout=60)
|
||||
self.assertTrue(os.path.isfile(marker_path), 'Marker file not written')
|
||||
with open(marker_path) as f:
|
||||
return json.load(f)['result']
|
||||
|
||||
def test_run_installer_returns_valid_json(self):
|
||||
"""RunInstaller returns valid JSON with success and libcef_path."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00022-2222-2222-2222-222222222222'
|
||||
|
||||
app_exe, _ = self.setup_launcher('E2E22App')
|
||||
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
config_json = json.dumps(config)
|
||||
|
||||
marker_path = os.path.join(self.temp_dir, 'e2e_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_CONFIG_JSON': config_json,
|
||||
'CEF_E2E_COMMAND': 'install',
|
||||
}
|
||||
|
||||
exit_code, _, stderr = self.run_installer(args=[],
|
||||
exe=app_exe,
|
||||
use_local_cdn=False,
|
||||
env=env)
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code, f'RunInstaller failed (exit {exit_code}): '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assertTrue(os.path.isfile(marker_path), 'Marker file not written')
|
||||
|
||||
with open(marker_path) as f:
|
||||
marker = json.load(f)
|
||||
|
||||
# Result is valid JSON with expected fields.
|
||||
result = marker.get('result', {})
|
||||
self.assertIsInstance(result, dict)
|
||||
self.assertTrue(result.get('success'), f'Not successful: {result}')
|
||||
self.assertEqual('committed', result.get('outcome'))
|
||||
self.assertNotIn('restart_recommended', result)
|
||||
self.assertTrue(result.get('libcef_path'), 'No libcef_path')
|
||||
self.assertIn('installed_version', result)
|
||||
self.assertIn(version, result.get('version_full', ''),
|
||||
f'version_full does not contain version: {result}')
|
||||
|
||||
# Version was actually installed on disk.
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_expanded_index_failure_is_hard_and_retryable(self):
|
||||
"""Checked expanded-index failure leaves no fabricated registration."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0f001-f001-f001-f001-f001f001f001'
|
||||
app_exe, _ = self.setup_launcher('E2EFaultIndexApp')
|
||||
os.makedirs(self.install_dir, exist_ok=True)
|
||||
with open(os.path.join(self.install_dir, 'versions.json'), 'w') as f:
|
||||
json.dump({'versions': []}, f)
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
|
||||
failed = self._run_export(app_exe, config, 'install',
|
||||
'fault_index_failed.json',
|
||||
{'CEF_INSTALLER_TEST_INDEX_FAULT': 'write'})
|
||||
self.assertFalse(failed['success'])
|
||||
self.assertEqual('failed', failed['outcome'])
|
||||
self.assertEqual(114, failed['error_code'])
|
||||
self.assertEqual('INDEX_ERROR', failed['error_name'])
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(self.install_dir, 'installer.json')))
|
||||
|
||||
retried = self._run_export(app_exe, config, 'install',
|
||||
'fault_index_retry.json')
|
||||
self.assertTrue(retried['success'], retried)
|
||||
self.assertEqual('committed', retried['outcome'])
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_database_failure_leaves_safe_partial_add(self):
|
||||
"""Directory/index publication precedes the required registration save."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0f002-f002-f002-f002-f002f002f002'
|
||||
app_exe, _ = self.setup_launcher('E2EFaultDatabaseApp')
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
|
||||
failed = self._run_export(app_exe, config, 'install',
|
||||
'fault_database_failed.json',
|
||||
{'CEF_INSTALLER_TEST_DATABASE_SAVE_FAILURE': '1'})
|
||||
self.assertFalse(failed['success'])
|
||||
self.assertEqual('DATABASE_ERROR', failed['error_name'])
|
||||
self.assert_version_installed(version)
|
||||
|
||||
retried = self._run_export(app_exe, config, 'install',
|
||||
'fault_database_retry.json')
|
||||
self.assertTrue(retried['success'], retried)
|
||||
db = self.read_database()
|
||||
self.assertIn(appid, [entry['uuid'] for entry in db.get('apps', [])])
|
||||
|
||||
def test_trash_reclamation_reports_cleanup_deferred(self):
|
||||
"""Logical uninstall commits before faulted physical trash reclamation."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0f003-f003-f003-f003-f003f003f003'
|
||||
app_exe, _ = self.setup_launcher('E2EFaultTrashApp')
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
|
||||
installed = self._run_export(app_exe, config, 'install',
|
||||
'fault_trash_install.json')
|
||||
self.assertTrue(installed['success'], installed)
|
||||
deferred = self._run_export(
|
||||
app_exe, config, 'uninstall', 'fault_trash_uninstall.json',
|
||||
{'CEF_INSTALLER_TEST_FILE_OPS_FAULT': 'trash_reclaim'})
|
||||
self.assertTrue(deferred['success'], deferred)
|
||||
self.assertEqual('cleanup_deferred', deferred['outcome'])
|
||||
self.assertTrue(deferred.get('warnings'))
|
||||
self.assert_version_not_installed(version)
|
||||
trash_dir = os.path.join(self.install_dir, '.trash')
|
||||
self.assertTrue(os.path.isdir(trash_dir) and os.listdir(trash_dir))
|
||||
|
||||
def test_existing_destination_quarantine_failure_and_retry(self):
|
||||
"""An existing-target collision fails quarantine, then replaces on retry."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e0f004-f004-f004-f004-f004f004f004'
|
||||
app_exe, _ = self.setup_launcher('E2EFaultRepairApp')
|
||||
destination = os.path.join(self.install_dir, 'Versions', version,
|
||||
'windows64')
|
||||
os.makedirs(destination, exist_ok=True)
|
||||
partial = os.path.join(destination, 'partial.txt')
|
||||
with open(partial, 'w') as f:
|
||||
f.write('partial')
|
||||
config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config['certificate_thumbprint'] = self.test_thumbprint
|
||||
|
||||
failed = self._run_export(
|
||||
app_exe, config, 'install', 'fault_quarantine_failed.json',
|
||||
{'CEF_INSTALLER_TEST_FILE_OPS_FAULT': 'quarantine_move'})
|
||||
self.assertFalse(failed['success'])
|
||||
self.assertEqual(117, failed['error_code'])
|
||||
self.assertEqual('QUARANTINE_ERROR', failed['error_name'])
|
||||
self.assertTrue(os.path.isfile(partial))
|
||||
staging_dir = os.path.join(self.install_dir, '.staging')
|
||||
self.assertFalse(os.path.isdir(staging_dir) and os.listdir(staging_dir))
|
||||
|
||||
retried = self._run_export(app_exe, config, 'install',
|
||||
'fault_quarantine_retry.json')
|
||||
self.assertTrue(retried['success'], retried)
|
||||
self.assertEqual('committed', retried['outcome'])
|
||||
self.assertFalse(os.path.exists(partial))
|
||||
self.assert_version_installed(version)
|
||||
self.assertFalse(os.path.isdir(staging_dir) and os.listdir(staging_dir))
|
||||
|
||||
def test_invalid_parent_window_failed_schema(self):
|
||||
app_exe, _ = self.setup_launcher('E2EParentInvalid')
|
||||
config = {
|
||||
'appid': 'e2e0bad0-bad0-bad0-bad0-bad000000000',
|
||||
'vmin': self.test_version,
|
||||
'parent_window': 'invalid',
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
marker_path = os.path.join(self.temp_dir, 'parent_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_CONFIG_JSON': json.dumps(config),
|
||||
'CEF_E2E_COMMAND': 'query',
|
||||
}
|
||||
self.run_installer(exe=app_exe, use_local_cdn=False, env=env)
|
||||
with open(marker_path) as f:
|
||||
result = json.load(f)['result']
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual('failed', result['outcome'])
|
||||
self.assertIsInstance(result['error_code'], int)
|
||||
self.assertEqual('CONFIG_ERROR', result['error_name'])
|
||||
self.assertIn('parent_window', result['error_message'])
|
||||
|
||||
@unittest.skipUnless(sys.maxsize > 2**32, 'requires a 64-bit process')
|
||||
def test_parent_window_above_uint32_is_accepted(self):
|
||||
app_exe, _ = self.setup_launcher('E2EParent64Bit')
|
||||
config = {
|
||||
'appid': 'e2e06464-6464-6464-6464-646464646464',
|
||||
'vmin': self.test_version,
|
||||
'parent_window': str(2**32 + 1),
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
marker_path = os.path.join(self.temp_dir, 'parent_64bit_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_CONFIG_JSON': json.dumps(config),
|
||||
'CEF_E2E_COMMAND': 'query',
|
||||
}
|
||||
self.run_installer(exe=app_exe, use_local_cdn=False, env=env)
|
||||
with open(marker_path) as f:
|
||||
result = json.load(f)['result']
|
||||
self.assertNotEqual('CONFIG_ERROR', result.get('error_name'))
|
||||
self.assertNotIn('parent_window', result.get('error_message', ''))
|
||||
|
||||
def test_run_installer_thread_safety(self):
|
||||
"""RunInstaller thread-local storage is thread-safe."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_a = 'e2e00023-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
appid_b = 'e2e00023-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
app_exe, _ = self.setup_launcher('E2E23App')
|
||||
|
||||
config_base = {
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config_base['certificate_thumbprint'] = self.test_thumbprint
|
||||
|
||||
config_a = dict(config_base, appid=appid_a)
|
||||
config_b = dict(config_base, appid=appid_b)
|
||||
|
||||
marker_path = os.path.join(self.temp_dir, 'e2e_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_COMMAND': 'install',
|
||||
'CEF_E2E_THREADING_MODE': 'multi',
|
||||
'CEF_E2E_CONFIG_JSON_A': json.dumps(config_a),
|
||||
'CEF_E2E_CONFIG_JSON_B': json.dumps(config_b),
|
||||
}
|
||||
|
||||
exit_code, _, stderr = self.run_installer(args=[],
|
||||
exe=app_exe,
|
||||
use_local_cdn=False,
|
||||
env=env,
|
||||
timeout=60)
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code,
|
||||
f'Multi-threaded RunInstaller failed (exit {exit_code}): '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assertTrue(os.path.isfile(marker_path), 'Marker file not written')
|
||||
|
||||
with open(marker_path) as f:
|
||||
marker = json.load(f)
|
||||
|
||||
result_a = marker.get('result_a', {})
|
||||
result_b = marker.get('result_b', {})
|
||||
self.assertTrue(result_a.get('success'),
|
||||
f'Thread A not successful: {result_a}')
|
||||
self.assertTrue(result_b.get('success'),
|
||||
f'Thread B not successful: {result_b}')
|
||||
|
||||
# Both apps should be in the database.
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertIn(appid_a, uuids, f'App A not in database: {db}')
|
||||
self.assertIn(appid_b, uuids, f'App B not in database: {db}')
|
||||
|
||||
def test_run_installer_parallel(self):
|
||||
"""RunInstaller is safe to call from multiple threads in parallel."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_a = 'e2e00024-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
appid_b = 'e2e00024-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
app_exe, _ = self.setup_launcher('E2E24App')
|
||||
|
||||
config_base = {
|
||||
'vmin': version,
|
||||
'abi_hash': abi_hash,
|
||||
'install_path': self.install_dir,
|
||||
'local_download_path': self.cdn_dir,
|
||||
'show_progress_ui': False,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
config_base['certificate_thumbprint'] = self.test_thumbprint
|
||||
|
||||
config_a = dict(config_base, appid=appid_a)
|
||||
config_b = dict(config_base, appid=appid_b)
|
||||
|
||||
marker_path = os.path.join(self.temp_dir, 'e2e_marker.json')
|
||||
env = {
|
||||
'CEF_E2E_MARKER_PATH': marker_path,
|
||||
'CEF_E2E_COMMAND': 'install',
|
||||
'CEF_E2E_THREADING_MODE': 'parallel',
|
||||
'CEF_E2E_CONFIG_JSON_A': json.dumps(config_a),
|
||||
'CEF_E2E_CONFIG_JSON_B': json.dumps(config_b),
|
||||
}
|
||||
|
||||
exit_code, _, stderr = self.run_installer(args=[],
|
||||
exe=app_exe,
|
||||
use_local_cdn=False,
|
||||
env=env,
|
||||
timeout=60)
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code,
|
||||
f'Parallel RunInstaller failed (exit {exit_code}): '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.assertTrue(os.path.isfile(marker_path), 'Marker file not written')
|
||||
|
||||
with open(marker_path) as f:
|
||||
marker = json.load(f)
|
||||
|
||||
result_a = marker.get('result_a', {})
|
||||
result_b = marker.get('result_b', {})
|
||||
self.assertTrue(result_a.get('success'),
|
||||
f'Thread A not successful: {result_a}')
|
||||
self.assertTrue(result_b.get('success'),
|
||||
f'Thread B not successful: {result_b}')
|
||||
|
||||
# Both apps should be in the database.
|
||||
db = self.read_database()
|
||||
uuids = [a.get('uuid') for a in db.get('apps', [])]
|
||||
self.assertIn(appid_a, uuids, f'App A not in database: {db}')
|
||||
self.assertIn(appid_b, uuids, f'App B not in database: {db}')
|
||||
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for the CEF installer uninstall flow."""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_CANCELLED, EXIT_CONFIG_ERROR,
|
||||
EXIT_DATABASE_ERROR, EXIT_INSTALL_ERROR,
|
||||
EXIT_INDEX_ERROR, EXIT_RELAUNCHED, EXIT_SUCCESS,
|
||||
FLAG_HEADLESS, FLAG_INSTALL_PATH, FLAG_UNINSTALL,
|
||||
FLAG_UNINSTALL_RELAUNCHED, FLAG_UPDATE,
|
||||
FLAG_BACKGROUND, FLAG_PARENT)
|
||||
from lifecycle_receiver import correlate_helper_output
|
||||
|
||||
|
||||
class TestUninstallFlow(E2ETestBase):
|
||||
|
||||
def _stage_default_console_launcher(self, module_name):
|
||||
"""Stage default-named bootstrapc.exe and a selected mock client DLL."""
|
||||
host_dir = os.path.join(self.temp_dir, f'host-{module_name}')
|
||||
os.makedirs(host_dir)
|
||||
host_exe = os.path.join(host_dir, 'bootstrapc.exe')
|
||||
module_path = os.path.join(host_dir, f'{module_name}.dll')
|
||||
shutil.copy2(os.path.join(self.build_dir, 'bootstrapc.exe'), host_exe)
|
||||
shutil.copy2(os.path.join(self.build_dir, 'chrome_elf.dll'),
|
||||
os.path.join(host_dir, 'chrome_elf.dll'))
|
||||
crash_config = os.path.join(self.build_dir, 'crash_reporter.cfg')
|
||||
if os.path.isfile(crash_config):
|
||||
shutil.copy2(crash_config, os.path.join(host_dir, 'crash_reporter.cfg'))
|
||||
shutil.copy2(self.mock_client_dll_src, module_path)
|
||||
return host_exe, module_path
|
||||
|
||||
def _run_default_console_uninstall(self, host_exe, module_name, parent_window,
|
||||
env, use_install_path_override):
|
||||
"""Run the async console parent without capturing child-inherited pipes."""
|
||||
command = [
|
||||
host_exe, f'--module={module_name}', FLAG_UNINSTALL, FLAG_BACKGROUND,
|
||||
FLAG_HEADLESS, f'{FLAG_PARENT}={parent_window}'
|
||||
]
|
||||
if use_install_path_override:
|
||||
command.append(f'{FLAG_INSTALL_PATH}={self.install_dir}')
|
||||
run_env = os.environ.copy()
|
||||
run_env.update(env)
|
||||
process = subprocess.run(command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=30,
|
||||
cwd=self.build_dir,
|
||||
env=run_env)
|
||||
return process.returncode
|
||||
|
||||
def _start_lifecycle_receiver(self, *options):
|
||||
helper = os.path.join(self.build_dir, 'cef_e2e_progress_helper.exe')
|
||||
if not os.path.isfile(helper):
|
||||
self.skipTest('cef_e2e_progress_helper.exe not found')
|
||||
output = os.path.join(self.temp_dir, f'lifecycle-{time.time_ns()}.json')
|
||||
process = subprocess.Popen([helper, output, '--lifecycle', *options],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=self.build_dir)
|
||||
line = process.stdout.readline().decode().strip()
|
||||
self.assertTrue(line, 'No HWND from lifecycle receiver')
|
||||
return process, int(line), output
|
||||
|
||||
def _finish_lifecycle_receiver(self, process, hwnd, output, await_terminal):
|
||||
try:
|
||||
if await_terminal:
|
||||
try:
|
||||
process.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
if process.poll() is None:
|
||||
ctypes.windll.user32.PostMessageW(hwnd, 0x0010, 0, 0)
|
||||
process.wait(timeout=10)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
self.assertTrue(os.path.isfile(output), 'Receiver output was not written')
|
||||
with open(output, encoding='utf-8') as output_file:
|
||||
captured = json.load(output_file)
|
||||
return captured, correlate_helper_output(captured)
|
||||
|
||||
def _install_for_lifecycle(self, appid):
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code, stderr.decode(errors='replace'))
|
||||
return version
|
||||
|
||||
def _wait_for_relaunch_child_exit(self, relaunch_dir, timeout=10):
|
||||
"""Wait for the child image to close before removing its test directory."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
shutil.rmtree(relaunch_dir)
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.05)
|
||||
self.fail(f'Relaunched child did not exit before timeout: {relaunch_dir}')
|
||||
|
||||
def _wait_for_path(self, path, description, timeout=10):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline and not os.path.isfile(path):
|
||||
time.sleep(0.05)
|
||||
self.assertTrue(os.path.isfile(path), description)
|
||||
|
||||
def _run_lifecycle_fault(self, appid, environment=None, receiver_options=()):
|
||||
version = self._install_for_lifecycle(appid)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(self.install_dir,
|
||||
'LifecycleFault')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver(*receiver_options)
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
env.update(environment or {})
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code,
|
||||
stderr.decode(errors='replace'))
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
self.assertEqual(1, len(correlated['handoffs']), captured)
|
||||
self.assertEqual(1, len(correlated['results']), captured)
|
||||
operation_id = next(iter(correlated['handoffs']))
|
||||
self.assertIn(operation_id, correlated['results'])
|
||||
for path in self.snapshot_uninstall_relaunch_dirs() - relaunch_dirs:
|
||||
self.addCleanup(shutil.rmtree, path, ignore_errors=True)
|
||||
return version, captured, correlated['results'][operation_id]
|
||||
|
||||
def test_uninstall_removes_app(self):
|
||||
"""External uninstall is synchronous and prunes the version."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00008-0008-0008-0008-000000000008'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Uninstall failed: {stderr.decode(errors="replace")}')
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
|
||||
self.assert_version_not_installed(version)
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
|
||||
def test_uninstall_with_other_apps(self):
|
||||
"""Synchronous uninstall preserves another app's version."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid_a = 'e2e00009-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
|
||||
appid_b = 'e2e00009-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
|
||||
|
||||
self.embed_test_config(appid=appid_a, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.embed_test_config(appid=appid_b, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
|
||||
self.embed_test_config(appid=appid_a, vmin=version, abi_hash=abi_hash)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
exit_code, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
|
||||
self.assert_version_installed(version)
|
||||
db = self.read_database()
|
||||
uuids = [app.get('uuid') for app in db.get('apps', [])]
|
||||
self.assertNotIn(appid_a, uuids)
|
||||
self.assertIn(appid_b, uuids)
|
||||
|
||||
def test_uninstall_contained_executable_relocation(self):
|
||||
"""An executable inside the writable target returns exactly 109."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0010-0010-0010-000000000010'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
staged_exe = self.stage_bootstrap_in_install_root(self.install_dir)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(
|
||||
EXIT_RELAUNCHED, exit_code, f'Unexpected exit code {exit_code}: '
|
||||
f'{stderr.decode(errors="replace")}')
|
||||
self.wait_for_relaunched_uninstall(appid,
|
||||
relaunch_dirs,
|
||||
os.path.basename(staged_exe),
|
||||
expected_index_versions=())
|
||||
|
||||
self.assert_version_not_installed(version)
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
|
||||
def test_uninstall_required_relaunch_failure_is_105(self):
|
||||
"""A required relaunch failure does not fall back to direct mutation."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0105-0105-0105-000000000105'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code, stderr.decode(errors='replace'))
|
||||
staged_exe = self.stage_bootstrap_in_install_root(
|
||||
self.install_dir, 'FailedContainedUninstall')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_RELAUNCH_FAILURE': '1',
|
||||
}
|
||||
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
|
||||
self.assertEqual(EXIT_INSTALL_ERROR, exit_code,
|
||||
stderr.decode(errors='replace'))
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_uninstall_marker_only_is_rejected(self):
|
||||
"""The internal marker cannot bypass trusted relaunch state."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0100-0100-0100-000000000100'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, FLAG_UNINSTALL_RELAUNCHED])
|
||||
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_uninstall_state_on_update_is_rejected(self):
|
||||
"""Relaunch marker/state transport is invalid on every other command."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0200-0200-0200-000000000200'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
relaunch_dir = tempfile.mkdtemp(prefix='cef_uninstall_')
|
||||
self.addCleanup(shutil.rmtree, relaunch_dir, ignore_errors=True)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(relaunch_dir,
|
||||
'StateOnUpdate')
|
||||
nonce = 'valid-state-on-update'
|
||||
with open(os.path.join(relaunch_dir, 'cef_uninstall_state.json'),
|
||||
'w',
|
||||
encoding='utf-8') as state_file:
|
||||
json.dump({'install_path': self.install_dir, 'nonce': nonce}, state_file)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
|
||||
exit_code, _, _ = self.run_installer([
|
||||
FLAG_UPDATE, FLAG_HEADLESS, FLAG_UNINSTALL_RELAUNCHED,
|
||||
f'/cef-uninstall-state={nonce}'
|
||||
],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_uninstall_invalid_exclusive_custom_has_no_relaunch(self):
|
||||
"""An invalid exclusive target fails normally without creating a child."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(appid='e2e00010-0300-0300-0300-000000000300',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash)
|
||||
invalid_target = os.path.join(self.temp_dir, 'not-a-directory')
|
||||
with open(invalid_target, 'w') as invalid_file:
|
||||
invalid_file.write('file')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
|
||||
exit_code, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
install_path=invalid_target)
|
||||
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
|
||||
def test_uninstall_read_only_exclusive_custom_has_no_relaunch(self):
|
||||
"""A real read-only exclusive custom store fails without a child."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0301-0301-0301-000000000301'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
|
||||
with self.scoped_read_only_directory(self.install_dir):
|
||||
exit_code, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS])
|
||||
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_uninstall_mutation_ineligible_admin_has_no_relaunch(self):
|
||||
"""An elevated admin role denied mutation never falls through per-user."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0302-0302-0302-000000000302'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
role_root = os.path.join(self.temp_dir, 'role-root')
|
||||
admin_store = os.path.join(role_root, 'Admin')
|
||||
per_user_store = os.path.join(role_root, 'PerUser')
|
||||
os.makedirs(per_user_store)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS],
|
||||
install_path=admin_store)
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertTrue(self._database_contains_app(admin_store, appid))
|
||||
version_path = os.path.join(admin_store, 'Versions', version, 'windows64')
|
||||
self.assertTrue(os.path.isdir(version_path))
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
env = {
|
||||
'CEF_INSTALLER_TEST_DIRECTORY_SCENARIO': 'admin_mutation_denied',
|
||||
'CEF_INSTALLER_TEST_DIRECTORY_ROOT': role_root,
|
||||
}
|
||||
|
||||
exit_code, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
use_install_path_override=False,
|
||||
env=env)
|
||||
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertTrue(self._database_contains_app(admin_store, appid))
|
||||
self.assertTrue(os.path.isdir(version_path))
|
||||
self.assertEqual([], os.listdir(per_user_store))
|
||||
|
||||
def test_uninstall_database_failure_is_synchronous(self):
|
||||
"""An external caller receives the exact controller database failure."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0106-0106-0106-000000000106'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
env={'CEF_INSTALLER_TEST_DATABASE_SAVE_FAILURE': '1'})
|
||||
|
||||
self.assertEqual(EXIT_DATABASE_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_uninstall_index_failure_is_synchronous(self):
|
||||
"""An external caller receives the exact checked-index failure."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0114-0114-0114-000000000114'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
env={'CEF_INSTALLER_TEST_INDEX_FAULT': 'write'})
|
||||
|
||||
self.assertEqual(EXIT_INDEX_ERROR, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_uninstall_cleanup_failure_is_synchronous_success(self):
|
||||
"""Post-commit physical cleanup failure remains exact exit zero."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
appid = 'e2e00010-0000-0000-0000-000000000109'
|
||||
self.embed_test_config(appid=appid, vmin=version, abi_hash=abi_hash)
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
env={'CEF_INSTALLER_TEST_FILE_OPS_FAULT': 'trash_move'})
|
||||
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assertEqual(relaunch_dirs, self.snapshot_uninstall_relaunch_dirs())
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_lifecycle_contained_uninstall_correlates_terminal(self):
|
||||
appid = 'e2e00010-1001-1001-1001-000000001001'
|
||||
version = self._install_for_lifecycle(appid)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(self.install_dir,
|
||||
'LifecycleContained')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code,
|
||||
stderr.decode(errors='replace'))
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
self.assertEqual(0, captured['rejected'])
|
||||
self.assertEqual(1, len(correlated['handoffs']),
|
||||
f'captured={captured!r}, correlated={correlated!r}')
|
||||
self.assertEqual(1, len(correlated['results']))
|
||||
operation_id = next(iter(correlated['handoffs']))
|
||||
self.assertIn(operation_id, correlated['results'])
|
||||
result = correlated['results'][operation_id]
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual('committed', result['outcome'])
|
||||
self.assertEqual(0, result['exit_code'])
|
||||
serialized = json.dumps(result)
|
||||
self.assertNotIn(self.temp_dir, serialized)
|
||||
self.assertNotIn('nonce', serialized.lower())
|
||||
self.wait_for_relaunched_uninstall(appid, relaunch_dirs,
|
||||
os.path.basename(staged_exe), ())
|
||||
self.assert_version_not_installed(version)
|
||||
|
||||
def test_lifecycle_external_uninstall_remains_synchronous(self):
|
||||
appid = 'e2e00010-1011-1011-1011-000000001011'
|
||||
self._install_for_lifecycle(appid)
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code, stderr.decode(errors='replace'))
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=False)
|
||||
self.assertGreater(len(captured['progress']), 0)
|
||||
self.assertEqual([], captured['lifecycle'])
|
||||
self.assertEqual({}, correlated['handoffs'])
|
||||
self.assertEqual({}, correlated['results'])
|
||||
|
||||
def test_lifecycle_no_parent_has_no_bound_context(self):
|
||||
appid = 'e2e00010-1012-1012-1012-000000001012'
|
||||
self._install_for_lifecycle(appid)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(self.install_dir,
|
||||
'LifecycleNoParent')
|
||||
barrier = os.path.join(self.temp_dir, 'no-parent-child-state-ready')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_CHILD_STATE_BARRIER': barrier,
|
||||
}
|
||||
exit_code, _, _ = self.run_installer([FLAG_UNINSTALL, FLAG_HEADLESS],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code)
|
||||
self._wait_for_path(barrier, 'Child did not reach trusted-state barrier')
|
||||
candidates = self.snapshot_uninstall_relaunch_dirs() - relaunch_dirs
|
||||
self.assertEqual(1, len(candidates))
|
||||
with open(os.path.join(next(iter(candidates)), 'cef_uninstall_state.json'),
|
||||
encoding='utf-8') as state_file:
|
||||
state = json.load(state_file)
|
||||
self.assertNotIn('operation_id', state)
|
||||
self.assertNotIn('parent_window', state)
|
||||
os.remove(barrier)
|
||||
for path in candidates:
|
||||
self._wait_for_relaunch_child_exit(path)
|
||||
|
||||
def test_lifecycle_invalid_trusted_invocation_emits_nothing(self):
|
||||
appid = 'e2e00010-1013-1013-1013-000000001013'
|
||||
self._install_for_lifecycle(appid)
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
exit_code, _, _ = self.run_installer([
|
||||
FLAG_UNINSTALL, FLAG_HEADLESS, FLAG_UNINSTALL_RELAUNCHED,
|
||||
f'{FLAG_PARENT}={hwnd}'
|
||||
])
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, exit_code)
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=False)
|
||||
self.assertEqual([], captured['lifecycle'])
|
||||
self.assertEqual({}, correlated['handoffs'])
|
||||
self.assertEqual({}, correlated['results'])
|
||||
|
||||
def test_lifecycle_background_uninstall_is_headless_and_correlated(self):
|
||||
appid = 'e2e00010-1002-1002-1002-000000001002'
|
||||
self._install_for_lifecycle(appid)
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver(
|
||||
'--lifecycle-return=2')
|
||||
env = {'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', '')}
|
||||
exit_code, _, stderr = self.run_installer([
|
||||
FLAG_UNINSTALL, FLAG_HEADLESS, FLAG_BACKGROUND, f'{FLAG_PARENT}={hwnd}'
|
||||
],
|
||||
env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code,
|
||||
stderr.decode(errors='replace'))
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
self.assertEqual(1, len(correlated['results']),
|
||||
f'captured={captured!r}, correlated={correlated!r}')
|
||||
self.assertTrue(next(iter(correlated['results'].values()))['success'])
|
||||
self.wait_for_relaunched_uninstall(appid, relaunch_dirs,
|
||||
os.path.basename(self.bootstrap_exe), ())
|
||||
|
||||
def test_selected_client_resource_relaunch_preserves_module_selection(self):
|
||||
appid = 'e2e00010-1101-1101-1101-000000001101'
|
||||
fallback_appid = 'e2e00010-1101-1101-1101-000000001199'
|
||||
version = self._install_for_lifecycle(appid)
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assertFalse(
|
||||
self._database_contains_app(self.install_dir, fallback_appid))
|
||||
host_exe, module_path = self._stage_default_console_launcher(
|
||||
'SelectedRelaunchClient')
|
||||
self.embed_test_config(appid=fallback_appid,
|
||||
vmin=version,
|
||||
exe=host_exe,
|
||||
enable_explicit_modes=True)
|
||||
client_config = {
|
||||
'appid': appid,
|
||||
'vmin': version,
|
||||
'install_path': self.install_dir,
|
||||
}
|
||||
if self.test_thumbprint:
|
||||
client_config['certificate_thumbprint'] = self.test_thumbprint
|
||||
self.embed_config_resource(module_path, client_config)
|
||||
|
||||
barrier = os.path.join(self.temp_dir, 'selected-client-child-ready')
|
||||
marker = os.path.join(self.temp_dir, 'unexpected-client-marker.json')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_CHILD_STATE_BARRIER': barrier,
|
||||
'CEF_E2E_MARKER_PATH': marker,
|
||||
}
|
||||
exit_code = self._run_default_console_uninstall(
|
||||
host_exe,
|
||||
'SelectedRelaunchClient',
|
||||
hwnd,
|
||||
env,
|
||||
use_install_path_override=False)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code)
|
||||
self._wait_for_path(barrier, 'Selected-client child did not accept state')
|
||||
candidates = self.snapshot_uninstall_relaunch_dirs() - relaunch_dirs
|
||||
self.assertEqual(1, len(candidates))
|
||||
relaunch_dir = next(iter(candidates))
|
||||
relaunch_files = os.listdir(relaunch_dir)
|
||||
self.assertTrue(
|
||||
os.path.isfile(os.path.join(relaunch_dir,
|
||||
'SelectedRelaunchClient.dll')),
|
||||
f'Relaunch file set: {relaunch_files}')
|
||||
with open(os.path.join(relaunch_dir, 'cef_uninstall_state.json'),
|
||||
encoding='utf-8') as state_file:
|
||||
state = json.load(state_file)
|
||||
self.assertEqual(os.path.normcase(os.path.abspath(self.install_dir)),
|
||||
os.path.normcase(os.path.abspath(state['install_path'])))
|
||||
self.assertNotIn('unchecked_cef_path', state)
|
||||
self.assertNotIn('bundled_cef_path', state)
|
||||
self.assertNotIn('config', state)
|
||||
os.remove(barrier)
|
||||
_, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
result = next(iter(correlated['results'].values()))
|
||||
self.assertTrue(result['success'])
|
||||
self.assertFalse(os.path.exists(marker),
|
||||
'Temporary child entered normal client execution')
|
||||
self.wait_for_relaunched_uninstall(appid, relaunch_dirs, 'bootstrapc.exe',
|
||||
())
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
|
||||
def test_bootstrap_fallback_relaunch_omits_no_resource_client(self):
|
||||
appid = 'e2e00010-1102-1102-1102-000000001102'
|
||||
version = self._install_for_lifecycle(appid)
|
||||
host_exe, _ = self._stage_default_console_launcher('NoResourceClient')
|
||||
self.embed_test_config(appid=appid, vmin=version, exe=host_exe)
|
||||
|
||||
barrier = os.path.join(self.temp_dir, 'bootstrap-fallback-child-ready')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_CHILD_STATE_BARRIER': barrier,
|
||||
}
|
||||
exit_code = self._run_default_console_uninstall(
|
||||
host_exe, 'NoResourceClient', hwnd, env, use_install_path_override=True)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code)
|
||||
self._wait_for_path(barrier,
|
||||
'Bootstrap-fallback child did not accept state')
|
||||
candidates = self.snapshot_uninstall_relaunch_dirs() - relaunch_dirs
|
||||
self.assertEqual(1, len(candidates))
|
||||
relaunch_dir = next(iter(candidates))
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(relaunch_dir, 'NoResourceClient.dll')))
|
||||
os.remove(barrier)
|
||||
_, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
self.assertTrue(next(iter(correlated['results'].values()))['success'])
|
||||
self.wait_for_relaunched_uninstall(appid, relaunch_dirs, 'bootstrapc.exe',
|
||||
())
|
||||
|
||||
def test_lifecycle_child_config_failure_reports_terminal(self):
|
||||
appid = 'e2e00010-1003-1003-1003-000000001003'
|
||||
self._install_for_lifecycle(appid)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(self.install_dir,
|
||||
'LifecycleConfigFailure')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_CHILD_CONFIG_FAILURE': '1',
|
||||
}
|
||||
exit_code, _, stderr = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code,
|
||||
stderr.decode(errors='replace'))
|
||||
_, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
result = next(iter(correlated['results'].values()))
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, result['exit_code'])
|
||||
self.assertEqual(EXIT_CONFIG_ERROR, result['error_code'])
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
for path in self.snapshot_uninstall_relaunch_dirs() - relaunch_dirs:
|
||||
self.addCleanup(shutil.rmtree, path, ignore_errors=True)
|
||||
|
||||
def test_lifecycle_relaunch_failure_emits_no_events(self):
|
||||
appid = 'e2e00010-1004-1004-1004-000000001004'
|
||||
self._install_for_lifecycle(appid)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(self.install_dir,
|
||||
'LifecycleLaunchFailure')
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver()
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_RELAUNCH_FAILURE': '1',
|
||||
}
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_INSTALL_ERROR, exit_code)
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=False)
|
||||
self.assertEqual([], captured['lifecycle'])
|
||||
self.assertEqual({}, correlated['handoffs'])
|
||||
self.assertEqual({}, correlated['results'])
|
||||
|
||||
def test_lifecycle_database_failure_reports_exact_terminal(self):
|
||||
appid = 'e2e00010-1007-1007-1007-000000001007'
|
||||
version, captured, result = self._run_lifecycle_fault(
|
||||
appid, {'CEF_INSTALLER_TEST_DATABASE_SAVE_FAILURE': '1'})
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual('failed', result['outcome'])
|
||||
self.assertEqual(EXIT_DATABASE_ERROR, result['exit_code'])
|
||||
self.assertEqual(EXIT_DATABASE_ERROR, result['error_code'])
|
||||
self.assertNotIn('retry_required', result)
|
||||
self.assertNotIn(self.temp_dir, json.dumps(captured))
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_lifecycle_index_failure_does_not_claim_rollback(self):
|
||||
appid = 'e2e00010-1008-1008-1008-000000001008'
|
||||
version, captured, result = self._run_lifecycle_fault(
|
||||
appid, {'CEF_INSTALLER_TEST_INDEX_FAULT': 'write'})
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual(EXIT_INDEX_ERROR, result['exit_code'])
|
||||
self.assertEqual(EXIT_INDEX_ERROR, result['error_code'])
|
||||
self.assertNotIn('registrations_committed', result)
|
||||
self.assertNotIn('rollback', json.dumps(result).lower())
|
||||
self.assertNotIn(self.temp_dir, json.dumps(captured))
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_lifecycle_deferred_cleanup_is_success_with_warning(self):
|
||||
appid = 'e2e00010-1009-1009-1009-000000001009'
|
||||
version, captured, result = self._run_lifecycle_fault(
|
||||
appid, {'CEF_INSTALLER_TEST_FILE_OPS_FAULT': 'trash_move'})
|
||||
self.assertTrue(result['success'])
|
||||
self.assertEqual('cleanup_deferred', result['outcome'])
|
||||
self.assertEqual(EXIT_SUCCESS, result['exit_code'])
|
||||
self.assertGreater(len(result.get('warnings', [])), 0)
|
||||
self.assertNotIn(self.temp_dir, json.dumps(captured))
|
||||
self.assertFalse(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_lifecycle_progress_cancellation_reports_exact_terminal(self):
|
||||
appid = 'e2e00010-1010-1010-1010-000000001010'
|
||||
version, captured, result = self._run_lifecycle_fault(
|
||||
appid, receiver_options=('--cancel',))
|
||||
self.assertGreater(len(captured['progress']), 0)
|
||||
self.assertFalse(result['success'])
|
||||
self.assertEqual('failed', result['outcome'])
|
||||
self.assertEqual(EXIT_CANCELLED, result['exit_code'])
|
||||
self.assertEqual(EXIT_CANCELLED, result['error_code'])
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_lifecycle_forced_child_termination_is_indeterminate(self):
|
||||
appid = 'e2e00010-1006-1006-1006-000000001006'
|
||||
self._install_for_lifecycle(appid)
|
||||
staged_exe = self.stage_bootstrap_in_install_root(
|
||||
self.install_dir, 'LifecycleForcedTermination')
|
||||
barrier = os.path.join(self.temp_dir, 'child-state-ready')
|
||||
relaunch_dirs = self.snapshot_uninstall_relaunch_dirs()
|
||||
receiver, hwnd, output = self._start_lifecycle_receiver(
|
||||
'--close-after-handoff')
|
||||
env = {
|
||||
'PATH': self.build_dir + os.pathsep + os.environ.get('PATH', ''),
|
||||
'CEF_INSTALLER_TEST_CHILD_STATE_BARRIER': barrier,
|
||||
}
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UNINSTALL, FLAG_HEADLESS, f'{FLAG_PARENT}={hwnd}'],
|
||||
exe=staged_exe,
|
||||
env=env)
|
||||
self.assertEqual(EXIT_RELAUNCHED, exit_code)
|
||||
captured, correlated = self._finish_lifecycle_receiver(receiver,
|
||||
hwnd,
|
||||
output,
|
||||
await_terminal=True)
|
||||
self.assertEqual(1, len(captured['lifecycle']))
|
||||
self.assertEqual({}, correlated['results'])
|
||||
|
||||
self._wait_for_path(barrier, 'Child did not reach state barrier')
|
||||
child_pid = int(next(iter(correlated['handoffs'].values()))['child_pid'])
|
||||
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
kernel32.OpenProcess.restype = ctypes.c_void_p
|
||||
kernel32.OpenProcess.argtypes = [
|
||||
ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32
|
||||
]
|
||||
kernel32.TerminateProcess.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||||
kernel32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||||
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
process_handle = kernel32.OpenProcess(0x00100001, False, child_pid)
|
||||
self.assertTrue(process_handle, 'Failed to open relaunched child')
|
||||
try:
|
||||
self.assertTrue(kernel32.TerminateProcess(process_handle, 108))
|
||||
self.assertEqual(0, kernel32.WaitForSingleObject(process_handle, 10000))
|
||||
finally:
|
||||
kernel32.CloseHandle(process_handle)
|
||||
os.remove(barrier)
|
||||
self.assertTrue(self._database_contains_app(self.install_dir, appid))
|
||||
for path in self.snapshot_uninstall_relaunch_dirs() - relaunch_dirs:
|
||||
self.addCleanup(shutil.rmtree, path, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
# Use of this source code is governed by a BSD-style license that can be
|
||||
# found in the LICENSE file.
|
||||
"""E2E tests for the CEF installer update flow."""
|
||||
|
||||
import unittest
|
||||
|
||||
from e2e_test_base import (E2ETestBase, EXIT_SUCCESS, FLAG_BACKGROUND,
|
||||
FLAG_FORCECHECK, FLAG_HEADLESS, FLAG_UPDATE)
|
||||
|
||||
|
||||
class TestUpdateFlow(E2ETestBase):
|
||||
|
||||
def test_update_finds_newer_version(self):
|
||||
"""Update downloads newer version when vmin moves forward."""
|
||||
abi_hash = 'a1b2c3d4e5f6'
|
||||
appid = 'e2e00005-0005-0005-0005-000000000005'
|
||||
|
||||
# Build CDN with initial version and install.
|
||||
v1 = self.test_version
|
||||
v1_1 = self.test_version_update
|
||||
self.build_cdn(version=v1, abi_hash=abi_hash)
|
||||
self.embed_test_config(appid=appid, vmin=v1, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code,
|
||||
f'Initial install failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(v1)
|
||||
|
||||
# Add newer version to CDN (manifest merges).
|
||||
self.build_cdn(version=v1_1, abi_hash=abi_hash)
|
||||
|
||||
# Raise vmin so the controller downloads the newer version.
|
||||
self.embed_test_config(appid=appid, vmin=v1_1, abi_hash=abi_hash)
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code,
|
||||
f'Update failed: {stderr.decode(errors="replace")}')
|
||||
|
||||
# Newer version installed, old version pruned.
|
||||
self.assert_version_installed(v1_1)
|
||||
self.assert_version_not_installed(v1)
|
||||
|
||||
db = self.read_database()
|
||||
found = any(a.get('uuid') == appid for a in db.get('apps', []))
|
||||
self.assertTrue(found, f'App not in database: {db}')
|
||||
|
||||
def test_update_with_forcecheck(self):
|
||||
"""Update with /cef-forcecheck bypasses manifest cache."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(
|
||||
appid='e2e00006-0006-0006-0006-000000000006',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
|
||||
# Install.
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
|
||||
# Normal update (may use cached manifest).
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
|
||||
# Forced update bypasses cache.
|
||||
exit_code, _, _ = self.run_installer(
|
||||
[FLAG_UPDATE, FLAG_HEADLESS, FLAG_FORCECHECK])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_explicit_background_update(self):
|
||||
"""Explicit background update completes without UI."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(
|
||||
appid='e2e00031-0031-0031-0031-000000000031',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
|
||||
exit_code, _, stderr = self.run_installer([FLAG_UPDATE, FLAG_BACKGROUND])
|
||||
|
||||
self.assertEqual(
|
||||
EXIT_SUCCESS, exit_code,
|
||||
f'Background update failed: {stderr.decode(errors="replace")}')
|
||||
self.assert_version_installed(version)
|
||||
|
||||
def test_update_no_newer_version(self):
|
||||
"""Update with no newer version available is a no-op success."""
|
||||
version, abi_hash = self.build_cdn()
|
||||
self.embed_test_config(
|
||||
appid='e2e00007-0007-0007-0007-000000000007',
|
||||
vmin=version,
|
||||
abi_hash=abi_hash,
|
||||
)
|
||||
|
||||
# Install.
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assert_version_installed(version)
|
||||
|
||||
# Second update -- already at latest, should succeed without changes.
|
||||
exit_code, _, _ = self.run_installer([FLAG_UPDATE, FLAG_HEADLESS])
|
||||
self.assertEqual(EXIT_SUCCESS, exit_code)
|
||||
self.assert_version_installed(version)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,223 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_ARCHIVE_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_ARCHIVE_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/containers/span.h"
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/functional/callback.h"
|
||||
#include "base/time/time.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Security limits to prevent resource exhaustion attacks.
|
||||
constexpr uint64_t kMaxFileSize = 4ULL * 1024 * 1024 * 1024; // 4 GB
|
||||
constexpr uint64_t kMaxTotalExtractionSize =
|
||||
10ULL * 1024 * 1024 * 1024; // 10 GB
|
||||
constexpr size_t kMaxPathLength = 4096;
|
||||
constexpr size_t kMaxEntryCount = 100000;
|
||||
|
||||
// Error codes for archive operations.
|
||||
enum class ArchiveError {
|
||||
kSuccess,
|
||||
kFileNotFound, // Archive file doesn't exist
|
||||
kFileReadError, // Could not read archive
|
||||
kInvalidFormat, // Not a valid tar.xz file (xz decompression failed)
|
||||
kInvalidHeader, // Tar header checksum mismatch or invalid
|
||||
kExtractionFailed, // General extraction failure
|
||||
kDiskFull, // Not enough space to extract
|
||||
kPathTraversal, // Archive contains path traversal attack (../)
|
||||
kAbsolutePath, // Archive contains absolute path
|
||||
kWriteError, // Could not write extracted file
|
||||
kUnsupportedEntryType, // Entry type not supported (e.g., device nodes)
|
||||
kCancelled, // Extraction cancelled via callback
|
||||
};
|
||||
|
||||
// Callback for extraction progress reporting.
|
||||
// Parameters: bytes_processed, total_bytes
|
||||
// Return false to cancel extraction.
|
||||
using ExtractionProgressCallback =
|
||||
base::RepeatingCallback<bool(uint64_t, uint64_t)>;
|
||||
|
||||
// Configuration for extraction (single struct for both paths).
|
||||
struct ExtractionConfig {
|
||||
// Background mode: minimizes system impact for non-interactive extraction
|
||||
// (e.g. scheduled updates). Always uses the streaming single-threaded path
|
||||
// (~128 KB memory) rather than the parallel path (~500 MB allocation).
|
||||
bool background_mode = false;
|
||||
|
||||
// Max threads (0 = auto-detect from CPU count).
|
||||
// Only used when parallel extraction is possible (multi-block archive).
|
||||
int max_threads = 0;
|
||||
|
||||
// Max memory per thread for decompression buffers (default 64MB).
|
||||
// Covers most LZMA2 dictionary sizes.
|
||||
size_t max_memory_per_thread = 64 * 1024 * 1024;
|
||||
};
|
||||
|
||||
// Primary API - auto-selects parallel vs single-threaded based on archive.
|
||||
// For multi-block XZ archives, uses parallel block decompression.
|
||||
// For single-block archives, falls back to streaming single-threaded path.
|
||||
//
|
||||
// - Creates dest_dir if it doesn't exist
|
||||
// - Extracts all files and directories preserving structure
|
||||
// - Validates no path traversal (rejects entries with ".." components)
|
||||
// - Validates no absolute paths (rejects entries starting with "/" or "C:\")
|
||||
// - Preserves modification times
|
||||
// - Archive format: xz/LZMA2-compressed tar (POSIX ustar format)
|
||||
//
|
||||
// Uses //third_party/lzma_sdk/src/C/Xz.h for xz decompression.
|
||||
ArchiveError ExtractTarXz(const base::FilePath& archive_path,
|
||||
const base::FilePath& dest_dir,
|
||||
const ExtractionConfig& config = {},
|
||||
ExtractionProgressCallback progress = {});
|
||||
|
||||
// Explicit single-threaded streaming extraction (for fallback).
|
||||
// Also used by installer_parallel_xz.cc as fallback.
|
||||
ArchiveError ExtractTarXzSingleThread(const base::FilePath& archive_path,
|
||||
const base::FilePath& dest_dir,
|
||||
ExtractionProgressCallback progress = {});
|
||||
|
||||
// Convert error code to human-readable string for logging.
|
||||
const char* ArchiveErrorToString(ArchiveError error);
|
||||
|
||||
// Decompress a single XZ block (not a full XZ stream).
|
||||
// |block_data| must start at the block header.
|
||||
// |check_type| is the stream check type (0=none, 1=CRC32, 4=CRC64, 10=SHA256).
|
||||
// |stream_flags| is the raw CXzStreamFlags value from the stream header/footer.
|
||||
// |output| must be pre-sized to the expected uncompressed size (from XZ index).
|
||||
// Returns true on success.
|
||||
bool DecompressXzBlock(base::span<const uint8_t> block_data,
|
||||
uint8_t check_type,
|
||||
uint16_t stream_flags,
|
||||
base::span<uint8_t> output);
|
||||
|
||||
// Extract tar archive from an in-memory buffer.
|
||||
// Processes tar headers and extracts files to |dest_dir|.
|
||||
ArchiveError ExtractTarFromBuffer(base::span<const uint8_t> tar_data,
|
||||
const base::FilePath& dest_dir,
|
||||
ExtractionProgressCallback progress = {});
|
||||
|
||||
// ============================================================================
|
||||
// Internal helpers exposed for testing. Not part of the public API.
|
||||
// ============================================================================
|
||||
namespace internal {
|
||||
|
||||
// Test observation for the most recent top-level extraction request.
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
void ResetLastExtractionBackgroundModeForTesting();
|
||||
std::optional<bool> GetLastExtractionBackgroundModeForTesting();
|
||||
#endif
|
||||
|
||||
// Information about a single entry in a tar archive.
|
||||
struct TarEntry {
|
||||
enum class Type {
|
||||
kRegularFile,
|
||||
kDirectory,
|
||||
kOther, // Symlinks, hard links, devices, etc. - skipped
|
||||
};
|
||||
|
||||
base::FilePath path; // Relative path within archive
|
||||
uint64_t size = 0; // File size in bytes
|
||||
base::Time mtime; // Last modification time
|
||||
Type type = Type::kOther;
|
||||
};
|
||||
|
||||
// TarReader parses tar headers and validates entries.
|
||||
// Reference: chrome/services/file_util/single_file_tar_reader.cc
|
||||
class TarReader {
|
||||
public:
|
||||
// Tar header constants (POSIX ustar format)
|
||||
// https://www.gnu.org/software/tar/manual/html_node/Standard.html
|
||||
static constexpr size_t kHeaderSize = 512;
|
||||
static constexpr size_t kNameOffset = 0;
|
||||
static constexpr size_t kNameLength = 100;
|
||||
static constexpr size_t kModeOffset = 100;
|
||||
static constexpr size_t kSizeOffset = 124;
|
||||
static constexpr size_t kSizeLength = 12;
|
||||
static constexpr size_t kMtimeOffset = 136;
|
||||
static constexpr size_t kMtimeLength = 12;
|
||||
static constexpr size_t kChecksumOffset = 148;
|
||||
static constexpr size_t kChecksumLength = 8;
|
||||
static constexpr size_t kTypeflagOffset = 156;
|
||||
static constexpr size_t kMagicOffset = 257;
|
||||
static constexpr size_t kMagicLength = 6;
|
||||
static constexpr size_t kPrefixOffset = 345;
|
||||
static constexpr size_t kPrefixLength = 155;
|
||||
|
||||
// Type flag values
|
||||
static constexpr char kTypeRegular = '0';
|
||||
static constexpr char kTypeRegularAlt = '\0'; // Old-style tar
|
||||
static constexpr char kTypeHardLink = '1';
|
||||
static constexpr char kTypeSymlink = '2';
|
||||
static constexpr char kTypeDirectory = '5';
|
||||
static constexpr char kTypePaxExtended = 'x'; // PAX extended header
|
||||
static constexpr char kTypePaxGlobal = 'g'; // PAX global header
|
||||
static constexpr char kTypeGnuLongName = 'L'; // GNU long filename
|
||||
static constexpr char kTypeGnuLongLink = 'K'; // GNU long linkname
|
||||
|
||||
// Parse a tar header, returns nullopt if invalid or end-of-archive (zero
|
||||
// block).
|
||||
static std::optional<TarEntry> ParseHeader(
|
||||
base::span<const uint8_t> header,
|
||||
const std::string& override_name = std::string());
|
||||
|
||||
// Check if header is an end-of-archive marker (512 zero bytes).
|
||||
static bool IsEndOfArchive(base::span<const uint8_t> header);
|
||||
|
||||
// Validate header checksum.
|
||||
static bool ValidateChecksum(base::span<const uint8_t> header);
|
||||
|
||||
// Read octal number from header field (handles GNU extension for large
|
||||
// files). Reference: SingleFileTarReader::ReadOctalNumber
|
||||
static std::optional<uint64_t> ReadOctalNumber(
|
||||
base::span<const uint8_t> field);
|
||||
|
||||
// Validate path is safe (no traversal, not absolute, no reserved names).
|
||||
static bool IsPathSafe(const std::string& path_str);
|
||||
|
||||
// Parse PAX extended header content to extract path override.
|
||||
// PAX format: "length key=value\n"
|
||||
static std::optional<std::string> ParsePaxPath(
|
||||
base::span<const uint8_t> content);
|
||||
};
|
||||
|
||||
// XzDecompressor decompresses xz/LZMA2 streams.
|
||||
// Reference: chrome/services/file_util/single_file_tar_xz_file_extractor.cc
|
||||
class XzDecompressor {
|
||||
public:
|
||||
XzDecompressor();
|
||||
~XzDecompressor();
|
||||
|
||||
XzDecompressor(const XzDecompressor&) = delete;
|
||||
XzDecompressor& operator=(const XzDecompressor&) = delete;
|
||||
|
||||
// Decompress a chunk of xz data.
|
||||
// Returns true on success, false on error.
|
||||
// Appends decompressed data to |output|.
|
||||
// Call repeatedly until IsFinished() returns true.
|
||||
bool DecompressChunk(base::span<const uint8_t> input,
|
||||
std::vector<uint8_t>* output,
|
||||
bool input_finished = false);
|
||||
|
||||
// Returns true when the xz stream is completely decompressed.
|
||||
bool IsFinished() const;
|
||||
|
||||
private:
|
||||
// Opaque pointer to CXzUnpacker (avoid including lzma_sdk headers here)
|
||||
void* state_ = nullptr;
|
||||
void* alloc_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace internal
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_ARCHIVE_H_
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,704 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_bootstrap_helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/functional/bind.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/string_util.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_e2e_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_launch_state.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_logger.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
std::optional<Command> ParseInstallerCommand(
|
||||
const base::CommandLine& command_line) {
|
||||
// Check for /cef-update or --cef-update
|
||||
if (command_line.HasSwitch(kSwitchUpdate)) {
|
||||
return Command::kUpdate;
|
||||
}
|
||||
|
||||
// Check for /cef-uninstall or --cef-uninstall
|
||||
if (command_line.HasSwitch(kSwitchUninstall)) {
|
||||
return Command::kUninstall;
|
||||
}
|
||||
|
||||
if (command_line.HasSwitch(kSwitchRetentionDryRun)) {
|
||||
return Command::kRetentionDryRun;
|
||||
}
|
||||
|
||||
if (command_line.HasSwitch(kSwitchRetentionApply)) {
|
||||
return Command::kRetentionApply;
|
||||
}
|
||||
|
||||
// No installer command present
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool HasConflictingInstallerCommands(const base::CommandLine& command_line) {
|
||||
int count = 0;
|
||||
for (const char* name : {kSwitchUpdate, kSwitchUninstall,
|
||||
kSwitchRetentionDryRun, kSwitchRetentionApply}) {
|
||||
if (command_line.HasSwitch(name)) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count > 1;
|
||||
}
|
||||
|
||||
bool HasMisappliedRetentionOptions(const base::CommandLine& command_line,
|
||||
std::optional<Command> command) {
|
||||
const bool retention_command = command == Command::kRetentionDryRun ||
|
||||
command == Command::kRetentionApply;
|
||||
return command_line.HasSwitch(kSwitchRetentionMaxAgeDays) &&
|
||||
!retention_command;
|
||||
}
|
||||
|
||||
bool ParseRetentionMaxAgeDays(const base::CommandLine& command_line,
|
||||
int* max_age_days) {
|
||||
if (!max_age_days) {
|
||||
return false;
|
||||
}
|
||||
*max_age_days = kDefaultRetentionMaxAgeDays;
|
||||
if (!command_line.HasSwitch(kSwitchRetentionMaxAgeDays)) {
|
||||
return true;
|
||||
}
|
||||
int value = 0;
|
||||
if (!base::StringToInt(
|
||||
command_line.GetSwitchValueASCII(kSwitchRetentionMaxAgeDays),
|
||||
&value) ||
|
||||
!IsValidRetentionMaxAgeDays(value)) {
|
||||
return false;
|
||||
}
|
||||
*max_age_days = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsUninstallRelaunched(const base::CommandLine& command_line) {
|
||||
return command_line.HasSwitch(kSwitchUninstallRelaunched);
|
||||
}
|
||||
|
||||
bool ParseParentWindowSwitch(const base::CommandLine& command_line,
|
||||
HWND* parent_window) {
|
||||
if (!parent_window || !command_line.HasSwitch(kSwitchParentWindow)) {
|
||||
return false;
|
||||
}
|
||||
const std::string& hwnd_str =
|
||||
command_line.GetSwitchValueASCII(kSwitchParentWindow);
|
||||
uint64_t hwnd_value = 0;
|
||||
if (!base::StringToUint64(hwnd_str, &hwnd_value) || hwnd_value == 0 ||
|
||||
hwnd_value > std::numeric_limits<uintptr_t>::max()) {
|
||||
return false;
|
||||
}
|
||||
*parent_window = reinterpret_cast<HWND>(static_cast<uintptr_t>(hwnd_value));
|
||||
return true;
|
||||
}
|
||||
|
||||
void ParseExtendedConfigFromCommandLine(const base::CommandLine& command_line,
|
||||
ExtendedConfig* extended_config) {
|
||||
if (!extended_config) {
|
||||
return;
|
||||
}
|
||||
|
||||
// /cef-forcecheck - force version check even if recently checked
|
||||
if (command_line.HasSwitch(kSwitchForceCheck)) {
|
||||
extended_config->force_check = true;
|
||||
VLOG(2) << "Extended config: force_check=true";
|
||||
}
|
||||
|
||||
// /cef-background selects low-impact extraction and also suppresses UI.
|
||||
// /cef-headless suppresses UI without changing extraction behavior.
|
||||
extended_config->background_mode = command_line.HasSwitch(kSwitchBackground);
|
||||
if (extended_config->background_mode ||
|
||||
command_line.HasSwitch(kSwitchHeadless)) {
|
||||
extended_config->show_progress_ui = false;
|
||||
VLOG(2) << "Extended config: show_progress_ui=false";
|
||||
}
|
||||
|
||||
// /cef-parent=<hwnd> - parent window handle for progress UI
|
||||
if (command_line.HasSwitch(kSwitchParentWindow)) {
|
||||
if (ParseParentWindowSwitch(command_line,
|
||||
&extended_config->parent_window)) {
|
||||
VLOG(2) << "Extended config: valid parent window supplied";
|
||||
} else {
|
||||
LOG(WARNING) << "Invalid parent window handle";
|
||||
}
|
||||
}
|
||||
|
||||
// /cef-download-path=<path> - read from local directory instead of CDN
|
||||
if (command_line.HasSwitch(kSwitchDownloadPath)) {
|
||||
std::string path = command_line.GetSwitchValueASCII(kSwitchDownloadPath);
|
||||
if (!path.empty()) {
|
||||
extended_config->local_download_path = path;
|
||||
VLOG(2) << "Extended config: local_download_path=" << path;
|
||||
} else {
|
||||
LOG(WARNING) << "/cef-download-path requires a path value";
|
||||
}
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
// /cef-install-path=<path> - override install directory
|
||||
if (command_line.HasSwitch(kSwitchInstallPath)) {
|
||||
std::string path = command_line.GetSwitchValueASCII(kSwitchInstallPath);
|
||||
if (!path.empty()) {
|
||||
extended_config->install_path = path;
|
||||
VLOG(2) << "Extended config: install_path=" << path;
|
||||
} else {
|
||||
LOG(WARNING) << "/cef-install-path requires a path value";
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// /cef-log-level=<level> - set minimum log level (info, warning, error)
|
||||
if (command_line.HasSwitch(kSwitchLogLevel)) {
|
||||
std::string level_str = command_line.GetSwitchValueASCII(kSwitchLogLevel);
|
||||
auto level = LogLevelFromString(level_str);
|
||||
if (level.has_value()) {
|
||||
extended_config->log_level = level.value();
|
||||
VLOG(2) << "Extended config: log_level=" << level_str;
|
||||
} else {
|
||||
LOG(WARNING) << "/cef-log-level invalid value: " << level_str
|
||||
<< " (expected: info, warning, error)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Command> CommandFromString(const std::string& str) {
|
||||
std::string lower = base::ToLowerASCII(str);
|
||||
|
||||
if (lower == "install") {
|
||||
return Command::kInstall;
|
||||
}
|
||||
if (lower == "update") {
|
||||
return Command::kUpdate;
|
||||
}
|
||||
if (lower == "uninstall") {
|
||||
return Command::kUninstall;
|
||||
}
|
||||
if (lower == "query") {
|
||||
return Command::kQuery;
|
||||
}
|
||||
if (lower == "prune") {
|
||||
return Command::kPrune;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ConfigLoadResult TryLoadInstallerConfig(HMODULE client_dll_module,
|
||||
Config* config,
|
||||
HMODULE bootstrap_module,
|
||||
bool trusted_uninstall_relaunch) {
|
||||
auto error_result = [](ConfigError error, const std::string& source,
|
||||
const std::string& diagnostic) {
|
||||
return ConfigLoadResult{
|
||||
.status = ConfigLoadStatus::kError,
|
||||
.error = error,
|
||||
.diagnostic =
|
||||
source + ": " +
|
||||
(diagnostic.empty() ? ConfigErrorToString(error) : diagnostic)};
|
||||
};
|
||||
if (!config) {
|
||||
return error_result(ConfigError::kMissingRequiredField, "Installer config",
|
||||
"Config output is null");
|
||||
}
|
||||
if (trusted_uninstall_relaunch &&
|
||||
internal::GetInstallerE2EConfig().child_config_failure) {
|
||||
return error_result(ConfigError::kJsonParseError, "Installer config",
|
||||
"Forced child config-load failure for E2E testing");
|
||||
}
|
||||
|
||||
if (!bootstrap_module) {
|
||||
bootstrap_module = ::GetModuleHandle(nullptr);
|
||||
}
|
||||
|
||||
// When client DLL is present, use its config (or fall back to bootstrap).
|
||||
if (client_dll_module) {
|
||||
std::string client_diagnostic;
|
||||
ConfigError client_error =
|
||||
ReadConfigFromResource(client_dll_module, config,
|
||||
{.allow_unchecked_cef_path = true,
|
||||
.allow_bundled_cef_path = true,
|
||||
.allow_install_path = true},
|
||||
&client_diagnostic);
|
||||
if (client_error != ConfigError::kSuccess &&
|
||||
client_error != ConfigError::kResourceNotFound) {
|
||||
return error_result(client_error, "Client DLL resource",
|
||||
client_diagnostic);
|
||||
}
|
||||
|
||||
// Read enable_explicit_modes from the bootstrap exe's own resource.
|
||||
// This flag is only authoritative in the exe; the client DLL cannot set
|
||||
// it, and it does not change application-config provenance.
|
||||
Config bootstrap_config;
|
||||
std::string bootstrap_diagnostic;
|
||||
ConfigError bootstrap_error = ReadConfigFromResource(
|
||||
bootstrap_module, &bootstrap_config,
|
||||
{.allow_enable_explicit_modes = true}, &bootstrap_diagnostic);
|
||||
if (bootstrap_error != ConfigError::kSuccess &&
|
||||
bootstrap_error != ConfigError::kResourceNotFound) {
|
||||
return error_result(bootstrap_error, "Bootstrap resource",
|
||||
bootstrap_diagnostic);
|
||||
}
|
||||
|
||||
if (client_error == ConfigError::kSuccess) {
|
||||
VLOG(1) << "Installer config loaded from client DLL resource (appid="
|
||||
<< config->appid << ", vmin=" << config->vmin << ")";
|
||||
} else {
|
||||
VLOG(2) << "Client DLL resource config not found";
|
||||
|
||||
if (bootstrap_error == ConfigError::kSuccess) {
|
||||
*config = bootstrap_config;
|
||||
VLOG(1) << "Installer config loaded from bootstrap resource (appid="
|
||||
<< config->appid << ", vmin=" << config->vmin << ")";
|
||||
return {.status = ConfigLoadStatus::kLoaded,
|
||||
.source = ConfigLoadSource::kBootstrapResource};
|
||||
}
|
||||
|
||||
VLOG(1) << "No installer config found";
|
||||
return {.status = ConfigLoadStatus::kNotFound,
|
||||
.error = ConfigError::kResourceNotFound,
|
||||
.diagnostic = "No embedded installer config resource found"};
|
||||
}
|
||||
|
||||
if (bootstrap_error == ConfigError::kSuccess) {
|
||||
config->enable_explicit_modes = bootstrap_config.enable_explicit_modes;
|
||||
}
|
||||
|
||||
return {.status = ConfigLoadStatus::kLoaded,
|
||||
.source = ConfigLoadSource::kClientResource};
|
||||
}
|
||||
|
||||
// Standalone mode (no client DLL).
|
||||
std::string bootstrap_diagnostic;
|
||||
ConfigError bootstrap_error = ReadConfigFromResource(
|
||||
bootstrap_module, config, {.allow_enable_explicit_modes = true},
|
||||
&bootstrap_diagnostic);
|
||||
if (bootstrap_error == ConfigError::kSuccess) {
|
||||
VLOG(1) << "Installer config loaded from bootstrap resource (appid="
|
||||
<< config->appid << ", vmin=" << config->vmin << ")";
|
||||
return {.status = ConfigLoadStatus::kLoaded,
|
||||
.source = ConfigLoadSource::kBootstrapResource};
|
||||
}
|
||||
if (bootstrap_error != ConfigError::kResourceNotFound) {
|
||||
return error_result(bootstrap_error, "Bootstrap resource",
|
||||
bootstrap_diagnostic);
|
||||
}
|
||||
|
||||
VLOG(1) << "No installer config found";
|
||||
return {.status = ConfigLoadStatus::kNotFound,
|
||||
.error = ConfigError::kResourceNotFound,
|
||||
.diagnostic = "No embedded installer config resource found"};
|
||||
}
|
||||
|
||||
bool ResolveClientInstallPath(HMODULE client_dll_module, Config* config) {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
if (config->install_path.empty()) {
|
||||
return true;
|
||||
}
|
||||
if (!client_dll_module) {
|
||||
return false;
|
||||
}
|
||||
|
||||
wchar_t module_path[MAX_PATH];
|
||||
const DWORD len =
|
||||
::GetModuleFileNameW(client_dll_module, module_path, MAX_PATH);
|
||||
if (len == 0 || len >= MAX_PATH) {
|
||||
return false;
|
||||
}
|
||||
const base::FilePath resolved = ResolvePathRelativeTo(
|
||||
config->install_path, base::FilePath(module_path).DirName());
|
||||
if (resolved.empty()) {
|
||||
return false;
|
||||
}
|
||||
config->install_path = resolved.AsUTF8Unsafe();
|
||||
return true;
|
||||
}
|
||||
|
||||
InstallerStartupState MakeInstallerStartupState(
|
||||
const ConfigLoadResult& load_result,
|
||||
const Config& config) {
|
||||
InstallerStartupState state;
|
||||
state.configured = load_result.status == ConfigLoadStatus::kLoaded;
|
||||
if (state.configured) {
|
||||
state.effective_vmin = config.vmin;
|
||||
state.effective_vmax = config.vmax;
|
||||
} else if (load_result.status == ConfigLoadStatus::kError) {
|
||||
state.error_code = kExitCodeConfigError;
|
||||
state.error_message = load_result.diagnostic;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
void ApplyInstallerResultToStartupState(const Result& result,
|
||||
const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
bool apply_launch_health,
|
||||
InstallerStartupState* state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
state->configured = true;
|
||||
state->effective_vmin = config.vmin;
|
||||
state->effective_vmax = config.vmax;
|
||||
state->config = config;
|
||||
state->extended = extended;
|
||||
state->launch_health = config.launch_health;
|
||||
state->launch_state_path.clear();
|
||||
state->launch_consecutive_failures = 0;
|
||||
state->launch_version.clear();
|
||||
state->launch_platform.clear();
|
||||
state->launch_cleanup_paths.clear();
|
||||
state->liveness_path.clear();
|
||||
state->appid.clear();
|
||||
state->appid_hash.clear();
|
||||
state->version_lease.reset();
|
||||
if (result.success) {
|
||||
state->libcef_path = result.libcef_path;
|
||||
state->is_bundled = result.is_bundled;
|
||||
state->version_full = result.version_full;
|
||||
state->error_code = kExitCodeSuccess;
|
||||
state->error_message.clear();
|
||||
state->version_lease = result.version_lease;
|
||||
state->liveness_path = result.liveness_path;
|
||||
if (apply_launch_health && !result.libcef_path.empty()) {
|
||||
state->launch_state_path = result.launch_state_path;
|
||||
state->launch_consecutive_failures = result.launch_consecutive_failures;
|
||||
state->launch_version = result.launch_version;
|
||||
state->launch_platform = result.launch_platform;
|
||||
state->launch_cleanup_paths = result.launch_cleanup_paths;
|
||||
state->appid = config.appid;
|
||||
state->appid_hash = GetAppidHash(config.appid);
|
||||
}
|
||||
} else {
|
||||
state->libcef_path.clear();
|
||||
state->is_bundled = false;
|
||||
state->version_full.clear();
|
||||
state->error_code = result.error_code;
|
||||
state->error_message = result.error_message;
|
||||
}
|
||||
}
|
||||
|
||||
void PopulateInstallerVersionInfo(const InstallerStartupState& state,
|
||||
cef_version_info_t* info) {
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
#if CEF_API_ADDED(CEF_NEXT)
|
||||
if (info->size >= CEF_VERSION_INFO_SIZE_WITH_INSTALLER_ERROR) {
|
||||
info->libcef_path =
|
||||
state.libcef_path.empty() ? nullptr : state.libcef_path.value().c_str();
|
||||
info->libcef_is_bundled = state.is_bundled;
|
||||
info->libcef_version_full =
|
||||
state.version_full.empty() ? nullptr : state.version_full.c_str();
|
||||
info->installer_error_code = state.error_code;
|
||||
info->installer_error_message =
|
||||
state.error_message.empty() ? nullptr : state.error_message.c_str();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
InstallerCrashAnnotationValues BuildInstallerCrashAnnotationValues(
|
||||
const InstallerStartupState& state) {
|
||||
InstallerCrashAnnotationValues values;
|
||||
if (state.error_code == kExitCodeSuccess) {
|
||||
return values;
|
||||
}
|
||||
values.has_failure = true;
|
||||
values.error = base::NumberToString(state.error_code);
|
||||
values.message = state.error_message;
|
||||
values.vmin = state.effective_vmin;
|
||||
values.vmax =
|
||||
state.effective_vmax.empty() ? "unbounded" : state.effective_vmax;
|
||||
if (state.error_code == kExitCodePolicyDenied) {
|
||||
values.policy_denied = "1";
|
||||
}
|
||||
if (state.error_code == kExitCodeCancelled) {
|
||||
values.cancelled = "1";
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
namespace {
|
||||
|
||||
bool WriteAllToHandleImpl(HANDLE handle,
|
||||
std::string_view data,
|
||||
DWORD max_chunk_size,
|
||||
HandleWriteCallback write) {
|
||||
if (!handle || handle == INVALID_HANDLE_VALUE) {
|
||||
::SetLastError(ERROR_INVALID_HANDLE);
|
||||
return false;
|
||||
}
|
||||
if (max_chunk_size == 0 || write.is_null()) {
|
||||
::SetLastError(ERROR_INVALID_PARAMETER);
|
||||
return false;
|
||||
}
|
||||
while (!data.empty()) {
|
||||
const DWORD chunk_size = static_cast<DWORD>(
|
||||
std::min(data.size(), static_cast<size_t>(max_chunk_size)));
|
||||
DWORD written = 0;
|
||||
if (!write.Run(handle, data.data(), chunk_size, &written)) {
|
||||
return false;
|
||||
}
|
||||
if (written == 0 || written > chunk_size) {
|
||||
::SetLastError(ERROR_WRITE_FAULT);
|
||||
return false;
|
||||
}
|
||||
data.remove_prefix(written);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
UninstallInvocationState ClassifyUninstallInvocation(
|
||||
std::optional<Command> command,
|
||||
bool has_relaunch_marker,
|
||||
bool has_relaunch_state,
|
||||
bool trusted_state_valid) {
|
||||
if (!has_relaunch_marker && !has_relaunch_state) {
|
||||
return command == Command::kUninstall
|
||||
? UninstallInvocationState::kOriginal
|
||||
: UninstallInvocationState::kNotUninstall;
|
||||
}
|
||||
if (command != Command::kUninstall || !has_relaunch_marker ||
|
||||
!has_relaunch_state || !trusted_state_valid) {
|
||||
return UninstallInvocationState::kInvalid;
|
||||
}
|
||||
return UninstallInvocationState::kRelaunched;
|
||||
}
|
||||
|
||||
} // namespace internal
|
||||
|
||||
std::optional<UninstallInvocationContext> ResolveUninstallInvocationContext(
|
||||
const base::CommandLine& command_line,
|
||||
const base::FilePath& exe_path,
|
||||
std::optional<Command> command) {
|
||||
const bool has_relaunch_marker = IsUninstallRelaunched(command_line);
|
||||
const bool has_relaunch_state = command_line.HasSwitch(kSwitchUninstallState);
|
||||
bool trusted_state_valid = false;
|
||||
UninstallRelaunchState relaunch_state;
|
||||
|
||||
if (command == Command::kUninstall && has_relaunch_marker &&
|
||||
has_relaunch_state) {
|
||||
HWND command_parent_window = nullptr;
|
||||
ParseParentWindowSwitch(command_line, &command_parent_window);
|
||||
trusted_state_valid = internal::ReadUninstallRelaunchState(
|
||||
exe_path, command_line.GetSwitchValueASCII(kSwitchUninstallState),
|
||||
command_parent_window, &relaunch_state);
|
||||
if (trusted_state_valid) {
|
||||
internal::WaitAtInstallerE2EChildStateBarrier();
|
||||
}
|
||||
}
|
||||
|
||||
const UninstallInvocationState invocation =
|
||||
internal::ClassifyUninstallInvocation(command, has_relaunch_marker,
|
||||
has_relaunch_state,
|
||||
trusted_state_valid);
|
||||
if (invocation == UninstallInvocationState::kInvalid) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return UninstallInvocationContext{invocation, std::move(relaunch_state)};
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
|
||||
UninstallExecutionDecision DecideUninstallExecution(
|
||||
UninstallInvocationState invocation,
|
||||
bool has_writable_target,
|
||||
bool background_mode,
|
||||
PathContainment containment) {
|
||||
if (invocation == UninstallInvocationState::kInvalid ||
|
||||
invocation == UninstallInvocationState::kNotUninstall) {
|
||||
return {.execution = UninstallExecution::kReject,
|
||||
.error_code = kExitCodeConfigError};
|
||||
}
|
||||
if (invocation == UninstallInvocationState::kRelaunched ||
|
||||
!has_writable_target) {
|
||||
return {.execution = UninstallExecution::kInProcess,
|
||||
.error_code = kExitCodeSuccess};
|
||||
}
|
||||
if (background_mode || containment == PathContainment::kContained) {
|
||||
return {.execution = UninstallExecution::kRelaunch,
|
||||
.error_code = kExitCodeSuccess};
|
||||
}
|
||||
if (containment == PathContainment::kOutside) {
|
||||
return {.execution = UninstallExecution::kInProcess,
|
||||
.error_code = kExitCodeSuccess};
|
||||
}
|
||||
return {.execution = UninstallExecution::kReject,
|
||||
.error_code = kExitCodeInstallError};
|
||||
}
|
||||
|
||||
} // namespace internal
|
||||
|
||||
int UninstallRelaunchExitCode(UninstallRelaunchStatus status) {
|
||||
return status == UninstallRelaunchStatus::kStarted ? kExitCodeRelaunched
|
||||
: kExitCodeInstallError;
|
||||
}
|
||||
|
||||
UninstallPreflightResult PrepareUninstall(const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& executable_path,
|
||||
UninstallInvocationState invocation) {
|
||||
UninstallPreflightResult result;
|
||||
if (invocation != UninstallInvocationState::kOriginal &&
|
||||
invocation != UninstallInvocationState::kRelaunched) {
|
||||
result.decision = internal::DecideUninstallExecution(
|
||||
invocation, false, extended.background_mode,
|
||||
internal::PathContainment::kIndeterminate);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (invocation == UninstallInvocationState::kOriginal) {
|
||||
const internal::InstallerE2EConfig& e2e = internal::GetInstallerE2EConfig();
|
||||
if (e2e.directory_override ==
|
||||
internal::InstallerE2EDirectoryOverride::kInvalid) {
|
||||
result.decision = {UninstallExecution::kReject, kExitCodeConfigError};
|
||||
return result;
|
||||
}
|
||||
if (e2e.directory_override ==
|
||||
internal::InstallerE2EDirectoryOverride::kAdminMutationDenied) {
|
||||
const base::FilePath admin = e2e.directory_root.Append(L"Admin");
|
||||
const base::FilePath per_user = e2e.directory_root.Append(L"PerUser");
|
||||
if (!base::DirectoryExists(admin) || !base::DirectoryExists(per_user)) {
|
||||
result.decision = {UninstallExecution::kReject, kExitCodeConfigError};
|
||||
return result;
|
||||
}
|
||||
internal::OverrideInstallDirectoryCandidatesForTesting({
|
||||
{admin, DirectoryRole::kHklmDefault, true, true},
|
||||
{per_user, DirectoryRole::kPerUserDefault, true, true},
|
||||
});
|
||||
internal::OverrideProcessElevationForTesting(true);
|
||||
internal::OverrideAdminMutationAllowedForTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
PreparedUninstall prepared;
|
||||
prepared.config_binding = ConfigToJson(config);
|
||||
prepared.install_path = extended.install_path;
|
||||
prepared.resolution_context.mutation_capable = true;
|
||||
prepared.resolution_context.is_elevated = IsCurrentProcessElevated();
|
||||
prepared.resolution_context.allow_admin_mutation =
|
||||
IsAdminMutationAllowed(false, config.enable_explicit_modes);
|
||||
prepared.policy_result = LoadEnterprisePolicy();
|
||||
if (prepared.policy_result.valid()) {
|
||||
prepared.resolution_context.allow_shared_user_store =
|
||||
prepared.policy_result.policy.allow_shared_user_store;
|
||||
prepared.directories = ResolveInstallDirectories(
|
||||
extended.install_path, prepared.resolution_context);
|
||||
}
|
||||
|
||||
const bool has_writable_target =
|
||||
prepared.directories &&
|
||||
prepared.directories->write_error == PathError::kSuccess &&
|
||||
!prepared.directories->writable_dir.empty();
|
||||
internal::PathContainment containment =
|
||||
internal::PathContainment::kIndeterminate;
|
||||
if (invocation == UninstallInvocationState::kOriginal &&
|
||||
has_writable_target && !extended.background_mode) {
|
||||
containment = internal::GetPhysicalPathContainment(
|
||||
prepared.directories->writable_dir, executable_path);
|
||||
}
|
||||
result.decision = internal::DecideUninstallExecution(
|
||||
invocation, has_writable_target, extended.background_mode, containment);
|
||||
prepared.execution = result.decision.execution;
|
||||
result.prepared = std::move(prepared);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string FormatRetentionOutput(const Result& result, bool json_output) {
|
||||
if (json_output) {
|
||||
return result.ToJson() + "\r\n";
|
||||
}
|
||||
|
||||
std::string output =
|
||||
"retention outcome=" + std::string(OutcomeToString(result.outcome)) +
|
||||
" max_age_days=" + base::NumberToString(result.retention_max_age_days) +
|
||||
" registrations_committed=" +
|
||||
(result.registrations_committed ? "true" : "false") +
|
||||
" versions_pruned=" + (result.versions_pruned ? "true" : "false") +
|
||||
" retry_required=" + (result.retry_required ? "true" : "false") + "\r\n";
|
||||
if (result.retention_plan) {
|
||||
if (!result.retention_plan->blocker.empty()) {
|
||||
output += "store blocker=" + result.retention_plan->blocker + "\r\n";
|
||||
}
|
||||
for (const auto& registration : result.retention_plan->registrations) {
|
||||
output +=
|
||||
"registration appid=" + registration.entry.uuid +
|
||||
" platform=" + registration.entry.platform +
|
||||
" vmin=" + registration.entry.vmin +
|
||||
" vmax=" + registration.entry.vmax +
|
||||
" abi_hash=" + registration.entry.abi_hash + " evidence_kind=" +
|
||||
RetentionEvidenceKindToString(registration.evidence.kind) +
|
||||
" evidence_time=" +
|
||||
base::NumberToString(registration.evidence.timestamp) + " age_days=" +
|
||||
(registration.age_days ? base::NumberToString(*registration.age_days)
|
||||
: "unknown") +
|
||||
" decision=" +
|
||||
RetentionRegistrationDecisionToString(registration.decision) +
|
||||
" reason=" + RetentionReasonToString(registration.reason);
|
||||
if (!registration.evidence.diagnostic.empty()) {
|
||||
output += " diagnostic=" + registration.evidence.diagnostic;
|
||||
}
|
||||
output += "\r\n";
|
||||
}
|
||||
for (const auto& version : result.retention_plan->versions) {
|
||||
output +=
|
||||
"version value=" + version.version.ToString() +
|
||||
" platform=" + version.platform +
|
||||
" required_before=" + (version.required_before ? "true" : "false") +
|
||||
" required_after=" + (version.required_after ? "true" : "false") +
|
||||
" expected_removal=" + (version.expected_removal ? "true" : "false") +
|
||||
" cleanup_deferred=" + (version.cleanup_deferred ? "true" : "false") +
|
||||
" decision=" + RetentionVersionDecisionToString(version.decision) +
|
||||
" reason=" + RetentionReasonToString(version.reason) + "\r\n";
|
||||
}
|
||||
}
|
||||
if (!result.error_message.empty()) {
|
||||
output += "error=" + result.error_message + "\r\n";
|
||||
}
|
||||
std::vector<std::string> warnings = result.warnings;
|
||||
std::ranges::sort(warnings);
|
||||
for (const auto& warning : warnings) {
|
||||
output += "warning=" + warning + "\r\n";
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
bool WriteAllToHandle(HANDLE handle, std::string_view data) {
|
||||
return internal::WriteAllToHandleImpl(
|
||||
handle, data, std::numeric_limits<DWORD>::max(),
|
||||
base::BindRepeating([](HANDLE destination, const char* bytes, DWORD size,
|
||||
DWORD* written) {
|
||||
return !!::WriteFile(destination, bytes, size, written, nullptr);
|
||||
}));
|
||||
}
|
||||
|
||||
namespace internal {
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
bool WriteAllToHandleForTesting(HANDLE handle,
|
||||
std::string_view data,
|
||||
DWORD max_chunk_size,
|
||||
HandleWriteCallback write) {
|
||||
return WriteAllToHandleImpl(handle, data, max_chunk_size, std::move(write));
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace internal
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,300 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_BOOTSTRAP_HELPERS_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_BOOTSTRAP_HELPERS_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#include "base/command_line.h"
|
||||
#include "base/files/file_path.h"
|
||||
#include "cef/include/cef_version_info.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_controller.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_relaunch.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Command-line switch names for installer modes (used with / prefix on Windows)
|
||||
// These are parsed from the command line when running in standalone mode.
|
||||
constexpr const char kSwitchUpdate[] = "cef-update";
|
||||
constexpr const char kSwitchUninstall[] = "cef-uninstall";
|
||||
constexpr const char kSwitchRetentionDryRun[] = "cef-retention-dry-run";
|
||||
constexpr const char kSwitchRetentionApply[] = "cef-retention-apply";
|
||||
constexpr const char kSwitchRetentionMaxAgeDays[] = "cef-max-age-days";
|
||||
constexpr const char kSwitchUninstallRelaunched[] = "cef-uninstall-relaunched";
|
||||
// Internal marker indicating that a self-relocated uninstall must load the
|
||||
// trusted relaunch state written beside the temporary executable.
|
||||
constexpr const char kSwitchUninstallState[] = "cef-uninstall-state";
|
||||
constexpr const char kSwitchForceCheck[] = "cef-forcecheck";
|
||||
constexpr const char kSwitchBackground[] = "cef-background";
|
||||
constexpr const char kSwitchHeadless[] = "cef-headless";
|
||||
constexpr const char kSwitchParentWindow[] = "cef-parent";
|
||||
// /cef-download-path=<path> - Read manifests and archives from a local
|
||||
// directory instead of downloading from CDN. Works as a modifier for any
|
||||
// command (/cef-update, /cef-uninstall, auto-install). All post-download
|
||||
// validation (hash, signature, size limits) still applies.
|
||||
constexpr const char kSwitchDownloadPath[] = "cef-download-path";
|
||||
// /cef-log-level=<level> - Set minimum log level for cef_installer.log.
|
||||
// Accepted values: info, warning, error (case-insensitive). Default: warning.
|
||||
constexpr const char kSwitchLogLevel[] = "cef-log-level";
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
// /cef-install-path=<path> - Override the CEF installation directory. In
|
||||
// standalone mode, bypasses the normal directory resolution (registry ->
|
||||
// ProgramFiles -> LocalAppData). When a client DLL is present, install_path
|
||||
// is passed programmatically via the RunInstaller JSON instead. Only
|
||||
// available in non-official builds for testing.
|
||||
constexpr const char kSwitchInstallPath[] = "cef-install-path";
|
||||
#endif
|
||||
|
||||
// ============================================================================
|
||||
// Command-line Parsing
|
||||
// ============================================================================
|
||||
|
||||
// Parse installer command from command-line arguments.
|
||||
// Returns the Command if /cef-update or /cef-uninstall is present, nullopt
|
||||
// otherwise. On Windows, switches can use either -- or / prefix (e.g.,
|
||||
// /cef-update or --cef-update)
|
||||
std::optional<Command> ParseInstallerCommand(
|
||||
const base::CommandLine& command_line);
|
||||
|
||||
// Returns true when more than one explicit installer operation was supplied.
|
||||
bool HasConflictingInstallerCommands(const base::CommandLine& command_line);
|
||||
|
||||
// Returns true if retention-only options are present without a retention
|
||||
// operation.
|
||||
bool HasMisappliedRetentionOptions(const base::CommandLine& command_line,
|
||||
std::optional<Command> command);
|
||||
|
||||
// Parse /cef-max-age-days for retention commands. Returns false for a missing
|
||||
// value, non-integer, or value outside 90..3650.
|
||||
bool ParseRetentionMaxAgeDays(const base::CommandLine& command_line,
|
||||
int* max_age_days);
|
||||
|
||||
// Check if the internal --cef-uninstall-relaunched transport marker is
|
||||
// present. The marker alone is never trusted; bootstrap also requires the
|
||||
// nonce-bound state file written beside the temporary executable.
|
||||
bool IsUninstallRelaunched(const base::CommandLine& command_line);
|
||||
|
||||
// Parse extended configuration flags from command-line.
|
||||
// Modifies the provided ExtendedConfig based on command-line switches.
|
||||
// Recognized switches:
|
||||
// /cef-forcecheck - set force_check = true
|
||||
// /cef-background - set show_progress_ui = false (also sets low priority)
|
||||
// /cef-headless - set show_progress_ui = false
|
||||
// /cef-parent=<hwnd> - set parent_window to specified HWND value
|
||||
// /cef-download-path=<path> - set local_download_path
|
||||
// /cef-log-level=<level> - set log_level (info, warning, error)
|
||||
void ParseExtendedConfigFromCommandLine(const base::CommandLine& command_line,
|
||||
ExtendedConfig* extended_config);
|
||||
|
||||
// Parses /cef-parent as an unsigned pointer-width decimal value. This does not
|
||||
// require the HWND to remain valid; callers decide whether current IsWindow
|
||||
// validity is required for their phase.
|
||||
bool ParseParentWindowSwitch(const base::CommandLine& command_line,
|
||||
HWND* parent_window);
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
// Parse Command from string (case-insensitive).
|
||||
// Accepts: "install", "update", "uninstall", "query"
|
||||
// Returns nullopt if string doesn't match any valid command.
|
||||
std::optional<Command> CommandFromString(const std::string& str);
|
||||
|
||||
// ============================================================================
|
||||
// Config Loading
|
||||
// ============================================================================
|
||||
|
||||
// Try to load installer config using the resource priority chain:
|
||||
// 1. Client DLL resource (if client_dll_module != nullptr)
|
||||
// 2. Bootstrap resource (fallback when client DLL has no config, or
|
||||
// as the standalone source)
|
||||
//
|
||||
// enable_explicit_modes is always read from the bootstrap exe's resource,
|
||||
// regardless of which source provides the application config. The client DLL
|
||||
// cannot set this flag, and application-config provenance never describes it.
|
||||
//
|
||||
// |bootstrap_module| defaults to GetModuleHandle(nullptr) when nullptr.
|
||||
// Testable via explicit module handles for the bootstrap_module parameter.
|
||||
enum class ConfigLoadStatus {
|
||||
kLoaded,
|
||||
kNotFound,
|
||||
kError,
|
||||
};
|
||||
|
||||
// Internal provenance for a successfully loaded application config. kNone is
|
||||
// required for not-found and every error result.
|
||||
enum class ConfigLoadSource {
|
||||
kNone,
|
||||
kClientResource,
|
||||
kBootstrapResource,
|
||||
};
|
||||
|
||||
struct ConfigLoadResult {
|
||||
ConfigLoadStatus status = ConfigLoadStatus::kNotFound;
|
||||
ConfigError error = ConfigError::kSuccess;
|
||||
ConfigLoadSource source = ConfigLoadSource::kNone;
|
||||
std::string diagnostic;
|
||||
};
|
||||
|
||||
ConfigLoadResult TryLoadInstallerConfig(
|
||||
HMODULE client_dll_module,
|
||||
Config* config,
|
||||
HMODULE bootstrap_module = nullptr,
|
||||
bool trusted_uninstall_relaunch = false);
|
||||
|
||||
// Resolves a non-empty client-resource install_path relative to the module
|
||||
// directory. On failure the value remains non-empty so exclusive resolution
|
||||
// cannot silently fall back to a default store.
|
||||
bool ResolveClientInstallPath(HMODULE client_dll_module, Config* config);
|
||||
|
||||
// Owned installer state that remains alive through the client entry point.
|
||||
struct InstallerStartupState {
|
||||
bool configured = false;
|
||||
base::FilePath libcef_path;
|
||||
bool is_bundled = false;
|
||||
std::string version_full;
|
||||
int error_code = kExitCodeSuccess;
|
||||
std::string error_message;
|
||||
std::string effective_vmin;
|
||||
std::string effective_vmax;
|
||||
|
||||
// Launch-health handoff.
|
||||
LaunchHealthMode launch_health = LaunchHealthMode::kOff;
|
||||
base::FilePath launch_state_path;
|
||||
int launch_consecutive_failures = 0;
|
||||
std::string launch_version;
|
||||
std::string launch_platform;
|
||||
std::vector<base::FilePath> launch_cleanup_paths;
|
||||
base::FilePath liveness_path;
|
||||
std::string appid;
|
||||
std::wstring appid_hash;
|
||||
Config config;
|
||||
ExtendedConfig extended;
|
||||
std::shared_ptr<VersionLease> version_lease;
|
||||
};
|
||||
|
||||
InstallerStartupState MakeInstallerStartupState(
|
||||
const ConfigLoadResult& load_result,
|
||||
const Config& config);
|
||||
void ApplyInstallerResultToStartupState(const Result& result,
|
||||
const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
bool apply_launch_health,
|
||||
InstallerStartupState* state);
|
||||
void PopulateInstallerVersionInfo(const InstallerStartupState& state,
|
||||
cef_version_info_t* info);
|
||||
|
||||
struct InstallerCrashAnnotationValues {
|
||||
bool has_failure = false;
|
||||
std::string error;
|
||||
std::string message;
|
||||
std::string vmin;
|
||||
std::string vmax;
|
||||
std::string policy_denied;
|
||||
std::string cancelled;
|
||||
};
|
||||
|
||||
InstallerCrashAnnotationValues BuildInstallerCrashAnnotationValues(
|
||||
const InstallerStartupState& state);
|
||||
|
||||
// Trusted relaunch classification. kInvalid covers a marker/state mismatch,
|
||||
// invalid trusted state, or relaunch transport state on a non-uninstall
|
||||
// command. kNotUninstall is the ordinary no-transport case.
|
||||
enum class UninstallInvocationState {
|
||||
kNotUninstall,
|
||||
kOriginal,
|
||||
kRelaunched,
|
||||
kInvalid,
|
||||
};
|
||||
|
||||
struct UninstallInvocationContext {
|
||||
UninstallInvocationState invocation = UninstallInvocationState::kInvalid;
|
||||
UninstallRelaunchState relaunch_state;
|
||||
};
|
||||
|
||||
struct UninstallExecutionDecision {
|
||||
UninstallExecution execution = UninstallExecution::kReject;
|
||||
int error_code = kExitCodeConfigError;
|
||||
};
|
||||
|
||||
// Validates the relaunch marker/state command shape and loads the minimal
|
||||
// nonce-bound state needed before installer config is available. Returns null
|
||||
// for malformed transport or untrusted state.
|
||||
std::optional<UninstallInvocationContext> ResolveUninstallInvocationContext(
|
||||
const base::CommandLine& command_line,
|
||||
const base::FilePath& exe_path,
|
||||
std::optional<Command> command);
|
||||
|
||||
namespace internal {
|
||||
|
||||
// Classifies the relaunch marker/state pair after any trusted state read.
|
||||
UninstallInvocationState ClassifyUninstallInvocation(
|
||||
std::optional<Command> command,
|
||||
bool has_relaunch_marker,
|
||||
bool has_relaunch_state,
|
||||
bool trusted_state_valid);
|
||||
|
||||
// Pure execution matrix used by bootstrap preflight and table-driven tests.
|
||||
UninstallExecutionDecision DecideUninstallExecution(
|
||||
UninstallInvocationState invocation,
|
||||
bool has_writable_target,
|
||||
bool background_mode,
|
||||
PathContainment containment);
|
||||
|
||||
} // namespace internal
|
||||
|
||||
// A successful child launch is pending (109); any relaunch preparation or
|
||||
// process-creation failure is an installer setup failure (105).
|
||||
int UninstallRelaunchExitCode(UninstallRelaunchStatus status);
|
||||
|
||||
struct UninstallPreflightResult {
|
||||
UninstallExecutionDecision decision;
|
||||
std::optional<PreparedUninstall> prepared;
|
||||
};
|
||||
|
||||
// Loads immutable policy, resolves the explicit mutation-permitted uninstall
|
||||
// target once, and chooses direct execution or asynchronous relaunch.
|
||||
// For an original, non-background uninstall with a writable target,
|
||||
// indeterminate physical containment returns 105 before mutation or relaunch.
|
||||
// A valid relaunched child also prepares a fresh snapshot but is always
|
||||
// in-process.
|
||||
UninstallPreflightResult PrepareUninstall(const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& executable_path,
|
||||
UninstallInvocationState invocation);
|
||||
|
||||
// Format explicit retention output for the console. Headless mode returns one
|
||||
// JSON object followed by CRLF; normal mode returns deterministic text.
|
||||
std::string FormatRetentionOutput(const Result& result, bool json_output);
|
||||
|
||||
// Write all bytes to a Win32 handle, retrying partial writes.
|
||||
bool WriteAllToHandle(HANDLE handle, std::string_view data);
|
||||
|
||||
namespace internal {
|
||||
|
||||
using HandleWriteCallback =
|
||||
base::RepeatingCallback<bool(HANDLE, const char*, DWORD, DWORD*)>;
|
||||
|
||||
// Injectable write implementation and chunk bound for deterministic tests.
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
bool WriteAllToHandleForTesting(HANDLE handle,
|
||||
std::string_view data,
|
||||
DWORD max_chunk_size,
|
||||
HandleWriteCallback write);
|
||||
#endif
|
||||
|
||||
} // namespace internal
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_BOOTSTRAP_HELPERS_H_
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,422 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_cdn_manifest.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <set>
|
||||
|
||||
#include "base/json/json_reader.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/string_util.h"
|
||||
#include "build/build_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_validation.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace {
|
||||
|
||||
// Validate that a filename is safe for URL construction.
|
||||
// Prevents path traversal and URL redirection attacks.
|
||||
bool IsValidArchiveFilename(const std::string& filename) {
|
||||
if (filename.empty() || filename.size() > kMaxFilenameLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not contain path separators or URL indicators
|
||||
if (filename.find('/') != std::string::npos ||
|
||||
filename.find('\\') != std::string::npos ||
|
||||
filename.find(':') != std::string::npos ||
|
||||
filename.find("..") != std::string::npos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must not start with a dot (hidden files)
|
||||
if (filename[0] == '.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must look like a valid archive filename.
|
||||
// Only .tar.xz is supported by the extraction code (ExtractTarXz).
|
||||
return base::EndsWith(filename, ".tar.xz",
|
||||
base::CompareCase::INSENSITIVE_ASCII);
|
||||
}
|
||||
|
||||
const char* CdnBuildExclusionReasonToString(CdnBuildExclusionReason reason) {
|
||||
switch (reason) {
|
||||
case CdnBuildExclusionReason::kAlreadyInstalled:
|
||||
return "already installed";
|
||||
case CdnBuildExclusionReason::kRevoked:
|
||||
return "revoked";
|
||||
case CdnBuildExclusionReason::kLaunchDisqualified:
|
||||
return "disqualified by launch health";
|
||||
case CdnBuildExclusionReason::kPriorDownloadFailure:
|
||||
return "prior download failure";
|
||||
}
|
||||
return "unknown exclusion";
|
||||
}
|
||||
|
||||
// Helper to parse a build entry from a JSON dict
|
||||
// Includes field length validation to prevent memory issues from malformed
|
||||
// data.
|
||||
ManifestError ParseBuildEntry(const base::DictValue& dict,
|
||||
CdnBuildEntry* entry) {
|
||||
const std::string* version = dict.FindString("version");
|
||||
if (!version || version->empty()) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
if (version->size() > kMaxVersionLength) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
entry->version = Version::Parse(*version);
|
||||
if (!entry->version.IsValid()) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
|
||||
const std::string* file = dict.FindString("file");
|
||||
if (!file || file->empty()) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
if (!IsValidArchiveFilename(*file)) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
entry->file = *file;
|
||||
|
||||
const std::string* sha1 = dict.FindString("sha1");
|
||||
if (!sha1 || sha1->empty()) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
if (sha1->size() > kMaxSha1Length) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
entry->sha1 = *sha1;
|
||||
|
||||
// last_modified is optional but expected
|
||||
const std::string* last_modified = dict.FindString("last_modified");
|
||||
if (last_modified && last_modified->size() > kMaxTimestampLength) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
entry->last_modified = last_modified ? *last_modified : "";
|
||||
|
||||
// abi_hash is optional (may not be present on all platforms).
|
||||
// Must be hex-only to prevent URL metacharacter injection in BuildAbiHashUrl.
|
||||
const std::string* abi_hash = dict.FindString("abi_hash");
|
||||
if (abi_hash && !abi_hash->empty()) {
|
||||
if (!IsValidAbiHash(*abi_hash)) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
entry->abi_hash = *abi_hash;
|
||||
} else {
|
||||
entry->abi_hash.clear();
|
||||
}
|
||||
|
||||
return ManifestError::kSuccess;
|
||||
}
|
||||
|
||||
// Ensure URL ends with a slash
|
||||
std::string NormalizeBaseUrl(const std::string& base_url) {
|
||||
if (base_url.empty() || base_url.back() == '/') {
|
||||
return base_url;
|
||||
}
|
||||
return base_url + "/";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ManifestError ParseStableMilestone(const std::string& content, int* milestone) {
|
||||
if (!milestone) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
|
||||
// Trim whitespace
|
||||
std::string trimmed = content;
|
||||
base::TrimWhitespaceASCII(trimmed, base::TRIM_ALL, &trimmed);
|
||||
|
||||
if (trimmed.empty()) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
|
||||
int value = 0;
|
||||
if (!base::StringToInt(trimmed, &value)) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
|
||||
if (value <= 0) {
|
||||
return ManifestError::kInvalidFieldValue;
|
||||
}
|
||||
|
||||
*milestone = value;
|
||||
return ManifestError::kSuccess;
|
||||
}
|
||||
|
||||
ManifestError ParseMilestoneManifest(
|
||||
const std::string& json,
|
||||
std::map<std::string, CdnBuildEntry>* entries) {
|
||||
if (!entries) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
|
||||
entries->clear();
|
||||
|
||||
std::optional<base::DictValue> parsed =
|
||||
base::JSONReader::ReadDict(json, base::JSON_PARSE_RFC);
|
||||
if (!parsed) {
|
||||
return ManifestError::kJsonParseError;
|
||||
}
|
||||
|
||||
// Each key is a platform name, value is a build entry
|
||||
for (const auto [platform, value] : *parsed) {
|
||||
const base::DictValue* entry_dict = value.GetIfDict();
|
||||
if (!entry_dict) {
|
||||
return ManifestError::kJsonParseError;
|
||||
}
|
||||
|
||||
CdnBuildEntry entry;
|
||||
ManifestError entry_err = ParseBuildEntry(*entry_dict, &entry);
|
||||
if (entry_err != ManifestError::kSuccess) {
|
||||
return entry_err;
|
||||
}
|
||||
|
||||
(*entries)[platform] = std::move(entry);
|
||||
}
|
||||
|
||||
return ManifestError::kSuccess;
|
||||
}
|
||||
|
||||
ManifestError ParsePlatformManifest(const std::string& json,
|
||||
std::vector<CdnBuildEntry>* entries) {
|
||||
if (!entries) {
|
||||
return ManifestError::kMissingRequiredField;
|
||||
}
|
||||
|
||||
entries->clear();
|
||||
|
||||
std::optional<base::ListValue> parsed =
|
||||
base::JSONReader::ReadList(json, base::JSON_PARSE_RFC);
|
||||
if (!parsed) {
|
||||
return ManifestError::kJsonParseError;
|
||||
}
|
||||
|
||||
for (const base::Value& item : *parsed) {
|
||||
const base::DictValue* entry_dict = item.GetIfDict();
|
||||
if (!entry_dict) {
|
||||
return ManifestError::kJsonParseError;
|
||||
}
|
||||
|
||||
CdnBuildEntry entry;
|
||||
ManifestError entry_err = ParseBuildEntry(*entry_dict, &entry);
|
||||
if (entry_err != ManifestError::kSuccess) {
|
||||
return entry_err;
|
||||
}
|
||||
|
||||
entries->push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return ManifestError::kSuccess;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Returns the channel suffix for URLs (empty for stable, "_beta" for beta)
|
||||
std::string GetChannelSuffix(const std::string& channel) {
|
||||
if (!channel.empty()) {
|
||||
return "_" + channel;
|
||||
}
|
||||
return ""; // empty = stable, no suffix
|
||||
}
|
||||
|
||||
// Returns the channel name for txt files ("stable" if empty, otherwise channel)
|
||||
std::string GetChannelName(const std::string& channel) {
|
||||
return channel.empty() ? std::string(kChannelStable) : channel;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string BuildChannelUrl(const std::string& base_url,
|
||||
const std::string& channel) {
|
||||
// stable.txt or beta.txt
|
||||
return NormalizeBaseUrl(base_url) + GetChannelName(channel) + ".txt";
|
||||
}
|
||||
|
||||
std::string BuildStableUrl(const std::string& base_url) {
|
||||
return BuildChannelUrl(base_url, "");
|
||||
}
|
||||
|
||||
std::string BuildMilestoneUrl(const std::string& base_url,
|
||||
int milestone,
|
||||
const std::string& channel) {
|
||||
// stable: 137.json, beta: 137_beta.json
|
||||
return NormalizeBaseUrl(base_url) + base::NumberToString(milestone) +
|
||||
GetChannelSuffix(channel) + ".json";
|
||||
}
|
||||
|
||||
std::string BuildPlatformUrl(const std::string& base_url,
|
||||
int milestone,
|
||||
const std::string& platform,
|
||||
const std::string& channel) {
|
||||
// stable: 137_windows64.json, beta: 137_windows64_beta.json
|
||||
return NormalizeBaseUrl(base_url) + base::NumberToString(milestone) + "_" +
|
||||
platform + GetChannelSuffix(channel) + ".json";
|
||||
}
|
||||
|
||||
std::string BuildAbiHashUrl(const std::string& base_url,
|
||||
const std::string& abi_hash,
|
||||
const std::string& platform,
|
||||
const std::string& channel) {
|
||||
// stable: abc123_windows64.json, beta: abc123_windows64_beta.json
|
||||
return NormalizeBaseUrl(base_url) + abi_hash + "_" + platform +
|
||||
GetChannelSuffix(channel) + ".json";
|
||||
}
|
||||
|
||||
std::string BuildArchiveUrl(const std::string& base_url,
|
||||
const std::string& filename) {
|
||||
return NormalizeBaseUrl(base_url) + filename;
|
||||
}
|
||||
|
||||
std::string BuildHashFileUrl(const std::string& base_url,
|
||||
const std::string& filename) {
|
||||
return NormalizeBaseUrl(base_url) + filename + ".sha256";
|
||||
}
|
||||
|
||||
std::string BuildRevocationListUrl(const std::string& base_url) {
|
||||
return NormalizeBaseUrl(base_url) + std::string(kRevocationListPath);
|
||||
}
|
||||
|
||||
std::optional<CdnBuildEntry> FindBestBuildEntry(
|
||||
const std::vector<CdnBuildEntry>& entries,
|
||||
const std::string& vmin,
|
||||
const std::string& vmax,
|
||||
const std::string& abi_hash,
|
||||
const std::set<Version>& skip_versions) {
|
||||
std::optional<CdnBuildEntry> best;
|
||||
Version best_version;
|
||||
|
||||
for (const auto& entry : entries) {
|
||||
// Skip versions already installed locally.
|
||||
if (skip_versions.count(entry.version) > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by abi_hash if specified
|
||||
if (!abi_hash.empty() && entry.abi_hash != abi_hash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by version range
|
||||
if (!entry.version.IsInRange(vmin, vmax)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select the newest matching version
|
||||
if (!best || entry.version > best_version) {
|
||||
best = entry;
|
||||
best_version = entry.version;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
std::string BuildNoMatchingCdnVersionMessage(
|
||||
const std::vector<CdnBuildEntry>& entries,
|
||||
const std::string& platform,
|
||||
const std::string& vmin,
|
||||
const std::string& vmax,
|
||||
const std::string& abi_hash,
|
||||
const CdnBuildExclusionReasons& exclusion_reasons) {
|
||||
std::string message =
|
||||
"No CDN version matches requirements (platform = " + platform +
|
||||
", version >= " + vmin;
|
||||
if (!vmax.empty()) {
|
||||
message += ", version <= " + vmax;
|
||||
}
|
||||
if (!abi_hash.empty()) {
|
||||
message += ", ABI hash = " + abi_hash;
|
||||
}
|
||||
message += ")";
|
||||
|
||||
if (entries.empty()) {
|
||||
return message + "; no validated CDN manifest candidates were available";
|
||||
}
|
||||
|
||||
constexpr size_t kMaxDiagnosticCandidates = 5;
|
||||
const size_t candidate_count =
|
||||
std::min(entries.size(), kMaxDiagnosticCandidates);
|
||||
const Version min_version = Version::Parse(vmin);
|
||||
const Version max_version = Version::Parse(vmax);
|
||||
message += "; candidate failures: ";
|
||||
for (size_t i = 0; i < candidate_count; ++i) {
|
||||
if (i > 0) {
|
||||
message += "; ";
|
||||
}
|
||||
const CdnBuildEntry& entry = entries[i];
|
||||
message += entry.version.ToString() + " [";
|
||||
const auto exclusion = exclusion_reasons.find(entry.version);
|
||||
if (exclusion != exclusion_reasons.end()) {
|
||||
message += "excluded: ";
|
||||
size_t reason_index = 0;
|
||||
for (CdnBuildExclusionReason reason : exclusion->second) {
|
||||
if (reason_index++ > 0) {
|
||||
message += ", ";
|
||||
}
|
||||
message += CdnBuildExclusionReasonToString(reason);
|
||||
}
|
||||
if (exclusion->second.empty()) {
|
||||
message += "unknown reason";
|
||||
}
|
||||
} else {
|
||||
bool has_failure = false;
|
||||
auto AppendFailure = [&](const std::string& failure) {
|
||||
if (has_failure) {
|
||||
message += ", ";
|
||||
}
|
||||
message += failure;
|
||||
has_failure = true;
|
||||
};
|
||||
if (!abi_hash.empty() && entry.abi_hash != abi_hash) {
|
||||
AppendFailure(
|
||||
"ABI hash mismatch (got " +
|
||||
(entry.abi_hash.empty() ? std::string("<empty>") : entry.abi_hash) +
|
||||
")");
|
||||
}
|
||||
if (!entry.version.IsInRange(vmin, vmax)) {
|
||||
if (min_version.IsValid() && entry.version < min_version) {
|
||||
AppendFailure("version below vmin");
|
||||
} else if (max_version.IsValid() && entry.version > max_version) {
|
||||
AppendFailure("version above vmax");
|
||||
} else {
|
||||
AppendFailure("version outside required range");
|
||||
}
|
||||
}
|
||||
if (!has_failure) {
|
||||
message += "matched compatibility requirements but was excluded";
|
||||
}
|
||||
}
|
||||
message += "]";
|
||||
}
|
||||
if (entries.size() > candidate_count) {
|
||||
message += "; ... " +
|
||||
base::NumberToString(entries.size() - candidate_count) +
|
||||
" more candidate(s)";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
const char* ManifestErrorToString(ManifestError error) {
|
||||
switch (error) {
|
||||
case ManifestError::kSuccess:
|
||||
return "Success";
|
||||
case ManifestError::kJsonParseError:
|
||||
return "JSON parse error";
|
||||
case ManifestError::kMissingRequiredField:
|
||||
return "Missing required field";
|
||||
case ManifestError::kInvalidFieldValue:
|
||||
return "Invalid field value";
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CDN_MANIFEST_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CDN_MANIFEST_H_
|
||||
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Represents a single CEF build entry from CDN manifest.
|
||||
struct CdnBuildEntry {
|
||||
Version version; // Short version (e.g., "137.3.5")
|
||||
std::string file; // Signed archive filename
|
||||
std::string sha1; // SHA1 hash of the signed archive
|
||||
std::string last_modified; // ISO 8601 timestamp
|
||||
std::string abi_hash; // Sandbox compatibility hash (may be empty)
|
||||
};
|
||||
|
||||
enum class CdnBuildExclusionReason {
|
||||
kAlreadyInstalled,
|
||||
kRevoked,
|
||||
kLaunchDisqualified,
|
||||
kPriorDownloadFailure,
|
||||
};
|
||||
|
||||
using CdnBuildExclusionReasons =
|
||||
std::map<Version, std::set<CdnBuildExclusionReason>>;
|
||||
|
||||
// Error codes for CDN manifest operations.
|
||||
enum class ManifestError {
|
||||
kSuccess,
|
||||
kJsonParseError, // Malformed JSON
|
||||
kMissingRequiredField, // Required field not present
|
||||
kInvalidFieldValue, // Field present but invalid format
|
||||
};
|
||||
|
||||
// Parse stable.txt content (just a milestone number).
|
||||
// Content example: "137"
|
||||
ManifestError ParseStableMilestone(const std::string& content, int* milestone);
|
||||
|
||||
// Parse {milestone}.json - newest build per platform.
|
||||
// Returns map of platform -> build entry.
|
||||
// Example platforms: "windows64", "windows32", "windowsarm64", "linux64"
|
||||
ManifestError ParseMilestoneManifest(
|
||||
const std::string& json,
|
||||
std::map<std::string, CdnBuildEntry>* entries);
|
||||
|
||||
// Parse {milestone}_{platform}.json or {abi_hash}_{platform}.json.
|
||||
// Returns list of builds sorted newest-first.
|
||||
ManifestError ParsePlatformManifest(const std::string& json,
|
||||
std::vector<CdnBuildEntry>* entries);
|
||||
|
||||
// Build CDN URLs for various query files.
|
||||
// Base URL example: "https://cef-builds.spotifycdn.com/"
|
||||
//
|
||||
// Channel parameter: "" (stable, default) or "beta"
|
||||
// - stable (empty): stable.txt, 137.json, 137_windows64.json
|
||||
// - beta: beta.txt, 137_beta.json, 137_windows64_beta.json
|
||||
|
||||
// Build URL for channel milestone file (stable.txt or beta.txt)
|
||||
std::string BuildChannelUrl(const std::string& base_url,
|
||||
const std::string& channel);
|
||||
|
||||
// Legacy: equivalent to BuildChannelUrl(base_url, "")
|
||||
std::string BuildStableUrl(const std::string& base_url);
|
||||
|
||||
// Build URL for milestone manifest with optional channel suffix
|
||||
std::string BuildMilestoneUrl(const std::string& base_url,
|
||||
int milestone,
|
||||
const std::string& channel = "");
|
||||
|
||||
// Build URL for platform manifest with optional channel suffix
|
||||
std::string BuildPlatformUrl(const std::string& base_url,
|
||||
int milestone,
|
||||
const std::string& platform,
|
||||
const std::string& channel = "");
|
||||
|
||||
// Build URL for ABI hash manifest with optional channel suffix
|
||||
std::string BuildAbiHashUrl(const std::string& base_url,
|
||||
const std::string& abi_hash,
|
||||
const std::string& platform,
|
||||
const std::string& channel = "");
|
||||
|
||||
std::string BuildArchiveUrl(const std::string& base_url,
|
||||
const std::string& filename);
|
||||
std::string BuildHashFileUrl(const std::string& base_url,
|
||||
const std::string& filename); // .sha256 file
|
||||
|
||||
// Find the best build entry for an app's requirements.
|
||||
// Filters by abi_hash (if specified) and version range, returns newest match.
|
||||
// Entries whose version appears in |skip_versions| are excluded.
|
||||
std::optional<CdnBuildEntry> FindBestBuildEntry(
|
||||
const std::vector<CdnBuildEntry>& entries,
|
||||
const std::string& vmin,
|
||||
const std::string& vmax,
|
||||
const std::string& abi_hash,
|
||||
const std::set<Version>& skip_versions = {});
|
||||
|
||||
// Build a bounded diagnostic for a failed FindBestBuildEntry() call. Reports
|
||||
// the effective requirements and the checks that rejected each candidate.
|
||||
std::string BuildNoMatchingCdnVersionMessage(
|
||||
const std::vector<CdnBuildEntry>& entries,
|
||||
const std::string& platform,
|
||||
const std::string& vmin,
|
||||
const std::string& vmax,
|
||||
const std::string& abi_hash,
|
||||
const CdnBuildExclusionReasons& exclusion_reasons = {});
|
||||
|
||||
// Build URL for revocation list.
|
||||
// Returns: <base_url>/revoked.json
|
||||
std::string BuildRevocationListUrl(const std::string& base_url);
|
||||
|
||||
// Convert error code to human-readable string for logging.
|
||||
const char* ManifestErrorToString(ManifestError error);
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CDN_MANIFEST_H_
|
||||
@@ -0,0 +1,754 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_cdn_manifest.h"
|
||||
|
||||
#include <set>
|
||||
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer {
|
||||
namespace {
|
||||
|
||||
// ============================================================================
|
||||
// ParseStableMilestone tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseStableMilestoneValid) {
|
||||
int milestone = 0;
|
||||
ManifestError result = ParseStableMilestone("137", &milestone);
|
||||
|
||||
EXPECT_EQ(ManifestError::kSuccess, result);
|
||||
EXPECT_EQ(137, milestone);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseStableMilestoneWithWhitespace) {
|
||||
int milestone = 0;
|
||||
ManifestError result = ParseStableMilestone(" 137\n", &milestone);
|
||||
|
||||
EXPECT_EQ(ManifestError::kSuccess, result);
|
||||
EXPECT_EQ(137, milestone);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseStableMilestoneEmpty) {
|
||||
int milestone = 0;
|
||||
ManifestError result = ParseStableMilestone("", &milestone);
|
||||
|
||||
EXPECT_EQ(ManifestError::kMissingRequiredField, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseStableMilestoneNonNumeric) {
|
||||
int milestone = 0;
|
||||
ManifestError result = ParseStableMilestone("abc", &milestone);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseStableMilestoneNegative) {
|
||||
int milestone = 0;
|
||||
ManifestError result = ParseStableMilestone("-1", &milestone);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseStableMilestoneZero) {
|
||||
int milestone = 0;
|
||||
ManifestError result = ParseStableMilestone("0", &milestone);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ParseMilestoneManifest tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseMilestoneManifestValid) {
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"abi_hash": "abc123def4567890",
|
||||
"file": "cef_binary_137.3.5_windows64_signed.tar.xz",
|
||||
"last_modified": "2026-02-10T12:00:00.000000Z",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
},
|
||||
"linux64": {
|
||||
"file": "cef_binary_137.3.5_linux64_signed.tar.xz",
|
||||
"last_modified": "2026-02-10T12:00:00.000000Z",
|
||||
"sha1": "1234567890abcdef1234567890abcdef12345678",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kSuccess, result);
|
||||
ASSERT_EQ(2u, entries.size());
|
||||
|
||||
ASSERT_TRUE(entries.count("windows64") > 0);
|
||||
EXPECT_EQ("137.3.5", entries["windows64"].version.ToString());
|
||||
EXPECT_EQ("abc123def4567890", entries["windows64"].abi_hash);
|
||||
EXPECT_EQ("cef_binary_137.3.5_windows64_signed.tar.xz",
|
||||
entries["windows64"].file);
|
||||
|
||||
ASSERT_TRUE(entries.count("linux64") > 0);
|
||||
EXPECT_EQ("137.3.5", entries["linux64"].version.ToString());
|
||||
EXPECT_TRUE(entries["linux64"].abi_hash.empty()); // Not present in JSON
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseMilestoneManifestMissingField) {
|
||||
// Missing "file" field
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kMissingRequiredField, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseMilestoneManifestMalformed) {
|
||||
const char* json = "not valid json {";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kJsonParseError, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ParsePlatformManifest tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParsePlatformManifestArray) {
|
||||
const char* json = R"([
|
||||
{
|
||||
"abi_hash": "abc123def4567890",
|
||||
"file": "cef_binary_137.3.5_windows64_signed.tar.xz",
|
||||
"last_modified": "2026-02-10T12:00:00.000000Z",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
},
|
||||
{
|
||||
"abi_hash": "abc123def4567890",
|
||||
"file": "cef_binary_137.2.0_windows64_signed.tar.xz",
|
||||
"last_modified": "2026-02-05T12:00:00.000000Z",
|
||||
"sha1": "1234567890abcdef1234567890abcdef12345678",
|
||||
"version": "137.2.0"
|
||||
}
|
||||
])";
|
||||
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
ManifestError result = ParsePlatformManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kSuccess, result);
|
||||
ASSERT_EQ(2u, entries.size());
|
||||
|
||||
EXPECT_EQ("137.3.5", entries[0].version.ToString());
|
||||
EXPECT_EQ("137.2.0", entries[1].version.ToString());
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParsePlatformManifestEmpty) {
|
||||
const char* json = "[]";
|
||||
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
ManifestError result = ParsePlatformManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kSuccess, result);
|
||||
EXPECT_TRUE(entries.empty());
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParsePlatformManifestMalformed) {
|
||||
const char* json = "not json";
|
||||
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
ManifestError result = ParsePlatformManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kJsonParseError, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// URL building tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildUrls) {
|
||||
const std::string base = "https://cef-builds.spotifycdn.com/";
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/stable.txt",
|
||||
BuildStableUrl(base));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137.json",
|
||||
BuildMilestoneUrl(base, 137));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137_windows64.json",
|
||||
BuildPlatformUrl(base, 137, "windows64"));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/abc123_windows64.json",
|
||||
BuildAbiHashUrl(base, "abc123", "windows64"));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/archive.tar.xz",
|
||||
BuildArchiveUrl(base, "archive.tar.xz"));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/archive.tar.xz.sha256",
|
||||
BuildHashFileUrl(base, "archive.tar.xz"));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/revoked.json",
|
||||
BuildRevocationListUrl(base));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildUrlsNoTrailingSlash) {
|
||||
// Base URL without trailing slash should still work
|
||||
const std::string base = "https://cef-builds.spotifycdn.com";
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/stable.txt",
|
||||
BuildStableUrl(base));
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137.json",
|
||||
BuildMilestoneUrl(base, 137));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel URL building tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildChannelUrlStable) {
|
||||
const std::string base = "https://cef-builds.spotifycdn.com/";
|
||||
|
||||
// Empty channel = stable
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/stable.txt",
|
||||
BuildChannelUrl(base, ""));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildChannelUrlBeta) {
|
||||
const std::string base = "https://cef-builds.spotifycdn.com/";
|
||||
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/beta.txt",
|
||||
BuildChannelUrl(base, "beta"));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildMilestoneUrlWithChannel) {
|
||||
const std::string base = "https://cef-builds.spotifycdn.com/";
|
||||
|
||||
// Empty channel = stable (no suffix)
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137.json",
|
||||
BuildMilestoneUrl(base, 137, ""));
|
||||
|
||||
// Beta channel
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137_beta.json",
|
||||
BuildMilestoneUrl(base, 137, "beta"));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildPlatformUrlWithChannel) {
|
||||
const std::string base = "https://cef-builds.spotifycdn.com/";
|
||||
|
||||
// Empty channel = stable (no suffix)
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137_windows64.json",
|
||||
BuildPlatformUrl(base, 137, "windows64", ""));
|
||||
|
||||
// Beta channel
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/137_windows64_beta.json",
|
||||
BuildPlatformUrl(base, 137, "windows64", "beta"));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, BuildAbiHashUrlWithChannel) {
|
||||
const std::string base = "https://cef-builds.spotifycdn.com/";
|
||||
|
||||
// Empty channel = stable (no suffix)
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/abc123_windows64.json",
|
||||
BuildAbiHashUrl(base, "abc123", "windows64", ""));
|
||||
|
||||
// Beta channel
|
||||
EXPECT_EQ("https://cef-builds.spotifycdn.com/abc123_windows64_beta.json",
|
||||
BuildAbiHashUrl(base, "abc123", "windows64", "beta"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FindBestBuildEntry tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntry) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
CdnBuildEntry e1;
|
||||
e1.version = Version::Parse("137.1.0");
|
||||
e1.abi_hash = "abc123";
|
||||
e1.file = "file1.tar.xz";
|
||||
e1.sha1 = "hash1";
|
||||
entries.push_back(e1);
|
||||
|
||||
CdnBuildEntry e2;
|
||||
e2.version = Version::Parse("137.3.0");
|
||||
e2.abi_hash = "abc123";
|
||||
e2.file = "file2.tar.xz";
|
||||
e2.sha1 = "hash2";
|
||||
entries.push_back(e2);
|
||||
|
||||
CdnBuildEntry e3;
|
||||
e3.version = Version::Parse("137.2.0");
|
||||
e3.abi_hash = "def456"; // Different abi_hash
|
||||
e3.file = "file3.tar.xz";
|
||||
e3.sha1 = "hash3";
|
||||
entries.push_back(e3);
|
||||
|
||||
// Should find newest with matching abi_hash
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.99", "abc123");
|
||||
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ("137.3.0", result->version.ToString());
|
||||
EXPECT_EQ("abc123", result->abi_hash);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntryEmptyAbiHash) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
CdnBuildEntry e1;
|
||||
e1.version = Version::Parse("137.1.0");
|
||||
e1.abi_hash = "abc123";
|
||||
e1.file = "file1.tar.xz";
|
||||
e1.sha1 = "hash1";
|
||||
entries.push_back(e1);
|
||||
|
||||
CdnBuildEntry e2;
|
||||
e2.version = Version::Parse("137.3.0");
|
||||
e2.abi_hash = "def456";
|
||||
e2.file = "file2.tar.xz";
|
||||
e2.sha1 = "hash2";
|
||||
entries.push_back(e2);
|
||||
|
||||
// Empty abi_hash means match any
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.99", "");
|
||||
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ("137.3.0",
|
||||
result->version.ToString()); // Newest regardless of hash
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntryNoMatch) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
CdnBuildEntry e1;
|
||||
e1.version = Version::Parse("136.1.0"); // Below range
|
||||
e1.abi_hash = "abc123";
|
||||
e1.file = "file1.tar.xz";
|
||||
e1.sha1 = "hash1";
|
||||
entries.push_back(e1);
|
||||
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.99", "abc123");
|
||||
|
||||
EXPECT_FALSE(result.has_value());
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntryVersionRange) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
CdnBuildEntry e1;
|
||||
e1.version = Version::Parse("137.1.0");
|
||||
e1.abi_hash = "abc123";
|
||||
e1.file = "file1.tar.xz";
|
||||
e1.sha1 = "hash1";
|
||||
entries.push_back(e1);
|
||||
|
||||
CdnBuildEntry e2;
|
||||
e2.version = Version::Parse("137.5.0"); // Above range
|
||||
e2.abi_hash = "abc123";
|
||||
e2.file = "file2.tar.xz";
|
||||
e2.sha1 = "hash2";
|
||||
entries.push_back(e2);
|
||||
|
||||
// Max version is 137.3
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.3", "abc123");
|
||||
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ("137.1.0", result->version.ToString()); // Only one in range
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntryEmptyList) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.99", "abc123");
|
||||
|
||||
EXPECT_FALSE(result.has_value());
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntrySkipsInstalledVersions) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
CdnBuildEntry e1;
|
||||
e1.version = Version::Parse("137.1.0");
|
||||
e1.abi_hash = "abc123";
|
||||
e1.file = "file1.tar.xz";
|
||||
e1.sha1 = "hash1";
|
||||
entries.push_back(e1);
|
||||
|
||||
CdnBuildEntry e2;
|
||||
e2.version = Version::Parse("137.2.0");
|
||||
e2.abi_hash = "abc123";
|
||||
e2.file = "file2.tar.xz";
|
||||
e2.sha1 = "hash2";
|
||||
entries.push_back(e2);
|
||||
|
||||
CdnBuildEntry e3;
|
||||
e3.version = Version::Parse("137.3.0");
|
||||
e3.abi_hash = "abc123";
|
||||
e3.file = "file3.tar.xz";
|
||||
e3.sha1 = "hash3";
|
||||
entries.push_back(e3);
|
||||
|
||||
std::set<Version> skip;
|
||||
skip.insert(e2.version);
|
||||
skip.insert(e3.version);
|
||||
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.99", "abc123", skip);
|
||||
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(e1.version, result->version);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, FindBestBuildEntryAllSkipped) {
|
||||
std::vector<CdnBuildEntry> entries;
|
||||
|
||||
CdnBuildEntry e1;
|
||||
e1.version = Version::Parse("137.1.0");
|
||||
e1.abi_hash = "abc123";
|
||||
e1.file = "file1.tar.xz";
|
||||
e1.sha1 = "hash1";
|
||||
entries.push_back(e1);
|
||||
|
||||
std::set<Version> skip;
|
||||
skip.insert(e1.version);
|
||||
|
||||
auto result = FindBestBuildEntry(entries, "137.0", "137.99", "abc123", skip);
|
||||
|
||||
EXPECT_FALSE(result.has_value());
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest,
|
||||
NoMatchingVersionMessageListsFailedRequirements) {
|
||||
CdnBuildEntry below_and_wrong_abi;
|
||||
below_and_wrong_abi.version = Version::Parse("136.1.0");
|
||||
below_and_wrong_abi.abi_hash = "def456";
|
||||
|
||||
CdnBuildEntry above;
|
||||
above.version = Version::Parse("138.1.0");
|
||||
above.abi_hash = "abc123";
|
||||
|
||||
CdnBuildEntry excluded;
|
||||
excluded.version = Version::Parse("137.2.0");
|
||||
excluded.abi_hash = "abc123";
|
||||
|
||||
CdnBuildExclusionReasons exclusion_reasons = {
|
||||
{excluded.version, {CdnBuildExclusionReason::kRevoked}}};
|
||||
const std::string message = BuildNoMatchingCdnVersionMessage(
|
||||
{below_and_wrong_abi, above, excluded}, "windows64", "137.0", "137.99",
|
||||
"abc123", exclusion_reasons);
|
||||
|
||||
EXPECT_NE(std::string::npos,
|
||||
message.find("platform = windows64, version >= 137.0, version <= "
|
||||
"137.99, ABI hash = abc123"));
|
||||
EXPECT_NE(std::string::npos,
|
||||
message.find("136.1.0 [ABI hash mismatch (got def456), version "
|
||||
"below vmin]"));
|
||||
EXPECT_NE(std::string::npos, message.find("138.1.0 [version above vmax]"));
|
||||
EXPECT_NE(std::string::npos, message.find("137.2.0 [excluded: revoked]"));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest,
|
||||
NoMatchingVersionMessageListsEachExclusionReason) {
|
||||
CdnBuildEntry installed;
|
||||
installed.version = Version::Parse("137.1.0");
|
||||
CdnBuildEntry disqualified;
|
||||
disqualified.version = Version::Parse("137.2.0");
|
||||
CdnBuildEntry failed;
|
||||
failed.version = Version::Parse("137.3.0");
|
||||
CdnBuildExclusionReasons exclusion_reasons = {
|
||||
{installed.version, {CdnBuildExclusionReason::kAlreadyInstalled}},
|
||||
{disqualified.version,
|
||||
{CdnBuildExclusionReason::kLaunchDisqualified,
|
||||
CdnBuildExclusionReason::kRevoked}},
|
||||
{failed.version, {CdnBuildExclusionReason::kPriorDownloadFailure}}};
|
||||
|
||||
const std::string message = BuildNoMatchingCdnVersionMessage(
|
||||
{installed, disqualified, failed}, "windows64", "137.0", "", "",
|
||||
exclusion_reasons);
|
||||
|
||||
EXPECT_NE(std::string::npos,
|
||||
message.find("137.1.0 [excluded: already installed]"));
|
||||
EXPECT_NE(std::string::npos,
|
||||
message.find("137.2.0 [excluded: revoked, disqualified by launch "
|
||||
"health]"));
|
||||
EXPECT_NE(std::string::npos,
|
||||
message.find("137.3.0 [excluded: prior download failure]"));
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest,
|
||||
NoMatchingVersionMessageReportsMissingCandidates) {
|
||||
const std::string message =
|
||||
BuildNoMatchingCdnVersionMessage({}, "windows64", "151.1", "", "abc123");
|
||||
|
||||
EXPECT_EQ(
|
||||
"No CDN version matches requirements (platform = windows64, version >= "
|
||||
"151.1, ABI hash = abc123); no validated CDN manifest candidates were "
|
||||
"available",
|
||||
message);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security: Filename Validation Tests (M1)
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsPathTraversal) {
|
||||
// Filename with ".." should be rejected
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"file": "../../../evil.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsForwardSlash) {
|
||||
// Filename with "/" should be rejected
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"file": "subdir/archive.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsBackslash) {
|
||||
// Filename with "\" should be rejected
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"file": "subdir\\archive.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsUrlScheme) {
|
||||
// Filename with ":" (URL scheme indicator) should be rejected
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"file": "https://evil.com/malware.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsHiddenFile) {
|
||||
// Filename starting with "." should be rejected
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"file": ".hidden.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsInvalidExtension) {
|
||||
// Filename without valid archive extension should be rejected
|
||||
const char* json = R"({
|
||||
"windows64": {
|
||||
"file": "archive.exe",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryAcceptsValidExtensions) {
|
||||
// Only .tar.xz is supported by the extraction code (ExtractTarXz).
|
||||
const char* json_tarxz = R"({
|
||||
"windows64": {
|
||||
"file": "cef_binary_137.3.5.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json_tarxz, &entries);
|
||||
EXPECT_EQ(ManifestError::kSuccess, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsUnsupportedExtensions) {
|
||||
// .7z and .zip are not supported by the extraction code.
|
||||
const char* json_7z = R"({
|
||||
"windows64": {
|
||||
"file": "cef_binary_137.3.5.7z",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json_7z, &entries);
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
|
||||
const char* json_zip = R"({
|
||||
"windows64": {
|
||||
"file": "cef_binary_137.3.5.zip",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
entries.clear();
|
||||
result = ParseMilestoneManifest(json_zip, &entries);
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security: Field Length Limit Tests (M3)
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsOverlyLongVersion) {
|
||||
// Version field longer than 64 chars should be rejected
|
||||
std::string long_version(100, '1'); // 100 chars
|
||||
std::string json = R"({
|
||||
"windows64": {
|
||||
"file": "archive.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": ")" +
|
||||
long_version + R"("
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsOverlyLongSha1) {
|
||||
// SHA1 field longer than 40 chars should be rejected
|
||||
std::string long_sha1(50, 'a'); // 50 chars (should be 40)
|
||||
std::string json = R"({
|
||||
"windows64": {
|
||||
"file": "archive.tar.xz",
|
||||
"sha1": ")" + long_sha1 +
|
||||
R"(",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsOverlyLongAbiHash) {
|
||||
// abi_hash field longer than 256 chars should be rejected
|
||||
std::string long_hash(300, 'x'); // 300 chars
|
||||
std::string json = R"({
|
||||
"windows64": {
|
||||
"file": "archive.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5",
|
||||
"abi_hash": ")" +
|
||||
long_hash + R"("
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsOverlyLongTimestamp) {
|
||||
// last_modified field longer than 64 chars should be rejected
|
||||
std::string long_timestamp(100, 'T'); // 100 chars
|
||||
std::string json = R"({
|
||||
"windows64": {
|
||||
"file": "archive.tar.xz",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5",
|
||||
"last_modified": ")" +
|
||||
long_timestamp + R"("
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
TEST(InstallerCdnManifestTest, ParseBuildEntryRejectsOverlyLongFilename) {
|
||||
// Filename longer than 256 chars should be rejected
|
||||
std::string long_name(300, 'f');
|
||||
long_name += ".tar.xz"; // Add valid extension
|
||||
std::string json = R"({
|
||||
"windows64": {
|
||||
"file": ")" + long_name +
|
||||
R"(",
|
||||
"sha1": "5a7eeda17c97c9feff93f8c196d15f3145f8127e",
|
||||
"version": "137.3.5"
|
||||
}
|
||||
})";
|
||||
|
||||
std::map<std::string, CdnBuildEntry> entries;
|
||||
ManifestError result = ParseMilestoneManifest(json, &entries);
|
||||
|
||||
EXPECT_EQ(ManifestError::kInvalidFieldValue, result);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ManifestErrorToString tests
|
||||
// ============================================================================
|
||||
|
||||
TEST(InstallerCdnManifestTest, ManifestErrorToString) {
|
||||
EXPECT_STREQ("Success", ManifestErrorToString(ManifestError::kSuccess));
|
||||
EXPECT_STREQ("JSON parse error",
|
||||
ManifestErrorToString(ManifestError::kJsonParseError));
|
||||
EXPECT_STREQ("Missing required field",
|
||||
ManifestErrorToString(ManifestError::kMissingRequiredField));
|
||||
EXPECT_STREQ("Invalid field value",
|
||||
ManifestErrorToString(ManifestError::kInvalidFieldValue));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,375 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
|
||||
#include "base/json/json_reader.h"
|
||||
#include "base/json/json_writer.h"
|
||||
#include "base/json/string_escape.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/notreached.h"
|
||||
#include "base/strings/stringprintf.h"
|
||||
#include "base/uuid.h"
|
||||
#include "cef/include/cef_api_hash.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_policy.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_validation.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
#include "include/wrapper/cef_util_win.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace {
|
||||
|
||||
using config_fields::kAbiHashField;
|
||||
using config_fields::kAppIdField;
|
||||
using config_fields::kBundledCefPathField;
|
||||
using config_fields::kCdnUrlsField;
|
||||
using config_fields::kChannelField;
|
||||
using config_fields::kEnableExplicitModesField;
|
||||
using config_fields::kInstallPathField;
|
||||
using config_fields::kLaunchHealthField;
|
||||
using config_fields::kUncheckedCefPathField;
|
||||
using config_fields::kVmaxField;
|
||||
using config_fields::kVminField;
|
||||
|
||||
void WarnUnrecognizedConfigKeys(const base::DictValue& dict) {
|
||||
for (const auto item : dict) {
|
||||
if (config_fields::IsRecognizedConfigField(item.first)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string escaped_key;
|
||||
base::EscapeJSONString(item.first, /*put_in_quotes=*/true, &escaped_key);
|
||||
LOG(WARNING) << "Unrecognized installer config key: " << escaped_key;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* LaunchHealthModeToString(LaunchHealthMode mode) {
|
||||
switch (mode) {
|
||||
case LaunchHealthMode::kOff:
|
||||
return "off";
|
||||
case LaunchHealthMode::kExplicit:
|
||||
return "explicit";
|
||||
case LaunchHealthMode::kExitCode:
|
||||
return "exit_code";
|
||||
}
|
||||
NOTREACHED();
|
||||
}
|
||||
|
||||
std::string ConfigToJson(const Config& config) {
|
||||
base::DictValue dict;
|
||||
dict.Set(kAppIdField, config.appid);
|
||||
dict.Set(kVminField, config.vmin);
|
||||
if (!config.vmax.empty()) {
|
||||
dict.Set(kVmaxField, config.vmax);
|
||||
}
|
||||
if (!config.abi_hash.empty()) {
|
||||
dict.Set(kAbiHashField, config.abi_hash);
|
||||
}
|
||||
if (!config.channel.empty()) {
|
||||
dict.Set(kChannelField, config.channel);
|
||||
}
|
||||
if (!config.cdn_urls.empty()) {
|
||||
base::ListValue urls;
|
||||
for (const auto& url : config.cdn_urls) {
|
||||
urls.Append(url);
|
||||
}
|
||||
dict.Set(kCdnUrlsField, std::move(urls));
|
||||
}
|
||||
dict.Set(kLaunchHealthField,
|
||||
std::string(LaunchHealthModeToString(config.launch_health)));
|
||||
if (config.enable_explicit_modes) {
|
||||
dict.Set(kEnableExplicitModesField, true);
|
||||
}
|
||||
if (!config.unchecked_cef_path.empty()) {
|
||||
dict.Set(kUncheckedCefPathField, config.unchecked_cef_path);
|
||||
}
|
||||
if (!config.bundled_cef_path.empty()) {
|
||||
dict.Set(kBundledCefPathField, config.bundled_cef_path);
|
||||
}
|
||||
std::string json;
|
||||
base::JSONWriter::Write(dict, &json);
|
||||
return json;
|
||||
}
|
||||
|
||||
ConfigError ParseConfigFromJson(const std::string& json,
|
||||
Config* config,
|
||||
const ConfigParseOptions& options,
|
||||
std::string* diagnostic) {
|
||||
if (diagnostic) {
|
||||
diagnostic->clear();
|
||||
}
|
||||
if (!config) {
|
||||
if (diagnostic) {
|
||||
*diagnostic = "Config output is null";
|
||||
}
|
||||
return ConfigError::kMissingRequiredField;
|
||||
}
|
||||
|
||||
std::optional<base::DictValue> parsed =
|
||||
base::JSONReader::ReadDict(json, base::JSON_PARSE_RFC);
|
||||
if (!parsed) {
|
||||
if (diagnostic) {
|
||||
*diagnostic = "Malformed installer config JSON";
|
||||
}
|
||||
return ConfigError::kJsonParseError;
|
||||
}
|
||||
|
||||
const base::DictValue& dict = *parsed;
|
||||
WarnUnrecognizedConfigKeys(dict);
|
||||
|
||||
// Read appid (required)
|
||||
const std::string* appid = dict.FindString(kAppIdField);
|
||||
if (!appid || appid->empty()) {
|
||||
return ConfigError::kMissingRequiredField;
|
||||
}
|
||||
base::Uuid uuid = base::Uuid::ParseCaseInsensitive(*appid);
|
||||
if (!uuid.is_valid()) {
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
config->appid = *appid;
|
||||
|
||||
// Read vmin (required)
|
||||
const std::string* vmin = dict.FindString(kVminField);
|
||||
if (!vmin || vmin->empty()) {
|
||||
return ConfigError::kMissingRequiredField;
|
||||
}
|
||||
Version vmin_parsed = Version::Parse(*vmin);
|
||||
if (!vmin_parsed.IsValid()) {
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
const std::string configured_vmin = *vmin;
|
||||
config->vmin = configured_vmin;
|
||||
|
||||
// Clamp vmin to the bootstrap's API version - bootstrap can't use older CEF
|
||||
// than it was compiled against. Cap at CEF_API_VERSION_LAST to handle
|
||||
// CEF_API_VERSION being EXPERIMENTAL or NEXT (larger than any real version).
|
||||
constexpr int bootstrap_api_version = (CEF_API_VERSION < CEF_API_VERSION_LAST)
|
||||
? CEF_API_VERSION
|
||||
: CEF_API_VERSION_LAST;
|
||||
Version api_min = Version::FromApiVersion(bootstrap_api_version);
|
||||
if (api_min.IsValid() && api_min > vmin_parsed) {
|
||||
LOG(WARNING) << "Config vmin " << *vmin << " clamped to "
|
||||
<< api_min.ToString() << " (bootstrap API version)";
|
||||
config->vmin = api_min.ToString();
|
||||
}
|
||||
|
||||
// Read vmax (optional)
|
||||
const std::string* vmax = dict.FindString(kVmaxField);
|
||||
if (vmax && !vmax->empty()) {
|
||||
Version vmax_parsed = Version::Parse(*vmax);
|
||||
if (!vmax_parsed.IsValid()) {
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
config->vmax = *vmax;
|
||||
Version effective_vmin = Version::Parse(config->vmin);
|
||||
if (effective_vmin > vmax_parsed) {
|
||||
if (diagnostic) {
|
||||
if (vmin_parsed > vmax_parsed) {
|
||||
*diagnostic = base::StringPrintf(
|
||||
"Configured vmin %s exceeds configured vmax %s",
|
||||
configured_vmin.c_str(), vmax->c_str());
|
||||
} else {
|
||||
*diagnostic = base::StringPrintf(
|
||||
"Configured vmin %s has effective vmin %s after clamping to "
|
||||
"bootstrap API version %d, which exceeds configured vmax %s",
|
||||
configured_vmin.c_str(), config->vmin.c_str(),
|
||||
bootstrap_api_version, vmax->c_str());
|
||||
}
|
||||
}
|
||||
return ConfigError::kInvalidVersionRange;
|
||||
}
|
||||
} else {
|
||||
config->vmax.clear();
|
||||
}
|
||||
|
||||
// Read abi_hash (optional, must be hex if present)
|
||||
const std::string* abi_hash = dict.FindString(kAbiHashField);
|
||||
if (abi_hash && !abi_hash->empty()) {
|
||||
if (!IsValidAbiHash(*abi_hash)) {
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
config->abi_hash = *abi_hash;
|
||||
} else {
|
||||
config->abi_hash.clear();
|
||||
}
|
||||
|
||||
// Read channel (optional, empty = stable)
|
||||
const std::string* channel = dict.FindString(kChannelField);
|
||||
if (channel && !channel->empty()) {
|
||||
// Only "beta" is a valid non-default channel
|
||||
if (*channel != kChannelBeta) {
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
config->channel = *channel;
|
||||
} else {
|
||||
config->channel.clear(); // Empty = stable (default)
|
||||
}
|
||||
|
||||
const base::Value* cdn_urls = dict.Find(kCdnUrlsField);
|
||||
if (!cdn_urls) {
|
||||
config->cdn_urls.clear();
|
||||
} else if (!cdn_urls->is_list()) {
|
||||
config->cdn_urls.clear();
|
||||
if (diagnostic) {
|
||||
*diagnostic = "cdn_urls must be an array";
|
||||
}
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
} else {
|
||||
std::vector<std::string> values;
|
||||
values.reserve(cdn_urls->GetList().size());
|
||||
for (const auto& value : cdn_urls->GetList()) {
|
||||
if (!value.is_string()) {
|
||||
config->cdn_urls.clear();
|
||||
if (diagnostic) {
|
||||
*diagnostic = "cdn_urls entries must be strings";
|
||||
}
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
values.push_back(value.GetString());
|
||||
}
|
||||
if (!ValidateAndNormalizeCdnUrls(values, &config->cdn_urls, diagnostic)) {
|
||||
config->cdn_urls.clear();
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Launch health is a trusted core field in every Config source. Missing
|
||||
// legacy configs default to off; present values must use canonical strings.
|
||||
const base::Value* launch_health = dict.Find(kLaunchHealthField);
|
||||
if (!launch_health) {
|
||||
config->launch_health = LaunchHealthMode::kOff;
|
||||
} else if (!launch_health->is_string()) {
|
||||
if (diagnostic) {
|
||||
*diagnostic = "launch_health must be a string";
|
||||
}
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
} else if (launch_health->GetString() == "off") {
|
||||
config->launch_health = LaunchHealthMode::kOff;
|
||||
} else if (launch_health->GetString() == "explicit") {
|
||||
config->launch_health = LaunchHealthMode::kExplicit;
|
||||
} else if (launch_health->GetString() == "exit_code") {
|
||||
config->launch_health = LaunchHealthMode::kExitCode;
|
||||
} else {
|
||||
if (diagnostic) {
|
||||
*diagnostic = "launch_health must be one of: off, explicit, exit_code";
|
||||
}
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
}
|
||||
|
||||
// Only authoritative in the bootstrap's own embedded resource.
|
||||
// Ignored in client DLL resources and RunInstaller JSON.
|
||||
if (options.allow_enable_explicit_modes) {
|
||||
if (std::optional<bool> val = dict.FindBool(kEnableExplicitModesField)) {
|
||||
config->enable_explicit_modes = *val;
|
||||
} else {
|
||||
config->enable_explicit_modes = false;
|
||||
}
|
||||
} else {
|
||||
config->enable_explicit_modes = false;
|
||||
}
|
||||
|
||||
// Only from client DLL resource or RunInstaller JSON. Ignored in the
|
||||
// bootstrap resource, which has no client DLL directory to resolve relative
|
||||
// paths against.
|
||||
if (options.allow_unchecked_cef_path) {
|
||||
const std::string* unchecked_path = dict.FindString(kUncheckedCefPathField);
|
||||
if (unchecked_path && !unchecked_path->empty()) {
|
||||
config->unchecked_cef_path = *unchecked_path;
|
||||
} else {
|
||||
config->unchecked_cef_path.clear();
|
||||
}
|
||||
} else {
|
||||
config->unchecked_cef_path.clear();
|
||||
}
|
||||
|
||||
// Only from client DLL resource or RunInstaller JSON. Same gating rationale
|
||||
// as unchecked_cef_path: relative paths need a client DLL directory.
|
||||
if (options.allow_bundled_cef_path) {
|
||||
const std::string* bundled_path = dict.FindString(kBundledCefPathField);
|
||||
if (bundled_path && !bundled_path->empty()) {
|
||||
config->bundled_cef_path = *bundled_path;
|
||||
} else {
|
||||
config->bundled_cef_path.clear();
|
||||
}
|
||||
} else {
|
||||
config->bundled_cef_path.clear();
|
||||
}
|
||||
|
||||
// Only the trusted client DLL resource may select the automatic installer
|
||||
// namespace. Bootstrap resources cannot select a write location.
|
||||
// RunInstaller JSON is parsed independently into ExtendedConfig.
|
||||
if (options.allow_install_path) {
|
||||
const base::Value* install_path = dict.Find(kInstallPathField);
|
||||
if (!install_path) {
|
||||
config->install_path.clear();
|
||||
} else if (!install_path->is_string()) {
|
||||
config->install_path.clear();
|
||||
if (diagnostic) {
|
||||
*diagnostic = "install_path must be a string";
|
||||
}
|
||||
return ConfigError::kInvalidFieldValue;
|
||||
} else {
|
||||
config->install_path = install_path->GetString();
|
||||
}
|
||||
} else {
|
||||
config->install_path.clear();
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
// Read certificate_thumbprint (optional, test-only).
|
||||
const std::string* thumbprint =
|
||||
dict.FindString(config_fields::kCertificateThumbprintField);
|
||||
if (thumbprint && !thumbprint->empty()) {
|
||||
config->certificate_thumbprint = *thumbprint;
|
||||
} else {
|
||||
config->certificate_thumbprint.clear();
|
||||
}
|
||||
#endif
|
||||
|
||||
return ConfigError::kSuccess;
|
||||
}
|
||||
|
||||
ConfigError ReadConfigFromResource(HMODULE module,
|
||||
Config* config,
|
||||
const ConfigParseOptions& options,
|
||||
std::string* diagnostic) {
|
||||
if (!config) {
|
||||
return ConfigError::kMissingRequiredField;
|
||||
}
|
||||
|
||||
std::string json;
|
||||
if (!cef_util::ReadResourceData(module, kConfigResourceName, &json)) {
|
||||
if (diagnostic) {
|
||||
*diagnostic = "Embedded installer config resource not found";
|
||||
}
|
||||
return ConfigError::kResourceNotFound;
|
||||
}
|
||||
|
||||
return ParseConfigFromJson(json, config, options, diagnostic);
|
||||
}
|
||||
|
||||
const char* ConfigErrorToString(ConfigError error) {
|
||||
switch (error) {
|
||||
case ConfigError::kSuccess:
|
||||
return "Success";
|
||||
case ConfigError::kJsonParseError:
|
||||
return "Malformed JSON";
|
||||
case ConfigError::kMissingRequiredField:
|
||||
return "Required field missing";
|
||||
case ConfigError::kInvalidFieldValue:
|
||||
return "Invalid field value";
|
||||
case ConfigError::kInvalidVersionRange:
|
||||
return "Invalid version range";
|
||||
case ConfigError::kResourceNotFound:
|
||||
return "Embedded resource not found";
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,216 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONFIG_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONFIG_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace config_fields {
|
||||
|
||||
inline constexpr char kAppIdField[] = "appid";
|
||||
inline constexpr char kVminField[] = "vmin";
|
||||
inline constexpr char kVmaxField[] = "vmax";
|
||||
inline constexpr char kAbiHashField[] = "abi_hash";
|
||||
inline constexpr char kChannelField[] = "channel";
|
||||
inline constexpr char kCdnUrlsField[] = "cdn_urls";
|
||||
inline constexpr char kLaunchHealthField[] = "launch_health";
|
||||
inline constexpr char kEnableExplicitModesField[] = "enable_explicit_modes";
|
||||
inline constexpr char kUncheckedCefPathField[] = "unchecked_cef_path";
|
||||
inline constexpr char kBundledCefPathField[] = "bundled_cef_path";
|
||||
inline constexpr char kInstallPathField[] = "install_path";
|
||||
inline constexpr char kCertificateThumbprintField[] = "certificate_thumbprint";
|
||||
inline constexpr char kForceCheckField[] = "force_check";
|
||||
inline constexpr char kShowProgressUiField[] = "show_progress_ui";
|
||||
inline constexpr char kParentWindowField[] = "parent_window";
|
||||
inline constexpr char kLocalDownloadPathField[] = "local_download_path";
|
||||
inline constexpr char kLogLevelField[] = "log_level";
|
||||
inline constexpr char kDownloadTimeoutMsField[] = "download_timeout_ms";
|
||||
inline constexpr char kMaxAgeDaysField[] = "max_age_days";
|
||||
|
||||
inline constexpr std::array<std::string_view, 11> kApplicationConfigFields = {
|
||||
kAppIdField,
|
||||
kVminField,
|
||||
kVmaxField,
|
||||
kAbiHashField,
|
||||
kChannelField,
|
||||
kCdnUrlsField,
|
||||
kLaunchHealthField,
|
||||
kEnableExplicitModesField,
|
||||
kUncheckedCefPathField,
|
||||
kBundledCefPathField,
|
||||
kInstallPathField,
|
||||
};
|
||||
|
||||
inline constexpr std::array<std::string_view, 11> kExtendedConfigFields = {
|
||||
kCdnUrlsField, kInstallPathField,
|
||||
kBundledCefPathField, kCertificateThumbprintField,
|
||||
kForceCheckField, kShowProgressUiField,
|
||||
kParentWindowField, kLocalDownloadPathField,
|
||||
kLogLevelField, kDownloadTimeoutMsField,
|
||||
kMaxAgeDaysField,
|
||||
};
|
||||
|
||||
constexpr bool IsRecognizedConfigField(std::string_view key) {
|
||||
for (const std::string_view field : kApplicationConfigFields) {
|
||||
if (field == key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const std::string_view field : kExtendedConfigFields) {
|
||||
if (field == key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace config_fields
|
||||
|
||||
// Controls local launch-health tracking for this application.
|
||||
enum class LaunchHealthMode {
|
||||
kOff,
|
||||
kExplicit,
|
||||
kExitCode,
|
||||
};
|
||||
|
||||
const char* LaunchHealthModeToString(LaunchHealthMode mode);
|
||||
|
||||
// Controls which optional fields are parsed from config JSON. Each flag
|
||||
// corresponds to a field that is only meaningful in certain contexts (e.g.,
|
||||
// client DLL resource vs. bootstrap resource or operation API). When a flag is
|
||||
// false, the corresponding Config field retains its default value regardless
|
||||
// of what the JSON contains.
|
||||
//
|
||||
// SECURITY: install_path is accepted only from the trusted client DLL resource
|
||||
// as an application-owned namespace selector. Bootstrap resources cannot
|
||||
// select it. CDN URL lists are accepted from every selected application config
|
||||
// source. Command-line overrides for sensitive fields are compiled out of
|
||||
// official release builds entirely.
|
||||
struct ConfigParseOptions {
|
||||
bool allow_enable_explicit_modes = false;
|
||||
bool allow_unchecked_cef_path = false;
|
||||
bool allow_bundled_cef_path = false;
|
||||
bool allow_install_path = false;
|
||||
};
|
||||
|
||||
// Application configuration loaded from an embedded resource.
|
||||
struct Config {
|
||||
std::string appid; // UUID - unique identifier that never changes
|
||||
std::string vmin; // Minimum version (required, e.g., "137.1")
|
||||
std::string vmax; // Maximum version (optional, e.g., "137.99")
|
||||
std::string abi_hash; // Sandbox compatibility hash (16-char hex)
|
||||
std::string channel; // Release channel: "" (stable, default) or "beta"
|
||||
|
||||
// Ordered application CDN base URLs. Empty means the effective-source
|
||||
// resolver supplies the hardcoded default after considering policy and
|
||||
// operation-specific sources.
|
||||
std::vector<std::string> cdn_urls;
|
||||
|
||||
// Local launch-health tracking is opt-in. kExplicit is recommended for
|
||||
// applications that can reliably call RunInstaller("launch_success");
|
||||
// kExitCode preserves the legacy exit-code classification behavior.
|
||||
LaunchHealthMode launch_health = LaunchHealthMode::kOff;
|
||||
|
||||
// Enable standalone installer features. When true in the bootstrap's embedded
|
||||
// config:
|
||||
// 1. Allows explicit /cef-update and /cef-uninstall commands in standalone
|
||||
// mode
|
||||
// (no client DLL) and when a client DLL is present.
|
||||
// 2. Allows auto-install when no client DLL is present.
|
||||
//
|
||||
// When false (default), explicit commands and standalone auto-install are
|
||||
// blocked in official builds. The bootstrap can only function as a launcher
|
||||
// when a client DLL is present.
|
||||
//
|
||||
// This prevents signed bootstrap binaries from being repurposed as
|
||||
// arbitrary CEF installers. In non-official builds all standalone operations
|
||||
// are allowed regardless of this flag (for development/testing).
|
||||
bool enable_explicit_modes = false;
|
||||
|
||||
// Path to a directory containing libcef.dll, used as-is without any version,
|
||||
// ABI, platform, or signature checks. When set and libcef.dll exists at the
|
||||
// path, the installer returns immediately without contacting the CDN or
|
||||
// scanning the shared install directory.
|
||||
//
|
||||
// Relative paths are resolved against the directory containing the module
|
||||
// that provided this config (the client DLL in bootstrap mode). For example,
|
||||
// "." means "libcef.dll is next to the client DLL."
|
||||
//
|
||||
// This is intended for applications that bundle CEF alongside their own
|
||||
// binaries and want to skip the installer entirely. The application is
|
||||
// responsible for the integrity of the DLL at this path.
|
||||
//
|
||||
// If libcef.dll is not found at the path, the installer falls through to the
|
||||
// normal resolution flow (shared directory, CDN download).
|
||||
std::string unchecked_cef_path;
|
||||
|
||||
// Path to a directory containing a full CEF distribution (cef_version.json,
|
||||
// catalog.cat, Release/libcef.dll). Participates in version selection
|
||||
// (newer wins, installed wins ties, revocation demotion). Relative paths are
|
||||
// resolved against the directory containing the module that provided this
|
||||
// config (the client DLL in bootstrap mode).
|
||||
//
|
||||
// Only read from the client DLL's embedded resource or RunInstaller JSON.
|
||||
// Ignored in the bootstrap resource.
|
||||
std::string bundled_cef_path;
|
||||
|
||||
// Exclusive installer state namespace selected by the client DLL resource.
|
||||
// Relative paths are resolved against the client DLL directory before being
|
||||
// propagated to ExtendedConfig. Ignored in bootstrap resources;
|
||||
// RunInstaller JSON has an independent ExtendedConfig field.
|
||||
std::string install_path;
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
// Test-only: override the certificate thumbprint used for archive
|
||||
// verification. ApplyConfigThumbprintOverride() copies this into
|
||||
// ExtendedConfig::certificate_thumbprint and auto-enables signature testing
|
||||
// mode so self-signed test certs are accepted. Compiled out of official
|
||||
// builds.
|
||||
std::string certificate_thumbprint;
|
||||
#endif
|
||||
};
|
||||
|
||||
// Error codes for configuration parsing
|
||||
enum class ConfigError {
|
||||
kSuccess,
|
||||
kJsonParseError, // Malformed JSON
|
||||
kMissingRequiredField, // appid or vmin missing
|
||||
kInvalidFieldValue, // Field present but invalid format
|
||||
kInvalidVersionRange, // Effective vmin is greater than vmax
|
||||
kResourceNotFound, // Embedded resource not found
|
||||
};
|
||||
|
||||
// Serialize Config to JSON for prepared-uninstall configuration binding.
|
||||
std::string ConfigToJson(const Config& config);
|
||||
|
||||
// Parse JSON config string directly. |options| controls which context-specific
|
||||
// fields are parsed; see ConfigParseOptions.
|
||||
ConfigError ParseConfigFromJson(const std::string& json,
|
||||
Config* config,
|
||||
const ConfigParseOptions& options = {},
|
||||
std::string* diagnostic = nullptr);
|
||||
|
||||
// Read from embedded Windows resource (RT_RCDATA with string name).
|
||||
// Resource must be embedded in the client executable's .rc file:
|
||||
// CEF_INSTALLER_CONFIG RCDATA "installer_config.json"
|
||||
// |options| is forwarded to ParseConfigFromJson.
|
||||
ConfigError ReadConfigFromResource(HMODULE module,
|
||||
Config* config,
|
||||
const ConfigParseOptions& options = {},
|
||||
std::string* diagnostic = nullptr);
|
||||
|
||||
// Convert error code to human-readable string for logging
|
||||
const char* ConfigErrorToString(ConfigError error);
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONFIG_H_
|
||||
@@ -0,0 +1,892 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "base/check.h"
|
||||
#include "base/json/json_writer.h"
|
||||
#include "base/test/mock_log.h"
|
||||
#include "cef/include/cef_api_hash.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_policy.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
#include "testing/gmock/include/gmock/gmock.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer {
|
||||
namespace {
|
||||
|
||||
class InstallerConfigTest : public testing::Test {};
|
||||
|
||||
ConfigError ParseConfigWithCdnUrls(const std::vector<std::string>& urls,
|
||||
Config* config,
|
||||
std::string* diagnostic = nullptr) {
|
||||
base::DictValue dict;
|
||||
dict.Set("appid", "12345678-1234-1234-1234-123456789abc");
|
||||
dict.Set("vmin", Version::FromApiVersion(CEF_API_VERSION_LAST).ToString());
|
||||
base::ListValue list;
|
||||
for (const auto& url : urls) {
|
||||
list.Append(url);
|
||||
}
|
||||
dict.Set("cdn_urls", std::move(list));
|
||||
std::string json;
|
||||
CHECK(base::JSONWriter::Write(dict, &json));
|
||||
return ParseConfigFromJson(json, config, {}, diagnostic);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseValidJson) {
|
||||
// Use vmin based on CEF_API_VERSION_LAST so it won't be clamped
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
std::string json =
|
||||
R"({"appid": "12345678-1234-1234-1234-123456789abc", "vmin": ")" +
|
||||
vmin_str + R"(", "vmax": ")" + vmin_str +
|
||||
R"(.99", "abi_hash": "abc123def4567890"})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("12345678-1234-1234-1234-123456789abc", config.appid);
|
||||
EXPECT_EQ(vmin_str, config.vmin);
|
||||
EXPECT_EQ(vmin_str + ".99", config.vmax);
|
||||
EXPECT_EQ("abc123def4567890", config.abi_hash);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseOptionalVmax) {
|
||||
// Use vmin based on CEF_API_VERSION_LAST so it won't be clamped
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
std::string json =
|
||||
R"({"appid": "12345678-1234-1234-1234-123456789abc", "vmin": ")" +
|
||||
vmin_str + R"("})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ(vmin_str, config.vmin);
|
||||
EXPECT_TRUE(config.vmax.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, CdnUrlsPreserveOrderDuplicatesAndNormalize) {
|
||||
Config config;
|
||||
ASSERT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigWithCdnUrls(
|
||||
{"https://one.example/prefix", "https://two.example/",
|
||||
"https://one.example/prefix"},
|
||||
&config));
|
||||
EXPECT_EQ((std::vector<std::string>{"https://one.example/prefix/",
|
||||
"https://two.example/",
|
||||
"https://one.example/prefix/"}),
|
||||
config.cdn_urls);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, CdnUrlsRejectShapeAndCount) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
const std::string prefix =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"(","cdn_urls":)";
|
||||
for (
|
||||
const char* value :
|
||||
{"null", R"("https://one.example/")", "{}", "[]",
|
||||
R"(["https://one.example/",1])",
|
||||
R"(["https://1.example/","https://2.example/","https://3.example/","https://4.example/"])"}) {
|
||||
Config config;
|
||||
std::string diagnostic;
|
||||
EXPECT_EQ(
|
||||
ConfigError::kInvalidFieldValue,
|
||||
ParseConfigFromJson(prefix + value + "}", &config, {}, &diagnostic))
|
||||
<< value;
|
||||
EXPECT_TRUE(config.cdn_urls.empty()) << value;
|
||||
EXPECT_FALSE(diagnostic.empty()) << value;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, CdnUrlsRejectInvalidPublicUrls) {
|
||||
const std::vector<std::string> invalid = {
|
||||
"",
|
||||
"http://example.com/",
|
||||
"https://",
|
||||
"https://user@example.com/",
|
||||
"https://example.com/?q=1",
|
||||
"https://example.com/#fragment",
|
||||
"https://example.com/../bad",
|
||||
"https://example.com\\bad",
|
||||
"https://example.com/line\n",
|
||||
"https://example.com/" + std::string(kMaxCdnUrlBytes, 'a')};
|
||||
for (const auto& url : invalid) {
|
||||
Config config;
|
||||
std::string diagnostic;
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue,
|
||||
ParseConfigWithCdnUrls({url}, &config, &diagnostic))
|
||||
<< url;
|
||||
EXPECT_TRUE(config.cdn_urls.empty()) << url;
|
||||
EXPECT_FALSE(diagnostic.empty()) << url;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, CdnUrlsAbsentAndInvalidClearReusedObject) {
|
||||
Config config;
|
||||
ASSERT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigWithCdnUrls({"https://one.example/"}, &config));
|
||||
ASSERT_FALSE(config.cdn_urls.empty());
|
||||
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
ASSERT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" +
|
||||
vmin + R"("})",
|
||||
&config));
|
||||
EXPECT_TRUE(config.cdn_urls.empty());
|
||||
|
||||
ASSERT_EQ(ConfigError::kInvalidFieldValue,
|
||||
ParseConfigWithCdnUrls({}, &config));
|
||||
EXPECT_TRUE(config.cdn_urls.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, LaunchHealthDefaultsOff) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"("})";
|
||||
|
||||
Config config;
|
||||
ASSERT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &config));
|
||||
EXPECT_EQ(LaunchHealthMode::kOff, config.launch_health);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParsesLaunchHealthModes) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
for (const auto& [value, expected] :
|
||||
std::to_array<std::pair<const char*, LaunchHealthMode>>({
|
||||
{"off", LaunchHealthMode::kOff},
|
||||
{"explicit", LaunchHealthMode::kExplicit},
|
||||
{"exit_code", LaunchHealthMode::kExitCode},
|
||||
})) {
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"(","launch_health":")" + value + R"("})";
|
||||
Config config;
|
||||
ASSERT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &config))
|
||||
<< value;
|
||||
EXPECT_EQ(expected, config.launch_health) << value;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, RejectsInvalidLaunchHealth) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
for (const char* value :
|
||||
{"null", "true", R"("Explicit")", R"("")", R"(" exit_code ")"}) {
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"(","launch_health":)" + value + "}";
|
||||
Config config;
|
||||
std::string diagnostic;
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue,
|
||||
ParseConfigFromJson(json, &config, {}, &diagnostic))
|
||||
<< value;
|
||||
EXPECT_FALSE(diagnostic.empty()) << value;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseVminClampedToApiVersion) {
|
||||
// vmin lower than bootstrap API version should be clamped up
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "100.0"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
|
||||
// vmin should be clamped to the bootstrap's API version
|
||||
constexpr int bootstrap_api_version = (CEF_API_VERSION < CEF_API_VERSION_LAST)
|
||||
? CEF_API_VERSION
|
||||
: CEF_API_VERSION_LAST;
|
||||
Version expected_vmin = Version::FromApiVersion(bootstrap_api_version);
|
||||
EXPECT_EQ(expected_vmin.ToString(), config.vmin);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, VersionRangeValidAfterClamping) {
|
||||
constexpr int bootstrap_api_version = (CEF_API_VERSION < CEF_API_VERSION_LAST)
|
||||
? CEF_API_VERSION
|
||||
: CEF_API_VERSION_LAST;
|
||||
const std::string effective =
|
||||
Version::FromApiVersion(bootstrap_api_version).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":"1.0","vmax":")" +
|
||||
effective + R"(.99"})";
|
||||
Config config;
|
||||
EXPECT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &config));
|
||||
EXPECT_EQ(effective, config.vmin);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, VersionRangeEmptyAfterClamping) {
|
||||
constexpr int bootstrap_api_version = (CEF_API_VERSION < CEF_API_VERSION_LAST)
|
||||
? CEF_API_VERSION
|
||||
: CEF_API_VERSION_LAST;
|
||||
const std::string effective =
|
||||
Version::FromApiVersion(bootstrap_api_version).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":"1.0","vmax":"2.0"})";
|
||||
Config config;
|
||||
std::string diagnostic;
|
||||
EXPECT_EQ(ConfigError::kInvalidVersionRange,
|
||||
ParseConfigFromJson(json, &config, {}, &diagnostic));
|
||||
EXPECT_NE(std::string::npos, diagnostic.find("Configured vmin 1.0"));
|
||||
EXPECT_NE(std::string::npos, diagnostic.find("effective vmin " + effective));
|
||||
EXPECT_NE(std::string::npos,
|
||||
diagnostic.find("bootstrap API version " +
|
||||
std::to_string(bootstrap_api_version)));
|
||||
EXPECT_NE(std::string::npos, diagnostic.find("configured vmax 2.0"));
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, VersionRangeInvalidAsConfigured) {
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":"999.0","vmax":"998.0"})";
|
||||
Config config;
|
||||
std::string diagnostic;
|
||||
EXPECT_EQ(ConfigError::kInvalidVersionRange,
|
||||
ParseConfigFromJson(json, &config, {}, &diagnostic));
|
||||
EXPECT_EQ("Configured vmin 999.0 exceeds configured vmax 998.0", diagnostic);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, VersionRangeEqualAfterClamping) {
|
||||
constexpr int bootstrap_api_version = (CEF_API_VERSION < CEF_API_VERSION_LAST)
|
||||
? CEF_API_VERSION
|
||||
: CEF_API_VERSION_LAST;
|
||||
const std::string effective =
|
||||
Version::FromApiVersion(bootstrap_api_version).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":"1.0","vmax":")" +
|
||||
effective + R"("})";
|
||||
Config config;
|
||||
EXPECT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &config));
|
||||
EXPECT_EQ(effective, config.vmax);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInvalidJson) {
|
||||
const char* json = "not valid json {{{";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kJsonParseError, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseMissingAppid) {
|
||||
const char* json = R"({
|
||||
"vmin": "137.1",
|
||||
"vmax": "137.99"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kMissingRequiredField, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseMissingVmin) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmax": "137.99"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kMissingRequiredField, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInvalidUuid) {
|
||||
const char* json = R"({
|
||||
"appid": "not-a-valid-uuid",
|
||||
"vmin": "137.1"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseUnknownFieldsPreserved) {
|
||||
// Unknown fields warn but remain non-fatal for forward compatibility.
|
||||
// Use vmin based on CEF_API_VERSION_LAST so it won't be clamped
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
std::string json =
|
||||
R"({"appid": "12345678-1234-1234-1234-123456789abc", "vmin": ")" +
|
||||
vmin_str + R"(", "unknown_field": "some value", "another_unknown": 42})";
|
||||
|
||||
base::test::MockLog log;
|
||||
EXPECT_CALL(log, Log(::logging::LOGGING_WARNING, testing::_, testing::_,
|
||||
testing::_, testing::HasSubstr("unknown_field")))
|
||||
.WillOnce(testing::Return(true));
|
||||
EXPECT_CALL(log, Log(::logging::LOGGING_WARNING, testing::_, testing::_,
|
||||
testing::_, testing::HasSubstr("another_unknown")))
|
||||
.WillOnce(testing::Return(true));
|
||||
log.StartCapturingLogs();
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("12345678-1234-1234-1234-123456789abc", config.appid);
|
||||
EXPECT_EQ(vmin_str, config.vmin);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseCombinedConfigFieldsWithoutWarnings) {
|
||||
const std::string version =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
base::DictValue dict;
|
||||
dict.Set(config_fields::kAppIdField, "12345678-1234-1234-1234-123456789abc");
|
||||
dict.Set(config_fields::kVminField, version);
|
||||
dict.Set(config_fields::kVmaxField, version);
|
||||
dict.Set(config_fields::kAbiHashField, "abc123def4567890");
|
||||
dict.Set(config_fields::kChannelField, "beta");
|
||||
base::ListValue cdn_urls;
|
||||
cdn_urls.Append("https://example.test/cef/");
|
||||
dict.Set(config_fields::kCdnUrlsField, std::move(cdn_urls));
|
||||
dict.Set(config_fields::kLaunchHealthField, "explicit");
|
||||
dict.Set(config_fields::kEnableExplicitModesField, true);
|
||||
dict.Set(config_fields::kUncheckedCefPathField, "C:\\unchecked");
|
||||
dict.Set(config_fields::kBundledCefPathField, "C:\\bundled");
|
||||
dict.Set(config_fields::kInstallPathField, "C:\\install");
|
||||
dict.Set(config_fields::kCertificateThumbprintField, "thumbprint");
|
||||
dict.Set(config_fields::kForceCheckField, true);
|
||||
dict.Set(config_fields::kShowProgressUiField, false);
|
||||
dict.Set(config_fields::kParentWindowField, "0");
|
||||
dict.Set(config_fields::kLocalDownloadPathField, "C:\\mirror");
|
||||
dict.Set(config_fields::kLogLevelField, "warning");
|
||||
dict.Set(config_fields::kDownloadTimeoutMsField, 1000);
|
||||
dict.Set(config_fields::kMaxAgeDaysField, 90);
|
||||
std::string json;
|
||||
ASSERT_TRUE(base::JSONWriter::Write(dict, &json));
|
||||
|
||||
base::test::MockLog log;
|
||||
EXPECT_CALL(log, Log(::logging::LOGGING_WARNING, testing::_, testing::_,
|
||||
testing::_, testing::_))
|
||||
.Times(0);
|
||||
log.StartCapturingLogs();
|
||||
|
||||
ConfigParseOptions options;
|
||||
options.allow_enable_explicit_modes = true;
|
||||
options.allow_unchecked_cef_path = true;
|
||||
options.allow_bundled_cef_path = true;
|
||||
options.allow_install_path = true;
|
||||
Config config;
|
||||
EXPECT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &config, options));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Channel Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseChannelBeta) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"channel": "beta"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("beta", config.channel);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseChannelEmptyDefault) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.channel.empty()); // Empty = stable
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseChannelInvalid) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"channel": "canary"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseChannelStableExplicitInvalid) {
|
||||
// "stable" is not a valid explicit value - use empty string instead
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"channel": "stable"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue, error);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// enable_explicit_modes Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseEnableExplicitModesTrue) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"enable_explicit_modes": true
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_enable_explicit_modes = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.enable_explicit_modes);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseEnableExplicitModesFalse) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"enable_explicit_modes": false
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_enable_explicit_modes = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_FALSE(config.enable_explicit_modes);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseEnableExplicitModesDefault) {
|
||||
// Absent field defaults to false even when allowed
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_enable_explicit_modes = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_FALSE(config.enable_explicit_modes);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseEnableExplicitModesIgnoredByDefault) {
|
||||
// When allow_enable_explicit_modes is false (default), the field is ignored
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"enable_explicit_modes": true
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_FALSE(config.enable_explicit_modes);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// unchecked_cef_path Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseUncheckedCefPath) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"unchecked_cef_path": "."
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_unchecked_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ(".", config.unchecked_cef_path);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseUncheckedCefPathAbsolute) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"unchecked_cef_path": "C:\\App\\CEF"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_unchecked_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("C:\\App\\CEF", config.unchecked_cef_path);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseUncheckedCefPathEmptyIgnored) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"unchecked_cef_path": ""
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_unchecked_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.unchecked_cef_path.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseUncheckedCefPathAbsentDefault) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_unchecked_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.unchecked_cef_path.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseUncheckedCefPathIgnoredByDefault) {
|
||||
// When allow_unchecked_cef_path is false (default), the field is ignored
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"unchecked_cef_path": "C:\\App\\CEF"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.unchecked_cef_path.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// bundled_cef_path Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseBundledCefPath) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"bundled_cef_path": "bundled_cef"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_bundled_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("bundled_cef", config.bundled_cef_path);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseBundledCefPathAbsolute) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"bundled_cef_path": "C:\\App\\BundledCEF"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_bundled_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("C:\\App\\BundledCEF", config.bundled_cef_path);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseBundledCefPathEmptyIgnored) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"bundled_cef_path": ""
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error =
|
||||
ParseConfigFromJson(json, &config, {.allow_bundled_cef_path = true});
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.bundled_cef_path.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseBundledCefPathIgnoredByDefault) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"bundled_cef_path": "C:\\App\\BundledCEF"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.bundled_cef_path.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConfigErrorToString Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ConfigErrorToString) {
|
||||
EXPECT_STREQ("Success", ConfigErrorToString(ConfigError::kSuccess));
|
||||
EXPECT_STREQ("Malformed JSON",
|
||||
ConfigErrorToString(ConfigError::kJsonParseError));
|
||||
EXPECT_STREQ("Required field missing",
|
||||
ConfigErrorToString(ConfigError::kMissingRequiredField));
|
||||
EXPECT_STREQ("Invalid field value",
|
||||
ConfigErrorToString(ConfigError::kInvalidFieldValue));
|
||||
EXPECT_STREQ("Embedded resource not found",
|
||||
ConfigErrorToString(ConfigError::kResourceNotFound));
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInstallPathAllowedForClientResource) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"(","install_path":"cef\\private"})";
|
||||
|
||||
Config config;
|
||||
EXPECT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(json, &config, {.allow_install_path = true}));
|
||||
EXPECT_EQ("cef\\private", config.install_path);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInstallPathEmptyAndMissingClearValue) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
const std::string prefix =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin;
|
||||
Config config;
|
||||
ASSERT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(prefix + R"(","install_path":"first"})",
|
||||
&config, {.allow_install_path = true}));
|
||||
ASSERT_EQ("first", config.install_path);
|
||||
EXPECT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(prefix + R"(","install_path":""})", &config,
|
||||
{.allow_install_path = true}));
|
||||
EXPECT_TRUE(config.install_path.empty());
|
||||
config.install_path = "stale";
|
||||
EXPECT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(prefix + R"("})", &config,
|
||||
{.allow_install_path = true}));
|
||||
EXPECT_TRUE(config.install_path.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInstallPathWrongTypeRejected) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"(","install_path":7})";
|
||||
Config config;
|
||||
std::string diagnostic;
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue,
|
||||
ParseConfigFromJson(json, &config, {.allow_install_path = true},
|
||||
&diagnostic));
|
||||
EXPECT_EQ("install_path must be a string", diagnostic);
|
||||
EXPECT_TRUE(config.install_path.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInstallPathDisallowedAndReuseClearsValue) {
|
||||
const std::string vmin =
|
||||
Version::FromApiVersion(CEF_API_VERSION_LAST).ToString();
|
||||
const std::string json =
|
||||
R"({"appid":"12345678-1234-1234-1234-123456789abc","vmin":")" + vmin +
|
||||
R"(","install_path":"ignored"})";
|
||||
Config config;
|
||||
ASSERT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(json, &config, {.allow_install_path = true}));
|
||||
ASSERT_EQ("ignored", config.install_path);
|
||||
EXPECT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &config));
|
||||
EXPECT_TRUE(config.install_path.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ConfigToJson Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ConfigToJson_AllFields) {
|
||||
// Use vmin based on CEF_API_VERSION_LAST so it won't be clamped
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
|
||||
Config config;
|
||||
config.appid = "12345678-1234-1234-1234-123456789abc";
|
||||
config.vmin = vmin_str;
|
||||
config.vmax = vmin_str + ".99";
|
||||
config.abi_hash = "abc123def4567890";
|
||||
config.channel = "beta";
|
||||
config.cdn_urls = {"https://one.example/", "https://one.example/"};
|
||||
config.launch_health = LaunchHealthMode::kExplicit;
|
||||
config.enable_explicit_modes = true;
|
||||
config.unchecked_cef_path = "C:\\App\\CEF";
|
||||
config.bundled_cef_path = "C:\\App\\BundledCEF";
|
||||
config.install_path = "C:\\App\\PrivateStore";
|
||||
|
||||
std::string json = ConfigToJson(config);
|
||||
EXPECT_EQ(std::string::npos, json.find("install_path"));
|
||||
|
||||
// Round-trip: parse the JSON back and verify all fields
|
||||
Config parsed;
|
||||
EXPECT_EQ(ConfigError::kSuccess,
|
||||
ParseConfigFromJson(json, &parsed,
|
||||
{.allow_enable_explicit_modes = true,
|
||||
.allow_unchecked_cef_path = true,
|
||||
.allow_bundled_cef_path = true}));
|
||||
EXPECT_EQ(config.appid, parsed.appid);
|
||||
EXPECT_EQ(config.vmin, parsed.vmin);
|
||||
EXPECT_EQ(config.vmax, parsed.vmax);
|
||||
EXPECT_EQ(config.abi_hash, parsed.abi_hash);
|
||||
EXPECT_EQ(config.channel, parsed.channel);
|
||||
EXPECT_EQ(config.cdn_urls, parsed.cdn_urls);
|
||||
EXPECT_EQ(config.launch_health, parsed.launch_health);
|
||||
EXPECT_EQ(config.enable_explicit_modes, parsed.enable_explicit_modes);
|
||||
EXPECT_EQ(config.unchecked_cef_path, parsed.unchecked_cef_path);
|
||||
EXPECT_EQ(config.bundled_cef_path, parsed.bundled_cef_path);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ConfigToJson_RequiredFieldsOnly) {
|
||||
// Use vmin based on CEF_API_VERSION_LAST so it won't be clamped
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
|
||||
Config config;
|
||||
config.appid = "12345678-1234-1234-1234-123456789abc";
|
||||
config.vmin = vmin_str;
|
||||
|
||||
std::string json = ConfigToJson(config);
|
||||
|
||||
Config parsed;
|
||||
EXPECT_EQ(ConfigError::kSuccess, ParseConfigFromJson(json, &parsed));
|
||||
EXPECT_EQ(config.appid, parsed.appid);
|
||||
EXPECT_EQ(config.vmin, parsed.vmin);
|
||||
EXPECT_TRUE(parsed.vmax.empty());
|
||||
EXPECT_TRUE(parsed.abi_hash.empty());
|
||||
EXPECT_TRUE(parsed.channel.empty());
|
||||
EXPECT_TRUE(parsed.cdn_urls.empty());
|
||||
EXPECT_EQ(LaunchHealthMode::kOff, parsed.launch_health);
|
||||
EXPECT_FALSE(parsed.enable_explicit_modes);
|
||||
EXPECT_TRUE(parsed.unchecked_cef_path.empty());
|
||||
EXPECT_TRUE(parsed.bundled_cef_path.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ReadConfigFromResource Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerConfigTest, ReadConfigFromResource_NullConfig) {
|
||||
// Passing nullptr should return an error, not crash
|
||||
EXPECT_EQ(ConfigError::kMissingRequiredField,
|
||||
ReadConfigFromResource(GetModuleHandle(nullptr), nullptr));
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ReadConfigFromResource_ResourceNotFound) {
|
||||
Config config;
|
||||
// Current module has no CEF_INSTALLER_CONFIG resource
|
||||
EXPECT_EQ(ConfigError::kResourceNotFound,
|
||||
ReadConfigFromResource(GetModuleHandle(nullptr), &config));
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseEmptyVmin) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": ""
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
EXPECT_EQ(ConfigError::kMissingRequiredField, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInvalidVmin) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "not-a-version"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseInvalidVmax) {
|
||||
const char* json = R"({
|
||||
"appid": "12345678-1234-1234-1234-123456789abc",
|
||||
"vmin": "137.1",
|
||||
"vmax": "not-a-version"
|
||||
})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
EXPECT_EQ(ConfigError::kInvalidFieldValue, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseNullConfig) {
|
||||
ConfigError error = ParseConfigFromJson("{}", nullptr);
|
||||
EXPECT_EQ(ConfigError::kMissingRequiredField, error);
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
TEST_F(InstallerConfigTest, ParseCertificateThumbprint) {
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
std::string json =
|
||||
R"({"appid": "12345678-1234-1234-1234-123456789abc", "vmin": ")" +
|
||||
vmin_str +
|
||||
R"(", "certificate_thumbprint": "136B16359FD1209E8255DB538F3F02C8E8D8BB55"})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_EQ("136B16359FD1209E8255DB538F3F02C8E8D8BB55",
|
||||
config.certificate_thumbprint);
|
||||
}
|
||||
|
||||
TEST_F(InstallerConfigTest, ParseCertificateThumbprintAbsent) {
|
||||
Version next_ver = Version::FromApiVersion(CEF_API_VERSION_LAST);
|
||||
std::string vmin_str = next_ver.ToString();
|
||||
std::string json =
|
||||
R"({"appid": "12345678-1234-1234-1234-123456789abc", "vmin": ")" +
|
||||
vmin_str + R"("})";
|
||||
|
||||
Config config;
|
||||
ConfigError error = ParseConfigFromJson(json, &config);
|
||||
|
||||
EXPECT_EQ(ConfigError::kSuccess, error);
|
||||
EXPECT_TRUE(config.certificate_thumbprint.empty());
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONSTANTS_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONSTANTS_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <iterator>
|
||||
#include <string_view>
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Resource name for embedded JSON configuration (RT_RCDATA)
|
||||
// Using a string name instead of numeric ID to avoid conflicts with
|
||||
// application-defined resource IDs.
|
||||
constexpr const wchar_t* kConfigResourceName = L"CEF_INSTALLER_CONFIG";
|
||||
|
||||
// Resource name for compiled-in revocation list (RT_RCDATA)
|
||||
// Embedded in the bootstrap binary at build time from revoked.json.
|
||||
constexpr const wchar_t* kRevocationResourceName = L"CEF_REVOCATION_LIST";
|
||||
|
||||
// Default CDN base URL for CEF builds
|
||||
constexpr std::string_view kDefaultCdnBaseUrl =
|
||||
"https://cef-builds.spotifycdn.com/";
|
||||
|
||||
// Hardcoded certificate thumbprint for official CEF signed binaries (SHA-1)
|
||||
// This is the thumbprint of the code signing certificate used to sign
|
||||
// all official CEF distributions. Must be updated when certificate is rotated.
|
||||
// Format: 40 hex characters, uppercase, no separators
|
||||
constexpr std::string_view kCefCertificateThumbprint =
|
||||
"3906F72B1D5FA45A6334A8590495FAEB93347E5B";
|
||||
|
||||
// Revocation list URL (relative to CDN base)
|
||||
constexpr std::string_view kRevocationListPath = "revoked.json";
|
||||
|
||||
// Release channels
|
||||
constexpr std::string_view kChannelStable = "stable"; // Default channel
|
||||
constexpr std::string_view kChannelBeta = "beta";
|
||||
|
||||
// Installer database filename
|
||||
constexpr std::wstring_view kDatabaseFilename = L"installer.json";
|
||||
|
||||
// Version metadata filename (per version directory)
|
||||
constexpr std::wstring_view kVersionMetadataFilename = L"cef_version.json";
|
||||
|
||||
// Version index filename (per install directory root)
|
||||
constexpr std::wstring_view kVersionIndexFilename = L"versions.json";
|
||||
|
||||
// CEF library filename
|
||||
constexpr std::wstring_view kLibcefFilename = L"libcef.dll";
|
||||
|
||||
// Release subdirectory within CEF distribution archives.
|
||||
// CEF distributions place binaries and resources in a Release/ subdirectory.
|
||||
constexpr std::wstring_view kReleaseSubdirectory = L"Release";
|
||||
|
||||
// Catalog filename for signature verification
|
||||
constexpr std::wstring_view kCatalogFilename = L"catalog.cat";
|
||||
|
||||
// Revocation cache filename (disk-cached CDN delta)
|
||||
constexpr std::wstring_view kRevocationCacheFilename = L"revocation_cache.json";
|
||||
constexpr std::wstring_view kRevocationBackoffFilename =
|
||||
L"revocation_refresh_backoff.json";
|
||||
constexpr int kRevocationCacheValiditySeconds = 60 * 60;
|
||||
constexpr int kRevocationFailureBackoffSeconds = 15 * 60;
|
||||
constexpr int64_t kMaxRevocationBackoffFileSize = 4096;
|
||||
constexpr int kLaunchRevocationConnectTimeoutMs = 3000;
|
||||
constexpr int kLaunchRevocationReceiveTimeoutMs = 2000;
|
||||
constexpr int kLaunchRevocationOverallTimeoutMs = 5000;
|
||||
|
||||
// Automatic-startup emergency recovery limits. The scanner runs only after
|
||||
// index-backed and bundled selection misses, and only for roots whose index is
|
||||
// missing or invalid. The elapsed budget is soft because a synchronous Windows
|
||||
// filesystem operation cannot be interrupted at the deadline.
|
||||
constexpr size_t kEmergencyRecoveryMaxRoots = 4;
|
||||
constexpr size_t kEmergencyRecoveryMaxVersionEntries = 128;
|
||||
constexpr int kEmergencyRecoveryTimeBudgetMs = 500;
|
||||
|
||||
// Installer log filename
|
||||
constexpr std::wstring_view kLogFilename = L"cef_installer.log";
|
||||
|
||||
// Maximum log file size before rotation (1 MB)
|
||||
constexpr uint64_t kMaxLogFileSize = 1 * 1024 * 1024;
|
||||
|
||||
// Number of rotated log files to keep
|
||||
constexpr int kMaxRotatedLogFiles = 3;
|
||||
|
||||
// Mutex name prefix for installer lock
|
||||
// Full name: Global\CEF_Installer_<hash>
|
||||
constexpr std::string_view kMutexNamePrefix = "Global\\CEF_Installer_";
|
||||
|
||||
// Default lock acquisition timeout (30 seconds)
|
||||
constexpr uint32_t kDefaultLockTimeoutMs = 30000;
|
||||
|
||||
// Default download retry settings
|
||||
constexpr int kDefaultMaxRetries = 3;
|
||||
constexpr uint32_t kDefaultRetryDelayMs = 1000;
|
||||
|
||||
// Manifest cache validity duration (1 hour)
|
||||
constexpr int kManifestCacheValiditySeconds = 3600;
|
||||
|
||||
// Archive cache expiry duration (7 days). Cached archives are kept across
|
||||
// retries and only deleted after successful installation. This expiry catches
|
||||
// orphaned archives from failed installs that were never retried.
|
||||
constexpr int kArchiveCacheValiditySeconds = 7 * 24 * 60 * 60;
|
||||
|
||||
// CEF subdirectory name within standard locations (e.g., %ProgramFiles%\CEF)
|
||||
constexpr std::wstring_view kCefSubdirectory = L"CEF";
|
||||
|
||||
// Versions subdirectory within the install directory
|
||||
constexpr std::wstring_view kVersionsSubdirectory = L"Versions";
|
||||
|
||||
// Staging subdirectory for downloads and extraction.
|
||||
// Placed under the install directory to guarantee same-volume atomic renames
|
||||
// into Versions/<version>/<platform>.
|
||||
constexpr std::wstring_view kStagingSubdirectory = L".staging";
|
||||
|
||||
// Pending deletion directory
|
||||
constexpr std::wstring_view kTrashSubdirectory = L".trash";
|
||||
|
||||
// ============================================================================
|
||||
// Security: Field Length Limits
|
||||
// ============================================================================
|
||||
// Maximum lengths for string fields to prevent memory exhaustion from
|
||||
// malformed/malicious data. These limits are enforced when parsing JSON
|
||||
// from untrusted sources (manifests, database, metadata files).
|
||||
|
||||
constexpr size_t kMaxVersionLength = 64; // Version strings (e.g., "137.3.5")
|
||||
constexpr size_t kMaxUuidLength = 256; // Application UUIDs
|
||||
constexpr size_t kMaxAbiHashLength = 256; // ABI compatibility hashes
|
||||
constexpr size_t kMaxFilenameLength = 256; // Archive filenames
|
||||
constexpr size_t kMaxSha1Length = 40; // SHA1 hex strings
|
||||
constexpr size_t kMaxSha256Length = 64; // SHA256 hex strings
|
||||
constexpr size_t kMaxTimestampLength = 64; // ISO 8601 timestamps
|
||||
constexpr size_t kMaxVersionFullLength = 256; // Full version strings
|
||||
constexpr size_t kMaxReasonLength = 1024; // Revocation reason strings
|
||||
|
||||
// Maximum size for CDN revocation list downloads (1 MB).
|
||||
// Also used to bound the on-disk revocation cache file size (2x multiplier
|
||||
// to accommodate CRC32 footer overhead and merges across sessions).
|
||||
constexpr uint64_t kMaxRevocationDownloadSize = 1 * 1024 * 1024;
|
||||
constexpr int64_t kMaxRevocationCacheFileSize =
|
||||
static_cast<int64_t>(kMaxRevocationDownloadSize) * 2;
|
||||
constexpr size_t kMaxPlatformLength =
|
||||
32; // Platform strings (e.g., "windows64")
|
||||
|
||||
// Maximum number of entries in a revocation list. The 2 MB file size cap
|
||||
// (kMaxRevocationCacheFileSize) naturally limits entries to ~10K, but an
|
||||
// explicit limit provides a clearer guarantee against CPU exhaustion during
|
||||
// parsing and merge operations.
|
||||
constexpr size_t kMaxRevocationEntryCount = 10000;
|
||||
|
||||
// ============================================================================
|
||||
// Process Exit Codes
|
||||
// ============================================================================
|
||||
// Used when running in standalone installer mode (bootstrap.exe /cef-update,
|
||||
// etc.) Exit code 0 indicates success; non-zero indicates specific error
|
||||
// categories.
|
||||
//
|
||||
// These codes use a base offset of 100 to avoid conflicts with cef_resultcode_t
|
||||
// values from cef_types.h (Chrome codes: 0-40+, sandbox codes: 7006+).
|
||||
// The 100-199 range is reserved for installer-specific errors.
|
||||
|
||||
constexpr int kInstallerExitCodeBase = 100;
|
||||
|
||||
// The requested operation completed logically. Physical cleanup may still be
|
||||
// deferred and reported as a warning.
|
||||
constexpr int kExitCodeSuccess = 0;
|
||||
// Required installer configuration was missing or invalid.
|
||||
constexpr int kExitCodeConfigError = kInstallerExitCodeBase; // 100
|
||||
// A required download or other network operation failed.
|
||||
constexpr int kExitCodeNetworkError = kInstallerExitCodeBase + 1; // 101
|
||||
// Catalog signature or catalog content verification failed.
|
||||
constexpr int kExitCodeSignatureError = kInstallerExitCodeBase + 2; // 102
|
||||
// No non-revoked CEF version satisfied the requested constraints.
|
||||
constexpr int kExitCodeNoMatchingVersion = kInstallerExitCodeBase + 3; // 103
|
||||
// The downloaded archive could not be extracted safely.
|
||||
constexpr int kExitCodeExtractionError = kInstallerExitCodeBase + 4; // 104
|
||||
// Installer setup or publication failure, including indeterminate physical
|
||||
// containment or a required uninstall relaunch that could not be prepared or
|
||||
// started.
|
||||
constexpr int kExitCodeInstallError = kInstallerExitCodeBase + 5; // 105
|
||||
// The registration database could not be read, updated, or published.
|
||||
constexpr int kExitCodeDatabaseError = kInstallerExitCodeBase + 6; // 106
|
||||
// The writer lock could not be acquired before the configured timeout.
|
||||
constexpr int kExitCodeLockTimeout = kInstallerExitCodeBase + 7; // 107
|
||||
// The operation was cancelled before its logical commit.
|
||||
constexpr int kExitCodeCancelled = kInstallerExitCodeBase + 8; // 108
|
||||
// Uninstall work is pending in a successfully created child process. Returned
|
||||
// only for explicit background mode or self-removal from the writable target.
|
||||
constexpr int kExitCodeRelaunched = kInstallerExitCodeBase + 9; // 109
|
||||
// No active launch-health sentinel was found for launch-success confirmation.
|
||||
constexpr int kExitCodeNoSentinel = kInstallerExitCodeBase + 10; // 110
|
||||
// The launch-health sentinel could not be read, validated, or confirmed.
|
||||
constexpr int kExitCodeSentinelReadError = kInstallerExitCodeBase + 11; // 111
|
||||
// The launch-health sentinel belongs to a different process instance.
|
||||
constexpr int kExitCodeSentinelOwnerMismatch =
|
||||
kInstallerExitCodeBase + 12; // 112
|
||||
// Valid enterprise policy prohibited a required mutation or download.
|
||||
constexpr int kExitCodePolicyDenied = kInstallerExitCodeBase + 13; // 113
|
||||
// The checked version index could not be read, published, or revalidated.
|
||||
constexpr int kExitCodeIndexError = kInstallerExitCodeBase + 14; // 114
|
||||
// Writer-locked staging, index, or orphan recovery failed.
|
||||
constexpr int kExitCodeRecoveryError = kInstallerExitCodeBase + 15; // 115
|
||||
// Verified staging could not replace a proven-invalid destination.
|
||||
constexpr int kExitCodeRepairError = kInstallerExitCodeBase + 16; // 116
|
||||
// An invalid destination could not be moved safely to quarantine or trash.
|
||||
constexpr int kExitCodeQuarantineError = kInstallerExitCodeBase + 17; // 117
|
||||
// Final validation found that the retention removal scope had changed.
|
||||
constexpr int kExitCodeRetentionSnapshotChanged =
|
||||
kInstallerExitCodeBase + 18; // 118
|
||||
// Enterprise policy was unreadable, malformed, or internally conflicting.
|
||||
constexpr int kExitCodePolicyError = kInstallerExitCodeBase + 19; // 119
|
||||
// An unexpected or otherwise unclassified internal error occurred.
|
||||
constexpr int kExitCodeUnknownError = kInstallerExitCodeBase + 99; // 199
|
||||
|
||||
// ============================================================================
|
||||
// WM_COPYDATA Progress Notifications
|
||||
// ============================================================================
|
||||
// Used for progress communication between the installer and a parent window.
|
||||
|
||||
// COPYDATASTRUCT.dwData identifier for CEF installer progress messages.
|
||||
// 'CEFI' in little-endian: 0x49464543
|
||||
// Type is uintptr_t to match ULONG_PTR without requiring <windows.h>.
|
||||
constexpr uintptr_t kWmCopyDataInstallerProgress = 0x43454649;
|
||||
|
||||
// Separate 'CEFR' channel for uninstall lifecycle handoff and terminal result
|
||||
// envelopes. Existing progress receivers may ignore this additive identity.
|
||||
constexpr uintptr_t kWmCopyDataInstallerLifecycle = 0x43454652;
|
||||
constexpr int kInstallerLifecycleProtocolVersion = 1;
|
||||
constexpr size_t kInstallerLifecycleMaxPayloadSize = 32 * 1024;
|
||||
constexpr size_t kInstallerLifecycleMaxErrorMessageSize = 4096;
|
||||
constexpr size_t kInstallerLifecycleMaxWarningSize = 1024;
|
||||
constexpr size_t kInstallerLifecycleMaxWarnings = 32;
|
||||
constexpr uint32_t kInstallerLifecycleSendTimeoutMs = 500;
|
||||
|
||||
// Return this value from a WM_COPYDATA handler to request cancellation.
|
||||
// Standard WM_COPYDATA return values (TRUE=handled, FALSE=not handled) are
|
||||
// not treated as cancellation — only this specific sentinel value cancels.
|
||||
// Type is intptr_t to match LRESULT without requiring <windows.h>.
|
||||
constexpr intptr_t kWmCopyDataResultCancel = 2;
|
||||
|
||||
// ============================================================================
|
||||
// Progress Steps
|
||||
// ============================================================================
|
||||
// Step indices for installer progress. Values are used as array indices.
|
||||
|
||||
enum Step {
|
||||
kStepInit = 0,
|
||||
kStepLock,
|
||||
kStepVersionCheck,
|
||||
kStepCdnResolve,
|
||||
kStepDownload,
|
||||
kStepExtract,
|
||||
kStepSignatureVerify,
|
||||
kStepInstall,
|
||||
kStepCommitting,
|
||||
kStepCleanup,
|
||||
kNumSteps,
|
||||
};
|
||||
|
||||
// Display strings for each step (English fallback).
|
||||
inline constexpr const char* kStepDisplayStrings[] = {
|
||||
"Initializing...", // kStepInit
|
||||
"Initializing...", // kStepLock
|
||||
"Checking versions...", // kStepVersionCheck
|
||||
"Checking versions...", // kStepCdnResolve
|
||||
"Downloading...", // kStepDownload
|
||||
"Extracting...", // kStepExtract
|
||||
"Verifying signatures...", // kStepSignatureVerify
|
||||
"Installing...", // kStepInstall
|
||||
"Committing...", // kStepCommitting
|
||||
"Cleaning up...", // kStepCleanup
|
||||
};
|
||||
|
||||
static_assert(std::size(kStepDisplayStrings) == kNumSteps,
|
||||
"kStepDisplayStrings size mismatch");
|
||||
|
||||
// Returns the display string for a step (e.g., "Downloading...").
|
||||
inline const char* StepDisplayString(Step step) {
|
||||
if (step < 0 || step >= kNumSteps) {
|
||||
return "Unknown...";
|
||||
}
|
||||
return kStepDisplayStrings[step];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Installer Commands
|
||||
// ============================================================================
|
||||
// Commands that can be executed by the installer.
|
||||
|
||||
// Directory name for launch state files under the install directory.
|
||||
// Files in this directory survive version pruning so crash history is
|
||||
// preserved.
|
||||
constexpr std::wstring_view kLaunchStateDirName = L".launch";
|
||||
constexpr int64_t kMaxLaunchStateFileSize = 4096;
|
||||
constexpr uint64_t kLivenessRefreshIntervalFileTimeTicks =
|
||||
30ULL * 24 * 60 * 60 * 10000000;
|
||||
constexpr uint64_t kLaunchStateGcMaxAge = 90ULL * 24 * 60 * 60 * 10000000;
|
||||
|
||||
// Maximum consecutive launch failures before a version is disqualified
|
||||
constexpr int kMaxConsecutiveFailures = 3;
|
||||
|
||||
enum class Command {
|
||||
kInstall, // Install CEF for an application
|
||||
kUpdate, // Update CEF to latest compatible version
|
||||
kUninstall, // Unregister application and prune unused versions
|
||||
kQuery, // Query installed versions (no modifications)
|
||||
kPrune, // Prune unused versions only (no version resolution)
|
||||
kRetentionDryRun, // Report explicit registration-retention effects
|
||||
kRetentionApply, // Apply recomputed registration retention
|
||||
kLaunchSuccess, // Confirm launch health — version is working
|
||||
};
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONSTANTS_H_
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,629 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONTROLLER_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONTROLLER_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/functional/callback.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_cdn_manifest.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_database.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_lock.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_logger.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_policy.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_retention.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_revocation.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version_metadata.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
enum class ArchiveError;
|
||||
enum class SignatureError;
|
||||
|
||||
enum class ExecutionContext {
|
||||
kExplicitCommand,
|
||||
kAutomaticStartup,
|
||||
};
|
||||
|
||||
enum class UninstallExecution {
|
||||
kInProcess,
|
||||
kRelaunch,
|
||||
kReject,
|
||||
};
|
||||
|
||||
// Process-local uninstall preparation shared by bootstrap preflight and the
|
||||
// direct controller run. This is never serialized or exposed by RunInstaller.
|
||||
struct PreparedUninstall {
|
||||
Command command = Command::kUninstall;
|
||||
UninstallExecution execution = UninstallExecution::kReject;
|
||||
std::string config_binding;
|
||||
std::string install_path;
|
||||
PolicyLoadResult policy_result;
|
||||
DirectoryResolutionContext resolution_context;
|
||||
std::optional<InstallDirectories> directories;
|
||||
};
|
||||
|
||||
// Extended configuration settings
|
||||
//
|
||||
// SECURITY / TRUST MODEL:
|
||||
// =======================
|
||||
// ExtendedConfig allows overriding security-sensitive settings like CDN URL
|
||||
// and certificate thumbprint. This is INTENTIONAL and assumes the config JSON
|
||||
// comes from a TRUSTED source (the client DLL embedding CEF).
|
||||
//
|
||||
// The trust model is:
|
||||
// - Client application is trusted (it's running the installer)
|
||||
// - Config JSON passed to RunInstaller is trusted (from client DLL)
|
||||
// - CDN responses are verified via certificate pinning
|
||||
// - Downloaded binaries are verified via code signature
|
||||
//
|
||||
// The override capabilities exist for:
|
||||
// - Testing with mock CDN servers (cdn_urls)
|
||||
// - Supporting alternative signing certificates (certificate_thumbprint)
|
||||
// - Custom deployment scenarios (install_path, bundled_cef_path)
|
||||
//
|
||||
// If config JSON could come from untrusted sources (e.g., user input, network),
|
||||
// the caller MUST validate/sanitize it before passing to RunInstaller.
|
||||
//
|
||||
// HOW EXTENDED CONFIG IS POPULATED:
|
||||
// =================================
|
||||
// ExtendedConfig is populated through multiple sources with the following
|
||||
// precedence:
|
||||
//
|
||||
// 1. **Programmatic (Option C)**: When client DLL calls RunInstaller() with a
|
||||
// JSON config string, extended settings can be included in the JSON:
|
||||
// {
|
||||
// "appid": "...", "vmin": "...", "vmax": "...", "abi_hash": "...",
|
||||
// "cdn_urls": ["https://custom.cdn.com/"],
|
||||
// "bundled_cef_path": "C:\\App\\CEF"
|
||||
// }
|
||||
// The Controller parses both Config and ExtendedConfig from this JSON.
|
||||
//
|
||||
// 2. **Command-line flags**: When running in standalone mode, flags like
|
||||
// /cef-forcecheck, /cef-background, /cef-headless, /cef-parent are parsed
|
||||
// into ExtendedConfig by ParseExtendedConfig() in bootstrap_win.cc.
|
||||
//
|
||||
// 3. **Defaults**: All fields have sensible defaults (see struct definition).
|
||||
// If not specified, defaults are used.
|
||||
//
|
||||
// The client DLL resource may additionally provide bundled_cef_path and
|
||||
// install_path. install_path is resolved against that DLL and copied here only
|
||||
// when an operation-specific value has not already been supplied. Bootstrap
|
||||
// resources cannot provide install_path.
|
||||
//
|
||||
struct ExtendedConfig {
|
||||
// Ordered operation-specific CDN base URLs. Empty means the selected
|
||||
// application config or hardcoded default applies. Public JSON parsing
|
||||
// requires one through three HTTPS URLs; non-official tests may inject HTTP
|
||||
// values directly after parsing.
|
||||
std::vector<std::string> cdn_urls;
|
||||
|
||||
// Custom CEF installation path (default: use ResolveInstallDirectories())
|
||||
// If empty, uses the standard search order:
|
||||
// registry -> %ProgramFiles% -> %LocalAppData%
|
||||
//
|
||||
// A custom path is authoritative and exclusive. A safe writable path is the
|
||||
// sole read/write store. A safe existing read-only path is the sole readable
|
||||
// store for query and automatic selection. Invalid, unsafe, inaccessible, or
|
||||
// missing read-only paths fail without default-search fallback.
|
||||
//
|
||||
// Security measures applied to custom paths:
|
||||
// - Reparse points (symlinks/junctions) are rejected
|
||||
std::string install_path;
|
||||
|
||||
// Path to bundled CEF for full installers (default: none)
|
||||
// If set and no newer/compatible version is installed, use this bundled
|
||||
// version. A newer version may still be installed in background and used
|
||||
// after relaunch.
|
||||
//
|
||||
// Bundled CEF handling:
|
||||
// 1. The bundled CEF must be pre-extracted (not a .tar.xz archive)
|
||||
// 2. The bundled directory must contain cef_version.json with
|
||||
// version/abi_hash
|
||||
// 3. catalog.cat must be a regular non-reparse file, but its signature and
|
||||
// members are not verified at runtime
|
||||
// 4. If bundled version is compatible AND no better installed version
|
||||
// exists, the bundled version will be loaded in-place
|
||||
// 5. If a newer compatible version is already installed, bundled CEF is
|
||||
// ignored
|
||||
//
|
||||
// This enables offline/air-gapped installation while still preferring
|
||||
// newer shared versions when available.
|
||||
std::string bundled_cef_path;
|
||||
|
||||
// Expected certificate thumbprint for CEF binaries.
|
||||
// Default: kCefCertificateThumbprint (hardcoded official CEF certificate)
|
||||
//
|
||||
// SECURITY NOTE: This is the primary security control. Overriding this
|
||||
// allows binaries signed by a different certificate to be installed.
|
||||
// Only override for testing or when using legitimately re-signed binaries.
|
||||
// An attacker who can control this value can bypass signature verification.
|
||||
std::string certificate_thumbprint = std::string(kCefCertificateThumbprint);
|
||||
|
||||
// Force version check even if recently checked
|
||||
bool force_check = false;
|
||||
|
||||
// Show progress UI (default: true)
|
||||
bool show_progress_ui = true;
|
||||
|
||||
// Internal command-line operation context. Set only by /cef-background;
|
||||
// headless UI suppression alone must not lower extraction impact.
|
||||
bool background_mode = false;
|
||||
|
||||
// Parent window handle for progress UI
|
||||
// If non-zero and show_progress_ui is true, the progress dialog is positioned
|
||||
// relative to this window (centered and topmost, but not directly parented
|
||||
// for security reasons). Also used for WM_COPYDATA progress notifications
|
||||
// when the caller wants to display custom UI.
|
||||
HWND parent_window = nullptr;
|
||||
|
||||
// Path to a local directory containing CDN-structured files.
|
||||
// When set, the installer reads manifest JSON, .tar.xz archives, and
|
||||
// .sha256 files from this directory instead of downloading from CDN.
|
||||
// The directory must contain the same files that would be available on CDN
|
||||
// (e.g., {milestone}_{platform}.json, archive .tar.xz, .sha256).
|
||||
// All usual validation (hash verification, signature checking, version
|
||||
// filtering) is still performed.
|
||||
std::string local_download_path;
|
||||
|
||||
// Minimum log level for file logging. Messages below this level are
|
||||
// discarded. Configurable via /cef-log-level=<level>.
|
||||
LogLevel log_level = LogLevel::kWarning;
|
||||
|
||||
// Lock acquisition timeout in milliseconds. 0 = use default
|
||||
// (kDefaultLockTimeoutMs). Used by post-exit pruning to specify a short
|
||||
// timeout so it doesn't block process exit.
|
||||
uint32_t lock_timeout_ms = 0;
|
||||
|
||||
// WinHTTP receive-response timeout for archive downloads (milliseconds).
|
||||
// 0 = use the default (60 s). Shorter values let tests avoid blocking for
|
||||
// the full default when the server is unresponsive.
|
||||
int download_timeout_ms = 0;
|
||||
|
||||
// Operation-specific registration-retention threshold. This is accepted
|
||||
// only by explicit retention commands and is never persisted as app config.
|
||||
int retention_max_age_days = kDefaultRetentionMaxAgeDays;
|
||||
};
|
||||
|
||||
// Applies the test-only certificate thumbprint from |config| to |extended| and
|
||||
// enables the corresponding signature-verification mode when needed. This is
|
||||
// a no-op in official release builds.
|
||||
void ApplyConfigThumbprintOverride(const Config& config,
|
||||
ExtendedConfig* extended);
|
||||
|
||||
struct EmergencyRecoveryScanLimits {
|
||||
size_t max_roots = kEmergencyRecoveryMaxRoots;
|
||||
size_t max_version_entries = kEmergencyRecoveryMaxVersionEntries;
|
||||
base::TimeDelta time_budget =
|
||||
base::Milliseconds(kEmergencyRecoveryTimeBudgetMs);
|
||||
};
|
||||
|
||||
// Overrides automatic-startup emergency recovery bounds for deterministic
|
||||
// tests. Passing nullopt restores production limits.
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
void SetEmergencyRecoveryScanLimitsForTesting(
|
||||
std::optional<EmergencyRecoveryScanLimits> limits);
|
||||
#endif
|
||||
|
||||
// Deterministic auxiliary cleanup failure for retention transaction tests.
|
||||
void SetRetentionEvidenceDeleteFailureForTesting(bool fail);
|
||||
void SetRetentionPostIndexFailureForTesting(bool fail);
|
||||
void SetRetentionPendingRestoreFailureForTesting(bool fail);
|
||||
void SetRetentionPostValidationEvidenceChangeForTesting(bool change);
|
||||
void SetLaunchStateGcPreDeleteHookForTesting(base::RepeatingClosure callback);
|
||||
|
||||
// Logical completion state for an installer operation. Current producers use
|
||||
// kCommitted or kFailed. kCleanupDeferred is reserved for command-specific
|
||||
// cleanup semantics; that outcome is successful.
|
||||
enum class Outcome {
|
||||
kCommitted,
|
||||
kCleanupDeferred,
|
||||
kFailed,
|
||||
};
|
||||
|
||||
// Result of an installer operation.
|
||||
struct Result {
|
||||
bool success = false;
|
||||
Outcome outcome = Outcome::kFailed;
|
||||
|
||||
// Error information (if !success)
|
||||
int error_code = kExitCodeSuccess; // One of kExitCode* constants
|
||||
// Unlocalized developer/support diagnostic. Never use for localized UI.
|
||||
std::string error_message;
|
||||
|
||||
// Non-fatal work left for a later mutating pass. Present only with a
|
||||
// successful kCleanupDeferred outcome.
|
||||
std::vector<std::string> warnings;
|
||||
|
||||
// Internal per-version cleanup effects. Not serialized independently;
|
||||
// retention copies these into each version report.
|
||||
std::set<VersionKey> deferred_version_cleanup;
|
||||
|
||||
// Present for explicit retention operations.
|
||||
std::optional<RetentionPlan> retention_plan;
|
||||
int retention_max_age_days = 0;
|
||||
bool registrations_committed = false;
|
||||
bool versions_pruned = false;
|
||||
bool retry_required = false;
|
||||
|
||||
// On successful install: path to libcef.dll
|
||||
base::FilePath libcef_path;
|
||||
|
||||
// Version that was installed/is being used
|
||||
std::string installed_version;
|
||||
|
||||
// Full version string from metadata (e.g.,
|
||||
// "150.0.1+gabc1234+chromium-150.0.7871.4")
|
||||
std::string version_full;
|
||||
|
||||
// Whether the resolved libcef.dll came from a bundled CEF directory
|
||||
// (as opposed to a CDN-downloaded installed version). When true, the
|
||||
// libcef.dll may be signed with the client application's certificate
|
||||
// rather than the CEF certificate (kCefCertificateThumbprint).
|
||||
bool is_bundled = false;
|
||||
|
||||
// Whether the selected version is a rollback (a different version was
|
||||
// selected because the newest version was disqualified by launch health).
|
||||
bool is_rollback = false;
|
||||
|
||||
// Path to the launch state file for the bootstrap to write sentinel
|
||||
// and update after RunWinMain. Empty if launch state is not active.
|
||||
base::FilePath launch_state_path;
|
||||
|
||||
// Consecutive failure count for the selected version. The bootstrap writes
|
||||
// this value into the sentinel before RunWinMain.
|
||||
int launch_consecutive_failures = 0;
|
||||
|
||||
// Version and platform for the sentinel file. Avoids re-parsing the
|
||||
// launch_state_path filename in the bootstrap.
|
||||
std::string launch_version;
|
||||
std::string launch_platform;
|
||||
|
||||
// Paths to .launch_* files from older versions. The bootstrap deletes
|
||||
// these on successful exit (version confirmed).
|
||||
std::vector<base::FilePath> launch_cleanup_paths;
|
||||
|
||||
// Version-less retention liveness record written before RunWinMain for
|
||||
// every launch-health mode.
|
||||
base::FilePath liveness_path;
|
||||
|
||||
// Internal use-time lease for an installed shared-store distribution.
|
||||
// Not serialized in the public Result JSON.
|
||||
std::shared_ptr<VersionLease> version_lease;
|
||||
|
||||
// Convert to JSON string for RunInstaller return value
|
||||
std::string ToJson() const;
|
||||
|
||||
// Parse from JSON string
|
||||
static std::optional<Result> FromJson(const std::string& json);
|
||||
|
||||
// Factory methods for common results
|
||||
static Result Success(const base::FilePath& libcef_path,
|
||||
const std::string& version,
|
||||
const std::string& version_full = "",
|
||||
bool is_bundled = false);
|
||||
static Result Error(int code, const std::string& message);
|
||||
};
|
||||
|
||||
// Progress callback for UI updates.
|
||||
// Parameters: step, bytes_done, bytes_total.
|
||||
// Return false to cancel the operation.
|
||||
using ProgressCallback =
|
||||
base::RepeatingCallback<bool(Step, uint64_t, uint64_t)>;
|
||||
|
||||
// Main installer controller class
|
||||
class Controller {
|
||||
public:
|
||||
Controller();
|
||||
~Controller();
|
||||
|
||||
Controller(const Controller&) = delete;
|
||||
Controller& operator=(const Controller&) = delete;
|
||||
|
||||
// Execute an installer command.
|
||||
// This is the main entry point implementing the RunInstaller API.
|
||||
//
|
||||
// Query and automatic-startup resolution use lock-free read paths.
|
||||
// Mutating commands acquire the singleton writer mutex, reconcile state,
|
||||
// update the database, perform any download/install work, and prune.
|
||||
//
|
||||
// Parameters:
|
||||
// - command: The operation to perform
|
||||
// - app_config: Application configuration (appid, vmin, vmax, abi_hash).
|
||||
// Must be pre-validated via ParseConfigFromJson() or
|
||||
// equivalent; only a lightweight empty-field check is
|
||||
// performed here.
|
||||
// - extended_config: Optional extended settings
|
||||
// - progress: Optional callback for progress updates
|
||||
//
|
||||
// Returns: Result with success/failure info and libcef.dll path on success
|
||||
Result Run(Command command,
|
||||
const Config& app_config,
|
||||
const ExtendedConfig& extended_config = {},
|
||||
ProgressCallback progress = {},
|
||||
ExecutionContext context = ExecutionContext::kExplicitCommand);
|
||||
|
||||
// Internal bootstrap entry point for an in-process explicit uninstall. The
|
||||
// prepared policy and directory snapshot must authorize direct execution and
|
||||
// match the supplied command configuration. Mismatches fail with
|
||||
// configuration error before mutation.
|
||||
Result RunPreparedUninstall(const Config& app_config,
|
||||
const ExtendedConfig& extended_config,
|
||||
const PreparedUninstall& prepared,
|
||||
ProgressCallback progress = {});
|
||||
|
||||
// Convenience overload that parses config from JSON string
|
||||
// (for RunInstaller export function)
|
||||
Result Run(Command command,
|
||||
const std::string& config_json,
|
||||
ProgressCallback progress = {});
|
||||
|
||||
// Read version indexes from multiple directories and merge results.
|
||||
// Deduplicates by (version, platform), keeping the entry from the first
|
||||
// (higher-priority) directory. When |allow_scan_fallback| is true, a
|
||||
// missing or corrupt index falls back to directory scanning.
|
||||
static std::vector<InstalledVersion> ReadMultipleVersionIndexes(
|
||||
const std::vector<base::FilePath>& directories,
|
||||
bool allow_scan_fallback = true,
|
||||
VersionIndexReadMode read_mode = VersionIndexReadMode::kDoomCorrupt);
|
||||
|
||||
private:
|
||||
Result RunImpl(Command command,
|
||||
const Config& app_config,
|
||||
const ExtendedConfig& extended_config,
|
||||
ProgressCallback progress,
|
||||
ExecutionContext context,
|
||||
const PreparedUninstall* prepared_uninstall);
|
||||
|
||||
// Individual execution steps
|
||||
Result AcquireLock(const base::FilePath& install_dir,
|
||||
uint32_t timeout_ms = 0);
|
||||
Result ResolveQuery(const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<base::FilePath>& read_dirs);
|
||||
std::optional<Result> ResolveStartupOffline(
|
||||
const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<base::FilePath>& read_dirs,
|
||||
const std::vector<RevokedVersionRange>& revoked,
|
||||
bool allow_last_resort = false);
|
||||
void TryRegisterStartup(const Config& config,
|
||||
const base::FilePath& install_dir);
|
||||
Result UpdateDatabase(Command command,
|
||||
const Config& config,
|
||||
const base::FilePath& install_dir);
|
||||
Result PublishRegistration(const Config& config,
|
||||
const base::FilePath& install_dir);
|
||||
Result ReconcileInstallState(const base::FilePath& install_dir);
|
||||
Result ComputeAndInstall(const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<base::FilePath>& read_dirs,
|
||||
Command command,
|
||||
ProgressCallback progress);
|
||||
// Prune binaries and repair launch debris. Ordinary pruning age-gates valid
|
||||
// orphaned/below-vmin launch records without allowing them to protect
|
||||
// versions; explicit retention apply does not run that age GC.
|
||||
Result PruneUnusedVersions(
|
||||
const base::FilePath& install_dir,
|
||||
const Config& current_config,
|
||||
std::vector<InstalledVersion> installed = {},
|
||||
const std::vector<RevokedVersionRange>& revoked = {},
|
||||
bool retention_apply = false,
|
||||
const std::set<VersionKey>* retention_scope = nullptr,
|
||||
base::OnceCallback<bool()> logical_commit_complete = {});
|
||||
Result RunRetention(Command command,
|
||||
DirectoryRole role,
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<base::FilePath>& read_dirs,
|
||||
const ExtendedConfig& extended_config,
|
||||
ProgressCallback progress);
|
||||
void ReleaseLock();
|
||||
|
||||
// CDN operations
|
||||
std::optional<CdnBuildEntry> QueryCdnForVersion(
|
||||
const Config& config,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& install_dir,
|
||||
const std::set<Version>& skip_versions = {},
|
||||
std::vector<CdnBuildEntry>* validated_entries = nullptr);
|
||||
Result DownloadAndInstall(const CdnBuildEntry& entry,
|
||||
const ExtendedConfig& extended,
|
||||
const base::FilePath& install_dir,
|
||||
ProgressCallback progress,
|
||||
bool* try_next_candidate = nullptr);
|
||||
|
||||
// Check if bundled CEF (from full installers) is suitable for use.
|
||||
// Reads valid cef_version.json from bundled_path and returns the candidate.
|
||||
// Platform, range, ABI, revocation, and disqualification are applied by the
|
||||
// shared side-effect-free selector.
|
||||
// catalog.cat must exist as a regular non-reparse file, but its signature and
|
||||
// members are not verified — hashing ~370 MB on every startup is too costly.
|
||||
// Bundled integrity is the app developer's responsibility (their package).
|
||||
// Returns nullopt when the layout or metadata is invalid.
|
||||
std::optional<InstalledVersion> CheckBundledCef(
|
||||
const Config& config,
|
||||
const ExtendedConfig& extended);
|
||||
|
||||
// Construct a validated Result::Success for a libcef.dll path. Calls
|
||||
// GetLibcefPath and validates that every component between |trusted_root|
|
||||
// and the resulting path is free of reparse points. Returns an error
|
||||
// Result if validation fails.
|
||||
Result MakeValidatedLibcefResult(const base::FilePath& trusted_root,
|
||||
const base::FilePath& version_dir,
|
||||
const std::string& version_str,
|
||||
const std::string& version_full = "",
|
||||
bool is_bundled = false);
|
||||
|
||||
// Internal state
|
||||
std::unique_ptr<SingletonLock> lock_;
|
||||
std::unique_ptr<Database> database_;
|
||||
bool cancelled_ = false;
|
||||
bool version_published_ = false;
|
||||
base::FilePath pending_archive_cleanup_path_;
|
||||
std::vector<RevokedVersionRange> effective_revocations_;
|
||||
EnterprisePolicy enterprise_policy_;
|
||||
EffectiveDownloadSource effective_download_source_;
|
||||
};
|
||||
|
||||
// Get the process exit code for a result
|
||||
int ResultToExitCode(const Result& result);
|
||||
|
||||
// Convert an outcome to its normalized JSON/logging representation.
|
||||
const char* OutcomeToString(Outcome outcome);
|
||||
|
||||
// Calculate overall progress percentage for the installer.
|
||||
// Maps step/byte progress to a 0-100% range where each step gets a portion:
|
||||
// Steps 0-1 (Init/Lock): 0-5%
|
||||
// Steps 2-3 (VersionCheck/CdnResolve): 5-15%
|
||||
// Step 4 (Download): 15-55%
|
||||
// Step 5 (Extract): 55-80%
|
||||
// Step 6 (SignatureVerify): 80-90%
|
||||
// Step 7 (Install): 90-95%
|
||||
// Step 8 (Committing): 95% (retention apply only)
|
||||
// Step 9 (Cleanup): 95-100%
|
||||
// If bytes_total > 0, interpolates within the step based on byte progress.
|
||||
int CalculateOverallProgress(Step step,
|
||||
uint64_t bytes_done,
|
||||
uint64_t bytes_total);
|
||||
|
||||
// Send progress notification to parent window via WM_COPYDATA.
|
||||
// Rate-limited to avoid flooding the receiver during rapid progress updates.
|
||||
// Uses SendMessageTimeout with SMTO_ABORTIFHUNG to prevent hanging.
|
||||
// Returns true to continue, false if the parent requested cancellation
|
||||
// (by returning kWmCopyDataResultCancel from its WM_COPYDATA handler).
|
||||
// Timeouts and errors are not treated as cancellation.
|
||||
bool SendProgressToParent(HWND parent,
|
||||
Step step,
|
||||
uint64_t bytes_done,
|
||||
uint64_t bytes_total);
|
||||
|
||||
// ============================================================================
|
||||
// Internal helpers exposed for testing. Not part of the public API.
|
||||
// ============================================================================
|
||||
namespace internal {
|
||||
|
||||
// Typed candidate-fallback classification shared by production control flow
|
||||
// and the retry-matrix unit tests.
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
bool IsCandidateRetryableArchiveErrorForTesting(ArchiveError error);
|
||||
bool IsCandidateRetryableSignatureErrorForTesting(SignatureError error);
|
||||
bool IsCandidateRetryableMetadataErrorForTesting(MetadataError error);
|
||||
#endif
|
||||
|
||||
// Parse ExtendedConfig from JSON string.
|
||||
// Used when parsing the combined config JSON in RunInstaller.
|
||||
bool ParseExtendedConfigFromJson(const std::string& json,
|
||||
ExtendedConfig* config,
|
||||
std::string* diagnostic = nullptr);
|
||||
|
||||
// Parse both Config and ExtendedConfig from a combined JSON string.
|
||||
bool ParseCombinedConfig(const std::string& json,
|
||||
Config* config,
|
||||
ExtendedConfig* extended,
|
||||
std::string* diagnostic = nullptr);
|
||||
|
||||
// Parse Command from string (case-insensitive)
|
||||
std::optional<Command> ParseCommand(const std::string& str);
|
||||
|
||||
// Handle the kLaunchSuccess command: confirm the active CEF version is
|
||||
// healthy by writing running=false, consecutive_failures=0 to the sentinel
|
||||
// file.
|
||||
//
|
||||
// This is a lightweight path — it does NOT go through Controller::Run, the
|
||||
// installer lock, the database, or progress UI. It reads the sentinel,
|
||||
// verifies PID ownership, and writes the confirmation directly.
|
||||
//
|
||||
// Returns a Result with:
|
||||
// - success=true (kExitCodeSuccess) on confirmation, already-confirmed
|
||||
// (idempotent), and writes back preserving all other fields.
|
||||
// - kExitCodeNoSentinel if |launch_state_path| is empty.
|
||||
// - kExitCodeSentinelReadError if the sentinel is missing or corrupt.
|
||||
// - kExitCodeSentinelOwnerMismatch if the sentinel is owned by another
|
||||
// process (PID/start-time mismatch).
|
||||
//
|
||||
// The no-arg overload resolves the path from the process-global active launch
|
||||
// state path (set by the bootstrap before RunWinMain). The path-taking
|
||||
// overload lets tests exercise the logic without mutating process-global
|
||||
// state.
|
||||
Result HandleLaunchSuccess();
|
||||
Result HandleLaunchSuccess(const base::FilePath& launch_state_path);
|
||||
|
||||
// Convert between exit code ints and JSON string representations.
|
||||
const char* ExitCodeToString(int code);
|
||||
int StringToExitCode(const std::string& str);
|
||||
std::optional<Outcome> StringToOutcome(const std::string& str);
|
||||
|
||||
// Returns the canonical name for a step (e.g., "downloading").
|
||||
// Used in WM_COPYDATA JSON progress notifications.
|
||||
const char* StepCanonicalName(Step step);
|
||||
|
||||
// Build JSON string for WM_COPYDATA progress notification.
|
||||
std::string BuildProgressJson(Step step,
|
||||
uint64_t bytes_done,
|
||||
uint64_t bytes_total);
|
||||
|
||||
// Reset rate limiting state for SendProgressToParent (for testing).
|
||||
void ResetProgressNotificationState();
|
||||
|
||||
// Enable testing mode for RunInstaller.
|
||||
// When enabled, allows HTTP URLs and suppresses error dialogs.
|
||||
// Call with true in test SetUp, false in TearDown.
|
||||
void SetTestingMode(bool enabled);
|
||||
|
||||
} // namespace internal
|
||||
|
||||
// ============================================================================
|
||||
// DLL Export Function
|
||||
// ============================================================================
|
||||
|
||||
// Exported function for client DLL to call (Option C: Programmatic
|
||||
// Configuration). This is the DLL export that wraps Controller.
|
||||
//
|
||||
// Parameters:
|
||||
// - command: "install", "update", "uninstall", or "query"
|
||||
// - config_json: JSON configuration string (or empty for default)
|
||||
//
|
||||
// Returns: JSON result with boolean success and an outcome of "committed",
|
||||
// "cleanup_deferred", or "failed". Failed results contain numeric
|
||||
// error_code, symbolic error_name, and error_message. Successful
|
||||
// results omit error fields and may include libcef_path,
|
||||
// installed_version, version_full, and is_bundled.
|
||||
// The returned pointer is valid until the next call to RunInstaller
|
||||
// on the same thread or until that thread exits. Copy the JSON before
|
||||
// either event if it must be retained.
|
||||
//
|
||||
// Thread safety: Uses thread-local storage for the returned string, so
|
||||
// concurrent calls from different threads are safe. The installer itself
|
||||
// serializes operations via a global lock.
|
||||
//
|
||||
// Example usage from client DLL:
|
||||
// const char* result = RunInstaller("install",
|
||||
// R"({"appid":"...", "vmin":"137.1"})");
|
||||
// auto parsed = Result::FromJson(result);
|
||||
// if (parsed && parsed->success &&
|
||||
// parsed->outcome != Outcome::kFailed)
|
||||
// LoadLibrary(parsed->libcef_path);
|
||||
extern "C" __declspec(dllexport) const char* RunInstaller(
|
||||
const char* command,
|
||||
const char* config_json);
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CONTROLLER_H_
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_crc.h"
|
||||
|
||||
#include "third_party/lzma_sdk/src/C/7zCrc.h"
|
||||
#include "third_party/lzma_sdk/src/C/XzCrc64.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
void EnsureCrcInitialized() {
|
||||
[[maybe_unused]] static bool initialized = [] {
|
||||
CrcGenerateTable();
|
||||
Crc64GenerateTable();
|
||||
return true;
|
||||
}();
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CRC_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CRC_H_
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// One-time initialization of CRC32 and CRC64 lookup tables (lzma_sdk).
|
||||
// Safe to call multiple times; subsequent calls are no-ops.
|
||||
// Must be called before CrcCalc() or any XZ decompression.
|
||||
void EnsureCrcInitialized();
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_CRC_H_
|
||||
@@ -0,0 +1,343 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_database.h"
|
||||
|
||||
#include <time.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/json/json_reader.h"
|
||||
#include "base/json/json_writer.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_e2e_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace {
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
bool g_fail_database_save = false;
|
||||
#endif
|
||||
|
||||
bool ShouldFailDatabaseSave() {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
if (g_fail_database_save) {
|
||||
return true;
|
||||
}
|
||||
return internal::GetInstallerE2EConfig().database_save_failure;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
// JSON field names
|
||||
constexpr char kSchemaVersionField[] = "schema_version";
|
||||
constexpr char kAppsField[] = "apps";
|
||||
constexpr char kUuidField[] = "uuid";
|
||||
constexpr char kPlatformField[] = "platform";
|
||||
constexpr char kVminField[] = "vmin";
|
||||
constexpr char kVmaxField[] = "vmax";
|
||||
constexpr char kAbiHashField[] = "abi_hash";
|
||||
constexpr char kPruningSuspendedUntilField[] = "pruning_suspended_until";
|
||||
|
||||
} // namespace
|
||||
|
||||
void SetDatabaseSaveFailureForTesting(bool fail) {
|
||||
#if defined(OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
(void)fail;
|
||||
#else
|
||||
g_fail_database_save = fail;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool AppEntry::operator==(const AppEntry& other) const {
|
||||
return uuid == other.uuid && platform == other.platform &&
|
||||
vmin == other.vmin && vmax == other.vmax && abi_hash == other.abi_hash;
|
||||
}
|
||||
|
||||
Database::Database() = default;
|
||||
Database::~Database() = default;
|
||||
|
||||
DatabaseError Database::Load(const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action) {
|
||||
// Reset to defaults
|
||||
schema_version_ = kCurrentSchemaVersion;
|
||||
apps_.clear();
|
||||
pruning_suspended_until_ = 0;
|
||||
|
||||
std::string json_content;
|
||||
IntegrityResult ir =
|
||||
ReadFileWithIntegrity(path, &json_content, mismatch_action);
|
||||
switch (ir) {
|
||||
case IntegrityResult::kFileNotFound:
|
||||
// File doesn't exist - start with empty database
|
||||
return DatabaseError::kSuccess;
|
||||
case IntegrityResult::kReadError:
|
||||
return DatabaseError::kFileReadError;
|
||||
case IntegrityResult::kIntegrityMismatch:
|
||||
// Corrupted file was already deleted by ReadFileWithIntegrity.
|
||||
// Return empty database — caller should call SuspendPruning().
|
||||
return DatabaseError::kIntegrityMismatch;
|
||||
case IntegrityResult::kSuccess:
|
||||
case IntegrityResult::kSuccessNoFooter:
|
||||
// Continue loading below.
|
||||
break;
|
||||
}
|
||||
|
||||
std::optional<base::DictValue> parsed =
|
||||
base::JSONReader::ReadDict(json_content, base::JSON_PARSE_RFC);
|
||||
if (!parsed) {
|
||||
return DatabaseError::kJsonParseError;
|
||||
}
|
||||
|
||||
// Read schema version
|
||||
std::optional<int> version = parsed->FindInt(kSchemaVersionField);
|
||||
if (version) {
|
||||
schema_version_ = *version;
|
||||
if (schema_version_ > kCurrentSchemaVersion) {
|
||||
// Schema is newer than we understand. Continue loading known fields
|
||||
// so the caller can still query, but return kSchemaVersionTooNew
|
||||
// below so the caller knows not to save (which would drop unknown
|
||||
// fields from the newer schema).
|
||||
}
|
||||
}
|
||||
|
||||
// Read apps array
|
||||
const base::ListValue* apps_list = parsed->FindList(kAppsField);
|
||||
if (apps_list) {
|
||||
for (const base::Value& app_value : *apps_list) {
|
||||
const base::DictValue* app_dict = app_value.GetIfDict();
|
||||
if (!app_dict) {
|
||||
continue;
|
||||
}
|
||||
|
||||
AppEntry entry;
|
||||
const std::string* uuid = app_dict->FindString(kUuidField);
|
||||
if (!uuid || uuid->empty() || uuid->size() > kMaxUuidLength) {
|
||||
continue; // Skip invalid entries
|
||||
}
|
||||
entry.uuid = *uuid;
|
||||
|
||||
const std::string* platform = app_dict->FindString(kPlatformField);
|
||||
if (!platform || platform->empty() ||
|
||||
platform->size() > kMaxPlatformLength) {
|
||||
continue; // Skip entries without valid platform
|
||||
}
|
||||
entry.platform = *platform;
|
||||
|
||||
const std::string* vmin = app_dict->FindString(kVminField);
|
||||
if (vmin && vmin->size() > kMaxVersionLength) {
|
||||
continue; // Skip entries with invalid fields
|
||||
}
|
||||
entry.vmin = vmin ? *vmin : "";
|
||||
|
||||
const std::string* vmax = app_dict->FindString(kVmaxField);
|
||||
if (vmax && vmax->size() > kMaxVersionLength) {
|
||||
continue; // Skip entries with invalid fields
|
||||
}
|
||||
entry.vmax = vmax ? *vmax : "";
|
||||
|
||||
const std::string* abi_hash = app_dict->FindString(kAbiHashField);
|
||||
if (abi_hash && abi_hash->size() > kMaxAbiHashLength) {
|
||||
continue; // Skip entries with invalid fields
|
||||
}
|
||||
entry.abi_hash = abi_hash ? *abi_hash : "";
|
||||
|
||||
apps_.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
|
||||
// Read pruning suspension timestamp (if present)
|
||||
auto suspension_value = parsed->Find(kPruningSuspendedUntilField);
|
||||
if (suspension_value) {
|
||||
if (suspension_value->is_string()) {
|
||||
int64_t ts;
|
||||
if (base::StringToInt64(suspension_value->GetString(), &ts)) {
|
||||
pruning_suspended_until_ = ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return error if schema is too new (after loading what we can)
|
||||
if (schema_version_ > kCurrentSchemaVersion) {
|
||||
return DatabaseError::kSchemaVersionTooNew;
|
||||
}
|
||||
|
||||
return DatabaseError::kSuccess;
|
||||
}
|
||||
|
||||
DatabaseError Database::Save(const base::FilePath& path) {
|
||||
if (ShouldFailDatabaseSave()) {
|
||||
return DatabaseError::kFileWriteError;
|
||||
}
|
||||
base::DictValue root;
|
||||
root.Set(kSchemaVersionField, schema_version_);
|
||||
|
||||
if (pruning_suspended_until_ > 0) {
|
||||
root.Set(kPruningSuspendedUntilField,
|
||||
base::NumberToString(pruning_suspended_until_));
|
||||
}
|
||||
|
||||
base::ListValue apps_list;
|
||||
for (const AppEntry& entry : apps_) {
|
||||
base::DictValue app_dict;
|
||||
app_dict.Set(kUuidField, entry.uuid);
|
||||
app_dict.Set(kPlatformField, entry.platform);
|
||||
app_dict.Set(kVminField, entry.vmin);
|
||||
if (!entry.vmax.empty()) {
|
||||
app_dict.Set(kVmaxField, entry.vmax);
|
||||
}
|
||||
if (!entry.abi_hash.empty()) {
|
||||
app_dict.Set(kAbiHashField, entry.abi_hash);
|
||||
}
|
||||
apps_list.Append(std::move(app_dict));
|
||||
}
|
||||
root.Set(kAppsField, std::move(apps_list));
|
||||
|
||||
std::string json_content;
|
||||
if (!base::JSONWriter::WriteWithOptions(
|
||||
root, base::JSONWriter::OPTIONS_PRETTY_PRINT, &json_content)) {
|
||||
return DatabaseError::kFileWriteError;
|
||||
}
|
||||
|
||||
// Write to a temp file then atomically rename to prevent partial writes.
|
||||
base::FilePath temp_path;
|
||||
if (!base::CreateTemporaryFileInDir(path.DirName(), &temp_path)) {
|
||||
return DatabaseError::kFileWriteError;
|
||||
}
|
||||
ScopedFileDeleter temp_deleter(temp_path);
|
||||
|
||||
if (!WriteFileWithIntegrity(temp_path, json_content)) {
|
||||
return DatabaseError::kFileWriteError;
|
||||
}
|
||||
|
||||
base::File::Error error;
|
||||
if (!base::ReplaceFile(temp_path, path, &error)) {
|
||||
return DatabaseError::kFileWriteError;
|
||||
}
|
||||
|
||||
temp_deleter.Release();
|
||||
return DatabaseError::kSuccess;
|
||||
}
|
||||
|
||||
int Database::GetSchemaVersion() const {
|
||||
return schema_version_;
|
||||
}
|
||||
|
||||
bool Database::RegisterApp(const AppEntry& entry) {
|
||||
// Find existing entry by (uuid, platform)
|
||||
auto it =
|
||||
std::find_if(apps_.begin(), apps_.end(), [&entry](const AppEntry& e) {
|
||||
return e.uuid == entry.uuid && e.platform == entry.platform;
|
||||
});
|
||||
|
||||
if (it != apps_.end()) {
|
||||
if (*it == entry) {
|
||||
return false;
|
||||
}
|
||||
*it = entry;
|
||||
} else {
|
||||
apps_.push_back(entry);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void Database::UnregisterApp(const std::string& uuid,
|
||||
const std::string& platform) {
|
||||
apps_.erase(std::remove_if(apps_.begin(), apps_.end(),
|
||||
[&uuid, &platform](const AppEntry& e) {
|
||||
return e.uuid == uuid && e.platform == platform;
|
||||
}),
|
||||
apps_.end());
|
||||
}
|
||||
|
||||
std::optional<AppEntry> Database::GetApp(const std::string& uuid,
|
||||
const std::string& platform) const {
|
||||
auto it = std::find_if(apps_.begin(), apps_.end(),
|
||||
[&uuid, &platform](const AppEntry& e) {
|
||||
return e.uuid == uuid && e.platform == platform;
|
||||
});
|
||||
|
||||
if (it != apps_.end()) {
|
||||
return *it;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::vector<AppEntry>& Database::GetAllApps() const {
|
||||
return apps_;
|
||||
}
|
||||
|
||||
bool Database::IsEmpty() const {
|
||||
return apps_.empty();
|
||||
}
|
||||
|
||||
Version Database::GetGlobalVmin(const std::string& platform) const {
|
||||
Version result;
|
||||
for (const auto& app : apps_) {
|
||||
if (app.platform != platform) {
|
||||
continue;
|
||||
}
|
||||
Version v = Version::Parse(app.vmin);
|
||||
if (v.IsValid() && (!result.IsValid() || v < result)) {
|
||||
result = v;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Database::CanPrune() const {
|
||||
// Don't prune if schema is newer than we understand.
|
||||
// This prevents older installers from deleting versions that
|
||||
// newer schema fields might reference.
|
||||
if (schema_version_ > kCurrentSchemaVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't prune while suspension is active. After corruption recovery,
|
||||
// app entries are lost and pruning would delete versions that other
|
||||
// (unregistered) apps still need.
|
||||
if (pruning_suspended_until_ > 0) {
|
||||
int64_t now = static_cast<int64_t>(time(nullptr));
|
||||
if (now < pruning_suspended_until_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void Database::SuspendPruning() {
|
||||
pruning_suspended_until_ =
|
||||
static_cast<int64_t>(time(nullptr)) + kPruningSuspensionSeconds;
|
||||
}
|
||||
|
||||
const char* DatabaseErrorToString(DatabaseError error) {
|
||||
switch (error) {
|
||||
case DatabaseError::kSuccess:
|
||||
return "Success";
|
||||
case DatabaseError::kFileNotFound:
|
||||
return "Database file not found";
|
||||
case DatabaseError::kFileReadError:
|
||||
return "Could not read database file";
|
||||
case DatabaseError::kFileWriteError:
|
||||
return "Could not write database file";
|
||||
case DatabaseError::kJsonParseError:
|
||||
return "Invalid JSON in database file";
|
||||
case DatabaseError::kSchemaVersionTooNew:
|
||||
return "Database schema version too new";
|
||||
case DatabaseError::kLockAcquisitionFailed:
|
||||
return "Could not acquire database lock";
|
||||
case DatabaseError::kIntegrityMismatch:
|
||||
return "Database file corrupted (CRC32 mismatch, file deleted)";
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_DATABASE_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_DATABASE_H_
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Current schema version - increment when adding fields.
|
||||
//
|
||||
// BACKWARDS COMPATIBILITY:
|
||||
// Multiple installer versions may share the same database file. Older
|
||||
// installers must be able to read databases written by newer ones (they
|
||||
// skip unknown fields) and newer installers must not break older ones.
|
||||
// Rules:
|
||||
// - New fields: add with sensible defaults so older readers work without them.
|
||||
// - Existing fields: never rename, remove, or change semantics.
|
||||
// - Apps array: per-app entries are re-serialized from AppEntry, so any new
|
||||
// per-app fields must be added to AppEntry and handled in Load/Save.
|
||||
// - Increment kCurrentSchemaVersion when adding fields. Older installers
|
||||
// seeing a higher version will return kSchemaVersionTooNew and skip
|
||||
// saving (avoiding data loss).
|
||||
constexpr int kCurrentSchemaVersion = 1;
|
||||
|
||||
// Grace period (seconds) before pruning resumes after corruption recovery.
|
||||
// 7 days gives apps time to re-register on their next launch.
|
||||
constexpr int64_t kPruningSuspensionSeconds = 7 * 24 * 60 * 60;
|
||||
|
||||
// Per-application registration entry
|
||||
// Unique key is (uuid, platform) - same app on different architectures are
|
||||
// separate entries.
|
||||
struct AppEntry {
|
||||
std::string uuid; // Unique app identifier (never changes)
|
||||
std::string platform; // Platform identifier (e.g., "windows64", "windows32")
|
||||
std::string vmin; // Minimum version requirement
|
||||
std::string vmax; // Maximum version requirement (optional)
|
||||
std::string abi_hash; // Sandbox compatibility hash
|
||||
|
||||
bool operator==(const AppEntry& other) const;
|
||||
};
|
||||
|
||||
// Error codes for database operations
|
||||
enum class DatabaseError {
|
||||
kSuccess,
|
||||
kFileNotFound, // Database file doesn't exist (OK for new install)
|
||||
kFileReadError, // Could not read database file
|
||||
kFileWriteError, // Could not write database file
|
||||
kJsonParseError, // Database file contains invalid JSON
|
||||
kSchemaVersionTooNew, // Database schema newer than we understand
|
||||
kLockAcquisitionFailed, // Could not acquire database lock
|
||||
kIntegrityMismatch, // CRC32 mismatch — corrupted file was deleted
|
||||
};
|
||||
|
||||
// Deterministic save failure for transaction tests. Typed E2E configuration
|
||||
// maps the matching non-official subprocess fault at this component boundary.
|
||||
void SetDatabaseSaveFailureForTesting(bool fail);
|
||||
|
||||
// Manages the shared installer.json database.
|
||||
// Thread-safety: Not thread-safe. Use with external locking if needed.
|
||||
class Database {
|
||||
public:
|
||||
Database();
|
||||
~Database();
|
||||
|
||||
Database(const Database&) = delete;
|
||||
Database& operator=(const Database&) = delete;
|
||||
|
||||
// Load database from file. Creates empty database if file doesn't exist.
|
||||
// If the file has a CRC32 integrity footer and the checksum mismatches,
|
||||
// |mismatch_action| selects deletion for writer recovery or preservation for
|
||||
// read-only inspection (returns kIntegrityMismatch either way). Legacy files
|
||||
// without a footer are accepted.
|
||||
// Returns kSchemaVersionTooNew if schema_version > kCurrentSchemaVersion.
|
||||
DatabaseError Load(
|
||||
const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action = IntegrityMismatchAction::kDoom);
|
||||
|
||||
// Save database to file with CRC32 integrity footer.
|
||||
// Uses atomic write (temp file + rename) to prevent partial writes.
|
||||
DatabaseError Save(const base::FilePath& path);
|
||||
|
||||
// Get current schema version (from loaded database or kCurrentSchemaVersion)
|
||||
int GetSchemaVersion() const;
|
||||
|
||||
// CRUD operations - keyed by (uuid, platform)
|
||||
// - Add or update by (uuid, platform)
|
||||
bool RegisterApp(const AppEntry& entry);
|
||||
// - Remove by (uuid, platform)
|
||||
void UnregisterApp(const std::string& uuid, const std::string& platform);
|
||||
std::optional<AppEntry> GetApp(const std::string& uuid,
|
||||
const std::string& platform) const;
|
||||
const std::vector<AppEntry>& GetAllApps() const;
|
||||
|
||||
// Check if database is empty (no registered apps)
|
||||
bool IsEmpty() const;
|
||||
|
||||
// Return the minimum vmin across all registered apps for the given
|
||||
// platform, or an invalid Version if none match.
|
||||
Version GetGlobalVmin(const std::string& platform) const;
|
||||
|
||||
// Check if we can safely prune versions.
|
||||
// Returns false if schema_version > kCurrentSchemaVersion (skip pruning)
|
||||
// or if pruning is suspended (grace period after corruption recovery).
|
||||
bool CanPrune() const;
|
||||
|
||||
// Suspend pruning for kPruningSuspensionSeconds from now.
|
||||
// Called after corruption recovery to give apps time to re-register.
|
||||
// The suspension timestamp is persisted in the database JSON.
|
||||
void SuspendPruning();
|
||||
|
||||
private:
|
||||
int schema_version_ = kCurrentSchemaVersion;
|
||||
std::vector<AppEntry> apps_;
|
||||
|
||||
// Unix timestamp (seconds since epoch). Pruning is suspended until this
|
||||
// time. 0 means not suspended. Persisted as "pruning_suspended_until" in
|
||||
// the database JSON.
|
||||
int64_t pruning_suspended_until_ = 0;
|
||||
};
|
||||
|
||||
// Convert error code to human-readable string for logging
|
||||
const char* DatabaseErrorToString(DatabaseError error);
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_DATABASE_H_
|
||||
@@ -0,0 +1,887 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_database.h"
|
||||
|
||||
#include <time.h>
|
||||
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/files/scoped_temp_dir.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer {
|
||||
namespace {
|
||||
|
||||
class InstallerDatabaseTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override { ASSERT_TRUE(temp_dir_.CreateUniqueTempDir()); }
|
||||
|
||||
base::FilePath GetDatabasePath() const {
|
||||
return temp_dir_.GetPath().Append(L"installer.json");
|
||||
}
|
||||
|
||||
void WriteJsonFile(const std::string& content) {
|
||||
ASSERT_TRUE(base::WriteFile(GetDatabasePath(), content));
|
||||
}
|
||||
|
||||
base::ScopedTempDir temp_dir_;
|
||||
};
|
||||
|
||||
TEST_F(InstallerDatabaseTest, CreateEmpty) {
|
||||
Database db;
|
||||
EXPECT_TRUE(db.IsEmpty());
|
||||
EXPECT_EQ(kCurrentSchemaVersion, db.GetSchemaVersion());
|
||||
EXPECT_TRUE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SaveEmpty) {
|
||||
Database db;
|
||||
DatabaseError error = db.Save(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
EXPECT_TRUE(base::PathExists(GetDatabasePath()));
|
||||
|
||||
// Verify saved content (read through integrity layer to strip footer)
|
||||
std::string content;
|
||||
IntegrityResult ir = ReadFileWithIntegrity(GetDatabasePath(), &content);
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ir);
|
||||
EXPECT_NE(content.find("\"schema_version\""), std::string::npos);
|
||||
EXPECT_NE(content.find("\"apps\""), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadNonExistent) {
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
// Non-existent file should create empty database
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
EXPECT_TRUE(db.IsEmpty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, RegisterAppNew) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry;
|
||||
entry.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
entry.vmax = "137.99";
|
||||
entry.abi_hash = "abc123";
|
||||
|
||||
EXPECT_TRUE(db.RegisterApp(entry));
|
||||
|
||||
EXPECT_FALSE(db.IsEmpty());
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ(entry, apps[0]);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, RegisterAppUpdate) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry1;
|
||||
entry1.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry1.platform = "windows64";
|
||||
entry1.vmin = "137.1";
|
||||
entry1.vmax = "137.99";
|
||||
|
||||
EXPECT_TRUE(db.RegisterApp(entry1));
|
||||
|
||||
// Update same (uuid, platform) with different values
|
||||
AppEntry entry2;
|
||||
entry2.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry2.platform = "windows64";
|
||||
entry2.vmin = "138.0";
|
||||
entry2.vmax = "138.99";
|
||||
|
||||
EXPECT_TRUE(db.RegisterApp(entry2));
|
||||
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("138.0", apps[0].vmin);
|
||||
EXPECT_EQ("138.99", apps[0].vmax);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, RegisterAppUnchanged) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry;
|
||||
entry.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
entry.vmax = "137.99";
|
||||
entry.abi_hash = "abc123";
|
||||
|
||||
EXPECT_TRUE(db.RegisterApp(entry));
|
||||
EXPECT_FALSE(db.RegisterApp(entry));
|
||||
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ(entry, apps[0]);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, RegisterAppDifferentPlatforms) {
|
||||
Database db;
|
||||
|
||||
// Same UUID but different platforms should be separate entries
|
||||
AppEntry entry1;
|
||||
entry1.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry1.platform = "windows64";
|
||||
entry1.vmin = "137.1";
|
||||
|
||||
AppEntry entry2;
|
||||
entry2.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry2.platform = "windows32";
|
||||
entry2.vmin = "138.0";
|
||||
|
||||
EXPECT_TRUE(db.RegisterApp(entry1));
|
||||
EXPECT_TRUE(db.RegisterApp(entry2));
|
||||
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(2u, apps.size());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, UnregisterApp) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry;
|
||||
entry.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
|
||||
db.RegisterApp(entry);
|
||||
EXPECT_FALSE(db.IsEmpty());
|
||||
|
||||
db.UnregisterApp(entry.uuid, entry.platform);
|
||||
EXPECT_TRUE(db.IsEmpty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, UnregisterAppOnlyMatchingPlatform) {
|
||||
Database db;
|
||||
|
||||
// Register same UUID on two platforms
|
||||
AppEntry entry1;
|
||||
entry1.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry1.platform = "windows64";
|
||||
entry1.vmin = "137.1";
|
||||
|
||||
AppEntry entry2;
|
||||
entry2.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry2.platform = "windows32";
|
||||
entry2.vmin = "137.1";
|
||||
|
||||
db.RegisterApp(entry1);
|
||||
db.RegisterApp(entry2);
|
||||
EXPECT_EQ(2u, db.GetAllApps().size());
|
||||
|
||||
// Unregister only windows64 - windows32 should remain
|
||||
db.UnregisterApp(entry1.uuid, "windows64");
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("windows32", apps[0].platform);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetAppFound) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry;
|
||||
entry.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
entry.abi_hash = "a1a2a3";
|
||||
|
||||
db.RegisterApp(entry);
|
||||
|
||||
auto result = db.GetApp(entry.uuid, entry.platform);
|
||||
ASSERT_TRUE(result.has_value());
|
||||
EXPECT_EQ(entry, *result);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetAppNotFound) {
|
||||
Database db;
|
||||
|
||||
auto result = db.GetApp("nonexistent-uuid", "windows64");
|
||||
EXPECT_FALSE(result.has_value());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetAppWrongPlatform) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry;
|
||||
entry.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
|
||||
db.RegisterApp(entry);
|
||||
|
||||
// Same UUID but different platform should not be found
|
||||
auto result = db.GetApp(entry.uuid, "windows32");
|
||||
EXPECT_FALSE(result.has_value());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetAllApps) {
|
||||
Database db;
|
||||
|
||||
AppEntry entry1;
|
||||
entry1.uuid = "uuid-1";
|
||||
entry1.platform = "windows64";
|
||||
entry1.vmin = "137.1";
|
||||
|
||||
AppEntry entry2;
|
||||
entry2.uuid = "uuid-2";
|
||||
entry2.platform = "windows64";
|
||||
entry2.vmin = "138.0";
|
||||
|
||||
db.RegisterApp(entry1);
|
||||
db.RegisterApp(entry2);
|
||||
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(2u, apps.size());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SchemaVersionCurrent) {
|
||||
WriteJsonFile(R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "12345678-1234-1234-1234-123456789abc",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.1"
|
||||
}
|
||||
]
|
||||
})");
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
EXPECT_EQ(1, db.GetSchemaVersion());
|
||||
EXPECT_TRUE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SchemaVersionTooNew) {
|
||||
WriteJsonFile(R"({
|
||||
"schema_version": 999,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "12345678-1234-1234-1234-123456789abc",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.1"
|
||||
}
|
||||
]
|
||||
})");
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
// Should return error but still load data
|
||||
EXPECT_EQ(DatabaseError::kSchemaVersionTooNew, error);
|
||||
EXPECT_EQ(999, db.GetSchemaVersion());
|
||||
EXPECT_FALSE(db.IsEmpty()); // Data was still loaded
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, CanPruneFalseWhenSchemaTooNew) {
|
||||
WriteJsonFile(R"({
|
||||
"schema_version": 999,
|
||||
"apps": []
|
||||
})");
|
||||
|
||||
Database db;
|
||||
db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_FALSE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, JsonRoundTrip) {
|
||||
Database db1;
|
||||
|
||||
AppEntry entry;
|
||||
entry.uuid = "12345678-1234-1234-1234-123456789abc";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
entry.vmax = "137.99";
|
||||
entry.abi_hash = "abc123def4567890";
|
||||
|
||||
db1.RegisterApp(entry);
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db1.Save(GetDatabasePath()));
|
||||
|
||||
Database db2;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db2.Load(GetDatabasePath()));
|
||||
|
||||
auto apps = db2.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ(entry, apps[0]);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, UnknownFieldsDroppedOnSave) {
|
||||
// Unknown top-level fields from older schemas are not round-tripped.
|
||||
// We only save when schema_version <= kCurrentSchemaVersion, so unknown
|
||||
// fields can only come from older versions and are safe to drop.
|
||||
WriteJsonFile(R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "12345678-1234-1234-1234-123456789abc",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.1"
|
||||
}
|
||||
],
|
||||
"future_field": "some value",
|
||||
"another_unknown": 42
|
||||
})");
|
||||
|
||||
Database db;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Load(GetDatabasePath()));
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Save(GetDatabasePath()));
|
||||
|
||||
// Read back and verify unknown fields are not preserved
|
||||
std::string content;
|
||||
IntegrityResult ir = ReadFileWithIntegrity(GetDatabasePath(), &content);
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ir);
|
||||
EXPECT_EQ(content.find("future_field"), std::string::npos);
|
||||
EXPECT_EQ(content.find("another_unknown"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, DatabaseErrorToString) {
|
||||
EXPECT_STREQ("Success", DatabaseErrorToString(DatabaseError::kSuccess));
|
||||
EXPECT_STREQ("Database file not found",
|
||||
DatabaseErrorToString(DatabaseError::kFileNotFound));
|
||||
EXPECT_STREQ("Could not read database file",
|
||||
DatabaseErrorToString(DatabaseError::kFileReadError));
|
||||
EXPECT_STREQ("Could not write database file",
|
||||
DatabaseErrorToString(DatabaseError::kFileWriteError));
|
||||
EXPECT_STREQ("Invalid JSON in database file",
|
||||
DatabaseErrorToString(DatabaseError::kJsonParseError));
|
||||
EXPECT_STREQ("Database schema version too new",
|
||||
DatabaseErrorToString(DatabaseError::kSchemaVersionTooNew));
|
||||
EXPECT_STREQ("Could not acquire database lock",
|
||||
DatabaseErrorToString(DatabaseError::kLockAcquisitionFailed));
|
||||
EXPECT_STREQ("Database file corrupted (CRC32 mismatch, file deleted)",
|
||||
DatabaseErrorToString(DatabaseError::kIntegrityMismatch));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Security: Field Length Limit Tests (M2)
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadRejectsOverlyLongUuid) {
|
||||
// UUID longer than 256 chars should be skipped
|
||||
std::string long_uuid(300, 'u');
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": ")" + long_uuid +
|
||||
R"(",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.1"
|
||||
},
|
||||
{
|
||||
"uuid": "valid-uuid",
|
||||
"platform": "windows64",
|
||||
"vmin": "138.0"
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
// Only the valid entry should be loaded
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("valid-uuid", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadRejectsOverlyLongVmin) {
|
||||
// vmin longer than 64 chars should cause entry to be skipped
|
||||
std::string long_version(100, '1');
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "uuid-1",
|
||||
"platform": "windows64",
|
||||
"vmin": ")" + long_version +
|
||||
R"("
|
||||
},
|
||||
{
|
||||
"uuid": "uuid-2",
|
||||
"platform": "windows64",
|
||||
"vmin": "138.0"
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
// Only the valid entry should be loaded
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("uuid-2", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadRejectsOverlyLongVmax) {
|
||||
// vmax longer than 64 chars should cause entry to be skipped
|
||||
std::string long_version(100, '9');
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "uuid-1",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.0",
|
||||
"vmax": ")" + long_version +
|
||||
R"("
|
||||
},
|
||||
{
|
||||
"uuid": "uuid-2",
|
||||
"platform": "windows64",
|
||||
"vmin": "138.0"
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
// Only the valid entry should be loaded
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("uuid-2", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadRejectsOverlyLongAbiHash) {
|
||||
// abi_hash longer than 256 chars should cause entry to be skipped
|
||||
std::string long_hash(300, 'h');
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "uuid-1",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.0",
|
||||
"abi_hash": ")" +
|
||||
long_hash + R"("
|
||||
},
|
||||
{
|
||||
"uuid": "uuid-2",
|
||||
"platform": "windows64",
|
||||
"vmin": "138.0"
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
// Only the valid entry should be loaded
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("uuid-2", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadAcceptsValidFieldLengths) {
|
||||
// Fields at exactly the limit should be accepted
|
||||
std::string uuid_256(256, 'u');
|
||||
std::string version_64(64, '1');
|
||||
std::string hash_256(256, 'h');
|
||||
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": ")" + uuid_256 +
|
||||
R"(",
|
||||
"platform": "windows64",
|
||||
"vmin": ")" + version_64 +
|
||||
R"(",
|
||||
"abi_hash": ")" +
|
||||
hash_256 + R"("
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ(uuid_256, apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadRejectsOverlyLongPlatform) {
|
||||
// platform longer than 32 chars should cause entry to be skipped
|
||||
std::string long_platform(50, 'p');
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "uuid-1",
|
||||
"platform": ")" +
|
||||
long_platform +
|
||||
R"(",
|
||||
"vmin": "137.0"
|
||||
},
|
||||
{
|
||||
"uuid": "uuid-2",
|
||||
"platform": "windows64",
|
||||
"vmin": "138.0"
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
// Only the valid entry should be loaded
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("uuid-2", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadRejectsMissingPlatform) {
|
||||
// Entry without platform field should be skipped
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "uuid-1",
|
||||
"vmin": "137.0"
|
||||
},
|
||||
{
|
||||
"uuid": "uuid-2",
|
||||
"platform": "windows64",
|
||||
"vmin": "138.0"
|
||||
}
|
||||
]
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
// Only the valid entry should be loaded
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("uuid-2", apps[0].uuid);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integrity & Atomic Write Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SaveWritesIntegrityFooter) {
|
||||
Database db;
|
||||
AppEntry entry;
|
||||
entry.uuid = "test-uuid";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
db.RegisterApp(entry);
|
||||
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Save(GetDatabasePath()));
|
||||
|
||||
// File should have a valid integrity footer
|
||||
std::string content;
|
||||
IntegrityResult ir = ReadFileWithIntegrity(GetDatabasePath(), &content);
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ir);
|
||||
EXPECT_NE(content.find("test-uuid"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadAcceptsLegacyFileWithoutFooter) {
|
||||
// Legacy files (written without integrity footer) should still load
|
||||
WriteJsonFile(R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
{
|
||||
"uuid": "legacy-uuid",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.1"
|
||||
}
|
||||
]
|
||||
})");
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("legacy-uuid", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadDetectsCorruption) {
|
||||
// Write a valid file with integrity footer
|
||||
Database db;
|
||||
AppEntry entry;
|
||||
entry.uuid = "test-uuid";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "137.1";
|
||||
db.RegisterApp(entry);
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Save(GetDatabasePath()));
|
||||
|
||||
// Corrupt the file by flipping a byte in the content
|
||||
std::string raw;
|
||||
ASSERT_TRUE(base::ReadFileToString(GetDatabasePath(), &raw));
|
||||
ASSERT_GT(raw.size(), 20u);
|
||||
raw[5] ^= 0xFF; // Flip a byte in the JSON content
|
||||
ASSERT_TRUE(base::WriteFile(GetDatabasePath(), raw));
|
||||
|
||||
// Load should detect corruption, delete the file, return empty DB
|
||||
Database db2;
|
||||
DatabaseError error = db2.Load(GetDatabasePath());
|
||||
|
||||
EXPECT_EQ(DatabaseError::kIntegrityMismatch, error);
|
||||
EXPECT_TRUE(db2.IsEmpty());
|
||||
EXPECT_FALSE(base::PathExists(GetDatabasePath()));
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, RetentionReadPreservesCorruption) {
|
||||
Database database;
|
||||
database.RegisterApp({"test-uuid", "windows64", "137.1", "", ""});
|
||||
ASSERT_EQ(DatabaseError::kSuccess, database.Save(GetDatabasePath()));
|
||||
std::string raw;
|
||||
ASSERT_TRUE(base::ReadFileToString(GetDatabasePath(), &raw));
|
||||
ASSERT_GT(raw.size(), 20u);
|
||||
raw[5] ^= 0xFF;
|
||||
ASSERT_TRUE(base::WriteFile(GetDatabasePath(), raw));
|
||||
|
||||
Database inspected;
|
||||
EXPECT_EQ(
|
||||
DatabaseError::kIntegrityMismatch,
|
||||
inspected.Load(GetDatabasePath(), IntegrityMismatchAction::kPreserve));
|
||||
|
||||
std::string after;
|
||||
ASSERT_TRUE(base::ReadFileToString(GetDatabasePath(), &after));
|
||||
EXPECT_EQ(raw, after);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SaveThenLoadRoundTripsWithIntegrity) {
|
||||
Database db1;
|
||||
AppEntry entry;
|
||||
entry.uuid = "round-trip-uuid";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "138.0";
|
||||
entry.vmax = "138.99";
|
||||
entry.abi_hash = "abc123";
|
||||
db1.RegisterApp(entry);
|
||||
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db1.Save(GetDatabasePath()));
|
||||
|
||||
Database db2;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db2.Load(GetDatabasePath()));
|
||||
|
||||
auto apps = db2.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ(entry, apps[0]);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, DeterministicSaveFailure) {
|
||||
Database database;
|
||||
SetDatabaseSaveFailureForTesting(true);
|
||||
EXPECT_EQ(DatabaseError::kFileWriteError, database.Save(GetDatabasePath()));
|
||||
SetDatabaseSaveFailureForTesting(false);
|
||||
EXPECT_FALSE(base::PathExists(GetDatabasePath()));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pruning Suspension Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerDatabaseTest, CanPruneDefaultTrue) {
|
||||
Database db;
|
||||
EXPECT_TRUE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SuspendPruningPreventsCanPrune) {
|
||||
Database db;
|
||||
db.SuspendPruning();
|
||||
EXPECT_FALSE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SuspendPruningPersistsAcrossSaveLoad) {
|
||||
Database db1;
|
||||
db1.SuspendPruning();
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db1.Save(GetDatabasePath()));
|
||||
|
||||
Database db2;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db2.Load(GetDatabasePath()));
|
||||
EXPECT_FALSE(db2.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, ExpiredSuspensionAllowsPruning) {
|
||||
// Write a DB with a suspension timestamp in the past
|
||||
int64_t past = static_cast<int64_t>(time(nullptr)) - 100;
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"pruning_suspended_until": ")" +
|
||||
std::to_string(past) + R"(",
|
||||
"apps": []
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Load(GetDatabasePath()));
|
||||
EXPECT_TRUE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, ActiveSuspensionBlocksPruning) {
|
||||
// Write a DB with a suspension timestamp in the future
|
||||
int64_t future = static_cast<int64_t>(time(nullptr)) + 86400;
|
||||
std::string json = R"({
|
||||
"schema_version": 1,
|
||||
"pruning_suspended_until": ")" +
|
||||
std::to_string(future) + R"(",
|
||||
"apps": []
|
||||
})";
|
||||
WriteJsonFile(json);
|
||||
|
||||
Database db;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Load(GetDatabasePath()));
|
||||
EXPECT_FALSE(db.CanPrune());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, SuspensionFieldNotWrittenWhenZero) {
|
||||
Database db;
|
||||
ASSERT_EQ(DatabaseError::kSuccess, db.Save(GetDatabasePath()));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult ir = ReadFileWithIntegrity(GetDatabasePath(), &content);
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ir);
|
||||
EXPECT_EQ(content.find("pruning_suspended_until"), std::string::npos);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Coverage gap tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadFileReadError) {
|
||||
// Create a directory at the database path — reading it as a file fails.
|
||||
ASSERT_TRUE(base::CreateDirectory(GetDatabasePath()));
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
EXPECT_EQ(DatabaseError::kFileReadError, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadInvalidJsonLegacyFile) {
|
||||
// Legacy file (no integrity footer) with invalid JSON.
|
||||
WriteJsonFile("this is not valid json {{{");
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
EXPECT_EQ(DatabaseError::kJsonParseError, error);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadMissingAppsArray) {
|
||||
// Valid JSON but no "apps" key — should load with empty app list.
|
||||
WriteJsonFile(R"({"schema_version": 1})");
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
EXPECT_TRUE(db.IsEmpty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, LoadNonDictAppEntry) {
|
||||
// Apps array contains a non-object entry — should be skipped.
|
||||
WriteJsonFile(R"({
|
||||
"schema_version": 1,
|
||||
"apps": [
|
||||
"not an object",
|
||||
{
|
||||
"uuid": "valid-uuid",
|
||||
"platform": "windows64",
|
||||
"vmin": "137.1"
|
||||
}
|
||||
]
|
||||
})");
|
||||
|
||||
Database db;
|
||||
DatabaseError error = db.Load(GetDatabasePath());
|
||||
EXPECT_EQ(DatabaseError::kSuccess, error);
|
||||
auto apps = db.GetAllApps();
|
||||
ASSERT_EQ(1u, apps.size());
|
||||
EXPECT_EQ("valid-uuid", apps[0].uuid);
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetGlobalVmin_Empty) {
|
||||
Database db;
|
||||
EXPECT_FALSE(db.GetGlobalVmin("windows64").IsValid());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetGlobalVmin_SingleApp) {
|
||||
Database db;
|
||||
AppEntry entry;
|
||||
entry.uuid = "app1";
|
||||
entry.platform = "windows64";
|
||||
entry.vmin = "150.1.0";
|
||||
db.RegisterApp(entry);
|
||||
|
||||
Version vmin = db.GetGlobalVmin("windows64");
|
||||
EXPECT_TRUE(vmin.IsValid());
|
||||
EXPECT_EQ("150.1.0", vmin.ToString());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetGlobalVmin_MultipleApps) {
|
||||
Database db;
|
||||
AppEntry e1;
|
||||
e1.uuid = "app1";
|
||||
e1.platform = "windows64";
|
||||
e1.vmin = "150.2.0";
|
||||
db.RegisterApp(e1);
|
||||
|
||||
AppEntry e2;
|
||||
e2.uuid = "app2";
|
||||
e2.platform = "windows64";
|
||||
e2.vmin = "149.1.0";
|
||||
db.RegisterApp(e2);
|
||||
|
||||
AppEntry e3;
|
||||
e3.uuid = "app3";
|
||||
e3.platform = "windows64";
|
||||
e3.vmin = "151.0.0";
|
||||
db.RegisterApp(e3);
|
||||
|
||||
Version vmin = db.GetGlobalVmin("windows64");
|
||||
EXPECT_TRUE(vmin.IsValid());
|
||||
EXPECT_EQ("149.1.0", vmin.ToString());
|
||||
}
|
||||
|
||||
TEST_F(InstallerDatabaseTest, GetGlobalVmin_FiltersPlatform) {
|
||||
Database db;
|
||||
AppEntry e1;
|
||||
e1.uuid = "app1";
|
||||
e1.platform = "windows64";
|
||||
e1.vmin = "150.0.0";
|
||||
db.RegisterApp(e1);
|
||||
|
||||
AppEntry e2;
|
||||
e2.uuid = "app2";
|
||||
e2.platform = "windowsarm64";
|
||||
e2.vmin = "148.0.0";
|
||||
db.RegisterApp(e2);
|
||||
|
||||
EXPECT_EQ("150.0.0", db.GetGlobalVmin("windows64").ToString());
|
||||
EXPECT_EQ("148.0.0", db.GetGlobalVmin("windowsarm64").ToString());
|
||||
EXPECT_FALSE(db.GetGlobalVmin("linux64").IsValid());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace cef_installer
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,331 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_DOWNLOAD_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_DOWNLOAD_H_
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/functional/callback.h"
|
||||
#include "base/time/time.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Download progress callback.
|
||||
// Parameters: bytes_downloaded, total_bytes (0 if unknown)
|
||||
// Return false to cancel the download.
|
||||
using DownloadProgressCallback =
|
||||
base::RepeatingCallback<bool(uint64_t, uint64_t)>;
|
||||
|
||||
// Error codes for download operations.
|
||||
enum class DownloadError {
|
||||
kSuccess,
|
||||
kInvalidUrl, // URL is malformed or not HTTPS
|
||||
kNetworkError, // Connection failed, timeout, etc.
|
||||
kHttpError, // Server returned error status (4xx, 5xx)
|
||||
kFileWriteError, // Could not write to destination
|
||||
kHashMismatch, // Downloaded file hash doesn't match expected
|
||||
kCancelled, // Download cancelled via callback
|
||||
kFileTooLarge, // Downloaded file exceeds max_download_size
|
||||
kProtocolError, // Invalid Range/response protocol
|
||||
};
|
||||
|
||||
// Default maximum download size (500 MB for archives)
|
||||
constexpr uint64_t kDefaultMaxDownloadSize = 500 * 1024 * 1024;
|
||||
|
||||
// Default maximum size for in-memory downloads (10 MB for manifests, hashes)
|
||||
constexpr uint64_t kDefaultMaxStringDownloadSize = 10 * 1024 * 1024;
|
||||
|
||||
// One clean full-request retry shared by all configured origins for one
|
||||
// archive candidate. It is consumed by redirect-origin changes that need a
|
||||
// new request, invalid Range responses, final-size mismatch, or hash mismatch.
|
||||
struct CleanDownloadRetryBudget {
|
||||
bool consumed = false;
|
||||
|
||||
bool TryConsume() {
|
||||
if (consumed) {
|
||||
return false;
|
||||
}
|
||||
consumed = true;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Download options.
|
||||
struct DownloadOptions {
|
||||
// Expected SHA256 hash of the file (hex string, 64 chars).
|
||||
// If non-empty, downloaded file is verified against this hash.
|
||||
std::string expected_sha256;
|
||||
|
||||
// Expected SHA1 hash (hex string, 40 chars) - alternative to SHA256.
|
||||
std::string expected_sha1;
|
||||
|
||||
// Progress callback (optional).
|
||||
DownloadProgressCallback progress_callback;
|
||||
|
||||
// Maximum download size in bytes. Downloads exceeding this limit are rejected
|
||||
// to prevent disk exhaustion attacks. Set to 0 to disable the limit.
|
||||
// Default: 500 MB.
|
||||
uint64_t max_download_size = kDefaultMaxDownloadSize;
|
||||
|
||||
// TEST ONLY: Allow HTTP URLs for testing with local test servers.
|
||||
// In production, only HTTPS is allowed. This flag should never be set
|
||||
// outside of unit tests.
|
||||
bool allow_http_for_testing = false;
|
||||
|
||||
// TEST ONLY: Ignore certificate errors for loopback HTTPS test servers.
|
||||
// Production callers must leave this false; public configuration continues
|
||||
// to require normally trusted HTTPS endpoints.
|
||||
bool ignore_certificate_errors_for_testing = false;
|
||||
|
||||
// Override for the WinHTTP receive-response timeout (milliseconds).
|
||||
// 0 means use the default (60 s). Shorter values let cancellation-aware
|
||||
// callers avoid blocking for the full default timeout when the server is
|
||||
// unresponsive.
|
||||
int receive_timeout_ms = 0;
|
||||
|
||||
// Override for DNS resolution and connection timeouts (milliseconds).
|
||||
// 0 means use the defaults. Used with receive_timeout_ms to bound
|
||||
// launch-path revocation refresh.
|
||||
int connect_timeout_ms = 0;
|
||||
|
||||
// Path to a local directory containing CDN-structured files.
|
||||
// When set, DownloadFile and DownloadWithCache read/copy files from this
|
||||
// directory instead of fetching from the network. The filename is extracted
|
||||
// from the URL path (last component). All usual validation (hash
|
||||
// verification, size limits) is still performed.
|
||||
base::FilePath local_download_path;
|
||||
|
||||
// Enable origin-bound resumable transfer for a hash-addressed archive.
|
||||
// Small in-memory downloads and ordinary file downloads leave this false.
|
||||
bool enable_resume = false;
|
||||
|
||||
// Optional candidate-wide retry budget. When null, DownloadFile owns a
|
||||
// per-call budget. The pointed-to value must outlive DownloadFile.
|
||||
CleanDownloadRetryBudget* clean_retry_budget = nullptr;
|
||||
|
||||
// Optional response-status output for callers that must distinguish archive
|
||||
// propagation lag (404) from a general origin failure. Set only after
|
||||
// response headers are available; never used for policy decisions.
|
||||
int* response_status_code = nullptr;
|
||||
};
|
||||
|
||||
// Download a file from URL to destination path.
|
||||
// - URL must be HTTPS (HTTP URLs are rejected for security)
|
||||
// - Creates parent directories if needed
|
||||
// - Downloads to a temp file first, then renames on success
|
||||
// - Verifies hash if expected_sha256 or expected_sha1 is provided
|
||||
DownloadError DownloadFile(const std::string& url,
|
||||
const base::FilePath& dest_path,
|
||||
const DownloadOptions& options = {});
|
||||
|
||||
// Safely removes deterministic partial variants for one hash-keyed archive.
|
||||
// Used before configured-origin failover when sidecars change the hash path.
|
||||
DownloadError DiscardDownloadPartials(const base::FilePath& dest_path);
|
||||
|
||||
// Download a file and return its contents as a string.
|
||||
// Useful for small files like manifest JSON or hash files.
|
||||
// Same security requirements as DownloadFile.
|
||||
// Prefer DownloadWithCache() for repeated downloads of the same URL.
|
||||
DownloadError DownloadToString(const std::string& url,
|
||||
std::string* content,
|
||||
const DownloadOptions& options = {});
|
||||
|
||||
// Download a small response using asynchronous WinHTTP with a cancellable
|
||||
// absolute deadline. This is used by automatic-startup revocation refresh so
|
||||
// DNS, connection, response headers, and response body share one wall-clock
|
||||
// budget. |overall_timeout| must be positive. Progress callbacks are not
|
||||
// supported because blocking caller code cannot participate in the network
|
||||
// deadline; a configured callback returns kCancelled before network activity.
|
||||
DownloadError DownloadToStringWithDeadline(const std::string& url,
|
||||
std::string* content,
|
||||
base::TimeDelta overall_timeout,
|
||||
const DownloadOptions& options = {});
|
||||
|
||||
// Compute SHA256 hash of a file (returns lowercase hex string, 64 chars).
|
||||
// Returns empty string on error.
|
||||
std::string ComputeFileSha256(const base::FilePath& path);
|
||||
|
||||
// Compute SHA1 hash of a file (returns lowercase hex string, 40 chars).
|
||||
// Returns empty string on error.
|
||||
std::string ComputeFileSha1(const base::FilePath& path);
|
||||
|
||||
// Verify a file's hash against expected value.
|
||||
// Checks SHA256 first if provided, then SHA1.
|
||||
// Returns true if at least one hash matches.
|
||||
bool VerifyFileHash(const base::FilePath& path,
|
||||
const std::string& expected_sha256,
|
||||
const std::string& expected_sha1);
|
||||
|
||||
// ============================================================================
|
||||
// Cache Management for CDN Manifest Files
|
||||
// ============================================================================
|
||||
|
||||
// Get the cache directory path: <install_dir>/.cache/
|
||||
base::FilePath GetCacheDirectory(const base::FilePath& install_dir);
|
||||
|
||||
// Get the cached file path for a URL.
|
||||
// Converts URL to a safe filename: SHA256(url).cache
|
||||
base::FilePath GetCacheFilePath(const base::FilePath& cache_dir,
|
||||
const std::string& url);
|
||||
|
||||
// Check if a cached file is still valid (not expired).
|
||||
// Returns true if file exists, is not a reparse point, and was modified
|
||||
// within |max_age_seconds| (defaults to kManifestCacheValiditySeconds).
|
||||
// Optionally returns the file's last-modified time for reuse (e.g. for
|
||||
// If-Modified-Since headers).
|
||||
// Note: uses a fixed expiry rather than CDN cache-control headers to avoid
|
||||
// the complexity of HTTP header parsing. This is a reasonable approximation
|
||||
// for the small files being cached.
|
||||
bool IsCacheValid(const base::FilePath& cache_path,
|
||||
base::Time* out_last_modified = nullptr,
|
||||
int max_age_seconds = kManifestCacheValiditySeconds);
|
||||
|
||||
enum class StaleCacheFallback {
|
||||
kAllow,
|
||||
kSkip,
|
||||
};
|
||||
|
||||
enum class CacheWriteBehavior {
|
||||
kAutomatic,
|
||||
kDefer,
|
||||
};
|
||||
|
||||
enum class DownloadContentSource {
|
||||
kNetwork,
|
||||
kCache,
|
||||
};
|
||||
|
||||
// Reads an integrity-valid cache entry without performing network or creating
|
||||
// cache directories. |cache_key| is the source-neutral request identity used
|
||||
// when the entry was written.
|
||||
DownloadError ReadDownloadCache(const base::FilePath& cache_dir,
|
||||
std::string_view cache_key,
|
||||
std::string* content);
|
||||
|
||||
// Promotes artifact-specific validated content to a source-neutral cache
|
||||
// entry. Callers must validate |content| before calling this function.
|
||||
DownloadError WriteDownloadCache(const base::FilePath& cache_dir,
|
||||
std::string_view cache_key,
|
||||
std::string_view content);
|
||||
|
||||
// Discards a cache entry that failed artifact-specific validation.
|
||||
bool DiscardDownloadCache(const base::FilePath& cache_dir,
|
||||
std::string_view cache_key);
|
||||
|
||||
// Download a file with CDN-first caching.
|
||||
//
|
||||
// CDN-first flow (when cache exists and is within validity window):
|
||||
// 1. Send HEAD request with If-Modified-Since to validate cache with CDN
|
||||
// 2. If 304 Not Modified: serve from cache (CDN-validated)
|
||||
// 3. If 200 OK: download fresh content and update cache
|
||||
// 4. If network error: fall back to cache (offline fallback)
|
||||
//
|
||||
// When cache is missing, expired, or force_check is true:
|
||||
// 1. Download fresh content from CDN
|
||||
// 2. On network failure: try serving from cache as last resort
|
||||
//
|
||||
// This minimizes the window where tampered cache content could be served
|
||||
// to only when the CDN is unreachable.
|
||||
//
|
||||
// Set |stale_cache_fallback| to kSkip while trying ordered origins, then call
|
||||
// ReadDownloadCache() once after all origins fail. A non-empty |cache_key|
|
||||
// gives those origins one source-neutral cache identity. kDefer preserves
|
||||
// fresh-cache HEAD/304 behavior but leaves network content uncommitted until
|
||||
// the caller validates and promotes it with WriteDownloadCache().
|
||||
DownloadError DownloadWithCache(
|
||||
const std::string& url,
|
||||
const base::FilePath& cache_dir,
|
||||
std::string* content,
|
||||
const DownloadOptions& options = {},
|
||||
bool force_check = false,
|
||||
StaleCacheFallback stale_cache_fallback = StaleCacheFallback::kAllow,
|
||||
std::string_view cache_key = {},
|
||||
CacheWriteBehavior cache_write_behavior = CacheWriteBehavior::kAutomatic,
|
||||
DownloadContentSource* content_source = nullptr);
|
||||
|
||||
// Clean up expired cache files.
|
||||
void PruneCacheDirectory(const base::FilePath& cache_dir);
|
||||
|
||||
// Convert error code to human-readable string for logging.
|
||||
const char* DownloadErrorToString(DownloadError error);
|
||||
|
||||
// Check if a URL is valid for downloading (must be HTTPS).
|
||||
bool IsValidDownloadUrl(const std::string& url);
|
||||
|
||||
// ============================================================================
|
||||
// Internal helpers exposed for testing. Not part of the public API.
|
||||
// ============================================================================
|
||||
namespace internal {
|
||||
|
||||
// Parsed URL components for WinHTTP.
|
||||
struct UrlComponents {
|
||||
std::wstring host;
|
||||
std::wstring path;
|
||||
uint16_t port = 0;
|
||||
bool is_https = false;
|
||||
};
|
||||
|
||||
struct ContentRange {
|
||||
uint64_t start = 0;
|
||||
uint64_t end = 0;
|
||||
uint64_t total = 0;
|
||||
};
|
||||
|
||||
// Strictly parses one complete byte Content-Range value and requires its start
|
||||
// to equal |expected_start|. Wildcard totals, extra whitespace/ranges,
|
||||
// overflow, contradictions, and totals above |max_size| are rejected.
|
||||
bool ParseContentRange(std::string_view value,
|
||||
uint64_t expected_start,
|
||||
uint64_t max_size,
|
||||
ContentRange* output);
|
||||
|
||||
// Normalizes a URL origin as lower-case scheme/host plus effective port.
|
||||
// Paths, queries, fragments, and credentials are never returned.
|
||||
bool NormalizeUrlOrigin(const std::string& url, std::string* origin);
|
||||
|
||||
// Returns the deterministic configured/final-origin-bound partial path used by
|
||||
// resumable archive downloads. Empty indicates invalid input.
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
base::FilePath GetDownloadPartialPathForTesting(
|
||||
const base::FilePath& destination,
|
||||
const std::string& configured_url,
|
||||
const std::string& final_url);
|
||||
|
||||
// Prunes against an injected wall-clock value so archive TTL boundaries are
|
||||
// deterministic and do not depend on test execution timing.
|
||||
void PruneCacheDirectoryAtTimeForTesting(const base::FilePath& cache_dir,
|
||||
base::Time now);
|
||||
#endif
|
||||
|
||||
// Parse a URL into components for WinHTTP.
|
||||
bool ParseUrl(const std::string& url, UrlComponents* out);
|
||||
|
||||
// Format a base::Time as an HTTP-date string (RFC 7231) for If-Modified-Since.
|
||||
// Example: "Sat, 08 Mar 2026 12:00:00 GMT"
|
||||
std::wstring FormatHttpDate(const base::Time& time);
|
||||
|
||||
// Result of a HEAD request for CDN cache validation.
|
||||
enum class HeadResult {
|
||||
kNotModified, // 304: cache is current
|
||||
kModified, // 200: CDN has newer content
|
||||
kNetworkError, // Could not reach CDN
|
||||
};
|
||||
|
||||
// Perform a HEAD request with If-Modified-Since to validate cache freshness.
|
||||
// When options.local_download_path is set, skips the network request and
|
||||
// returns kModified so the caller always fetches fresh content from the local
|
||||
// directory.
|
||||
HeadResult HeadRequest(const std::string& url,
|
||||
const base::Time& cache_time,
|
||||
const DownloadOptions& options);
|
||||
|
||||
} // namespace internal
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_DOWNLOAD_H_
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,148 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_e2e_config.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
|
||||
#include "base/environment.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/no_destructor.h"
|
||||
#include "base/time/time.h"
|
||||
|
||||
namespace cef_installer::internal {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr base::TimeDelta kChildStateBarrierTimeout = base::Seconds(30);
|
||||
|
||||
InstallerE2EConfig LoadInstallerE2EConfigFromEnvironment() {
|
||||
InstallerE2EConfig config;
|
||||
auto environment = base::Environment::Create();
|
||||
|
||||
const std::optional<std::string> directory_scenario =
|
||||
environment->GetVar("CEF_INSTALLER_TEST_DIRECTORY_SCENARIO");
|
||||
const std::optional<std::string> directory_root =
|
||||
environment->GetVar("CEF_INSTALLER_TEST_DIRECTORY_ROOT");
|
||||
|
||||
#if defined(OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
if (directory_scenario || directory_root) {
|
||||
config.directory_override = InstallerE2EDirectoryOverride::kInvalid;
|
||||
}
|
||||
return config;
|
||||
#else
|
||||
if (directory_scenario || directory_root) {
|
||||
if (!directory_scenario || !directory_root ||
|
||||
*directory_scenario != "admin_mutation_denied") {
|
||||
config.directory_override = InstallerE2EDirectoryOverride::kInvalid;
|
||||
} else {
|
||||
config.directory_root = base::FilePath::FromUTF8Unsafe(*directory_root)
|
||||
.StripTrailingSeparators();
|
||||
config.directory_override =
|
||||
config.directory_root.IsAbsolute() &&
|
||||
!config.directory_root.ReferencesParent()
|
||||
? InstallerE2EDirectoryOverride::kAdminMutationDenied
|
||||
: InstallerE2EDirectoryOverride::kInvalid;
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto value =
|
||||
environment->GetVar("CEF_INSTALLER_TEST_FILE_OPS_FAULT")) {
|
||||
if (*value == "quarantine_move") {
|
||||
config.file_ops_fault = InstallerE2EFileOpsFault::kQuarantineMove;
|
||||
} else if (*value == "repair_move") {
|
||||
config.file_ops_fault = InstallerE2EFileOpsFault::kRepairMove;
|
||||
} else if (*value == "trash_move") {
|
||||
config.file_ops_fault = InstallerE2EFileOpsFault::kTrashMove;
|
||||
} else if (*value == "trash_reclaim") {
|
||||
config.file_ops_fault = InstallerE2EFileOpsFault::kTrashReclaim;
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto value =
|
||||
environment->GetVar("CEF_INSTALLER_TEST_INDEX_FAULT")) {
|
||||
if (*value == "write") {
|
||||
config.version_index_fault = InstallerE2EVersionIndexFault::kWrite;
|
||||
} else if (*value == "replace") {
|
||||
config.version_index_fault = InstallerE2EVersionIndexFault::kReplace;
|
||||
} else if (*value == "reread") {
|
||||
config.version_index_fault = InstallerE2EVersionIndexFault::kReread;
|
||||
} else if (*value == "validation") {
|
||||
config.version_index_fault = InstallerE2EVersionIndexFault::kValidation;
|
||||
}
|
||||
}
|
||||
|
||||
const auto enabled = [&environment](base::cstring_view name) {
|
||||
const auto value = environment->GetVar(name);
|
||||
return value && *value == "1";
|
||||
};
|
||||
config.database_save_failure =
|
||||
enabled("CEF_INSTALLER_TEST_DATABASE_SAVE_FAILURE");
|
||||
config.relaunch_failure = enabled("CEF_INSTALLER_TEST_RELAUNCH_FAILURE");
|
||||
config.child_config_failure =
|
||||
enabled("CEF_INSTALLER_TEST_CHILD_CONFIG_FAILURE");
|
||||
|
||||
if (const auto value =
|
||||
environment->GetVar("CEF_INSTALLER_TEST_CHILD_STATE_BARRIER")) {
|
||||
base::FilePath path = base::FilePath::FromUTF8Unsafe(*value);
|
||||
if (path.IsAbsolute() && !path.ReferencesParent()) {
|
||||
config.child_state_barrier = std::move(path);
|
||||
}
|
||||
}
|
||||
return config;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
std::optional<InstallerE2EConfig>& ConfigForTesting() {
|
||||
static base::NoDestructor<std::optional<InstallerE2EConfig>> config;
|
||||
return *config;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
const InstallerE2EConfig& GetInstallerE2EConfig() {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
if (ConfigForTesting()) {
|
||||
return *ConfigForTesting();
|
||||
}
|
||||
#endif
|
||||
static const base::NoDestructor<InstallerE2EConfig> config(
|
||||
LoadInstallerE2EConfigFromEnvironment());
|
||||
return *config;
|
||||
}
|
||||
|
||||
void WaitAtInstallerE2EChildStateBarrier() {
|
||||
const base::FilePath& path = GetInstallerE2EConfig().child_state_barrier;
|
||||
if (path.empty() || !base::WriteFile(path, "ready")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base::TimeTicks deadline =
|
||||
base::TimeTicks::Now() + kChildStateBarrierTimeout;
|
||||
while (base::PathExists(path) && base::TimeTicks::Now() < deadline) {
|
||||
::Sleep(25);
|
||||
}
|
||||
if (base::PathExists(path)) {
|
||||
LOG(ERROR) << "Timed out waiting at installer E2E child-state barrier";
|
||||
}
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
InstallerE2EConfig LoadInstallerE2EConfigFromEnvironmentForTesting() {
|
||||
return LoadInstallerE2EConfigFromEnvironment();
|
||||
}
|
||||
|
||||
void SetInstallerE2EConfigForTesting(
|
||||
const std::optional<InstallerE2EConfig>& config) {
|
||||
ConfigForTesting() = config;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace cef_installer::internal
|
||||
@@ -0,0 +1,67 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_E2E_CONFIG_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_E2E_CONFIG_H_
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
|
||||
namespace cef_installer::internal {
|
||||
|
||||
enum class InstallerE2EDirectoryOverride {
|
||||
kNone,
|
||||
kAdminMutationDenied,
|
||||
kInvalid,
|
||||
};
|
||||
|
||||
enum class InstallerE2EFileOpsFault {
|
||||
kNone,
|
||||
kQuarantineMove,
|
||||
kRepairMove,
|
||||
kTrashMove,
|
||||
kTrashReclaim,
|
||||
};
|
||||
|
||||
enum class InstallerE2EVersionIndexFault {
|
||||
kNone,
|
||||
kWrite,
|
||||
kReplace,
|
||||
kReread,
|
||||
kValidation,
|
||||
};
|
||||
|
||||
// Typed, process-wide configuration for subprocess/E2E-only fault injection.
|
||||
// Environment variables are parsed only here; product components consume
|
||||
// these values at the boundary that owns the simulated failure.
|
||||
struct InstallerE2EConfig {
|
||||
InstallerE2EDirectoryOverride directory_override =
|
||||
InstallerE2EDirectoryOverride::kNone;
|
||||
base::FilePath directory_root;
|
||||
InstallerE2EFileOpsFault file_ops_fault = InstallerE2EFileOpsFault::kNone;
|
||||
InstallerE2EVersionIndexFault version_index_fault =
|
||||
InstallerE2EVersionIndexFault::kNone;
|
||||
bool database_save_failure = false;
|
||||
bool relaunch_failure = false;
|
||||
bool child_config_failure = false;
|
||||
base::FilePath child_state_barrier;
|
||||
};
|
||||
|
||||
const InstallerE2EConfig& GetInstallerE2EConfig();
|
||||
|
||||
// Signals and waits at the trusted-child-state checkpoint when configured.
|
||||
// The wait is bounded so abandoned test state cannot stall the process
|
||||
// indefinitely.
|
||||
void WaitAtInstallerE2EChildStateBarrier();
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
InstallerE2EConfig LoadInstallerE2EConfigFromEnvironmentForTesting();
|
||||
void SetInstallerE2EConfigForTesting(
|
||||
const std::optional<InstallerE2EConfig>& config);
|
||||
#endif
|
||||
|
||||
} // namespace cef_installer::internal
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_E2E_CONFIG_H_
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_e2e_config.h"
|
||||
|
||||
#include "base/files/scoped_temp_dir.h"
|
||||
#include "base/scoped_environment_variable_override.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer::internal {
|
||||
namespace {
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
TEST(InstallerE2EConfigTest, ParsesTypedEnvironmentConfiguration) {
|
||||
base::ScopedTempDir temp_dir;
|
||||
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
|
||||
const base::FilePath barrier = temp_dir.GetPath().Append(L"barrier");
|
||||
base::ScopedEnvironmentVariableOverride directory_scenario(
|
||||
"CEF_INSTALLER_TEST_DIRECTORY_SCENARIO", "admin_mutation_denied");
|
||||
base::ScopedEnvironmentVariableOverride directory_root(
|
||||
"CEF_INSTALLER_TEST_DIRECTORY_ROOT", temp_dir.GetPath().AsUTF8Unsafe());
|
||||
base::ScopedEnvironmentVariableOverride file_ops(
|
||||
"CEF_INSTALLER_TEST_FILE_OPS_FAULT", "trash_reclaim");
|
||||
base::ScopedEnvironmentVariableOverride version_index(
|
||||
"CEF_INSTALLER_TEST_INDEX_FAULT", "validation");
|
||||
base::ScopedEnvironmentVariableOverride database(
|
||||
"CEF_INSTALLER_TEST_DATABASE_SAVE_FAILURE", "1");
|
||||
base::ScopedEnvironmentVariableOverride relaunch(
|
||||
"CEF_INSTALLER_TEST_RELAUNCH_FAILURE", "1");
|
||||
base::ScopedEnvironmentVariableOverride child_config(
|
||||
"CEF_INSTALLER_TEST_CHILD_CONFIG_FAILURE", "1");
|
||||
base::ScopedEnvironmentVariableOverride child_barrier(
|
||||
"CEF_INSTALLER_TEST_CHILD_STATE_BARRIER", barrier.AsUTF8Unsafe());
|
||||
|
||||
const InstallerE2EConfig config =
|
||||
LoadInstallerE2EConfigFromEnvironmentForTesting();
|
||||
EXPECT_EQ(InstallerE2EDirectoryOverride::kAdminMutationDenied,
|
||||
config.directory_override);
|
||||
EXPECT_EQ(temp_dir.GetPath(), config.directory_root);
|
||||
EXPECT_EQ(InstallerE2EFileOpsFault::kTrashReclaim, config.file_ops_fault);
|
||||
EXPECT_EQ(InstallerE2EVersionIndexFault::kValidation,
|
||||
config.version_index_fault);
|
||||
EXPECT_TRUE(config.database_save_failure);
|
||||
EXPECT_TRUE(config.relaunch_failure);
|
||||
EXPECT_TRUE(config.child_config_failure);
|
||||
EXPECT_EQ(barrier, config.child_state_barrier);
|
||||
}
|
||||
|
||||
TEST(InstallerE2EConfigTest, RejectsIncompleteDirectoryConfiguration) {
|
||||
base::ScopedTempDir temp_dir;
|
||||
ASSERT_TRUE(temp_dir.CreateUniqueTempDir());
|
||||
base::ScopedEnvironmentVariableOverride directory_root(
|
||||
"CEF_INSTALLER_TEST_DIRECTORY_ROOT", temp_dir.GetPath().AsUTF8Unsafe());
|
||||
|
||||
EXPECT_EQ(
|
||||
InstallerE2EDirectoryOverride::kInvalid,
|
||||
LoadInstallerE2EConfigFromEnvironmentForTesting().directory_override);
|
||||
}
|
||||
|
||||
TEST(InstallerE2EConfigTest, ParsesEveryFileOpsFault) {
|
||||
struct TestCase {
|
||||
const char* value;
|
||||
InstallerE2EFileOpsFault expected;
|
||||
};
|
||||
const TestCase cases[] = {
|
||||
{"quarantine_move", InstallerE2EFileOpsFault::kQuarantineMove},
|
||||
{"repair_move", InstallerE2EFileOpsFault::kRepairMove},
|
||||
{"trash_move", InstallerE2EFileOpsFault::kTrashMove},
|
||||
{"trash_reclaim", InstallerE2EFileOpsFault::kTrashReclaim},
|
||||
{"unknown", InstallerE2EFileOpsFault::kNone},
|
||||
};
|
||||
|
||||
for (const auto& test : cases) {
|
||||
SCOPED_TRACE(test.value);
|
||||
base::ScopedEnvironmentVariableOverride fault(
|
||||
"CEF_INSTALLER_TEST_FILE_OPS_FAULT", test.value);
|
||||
EXPECT_EQ(test.expected,
|
||||
LoadInstallerE2EConfigFromEnvironmentForTesting().file_ops_fault);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(InstallerE2EConfigTest, ParsesEveryVersionIndexFault) {
|
||||
struct TestCase {
|
||||
const char* value;
|
||||
InstallerE2EVersionIndexFault expected;
|
||||
};
|
||||
const TestCase cases[] = {
|
||||
{"write", InstallerE2EVersionIndexFault::kWrite},
|
||||
{"replace", InstallerE2EVersionIndexFault::kReplace},
|
||||
{"reread", InstallerE2EVersionIndexFault::kReread},
|
||||
{"validation", InstallerE2EVersionIndexFault::kValidation},
|
||||
{"unknown", InstallerE2EVersionIndexFault::kNone},
|
||||
};
|
||||
|
||||
for (const auto& test : cases) {
|
||||
SCOPED_TRACE(test.value);
|
||||
base::ScopedEnvironmentVariableOverride fault(
|
||||
"CEF_INSTALLER_TEST_INDEX_FAULT", test.value);
|
||||
EXPECT_EQ(
|
||||
test.expected,
|
||||
LoadInstallerE2EConfigFromEnvironmentForTesting().version_index_fault);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
} // namespace cef_installer::internal
|
||||
@@ -0,0 +1,479 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include <limits>
|
||||
#include <type_traits>
|
||||
|
||||
#include "base/files/file.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/functional/callback.h"
|
||||
#include "base/no_destructor.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_crc.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
#include "third_party/lzma_sdk/src/C/7zCrc.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr uint64_t kIntegrityFooterMagic = UINT64_C(0xCEF09E1F4A3B2C1D);
|
||||
|
||||
// Footer appended to files written with WriteFileWithIntegrity.
|
||||
// Matches the on-disk layout exactly (no implicit padding).
|
||||
struct IntegrityFooter {
|
||||
uint32_t data_crc32; // CRC32 of content preceding the footer
|
||||
uint32_t reserved; // Must be zero (future use)
|
||||
uint64_t magic; // kIntegrityFooterMagic
|
||||
};
|
||||
static_assert(sizeof(IntegrityFooter) == 16);
|
||||
static_assert(std::has_unique_object_representations_v<IntegrityFooter>);
|
||||
|
||||
constexpr size_t kFooterSize = sizeof(IntegrityFooter);
|
||||
|
||||
bool IsSameFileObject(base::File* left, base::File* right) {
|
||||
BY_HANDLE_FILE_INFORMATION left_info = {};
|
||||
BY_HANDLE_FILE_INFORMATION right_info = {};
|
||||
if (!::GetFileInformationByHandle(left->GetPlatformFile(), &left_info) ||
|
||||
!::GetFileInformationByHandle(right->GetPlatformFile(), &right_info)) {
|
||||
return true;
|
||||
}
|
||||
return left_info.dwVolumeSerialNumber == right_info.dwVolumeSerialNumber &&
|
||||
left_info.nFileIndexHigh == right_info.nFileIndexHigh &&
|
||||
left_info.nFileIndexLow == right_info.nFileIndexLow;
|
||||
}
|
||||
|
||||
base::File OpenForConditionalDeleteNoFollow(const base::FilePath& path) {
|
||||
return base::File(::CreateFileW(
|
||||
path.value().c_str(), GENERIC_READ | DELETE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
|
||||
}
|
||||
|
||||
base::File OpenForReadNoFollow(const base::FilePath& path) {
|
||||
return base::File(::CreateFileW(
|
||||
path.value().c_str(), GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, nullptr));
|
||||
}
|
||||
|
||||
bool IsOpenedReparsePoint(base::File* file) {
|
||||
FILE_ATTRIBUTE_TAG_INFO info = {};
|
||||
return !::GetFileInformationByHandleEx(file->GetPlatformFile(),
|
||||
FileAttributeTagInfo, &info,
|
||||
sizeof(info)) ||
|
||||
(info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
|
||||
}
|
||||
|
||||
std::optional<base::FilePath> GetOpenedResolvedPath(base::File* file) {
|
||||
constexpr DWORD kMaxFinalPathLength = 32768;
|
||||
DWORD capacity = MAX_PATH;
|
||||
std::wstring buffer(capacity, L'\0');
|
||||
DWORD length = ::GetFinalPathNameByHandleW(
|
||||
file->GetPlatformFile(), buffer.data(), capacity,
|
||||
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
|
||||
if (length == 0 || length >= kMaxFinalPathLength) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (length >= capacity) {
|
||||
capacity = length + 1;
|
||||
buffer.resize(capacity);
|
||||
length = ::GetFinalPathNameByHandleW(
|
||||
file->GetPlatformFile(), buffer.data(), capacity,
|
||||
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
|
||||
if (length == 0 || length >= capacity) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
buffer.resize(length);
|
||||
return base::FilePath(std::move(buffer));
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
base::NoDestructor<ConditionalDeleteHookForTesting> g_conditional_delete_hook;
|
||||
#endif
|
||||
|
||||
ConditionalDeleteResult FinishConditionalDelete(base::File* file,
|
||||
const base::FilePath& path) {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
if (*g_conditional_delete_hook) {
|
||||
g_conditional_delete_hook->Run(path);
|
||||
}
|
||||
#endif
|
||||
if (file->DeleteOnClose(true)) {
|
||||
return ConditionalDeleteResult::kDeleted;
|
||||
}
|
||||
|
||||
// ReplaceFile may already have detached the observed file object from
|
||||
// |path|. In that case the failed disposition cannot affect the replacement,
|
||||
// and cleanup of the observed object is already complete for our purposes.
|
||||
base::File current(path, base::File::FLAG_OPEN | base::File::FLAG_READ |
|
||||
base::File::FLAG_WIN_SHARE_DELETE);
|
||||
if (!current.IsValid()) {
|
||||
return base::PathExists(path) ? ConditionalDeleteResult::kChanged
|
||||
: ConditionalDeleteResult::kDeleted;
|
||||
}
|
||||
if (!IsSameFileObject(file, ¤t)) {
|
||||
return ConditionalDeleteResult::kDeleted;
|
||||
}
|
||||
return ConditionalDeleteResult::kError;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool WriteFileWithIntegrity(const base::FilePath& path,
|
||||
const std::string& content) {
|
||||
const base::FilePath parent = path.DirName();
|
||||
if (!base::DirectoryExists(parent) || IsReparsePoint(parent) ||
|
||||
IsReparsePoint(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureCrcInitialized();
|
||||
IntegrityFooter footer = {};
|
||||
footer.data_crc32 = CrcCalc(content.data(), content.size());
|
||||
footer.magic = kIntegrityFooterMagic;
|
||||
|
||||
std::string output;
|
||||
output.reserve(content.size() + kFooterSize);
|
||||
output.append(content);
|
||||
output.append(reinterpret_cast<const char*>(&footer), kFooterSize);
|
||||
|
||||
base::FilePath temp_path;
|
||||
if (!base::CreateTemporaryFileInDir(parent, &temp_path) ||
|
||||
IsReparsePoint(temp_path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool write_succeeded = false;
|
||||
{
|
||||
base::File temp(temp_path, base::File::FLAG_OPEN | base::File::FLAG_WRITE |
|
||||
base::File::FLAG_WIN_SHARE_DELETE);
|
||||
if (temp.IsValid()) {
|
||||
std::optional<size_t> written =
|
||||
temp.WriteAtCurrentPos(base::as_byte_span(output));
|
||||
write_succeeded = written && *written == output.size() && temp.Flush();
|
||||
}
|
||||
}
|
||||
const bool replaced =
|
||||
write_succeeded && base::ReplaceFile(temp_path, path, nullptr);
|
||||
if (!replaced) {
|
||||
base::DeleteFile(temp_path);
|
||||
}
|
||||
return replaced;
|
||||
}
|
||||
|
||||
IntegrityResult ReadFileWithIntegrity(const base::FilePath& path,
|
||||
std::string* content,
|
||||
IntegrityMismatchAction mismatch_action,
|
||||
size_t max_content_size,
|
||||
base::FilePath* resolved_path,
|
||||
std::string* raw_content,
|
||||
bool* too_large) {
|
||||
if (!content) {
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
if (too_large) {
|
||||
*too_large = false;
|
||||
}
|
||||
|
||||
if (!base::PathExists(path)) {
|
||||
return IntegrityResult::kFileNotFound;
|
||||
}
|
||||
|
||||
std::string raw;
|
||||
base::File file = OpenForReadNoFollow(path);
|
||||
if (!file.IsValid() || IsOpenedReparsePoint(&file)) {
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
if (resolved_path) {
|
||||
std::optional<base::FilePath> opened_path = GetOpenedResolvedPath(&file);
|
||||
if (!opened_path) {
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
*resolved_path = std::move(*opened_path);
|
||||
}
|
||||
int64_t length = file.GetLength();
|
||||
const size_t max_file_size =
|
||||
max_content_size <= std::numeric_limits<size_t>::max() - kFooterSize
|
||||
? max_content_size + kFooterSize
|
||||
: std::numeric_limits<size_t>::max();
|
||||
if (length < 0 || static_cast<uint64_t>(length) > max_file_size) {
|
||||
if (too_large && length >= 0 &&
|
||||
static_cast<uint64_t>(length) > max_file_size) {
|
||||
*too_large = true;
|
||||
}
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
raw.resize(static_cast<size_t>(length));
|
||||
std::optional<size_t> bytes_read =
|
||||
file.ReadAtCurrentPos(base::as_writable_byte_span(raw));
|
||||
if (!bytes_read || *bytes_read != raw.size()) {
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
if (raw_content) {
|
||||
*raw_content = raw;
|
||||
}
|
||||
|
||||
// File too small to contain a footer.
|
||||
if (raw.size() < kFooterSize) {
|
||||
if (raw.size() > max_content_size) {
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
*content = std::move(raw);
|
||||
return IntegrityResult::kSuccessNoFooter;
|
||||
}
|
||||
|
||||
// Check for magic number at end of file.
|
||||
IntegrityFooter footer;
|
||||
memcpy(&footer, raw.data() + raw.size() - kFooterSize, kFooterSize);
|
||||
|
||||
if (footer.magic != kIntegrityFooterMagic) {
|
||||
if (raw.size() > max_content_size) {
|
||||
return IntegrityResult::kReadError;
|
||||
}
|
||||
// No integrity footer — treat as legacy file.
|
||||
*content = std::move(raw);
|
||||
return IntegrityResult::kSuccessNoFooter;
|
||||
}
|
||||
|
||||
// Footer present — verify CRC32 of content.
|
||||
EnsureCrcInitialized();
|
||||
size_t content_size = raw.size() - kFooterSize;
|
||||
uint32_t actual_crc = CrcCalc(raw.data(), content_size);
|
||||
|
||||
if (actual_crc != footer.data_crc32) {
|
||||
if (mismatch_action == IntegrityMismatchAction::kDoom) {
|
||||
// Corrupted. Delete the file (doom pattern from Chromium's disk cache).
|
||||
base::DeleteFile(path);
|
||||
}
|
||||
return IntegrityResult::kIntegrityMismatch;
|
||||
}
|
||||
|
||||
// Integrity verified. Return content without footer.
|
||||
raw.resize(content_size);
|
||||
*content = std::move(raw);
|
||||
return IntegrityResult::kSuccess;
|
||||
}
|
||||
|
||||
std::optional<base::FilePath> GetSafeDirectoryResolvedPath(
|
||||
const base::FilePath& path) {
|
||||
base::File directory(::CreateFileW(
|
||||
path.value().c_str(), FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
nullptr));
|
||||
if (!directory.IsValid() || IsOpenedReparsePoint(&directory)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
FILE_ATTRIBUTE_TAG_INFO info = {};
|
||||
if (!::GetFileInformationByHandleEx(directory.GetPlatformFile(),
|
||||
FileAttributeTagInfo, &info,
|
||||
sizeof(info)) ||
|
||||
(info.FileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return GetOpenedResolvedPath(&directory);
|
||||
}
|
||||
|
||||
ConditionalDeleteResult DeleteFileWithIntegrityIfMatching(
|
||||
const base::FilePath& path,
|
||||
const std::string& expected_content,
|
||||
bool expected_integrity_protected,
|
||||
const base::FilePath& expected_resolved_parent) {
|
||||
base::File file = OpenForConditionalDeleteNoFollow(path);
|
||||
if (!file.IsValid()) {
|
||||
return !base::PathExists(path) || IsReparsePoint(path)
|
||||
? ConditionalDeleteResult::kChanged
|
||||
: ConditionalDeleteResult::kError;
|
||||
}
|
||||
if (IsOpenedReparsePoint(&file)) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
std::optional<base::FilePath> resolved_path = GetOpenedResolvedPath(&file);
|
||||
if (!resolved_path || expected_resolved_parent.empty() ||
|
||||
!base::FilePath::CompareEqualIgnoreCase(
|
||||
resolved_path->DirName().value(), expected_resolved_parent.value())) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
|
||||
int64_t length = file.GetLength();
|
||||
if (expected_content.size() > static_cast<size_t>(kMaxLaunchStateFileSize)) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
const size_t expected_length =
|
||||
expected_content.size() +
|
||||
(expected_integrity_protected ? kFooterSize : 0u);
|
||||
if (length < 0 || static_cast<uint64_t>(length) != expected_length) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
if (static_cast<uint64_t>(length) > std::numeric_limits<size_t>::max()) {
|
||||
return ConditionalDeleteResult::kError;
|
||||
}
|
||||
std::string raw(static_cast<size_t>(length), '\0');
|
||||
std::optional<size_t> bytes_read =
|
||||
file.ReadAtCurrentPos(base::as_writable_byte_span(raw));
|
||||
if (!bytes_read || *bytes_read != raw.size()) {
|
||||
return ConditionalDeleteResult::kError;
|
||||
}
|
||||
|
||||
IntegrityResult integrity = IntegrityResult::kSuccessNoFooter;
|
||||
std::string content;
|
||||
if (raw.size() < kFooterSize) {
|
||||
content = std::move(raw);
|
||||
} else {
|
||||
IntegrityFooter footer;
|
||||
memcpy(&footer, raw.data() + raw.size() - kFooterSize, kFooterSize);
|
||||
if (footer.magic != kIntegrityFooterMagic) {
|
||||
content = std::move(raw);
|
||||
} else {
|
||||
EnsureCrcInitialized();
|
||||
size_t content_size = raw.size() - kFooterSize;
|
||||
if (CrcCalc(raw.data(), content_size) != footer.data_crc32) {
|
||||
integrity = IntegrityResult::kIntegrityMismatch;
|
||||
} else {
|
||||
raw.resize(content_size);
|
||||
content = std::move(raw);
|
||||
integrity = IntegrityResult::kSuccess;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content != expected_content ||
|
||||
((integrity == IntegrityResult::kSuccess) !=
|
||||
expected_integrity_protected) ||
|
||||
(integrity != IntegrityResult::kSuccess &&
|
||||
integrity != IntegrityResult::kSuccessNoFooter)) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
return FinishConditionalDelete(&file, path);
|
||||
}
|
||||
|
||||
ConditionalDeleteResult DeleteFileRawIfMatching(
|
||||
const base::FilePath& path,
|
||||
const std::string& expected_raw_content,
|
||||
const base::FilePath& expected_resolved_parent) {
|
||||
base::File file = OpenForConditionalDeleteNoFollow(path);
|
||||
if (!file.IsValid()) {
|
||||
return !base::PathExists(path) || IsReparsePoint(path)
|
||||
? ConditionalDeleteResult::kChanged
|
||||
: ConditionalDeleteResult::kError;
|
||||
}
|
||||
if (IsOpenedReparsePoint(&file)) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
std::optional<base::FilePath> resolved_path = GetOpenedResolvedPath(&file);
|
||||
if (!resolved_path || expected_resolved_parent.empty() ||
|
||||
!base::FilePath::CompareEqualIgnoreCase(
|
||||
resolved_path->DirName().value(), expected_resolved_parent.value())) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
if (expected_raw_content.size() >
|
||||
static_cast<size_t>(kMaxLaunchStateFileSize) + kFooterSize) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
const int64_t length = file.GetLength();
|
||||
if (length < 0 ||
|
||||
static_cast<uint64_t>(length) != expected_raw_content.size()) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
std::string raw(static_cast<size_t>(length), '\0');
|
||||
std::optional<size_t> bytes_read =
|
||||
file.ReadAtCurrentPos(base::as_writable_byte_span(raw));
|
||||
if (!bytes_read || *bytes_read != raw.size()) {
|
||||
return ConditionalDeleteResult::kError;
|
||||
}
|
||||
if (raw != expected_raw_content) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
return FinishConditionalDelete(&file, path);
|
||||
}
|
||||
|
||||
ConditionalDeleteResult DeleteFileIfOversized(
|
||||
const base::FilePath& path,
|
||||
size_t max_content_size,
|
||||
const base::FilePath& expected_resolved_parent) {
|
||||
base::File file = OpenForConditionalDeleteNoFollow(path);
|
||||
if (!file.IsValid()) {
|
||||
return !base::PathExists(path) || IsReparsePoint(path)
|
||||
? ConditionalDeleteResult::kChanged
|
||||
: ConditionalDeleteResult::kError;
|
||||
}
|
||||
if (IsOpenedReparsePoint(&file)) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
std::optional<base::FilePath> resolved_path = GetOpenedResolvedPath(&file);
|
||||
if (!resolved_path || expected_resolved_parent.empty() ||
|
||||
!base::FilePath::CompareEqualIgnoreCase(
|
||||
resolved_path->DirName().value(), expected_resolved_parent.value())) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
const size_t max_file_size =
|
||||
max_content_size <= std::numeric_limits<size_t>::max() - kFooterSize
|
||||
? max_content_size + kFooterSize
|
||||
: std::numeric_limits<size_t>::max();
|
||||
const int64_t length = file.GetLength();
|
||||
if (length < 0) {
|
||||
return ConditionalDeleteResult::kError;
|
||||
}
|
||||
if (static_cast<uint64_t>(length) <= max_file_size) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
return FinishConditionalDelete(&file, path);
|
||||
}
|
||||
|
||||
ConditionalDeleteResult DeleteFileIfReparsePoint(
|
||||
const base::FilePath& path,
|
||||
const base::FilePath& expected_resolved_parent) {
|
||||
base::File file = OpenForConditionalDeleteNoFollow(path);
|
||||
if (!file.IsValid()) {
|
||||
return !base::PathExists(path) || !IsReparsePoint(path)
|
||||
? ConditionalDeleteResult::kChanged
|
||||
: ConditionalDeleteResult::kError;
|
||||
}
|
||||
FILE_ATTRIBUTE_TAG_INFO info = {};
|
||||
if (!::GetFileInformationByHandleEx(
|
||||
file.GetPlatformFile(), FileAttributeTagInfo, &info, sizeof(info))) {
|
||||
return ConditionalDeleteResult::kError;
|
||||
}
|
||||
if ((info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
std::optional<base::FilePath> resolved_path = GetOpenedResolvedPath(&file);
|
||||
if (!resolved_path || expected_resolved_parent.empty() ||
|
||||
!base::FilePath::CompareEqualIgnoreCase(
|
||||
resolved_path->DirName().value(), expected_resolved_parent.value())) {
|
||||
return ConditionalDeleteResult::kChanged;
|
||||
}
|
||||
return FinishConditionalDelete(&file, path);
|
||||
}
|
||||
|
||||
void SetConditionalDeleteHookForTesting(
|
||||
ConditionalDeleteHookForTesting callback) {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
*g_conditional_delete_hook = std::move(callback);
|
||||
#endif
|
||||
}
|
||||
|
||||
const char* IntegrityResultToString(IntegrityResult result) {
|
||||
switch (result) {
|
||||
case IntegrityResult::kSuccess:
|
||||
return "Success (verified)";
|
||||
case IntegrityResult::kSuccessNoFooter:
|
||||
return "Success (no integrity footer)";
|
||||
case IntegrityResult::kFileNotFound:
|
||||
return "File not found";
|
||||
case IntegrityResult::kReadError:
|
||||
return "Read error";
|
||||
case IntegrityResult::kIntegrityMismatch:
|
||||
return "Integrity mismatch";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_FILE_INTEGRITY_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_FILE_INTEGRITY_H_
|
||||
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/functional/callback_forward.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Result codes for integrity-checked file reads.
|
||||
enum class IntegrityResult {
|
||||
kSuccess, // Content read and CRC32 verified
|
||||
kSuccessNoFooter, // Content read, no integrity footer (legacy file)
|
||||
kFileNotFound, // File does not exist
|
||||
kReadError, // Could not read file
|
||||
kIntegrityMismatch, // CRC32 mismatch
|
||||
};
|
||||
|
||||
enum class IntegrityMismatchAction {
|
||||
kDoom,
|
||||
kPreserve,
|
||||
};
|
||||
|
||||
enum class ConditionalDeleteResult {
|
||||
kDeleted,
|
||||
kChanged,
|
||||
kError,
|
||||
};
|
||||
|
||||
// Atomically write content with a CRC32 integrity footer appended. The new
|
||||
// contents are written to a unique same-directory temporary file and replaced
|
||||
// into place only after the complete write succeeds. A failed write or replace
|
||||
// leaves the prior destination intact when the platform supports it. No partial
|
||||
// new destination is exposed to concurrent readers.
|
||||
//
|
||||
// On-disk format:
|
||||
// [content bytes]
|
||||
// [4 bytes: CRC32 of content, little-endian]
|
||||
// [4 bytes: reserved (zero)]
|
||||
// [8 bytes: magic number]
|
||||
//
|
||||
// Total footer overhead: 16 bytes.
|
||||
// Returns true on success. Returns false for unsafe reparse paths, temporary
|
||||
// creation/write failures, or replacement failures.
|
||||
bool WriteFileWithIntegrity(const base::FilePath& path,
|
||||
const std::string& content);
|
||||
|
||||
// Read a file, verifying its CRC32 integrity footer if present.
|
||||
//
|
||||
// - Footer present, CRC32 matches: returns kSuccess, |content| has the
|
||||
// original data with footer stripped.
|
||||
// - Footer present, CRC32 mismatch: returns kIntegrityMismatch and either
|
||||
// deletes or preserves the corrupted file according to |mismatch_action|.
|
||||
// - No footer (legacy file): returns kSuccessNoFooter, |content| has
|
||||
// the full file contents.
|
||||
//
|
||||
// The doom-on-mismatch behavior follows Chromium's disk cache pattern:
|
||||
// a corrupted entry is worse than a missing one.
|
||||
IntegrityResult ReadFileWithIntegrity(
|
||||
const base::FilePath& path,
|
||||
std::string* content,
|
||||
IntegrityMismatchAction mismatch_action = IntegrityMismatchAction::kDoom,
|
||||
size_t max_content_size = std::numeric_limits<size_t>::max(),
|
||||
base::FilePath* resolved_path = nullptr,
|
||||
std::string* raw_content = nullptr,
|
||||
bool* too_large = nullptr);
|
||||
|
||||
// Opens |path| without following a reparse point in the final component,
|
||||
// rejects a reparse-point directory, and returns the handle-resolved path.
|
||||
std::optional<base::FilePath> GetSafeDirectoryResolvedPath(
|
||||
const base::FilePath& path);
|
||||
|
||||
// Opens |path| itself without following a reparse point, verifies that the
|
||||
// handle-resolved parent is |expected_resolved_parent|, verifies the payload
|
||||
// and footer status on that handle, and marks that same file object for
|
||||
// deletion only on an exact match. A concurrent atomic replacement names a
|
||||
// different file object and is therefore preserved.
|
||||
ConditionalDeleteResult DeleteFileWithIntegrityIfMatching(
|
||||
const base::FilePath& path,
|
||||
const std::string& expected_content,
|
||||
bool expected_integrity_protected,
|
||||
const base::FilePath& expected_resolved_parent);
|
||||
|
||||
// Opens |path| without following a reparse point, verifies its resolved parent
|
||||
// and complete raw bytes, and marks that same file object for deletion only on
|
||||
// an exact match. This is used for conclusively invalid publications whose
|
||||
// integrity payload cannot be represented by the structured helper above.
|
||||
ConditionalDeleteResult DeleteFileRawIfMatching(
|
||||
const base::FilePath& path,
|
||||
const std::string& expected_raw_content,
|
||||
const base::FilePath& expected_resolved_parent);
|
||||
|
||||
// Marks the same no-follow file object for deletion only if it still exceeds
|
||||
// |max_content_size| plus the maximum integrity footer and its resolved parent
|
||||
// matches. A bounded valid replacement is preserved.
|
||||
ConditionalDeleteResult DeleteFileIfOversized(
|
||||
const base::FilePath& path,
|
||||
size_t max_content_size,
|
||||
const base::FilePath& expected_resolved_parent);
|
||||
|
||||
// Opens |path| without following the final component and marks that same file
|
||||
// object for deletion only if it is still a reparse point whose resolved
|
||||
// parent matches |expected_resolved_parent|. The reparse target is never
|
||||
// opened, and a concurrent replacement is preserved.
|
||||
ConditionalDeleteResult DeleteFileIfReparsePoint(
|
||||
const base::FilePath& path,
|
||||
const base::FilePath& expected_resolved_parent);
|
||||
|
||||
using ConditionalDeleteHookForTesting =
|
||||
base::RepeatingCallback<void(const base::FilePath&)>;
|
||||
void SetConditionalDeleteHookForTesting(
|
||||
ConditionalDeleteHookForTesting callback);
|
||||
|
||||
// Convert result to string for logging.
|
||||
const char* IntegrityResultToString(IntegrityResult result);
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_FILE_INTEGRITY_H_
|
||||
@@ -0,0 +1,452 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "base/files/file.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/files/scoped_temp_dir.h"
|
||||
#include "base/functional/bind.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer {
|
||||
namespace {
|
||||
|
||||
class InstallerFileIntegrityTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override { ASSERT_TRUE(temp_dir_.CreateUniqueTempDir()); }
|
||||
|
||||
void TearDown() override { SetConditionalDeleteHookForTesting({}); }
|
||||
|
||||
base::FilePath TestPath(const std::string& name) {
|
||||
return temp_dir_.GetPath().AppendASCII(name);
|
||||
}
|
||||
|
||||
base::FilePath ResolvedParent(const base::FilePath& path) {
|
||||
std::optional<base::FilePath> resolved =
|
||||
GetSafeDirectoryResolvedPath(path.DirName());
|
||||
EXPECT_TRUE(resolved);
|
||||
return resolved.value_or(base::FilePath());
|
||||
}
|
||||
|
||||
base::ScopedTempDir temp_dir_;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// WriteFileWithIntegrity Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, WriteCreatesFile) {
|
||||
base::FilePath path = TestPath("test.dat");
|
||||
|
||||
EXPECT_TRUE(WriteFileWithIntegrity(path, "hello"));
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
|
||||
// File should be content + 16-byte footer.
|
||||
std::optional<int64_t> size = base::GetFileSize(path);
|
||||
ASSERT_TRUE(size.has_value());
|
||||
EXPECT_EQ(5 + 16, size.value());
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, WriteEmptyContent) {
|
||||
base::FilePath path = TestPath("empty.dat");
|
||||
|
||||
EXPECT_TRUE(WriteFileWithIntegrity(path, ""));
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
|
||||
// File should be just the 16-byte footer.
|
||||
std::optional<int64_t> size = base::GetFileSize(path);
|
||||
ASSERT_TRUE(size.has_value());
|
||||
EXPECT_EQ(16, size.value());
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, OverwriteIsAtomicRoundTrip) {
|
||||
base::FilePath path = TestPath("overwrite.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "old"));
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "new"));
|
||||
|
||||
std::string content;
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ReadFileWithIntegrity(path, &content));
|
||||
EXPECT_EQ("new", content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ReplaceFailurePreservesPriorFile) {
|
||||
base::FilePath path = TestPath("locked.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "prior"));
|
||||
{
|
||||
base::File lock(path, base::File::FLAG_OPEN | base::File::FLAG_READ);
|
||||
ASSERT_TRUE(lock.IsValid());
|
||||
EXPECT_FALSE(WriteFileWithIntegrity(path, "replacement"));
|
||||
}
|
||||
|
||||
std::string content;
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ReadFileWithIntegrity(path, &content));
|
||||
EXPECT_EQ("prior", content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ConcurrentWritersPublishWholeFile) {
|
||||
base::FilePath path = TestPath("concurrent.dat");
|
||||
std::vector<std::string> payloads;
|
||||
std::vector<std::thread> writers;
|
||||
std::atomic<int> successes = 0;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
payloads.push_back(std::string(4096, static_cast<char>('A' + i)));
|
||||
}
|
||||
for (const auto& payload : payloads) {
|
||||
writers.emplace_back([&, payload] {
|
||||
if (WriteFileWithIntegrity(path, payload)) {
|
||||
++successes;
|
||||
}
|
||||
});
|
||||
}
|
||||
for (auto& writer : writers) {
|
||||
writer.join();
|
||||
}
|
||||
EXPECT_GT(successes.load(), 0);
|
||||
|
||||
std::string content;
|
||||
ASSERT_EQ(IntegrityResult::kSuccess, ReadFileWithIntegrity(path, &content));
|
||||
EXPECT_NE(payloads.end(),
|
||||
std::find(payloads.begin(), payloads.end(), content));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ReadFileWithIntegrity Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, RoundTrip) {
|
||||
base::FilePath path = TestPath("roundtrip.dat");
|
||||
std::string original = "Hello, CEF Installer!";
|
||||
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, original));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, result);
|
||||
EXPECT_EQ(original, content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, RoundTripEmptyContent) {
|
||||
base::FilePath path = TestPath("empty_roundtrip.dat");
|
||||
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, ""));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, result);
|
||||
EXPECT_TRUE(content.empty());
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, RoundTripLargeContent) {
|
||||
base::FilePath path = TestPath("large.dat");
|
||||
std::string original(100 * 1024, 'X'); // 100 KB
|
||||
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, original));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, result);
|
||||
EXPECT_EQ(original, content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, RoundTripJsonContent) {
|
||||
base::FilePath path = TestPath("manifest.cache");
|
||||
std::string json = R"({"version":"137.3.5","file":"cef_137.3.5.tar.xz"})";
|
||||
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, json));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, result);
|
||||
EXPECT_EQ(json, content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, RoundTripBinaryContent) {
|
||||
base::FilePath path = TestPath("binary.dat");
|
||||
// Content with null bytes and non-printable characters.
|
||||
std::string original = std::string("\x00\x01\x02\xff\xfe\xfd", 6);
|
||||
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, original));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, result);
|
||||
EXPECT_EQ(original, content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ReadNonExistentFile) {
|
||||
base::FilePath path = TestPath("nonexistent.dat");
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kFileNotFound, result);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ReadLegacyFileNoFooter) {
|
||||
base::FilePath path = TestPath("legacy.dat");
|
||||
std::string original = "legacy content without footer";
|
||||
ASSERT_TRUE(base::WriteFile(path, original));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccessNoFooter, result);
|
||||
EXPECT_EQ(original, content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ReadSmallLegacyFile) {
|
||||
base::FilePath path = TestPath("tiny.dat");
|
||||
// File smaller than 16-byte footer — always treated as legacy.
|
||||
std::string original = "tiny";
|
||||
ASSERT_TRUE(base::WriteFile(path, original));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccessNoFooter, result);
|
||||
EXPECT_EQ(original, content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ReadEmptyFile) {
|
||||
base::FilePath path = TestPath("empty.dat");
|
||||
ASSERT_TRUE(base::WriteFile(path, ""));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccessNoFooter, result);
|
||||
EXPECT_TRUE(content.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Corruption Detection (Doom Pattern)
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, CorruptedContentDetected) {
|
||||
base::FilePath path = TestPath("corrupt.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "original content"));
|
||||
ASSERT_TRUE(base::PathExists(path));
|
||||
|
||||
// Corrupt the content by overwriting the first byte.
|
||||
std::string raw;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &raw));
|
||||
raw[0] = ~raw[0]; // Flip bits in first byte
|
||||
ASSERT_TRUE(base::WriteFile(path, raw));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kIntegrityMismatch, result);
|
||||
// File should be doomed (deleted).
|
||||
EXPECT_FALSE(base::PathExists(path));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, CorruptedCrcDetected) {
|
||||
base::FilePath path = TestPath("bad_crc.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "test data"));
|
||||
|
||||
// Corrupt the CRC32 field (bytes at offset content_size).
|
||||
std::string raw;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &raw));
|
||||
size_t crc_offset = raw.size() - 16; // CRC32 is first 4 bytes of footer
|
||||
raw[crc_offset] = ~raw[crc_offset];
|
||||
ASSERT_TRUE(base::WriteFile(path, raw));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kIntegrityMismatch, result);
|
||||
EXPECT_FALSE(base::PathExists(path));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, CorruptedCrcCanBePreserved) {
|
||||
base::FilePath path = TestPath("preserved_bad_crc.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "test data"));
|
||||
|
||||
std::string raw;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &raw));
|
||||
raw[0] = ~raw[0];
|
||||
ASSERT_TRUE(base::WriteFile(path, raw));
|
||||
|
||||
std::string content;
|
||||
EXPECT_EQ(IntegrityResult::kIntegrityMismatch,
|
||||
ReadFileWithIntegrity(path, &content,
|
||||
IntegrityMismatchAction::kPreserve));
|
||||
std::string unchanged;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &unchanged));
|
||||
EXPECT_EQ(raw, unchanged);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest,
|
||||
ConditionalDeletePreservesAtomicReplacementAfterComparison) {
|
||||
base::FilePath path = TestPath("conditional_delete.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "old"));
|
||||
SetConditionalDeleteHookForTesting(
|
||||
base::BindRepeating([](const base::FilePath& replacement_path) {
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(replacement_path, "new"));
|
||||
}));
|
||||
|
||||
EXPECT_EQ(ConditionalDeleteResult::kDeleted,
|
||||
DeleteFileWithIntegrityIfMatching(path, "old", true,
|
||||
ResolvedParent(path)));
|
||||
|
||||
std::string content;
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ReadFileWithIntegrity(path, &content));
|
||||
EXPECT_EQ("new", content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ConditionalDeletePreservesChangedPayload) {
|
||||
base::FilePath path = TestPath("conditional_changed.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "new"));
|
||||
|
||||
EXPECT_EQ(ConditionalDeleteResult::kChanged,
|
||||
DeleteFileWithIntegrityIfMatching(path, "old", true,
|
||||
ResolvedParent(path)));
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest,
|
||||
ConditionalDeleteRejectsUnexpectedLengthBeforeReading) {
|
||||
base::FilePath path = TestPath("conditional_oversized.dat");
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(path, std::string(kMaxLaunchStateFileSize + 1024, 'x')));
|
||||
|
||||
EXPECT_EQ(ConditionalDeleteResult::kChanged,
|
||||
DeleteFileWithIntegrityIfMatching(path, "old", true,
|
||||
ResolvedParent(path)));
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ConditionalDeleteDoesNotFollowReparsePoint) {
|
||||
base::FilePath target = TestPath("conditional_target.dat");
|
||||
base::FilePath link = TestPath("conditional_link.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(target, "old"));
|
||||
constexpr DWORD kAllowUnprivilegedCreate = 0x2;
|
||||
if (!::CreateSymbolicLinkW(link.value().c_str(), target.value().c_str(),
|
||||
kAllowUnprivilegedCreate)) {
|
||||
GTEST_SKIP() << "Symbolic-link creation is unavailable";
|
||||
}
|
||||
|
||||
EXPECT_EQ(ConditionalDeleteResult::kChanged,
|
||||
DeleteFileWithIntegrityIfMatching(link, "old", true,
|
||||
ResolvedParent(link)));
|
||||
EXPECT_TRUE(base::PathExists(target));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest,
|
||||
ConditionalReparseDeleteRemovesLinkAndPreservesTarget) {
|
||||
base::FilePath target = TestPath("reparse_delete_target.dat");
|
||||
base::FilePath link = TestPath("reparse_delete_link.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(target, "target"));
|
||||
constexpr DWORD kAllowUnprivilegedCreate = 0x2;
|
||||
if (!::CreateSymbolicLinkW(link.value().c_str(), target.value().c_str(),
|
||||
kAllowUnprivilegedCreate)) {
|
||||
GTEST_SKIP() << "Symbolic-link creation is unavailable";
|
||||
}
|
||||
|
||||
EXPECT_EQ(ConditionalDeleteResult::kDeleted,
|
||||
DeleteFileIfReparsePoint(link, ResolvedParent(link)));
|
||||
EXPECT_FALSE(base::PathExists(link));
|
||||
std::string content;
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ReadFileWithIntegrity(target, &content));
|
||||
EXPECT_EQ("target", content);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest,
|
||||
ConditionalReparseDeletePreservesConcurrentReplacement) {
|
||||
base::FilePath target = TestPath("reparse_race_target.dat");
|
||||
base::FilePath link = TestPath("reparse_race_link.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(target, "target"));
|
||||
constexpr DWORD kAllowUnprivilegedCreate = 0x2;
|
||||
if (!::CreateSymbolicLinkW(link.value().c_str(), target.value().c_str(),
|
||||
kAllowUnprivilegedCreate)) {
|
||||
GTEST_SKIP() << "Symbolic-link creation is unavailable";
|
||||
}
|
||||
SetConditionalDeleteHookForTesting(
|
||||
base::BindRepeating([](const base::FilePath& hook_path) {
|
||||
ASSERT_TRUE(base::DeleteFile(hook_path));
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(hook_path, "replacement"));
|
||||
}));
|
||||
|
||||
EXPECT_EQ(ConditionalDeleteResult::kDeleted,
|
||||
DeleteFileIfReparsePoint(link, ResolvedParent(link)));
|
||||
std::string content;
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, ReadFileWithIntegrity(link, &content));
|
||||
EXPECT_EQ("replacement", content);
|
||||
EXPECT_TRUE(base::PathExists(target));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest,
|
||||
ConditionalDeleteRejectsRedirectedParentDirectory) {
|
||||
base::FilePath parent = TestPath("launch");
|
||||
base::FilePath target = TestPath("redirect_target");
|
||||
base::FilePath path = parent.Append(L"evidence");
|
||||
ASSERT_TRUE(base::CreateDirectory(parent));
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "old"));
|
||||
std::optional<base::FilePath> expected_parent =
|
||||
GetSafeDirectoryResolvedPath(parent);
|
||||
ASSERT_TRUE(expected_parent);
|
||||
|
||||
ASSERT_TRUE(base::DeletePathRecursively(parent));
|
||||
ASSERT_TRUE(base::CreateDirectory(target));
|
||||
base::FilePath redirected_file = target.Append(L"evidence");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(redirected_file, "old"));
|
||||
constexpr DWORD kDirectorySymlinkWithUnprivilegedCreate = 0x3;
|
||||
if (!::CreateSymbolicLinkW(parent.value().c_str(), target.value().c_str(),
|
||||
kDirectorySymlinkWithUnprivilegedCreate)) {
|
||||
GTEST_SKIP() << "Directory symbolic-link creation is unavailable";
|
||||
}
|
||||
|
||||
EXPECT_EQ(
|
||||
ConditionalDeleteResult::kChanged,
|
||||
DeleteFileWithIntegrityIfMatching(path, "old", true, *expected_parent));
|
||||
EXPECT_TRUE(base::PathExists(redirected_file));
|
||||
EXPECT_TRUE(::RemoveDirectoryW(parent.value().c_str()));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, TruncatedFileWithFooterMagic) {
|
||||
// A file that has exactly 16 bytes (just a footer, no content).
|
||||
// If the magic matches, it should verify the CRC of empty content.
|
||||
base::FilePath path = TestPath("footer_only.dat");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, ""));
|
||||
|
||||
std::string content;
|
||||
IntegrityResult result = ReadFileWithIntegrity(path, &content);
|
||||
|
||||
EXPECT_EQ(IntegrityResult::kSuccess, result);
|
||||
EXPECT_TRUE(content.empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IntegrityResultToString Tests
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(InstallerFileIntegrityTest, ResultToString) {
|
||||
EXPECT_STREQ("Success (verified)",
|
||||
IntegrityResultToString(IntegrityResult::kSuccess));
|
||||
EXPECT_STREQ("Success (no integrity footer)",
|
||||
IntegrityResultToString(IntegrityResult::kSuccessNoFooter));
|
||||
EXPECT_STREQ("File not found",
|
||||
IntegrityResultToString(IntegrityResult::kFileNotFound));
|
||||
EXPECT_STREQ("Read error",
|
||||
IntegrityResultToString(IntegrityResult::kReadError));
|
||||
EXPECT_STREQ("Integrity mismatch",
|
||||
IntegrityResultToString(IntegrityResult::kIntegrityMismatch));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,566 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "base/files/file_enumerator.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/rand_util.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/string_util.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_e2e_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
|
||||
namespace cef_installer {
|
||||
namespace {
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
FileOpsFault g_file_ops_fault = FileOpsFault::kNone;
|
||||
#endif
|
||||
|
||||
FileOpsFault EffectiveFileOpsFault() {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
if (g_file_ops_fault != FileOpsFault::kNone) {
|
||||
return g_file_ops_fault;
|
||||
}
|
||||
switch (internal::GetInstallerE2EConfig().file_ops_fault) {
|
||||
case internal::InstallerE2EFileOpsFault::kNone:
|
||||
break;
|
||||
case internal::InstallerE2EFileOpsFault::kQuarantineMove:
|
||||
return FileOpsFault::kQuarantineMove;
|
||||
case internal::InstallerE2EFileOpsFault::kRepairMove:
|
||||
return FileOpsFault::kRepairMove;
|
||||
case internal::InstallerE2EFileOpsFault::kTrashMove:
|
||||
return FileOpsFault::kTrashMove;
|
||||
case internal::InstallerE2EFileOpsFault::kTrashReclaim:
|
||||
return FileOpsFault::kTrashReclaim;
|
||||
}
|
||||
#endif
|
||||
return FileOpsFault::kNone;
|
||||
}
|
||||
|
||||
// Validate that a directory path is safe for file operations.
|
||||
// Returns false if the path is a reparse point (symlink/junction).
|
||||
bool IsDirectorySafe(const base::FilePath& dir_path) {
|
||||
if (!base::DirectoryExists(dir_path)) {
|
||||
return false;
|
||||
}
|
||||
return !IsReparsePoint(dir_path);
|
||||
}
|
||||
|
||||
// Generate a unique trash entry path under trash_root.
|
||||
base::FilePath GenerateTrashPath(const base::FilePath& trash_root,
|
||||
const Version& version) {
|
||||
// Format: <version>_<random>
|
||||
uint64_t random_value = base::RandUint64();
|
||||
std::string random_suffix = base::HexEncode(base::span<const uint8_t>(
|
||||
reinterpret_cast<const uint8_t*>(&random_value), sizeof(random_value)));
|
||||
std::string entry_name = version.ToString() + "_" + random_suffix;
|
||||
return trash_root.Append(base::FilePath::FromUTF8Unsafe(entry_name));
|
||||
}
|
||||
|
||||
// Map Windows error code to FileOpsError
|
||||
FileOpsError MapWindowsError(DWORD error) {
|
||||
switch (error) {
|
||||
case ERROR_SUCCESS:
|
||||
return FileOpsError::kSuccess;
|
||||
case ERROR_FILE_NOT_FOUND:
|
||||
case ERROR_PATH_NOT_FOUND:
|
||||
return FileOpsError::kSourceNotFound;
|
||||
case ERROR_ALREADY_EXISTS:
|
||||
case ERROR_FILE_EXISTS:
|
||||
return FileOpsError::kDestinationExists;
|
||||
case ERROR_ACCESS_DENIED:
|
||||
return FileOpsError::kAccessDenied;
|
||||
case ERROR_DISK_FULL:
|
||||
case ERROR_HANDLE_DISK_FULL:
|
||||
return FileOpsError::kDiskFull;
|
||||
case ERROR_SHARING_VIOLATION:
|
||||
case ERROR_LOCK_VIOLATION:
|
||||
return FileOpsError::kInUse;
|
||||
default:
|
||||
return FileOpsError::kRenameFailed;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a file or directory without ever enumerating a reparse point. A
|
||||
// partially deleted directory remains retryable when an entry is in use.
|
||||
bool TryDeletePath(const base::FilePath& path) {
|
||||
DWORD attributes = ::GetFileAttributesW(path.value().c_str());
|
||||
if (attributes == INVALID_FILE_ATTRIBUTES) {
|
||||
DWORD error = ::GetLastError();
|
||||
return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND;
|
||||
}
|
||||
|
||||
if (attributes & FILE_ATTRIBUTE_READONLY) {
|
||||
if (!::SetFileAttributesW(path.value().c_str(),
|
||||
attributes & ~FILE_ATTRIBUTE_READONLY)) {
|
||||
return false;
|
||||
}
|
||||
attributes &= ~FILE_ATTRIBUTE_READONLY;
|
||||
}
|
||||
|
||||
if (attributes & FILE_ATTRIBUTE_REPARSE_POINT) {
|
||||
return attributes & FILE_ATTRIBUTE_DIRECTORY
|
||||
? !!::RemoveDirectoryW(path.value().c_str())
|
||||
: !!::DeleteFileW(path.value().c_str());
|
||||
}
|
||||
|
||||
if (!(attributes & FILE_ATTRIBUTE_DIRECTORY)) {
|
||||
return !!::DeleteFileW(path.value().c_str());
|
||||
}
|
||||
|
||||
base::FileEnumerator enumerator(
|
||||
path, false,
|
||||
base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES,
|
||||
base::FilePath::StringType(),
|
||||
base::FileEnumerator::FolderSearchPolicy::MATCH_ONLY,
|
||||
base::FileEnumerator::ErrorPolicy::STOP_ENUMERATION);
|
||||
bool all_deleted = true;
|
||||
for (base::FilePath child = enumerator.Next(); !child.empty();
|
||||
child = enumerator.Next()) {
|
||||
if (!TryDeletePath(child)) {
|
||||
all_deleted = false;
|
||||
}
|
||||
}
|
||||
if (enumerator.GetError() != base::File::FILE_OK) {
|
||||
all_deleted = false;
|
||||
}
|
||||
if (!all_deleted) {
|
||||
return false;
|
||||
}
|
||||
return !!::RemoveDirectoryW(path.value().c_str());
|
||||
}
|
||||
|
||||
bool IsSafeCollisionTarget(const base::FilePath& install_dir,
|
||||
const base::FilePath& target) {
|
||||
if (!IsPathSafeForLoading(install_dir, target)) {
|
||||
return false;
|
||||
}
|
||||
DWORD attributes = ::GetFileAttributesW(target.value().c_str());
|
||||
if (attributes == INVALID_FILE_ATTRIBUTES ||
|
||||
(attributes & (FILE_ATTRIBUTE_REPARSE_POINT | FILE_ATTRIBUTE_DEVICE))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void SetFileOpsFaultForTesting(FileOpsFault fault) {
|
||||
#if defined(OFFICIAL_BUILD) && defined(NDEBUG)
|
||||
(void)fault;
|
||||
#else
|
||||
g_file_ops_fault = fault;
|
||||
#endif
|
||||
}
|
||||
|
||||
VersionLease::VersionLease(HANDLE handle) : handle_(handle) {}
|
||||
|
||||
VersionLease::~VersionLease() {
|
||||
if (IsValid()) {
|
||||
CloseHandle(handle_);
|
||||
}
|
||||
}
|
||||
|
||||
VersionLease::VersionLease(VersionLease&& other) noexcept
|
||||
: handle_(std::exchange(other.handle_, INVALID_HANDLE_VALUE)) {}
|
||||
|
||||
VersionLease& VersionLease::operator=(VersionLease&& other) noexcept {
|
||||
if (this != &other) {
|
||||
if (IsValid()) {
|
||||
CloseHandle(handle_);
|
||||
}
|
||||
handle_ = std::exchange(other.handle_, INVALID_HANDLE_VALUE);
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
bool VersionLease::IsValid() const {
|
||||
return handle_ != INVALID_HANDLE_VALUE;
|
||||
}
|
||||
|
||||
VersionLeaseError AcquireVersionLease(const base::FilePath& trusted_root,
|
||||
const base::FilePath& version_dir,
|
||||
std::unique_ptr<VersionLease>* lease) {
|
||||
if (!lease) {
|
||||
return VersionLeaseError::kInvalidPath;
|
||||
}
|
||||
lease->reset();
|
||||
|
||||
base::FilePath libcef_path = GetLibcefPath(version_dir);
|
||||
if (!IsPathSafeForLoading(trusted_root, libcef_path)) {
|
||||
if (!base::PathExists(version_dir) || !base::PathExists(libcef_path)) {
|
||||
return VersionLeaseError::kLostRace;
|
||||
}
|
||||
return VersionLeaseError::kInvalidPath;
|
||||
}
|
||||
|
||||
HANDLE handle = CreateFileW(libcef_path.value().c_str(), GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
|
||||
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (handle == INVALID_HANDLE_VALUE) {
|
||||
DWORD error = GetLastError();
|
||||
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) {
|
||||
return VersionLeaseError::kLostRace;
|
||||
}
|
||||
return VersionLeaseError::kOpenFailed;
|
||||
}
|
||||
|
||||
*lease = std::unique_ptr<VersionLease>(new VersionLease(handle));
|
||||
return VersionLeaseError::kSuccess;
|
||||
}
|
||||
|
||||
FileOpsError InstallVersion(const base::FilePath& source_dir,
|
||||
const base::FilePath& install_dir,
|
||||
const Version& version,
|
||||
bool* cleanup_deferred) {
|
||||
if (cleanup_deferred) {
|
||||
*cleanup_deferred = false;
|
||||
}
|
||||
FileOpsFault fault = EffectiveFileOpsFault();
|
||||
// Validate inputs
|
||||
if (!version.IsValid()) {
|
||||
return FileOpsError::kSourceNotFound;
|
||||
}
|
||||
|
||||
// 1. Verify source exists and is not a reparse point (symlink/junction)
|
||||
// This prevents attacks where an attacker replaces the temp directory with
|
||||
// a junction pointing to sensitive files.
|
||||
if (!IsDirectorySafe(source_dir)) {
|
||||
return FileOpsError::kSourceNotFound;
|
||||
}
|
||||
|
||||
// 2. Construct destination path
|
||||
base::FilePath dest_dir = GetVersionPath(install_dir, version);
|
||||
|
||||
// 3. Check existing ancestors before any collision mutation.
|
||||
base::FilePath parent_dir = dest_dir.DirName();
|
||||
for (base::FilePath dir = parent_dir;
|
||||
dir != install_dir && dir != dir.DirName(); dir = dir.DirName()) {
|
||||
if (base::PathExists(dir) && IsReparsePoint(dir)) {
|
||||
return FileOpsError::kAccessDenied;
|
||||
}
|
||||
}
|
||||
|
||||
base::FilePath quarantined;
|
||||
|
||||
// 4. Quarantine every safe existing target opaquely. Destination contents
|
||||
// are never read or classified; verified staging always wins the collision.
|
||||
if (base::PathExists(dest_dir)) {
|
||||
if (!IsSafeCollisionTarget(install_dir, dest_dir)) {
|
||||
return FileOpsError::kQuarantineFailed;
|
||||
}
|
||||
|
||||
base::FilePath trash_root = install_dir.Append(kTrashSubdirectory);
|
||||
if (!VerifySafeDirectoryPath(trash_root) ||
|
||||
(!base::DirectoryExists(trash_root) &&
|
||||
!base::CreateDirectory(trash_root))) {
|
||||
return FileOpsError::kQuarantineFailed;
|
||||
}
|
||||
quarantined = GenerateTrashPath(trash_root, version);
|
||||
if (fault == FileOpsFault::kQuarantineMove ||
|
||||
!::MoveFileExW(dest_dir.value().c_str(), quarantined.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH)) {
|
||||
return FileOpsError::kQuarantineFailed;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create parent directories if needed.
|
||||
if (!base::DirectoryExists(parent_dir) &&
|
||||
!base::CreateDirectory(parent_dir)) {
|
||||
return FileOpsError::kAccessDenied;
|
||||
}
|
||||
|
||||
// 6. Perform atomic rename
|
||||
// On Windows, MoveFileEx with MOVEFILE_WRITE_THROUGH ensures durability.
|
||||
// The caller is expected to place source_dir on the same volume as
|
||||
// install_dir (e.g., via the .staging/ subdirectory) so this always
|
||||
// succeeds as an atomic rename.
|
||||
if (fault == FileOpsFault::kRepairMove && !quarantined.empty()) {
|
||||
return FileOpsError::kRepairFailed;
|
||||
}
|
||||
if (!::MoveFileExW(source_dir.value().c_str(), dest_dir.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH)) {
|
||||
if (!quarantined.empty()) {
|
||||
return FileOpsError::kRepairFailed;
|
||||
}
|
||||
return MapWindowsError(::GetLastError());
|
||||
}
|
||||
|
||||
// 7. Reclaim the quarantined path best-effort without traversing reparse
|
||||
// points. Publication is already logically complete at this boundary.
|
||||
if (!quarantined.empty() &&
|
||||
(fault == FileOpsFault::kTrashReclaim || !TryDeletePath(quarantined)) &&
|
||||
cleanup_deferred) {
|
||||
*cleanup_deferred = true;
|
||||
}
|
||||
|
||||
return FileOpsError::kSuccess;
|
||||
}
|
||||
|
||||
FileOpsError UninstallVersion(const base::FilePath& install_dir,
|
||||
const Version& version,
|
||||
const std::string& platform) {
|
||||
// Validate inputs
|
||||
if (!version.IsValid() || platform.empty()) {
|
||||
return FileOpsError::kDestinationNotFound;
|
||||
}
|
||||
FileOpsFault fault = EffectiveFileOpsFault();
|
||||
|
||||
// 1. Verify source exists and is not a reparse point.
|
||||
base::FilePath source_dir = GetVersionPath(install_dir, version, platform);
|
||||
if (!IsDirectorySafe(source_dir)) {
|
||||
return FileOpsError::kDestinationNotFound;
|
||||
}
|
||||
|
||||
// 2. Check intermediate directories for reparse points
|
||||
// (walk from source_dir up to install_dir).
|
||||
for (base::FilePath dir = source_dir.DirName();
|
||||
dir != install_dir && dir != dir.DirName(); dir = dir.DirName()) {
|
||||
if (IsReparsePoint(dir)) {
|
||||
return FileOpsError::kDestinationNotFound;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create .trash/ directory if needed, checking for reparse points.
|
||||
base::FilePath trash_root = install_dir.Append(kTrashSubdirectory);
|
||||
if (!VerifySafeDirectoryPath(trash_root)) {
|
||||
return FileOpsError::kAccessDenied;
|
||||
}
|
||||
if (!base::DirectoryExists(trash_root) &&
|
||||
!base::CreateDirectory(trash_root)) {
|
||||
return FileOpsError::kAccessDenied;
|
||||
}
|
||||
|
||||
// 4. Generate trash entry path and atomic rename
|
||||
base::FilePath trash_dir = GenerateTrashPath(trash_root, version);
|
||||
if (fault == FileOpsFault::kTrashMove) {
|
||||
return FileOpsError::kRenameFailed;
|
||||
}
|
||||
if (!::MoveFileExW(source_dir.value().c_str(), trash_dir.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH)) {
|
||||
return MapWindowsError(::GetLastError());
|
||||
}
|
||||
|
||||
// 5. Try to delete trash directory
|
||||
FileOpsError result = FileOpsError::kSuccess;
|
||||
if (fault == FileOpsFault::kTrashReclaim || !TryDeletePath(trash_dir)) {
|
||||
// Files are in use - leave for retry on next run
|
||||
// This is not a failure; the version is effectively uninstalled
|
||||
// (moved to trash) and will be cleaned up later
|
||||
result = FileOpsError::kInUse;
|
||||
}
|
||||
|
||||
// 6. Try to remove empty parent version directory (e.g., Versions/137.3.5/)
|
||||
// This cleans up after the last platform for a version is uninstalled.
|
||||
// DeleteFile on a directory only succeeds if empty, so this is safe.
|
||||
base::FilePath version_parent = source_dir.DirName();
|
||||
base::DeleteFile(version_parent); // Ignore result - fails if not empty
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
FileOpsError UninstallVersion(const base::FilePath& install_dir,
|
||||
const Version& version) {
|
||||
return UninstallVersion(install_dir, version, GetCurrentPlatform());
|
||||
}
|
||||
|
||||
int RetryPendingDeletions(const base::FilePath& install_dir) {
|
||||
int deleted_count = 0;
|
||||
|
||||
base::FilePath trash_root = install_dir.Append(kTrashSubdirectory);
|
||||
if (!base::DirectoryExists(trash_root) || IsReparsePoint(trash_root)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Scan entries in .trash/ and try to delete them
|
||||
base::FileEnumerator enumerator(
|
||||
trash_root, false,
|
||||
base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES);
|
||||
for (base::FilePath path = enumerator.Next(); !path.empty();
|
||||
path = enumerator.Next()) {
|
||||
// TryDeletePath removes reparse points themselves and never enumerates
|
||||
// their targets.
|
||||
if (TryDeletePath(path)) {
|
||||
deleted_count++;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted_count;
|
||||
}
|
||||
|
||||
bool IsReparsePoint(const base::FilePath& path) {
|
||||
DWORD attrs = ::GetFileAttributesW(path.value().c_str());
|
||||
if (attrs == INVALID_FILE_ATTRIBUTES) {
|
||||
return false;
|
||||
}
|
||||
return (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0;
|
||||
}
|
||||
|
||||
bool VerifySafeFilePath(const base::FilePath& path, bool delete_if_exists) {
|
||||
if (IsReparsePoint(path)) {
|
||||
if (!base::DeleteFile(path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (delete_if_exists && base::PathExists(path)) {
|
||||
base::DeleteFile(path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VerifySafeDirectoryPath(const base::FilePath& path,
|
||||
bool delete_if_exists) {
|
||||
if (IsReparsePoint(path)) {
|
||||
if (!base::DeleteFile(path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (delete_if_exists && base::DirectoryExists(path)) {
|
||||
base::DeletePathRecursively(path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsReadableDirectory(const base::FilePath& path) {
|
||||
if (path.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!base::DirectoryExists(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Security: Reject reparse points (symlinks/junctions).
|
||||
return !IsReparsePoint(path);
|
||||
}
|
||||
|
||||
bool IsPathSafeForLoading(const base::FilePath& trusted_root,
|
||||
const base::FilePath& path,
|
||||
base::TimeTicks deadline,
|
||||
bool* time_limit_reached) {
|
||||
auto time_expired = [&]() {
|
||||
if (!deadline.is_null() && base::TimeTicks::Now() >= deadline) {
|
||||
if (time_limit_reached) {
|
||||
*time_limit_reached = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (time_limit_reached) {
|
||||
*time_limit_reached = false;
|
||||
}
|
||||
if (time_expired()) {
|
||||
return false;
|
||||
}
|
||||
if (trusted_root.empty() || path.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!trusted_root.IsAbsolute() || !path.IsAbsolute()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!trusted_root.IsParent(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collect components between root (exclusive) and path (inclusive) by
|
||||
// walking up from path to root.
|
||||
std::vector<base::FilePath> to_check;
|
||||
for (base::FilePath current = path; current != trusted_root;
|
||||
current = current.DirName()) {
|
||||
if (current == current.DirName()) {
|
||||
return false;
|
||||
}
|
||||
to_check.push_back(current);
|
||||
}
|
||||
|
||||
// Check top-down (reverse order) so we fail on the shallowest
|
||||
// reparse point first.
|
||||
for (auto it = to_check.rbegin(); it != to_check.rend(); ++it) {
|
||||
if (time_expired() || !base::PathExists(*it) || time_expired()) {
|
||||
return false;
|
||||
}
|
||||
if (IsReparsePoint(*it) || time_expired()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool IsSameDirectory(const base::FilePath& a, const base::FilePath& b) {
|
||||
base::FilePath abs_a = base::MakeAbsoluteFilePath(a);
|
||||
base::FilePath abs_b = base::MakeAbsoluteFilePath(b);
|
||||
if (abs_a.empty() || abs_b.empty()) {
|
||||
// MakeAbsoluteFilePath failed; fall back to case-insensitive compare.
|
||||
return base::FilePath::CompareEqualIgnoreCase(a.value(), b.value());
|
||||
}
|
||||
return base::FilePath::CompareEqualIgnoreCase(abs_a.value(), abs_b.value());
|
||||
}
|
||||
|
||||
const char* FileOpsErrorToString(FileOpsError error) {
|
||||
switch (error) {
|
||||
case FileOpsError::kSuccess:
|
||||
return "Success";
|
||||
case FileOpsError::kSourceNotFound:
|
||||
return "Source directory not found";
|
||||
case FileOpsError::kDestinationExists:
|
||||
return "Destination already exists";
|
||||
case FileOpsError::kDestinationNotFound:
|
||||
return "Destination not found";
|
||||
case FileOpsError::kAccessDenied:
|
||||
return "Access denied";
|
||||
case FileOpsError::kDiskFull:
|
||||
return "Disk full";
|
||||
case FileOpsError::kInUse:
|
||||
return "Files are in use";
|
||||
case FileOpsError::kRenameFailed:
|
||||
return "Rename operation failed";
|
||||
case FileOpsError::kDeleteFailed:
|
||||
return "Delete operation failed";
|
||||
case FileOpsError::kQuarantineFailed:
|
||||
return "Destination quarantine failed";
|
||||
case FileOpsError::kRepairFailed:
|
||||
return "Destination repair failed";
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
ScopedFileDeleter::ScopedFileDeleter(const base::FilePath& path)
|
||||
: path_(path) {}
|
||||
|
||||
ScopedFileDeleter::~ScopedFileDeleter() {
|
||||
if (!path_.empty()) {
|
||||
base::DeleteFile(path_);
|
||||
}
|
||||
}
|
||||
|
||||
void ScopedFileDeleter::Release() {
|
||||
path_ = base::FilePath();
|
||||
}
|
||||
|
||||
ScopedDirectoryDeleter::ScopedDirectoryDeleter(const base::FilePath& path)
|
||||
: path_(path) {}
|
||||
|
||||
ScopedDirectoryDeleter::~ScopedDirectoryDeleter() {
|
||||
if (!path_.empty()) {
|
||||
base::DeletePathRecursively(path_);
|
||||
}
|
||||
}
|
||||
|
||||
void ScopedDirectoryDeleter::Release() {
|
||||
path_ = base::FilePath();
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,234 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_FILE_OPS_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_FILE_OPS_H_
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/time/time.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
// Error codes for file operations
|
||||
enum class FileOpsError {
|
||||
kSuccess,
|
||||
kSourceNotFound, // Source directory doesn't exist
|
||||
kDestinationExists, // Destination already exists (for install)
|
||||
kDestinationNotFound, // Destination doesn't exist (for uninstall)
|
||||
kAccessDenied, // Permission denied
|
||||
kDiskFull, // Not enough space
|
||||
kInUse, // Files are in use by another process
|
||||
kRenameFailed, // Atomic rename operation failed
|
||||
kDeleteFailed, // Could not delete files
|
||||
kQuarantineFailed, // Existing destination could not be quarantined
|
||||
kRepairFailed, // Verified staging could not replace quarantine
|
||||
};
|
||||
|
||||
enum class FileOpsFault {
|
||||
kNone,
|
||||
kQuarantineMove,
|
||||
kRepairMove,
|
||||
kTrashMove,
|
||||
kTrashReclaim,
|
||||
};
|
||||
|
||||
enum class VersionLeaseError {
|
||||
kSuccess,
|
||||
kLostRace,
|
||||
kInvalidPath,
|
||||
kOpenFailed,
|
||||
};
|
||||
|
||||
// Move-only RAII lease that keeps a selected shared-store distribution usable.
|
||||
// The fixed libcef.dll handle permits other readers but denies delete sharing,
|
||||
// preventing a whole-version directory move until the final lease is released.
|
||||
class VersionLease {
|
||||
public:
|
||||
~VersionLease();
|
||||
|
||||
VersionLease(VersionLease&& other) noexcept;
|
||||
VersionLease& operator=(VersionLease&& other) noexcept;
|
||||
|
||||
VersionLease(const VersionLease&) = delete;
|
||||
VersionLease& operator=(const VersionLease&) = delete;
|
||||
|
||||
bool IsValid() const;
|
||||
|
||||
private:
|
||||
friend VersionLeaseError AcquireVersionLease(
|
||||
const base::FilePath& trusted_root,
|
||||
const base::FilePath& version_dir,
|
||||
std::unique_ptr<VersionLease>* lease);
|
||||
|
||||
explicit VersionLease(HANDLE handle);
|
||||
|
||||
HANDLE handle_ = INVALID_HANDLE_VALUE;
|
||||
};
|
||||
|
||||
// Acquire a lease for Release/libcef.dll beneath a validated installed version
|
||||
// root. kLostRace means removal won and selection may be retried; other errors
|
||||
// are hard validation/open failures.
|
||||
VersionLeaseError AcquireVersionLease(const base::FilePath& trusted_root,
|
||||
const base::FilePath& version_dir,
|
||||
std::unique_ptr<VersionLease>* lease);
|
||||
|
||||
// Deterministic mutation-boundary faults. Typed E2E configuration maps the
|
||||
// equivalent non-official subprocess values quarantine_move, repair_move,
|
||||
// trash_move, and trash_reclaim at this component boundary.
|
||||
void SetFileOpsFaultForTesting(FileOpsFault fault);
|
||||
|
||||
// Install a CEF version from extracted temp directory to final location.
|
||||
// Uses atomic rename for safety (all-or-nothing).
|
||||
//
|
||||
// Prerequisites: Caller must have already verified the source directory
|
||||
// contents (signature/catalog verification) before calling this function.
|
||||
//
|
||||
// Parameters:
|
||||
// - source_dir: Operation-owned temp directory containing extracted and
|
||||
// verified CEF files. It is consumed only by a successful rename.
|
||||
// - install_dir: Base install directory (e.g., "C:\Program Files\CEF")
|
||||
// - version: CEF version being installed
|
||||
// - cleanup_deferred: Set when publication succeeds but reclamation of a
|
||||
// quarantined target fails. The caller must surface this only after all
|
||||
// later logical publication and result validation succeeds.
|
||||
//
|
||||
// Process:
|
||||
// 1. Verify source_dir exists and is not a reparse point
|
||||
// 2. Construct dest: <install_dir>\Versions\<version>\<platform>
|
||||
// 3. Walk dest up to install_dir checking existing ancestors for reparse points
|
||||
// 4. If dest exists as a safe regular file or directory, move it opaquely to
|
||||
// reparse-safe .trash/. A quarantine failure preserves both dest and source.
|
||||
// 5. Create parent directories if needed
|
||||
// 6. Rename source_dir to dest (atomic, same volume). Failure after quarantine
|
||||
// preserves source, leaves the old target in .trash/, and leaves dest
|
||||
// absent.
|
||||
// 7. Reclaim a quarantined target without traversing reparse points;
|
||||
// reclamation failure is deferred physical cleanup and never rolls back
|
||||
// publication.
|
||||
//
|
||||
// IMPORTANT: source_dir MUST be on the same volume as install_dir. The
|
||||
// controller ensures this by staging under <install_dir>\.staging\.
|
||||
// Cross-volume renames will fail with kRenameFailed.
|
||||
FileOpsError InstallVersion(const base::FilePath& source_dir,
|
||||
const base::FilePath& install_dir,
|
||||
const Version& version,
|
||||
bool* cleanup_deferred = nullptr);
|
||||
|
||||
// Remove a CEF version that is no longer needed.
|
||||
//
|
||||
// Process:
|
||||
// 1. Verify source exists and is not a reparse point
|
||||
// 2. Check intermediate directories for reparse points
|
||||
// 3. Create <install_dir>\.trash\ if needed (with reparse point check)
|
||||
// 4. Rename source to <install_dir>\.trash\<version>_<random> (atomic)
|
||||
// 5. Delete trash entry recursively
|
||||
// 6. If delete fails (files in use), leave for retry on next run
|
||||
// 7. If parent version directory is empty, remove it too
|
||||
//
|
||||
// Returns kInUse if files are locked; caller should retry later.
|
||||
// Uses current platform.
|
||||
FileOpsError UninstallVersion(const base::FilePath& install_dir,
|
||||
const Version& version);
|
||||
|
||||
// Uninstall a specific platform of a CEF version.
|
||||
// Same as above but for a specified platform instead of current platform.
|
||||
// Returns kInUse if files are locked; caller should retry later.
|
||||
FileOpsError UninstallVersion(const base::FilePath& install_dir,
|
||||
const Version& version,
|
||||
const std::string& platform);
|
||||
|
||||
// Retry deletion of previously failed uninstalls.
|
||||
// Called automatically at the start of each installer operation.
|
||||
// Returns the number of successfully deleted trash entries (files or
|
||||
// directories).
|
||||
int RetryPendingDeletions(const base::FilePath& install_dir);
|
||||
|
||||
// Check if a path is a reparse point (symlink or junction).
|
||||
// Used to prevent symlink/junction attacks where an attacker creates a
|
||||
// junction pointing to a sensitive directory.
|
||||
// Returns false if path doesn't exist or attributes can't be read.
|
||||
bool IsReparsePoint(const base::FilePath& path);
|
||||
|
||||
// Verify a file path is safe (not a reparse point). If the path is a reparse
|
||||
// point, removes the reparse point itself (not its target).
|
||||
// If |delete_if_exists| is true, also deletes the file at the path.
|
||||
// Returns true if the path is safe to use (was not a reparse point, or the
|
||||
// reparse point was successfully removed). Returns false only if a reparse
|
||||
// point exists but could not be removed.
|
||||
bool VerifySafeFilePath(const base::FilePath& path,
|
||||
bool delete_if_exists = false);
|
||||
|
||||
// Verify a directory path is safe (not a reparse point). If the path is a
|
||||
// reparse point, removes the reparse point itself (not its target).
|
||||
// If |delete_if_exists| is true, also deletes the directory recursively.
|
||||
// Returns true if the path is safe to use (was not a reparse point, or the
|
||||
// reparse point was successfully removed). Returns false only if a reparse
|
||||
// point exists but could not be removed.
|
||||
bool VerifySafeDirectoryPath(const base::FilePath& path,
|
||||
bool delete_if_exists = false);
|
||||
|
||||
// Check if a directory exists and is not a reparse point (read-only check).
|
||||
// Does NOT require write access. Does NOT create the directory.
|
||||
bool IsReadableDirectory(const base::FilePath& path);
|
||||
|
||||
// Validate that every path component between |trusted_root| (exclusive) and
|
||||
// |path| (inclusive) exists and is not a reparse point.
|
||||
// Both paths must be absolute. |trusted_root| itself is not checked —
|
||||
// assumed safe by the caller.
|
||||
// Returns false if any component is a reparse point, does not exist, or
|
||||
// either path is relative.
|
||||
bool IsPathSafeForLoading(const base::FilePath& trusted_root,
|
||||
const base::FilePath& path,
|
||||
base::TimeTicks deadline = {},
|
||||
bool* time_limit_reached = nullptr);
|
||||
|
||||
// Returns true if two paths resolve to the same directory.
|
||||
// Uses MakeAbsoluteFilePath for canonicalization, with case-insensitive
|
||||
// fallback comparison on Windows.
|
||||
bool IsSameDirectory(const base::FilePath& a, const base::FilePath& b);
|
||||
|
||||
// Convert error code to human-readable string for logging
|
||||
const char* FileOpsErrorToString(FileOpsError error);
|
||||
|
||||
// RAII helper that deletes a file when it goes out of scope.
|
||||
// Call Release() to prevent deletion (e.g., after successful rename).
|
||||
class ScopedFileDeleter {
|
||||
public:
|
||||
explicit ScopedFileDeleter(const base::FilePath& path);
|
||||
~ScopedFileDeleter();
|
||||
|
||||
ScopedFileDeleter(const ScopedFileDeleter&) = delete;
|
||||
ScopedFileDeleter& operator=(const ScopedFileDeleter&) = delete;
|
||||
|
||||
void Release();
|
||||
|
||||
private:
|
||||
base::FilePath path_;
|
||||
};
|
||||
|
||||
// RAII helper that recursively deletes a directory when it goes out of scope.
|
||||
// Call Release() to prevent deletion.
|
||||
class ScopedDirectoryDeleter {
|
||||
public:
|
||||
explicit ScopedDirectoryDeleter(const base::FilePath& path);
|
||||
~ScopedDirectoryDeleter();
|
||||
|
||||
ScopedDirectoryDeleter(const ScopedDirectoryDeleter&) = delete;
|
||||
ScopedDirectoryDeleter& operator=(const ScopedDirectoryDeleter&) = delete;
|
||||
|
||||
void Release();
|
||||
|
||||
private:
|
||||
base::FilePath path_;
|
||||
};
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_FILE_OPS_H_
|
||||
@@ -0,0 +1,943 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "base/command_line.h"
|
||||
#include "base/files/file_enumerator.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/files/scoped_temp_dir.h"
|
||||
#include "base/test/file_path_reparse_point_win.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version_metadata.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer {
|
||||
namespace {
|
||||
|
||||
TEST(InstallerVersionLeaseChildTest, HoldUntilKilled) {
|
||||
const base::CommandLine* command = base::CommandLine::ForCurrentProcess();
|
||||
base::FilePath trusted_root =
|
||||
command->GetSwitchValuePath("cef-lease-child-root");
|
||||
base::FilePath version_dir =
|
||||
command->GetSwitchValuePath("cef-lease-child-version");
|
||||
std::wstring event_name =
|
||||
command->GetSwitchValueNative("cef-lease-child-event");
|
||||
if (trusted_root.empty() || version_dir.empty() || event_name.empty()) {
|
||||
GTEST_SKIP();
|
||||
}
|
||||
std::unique_ptr<VersionLease> lease;
|
||||
ASSERT_EQ(VersionLeaseError::kSuccess,
|
||||
AcquireVersionLease(trusted_root, version_dir, &lease));
|
||||
HANDLE event = OpenEventW(EVENT_MODIFY_STATE, FALSE, event_name.c_str());
|
||||
ASSERT_NE(nullptr, event);
|
||||
ASSERT_TRUE(SetEvent(event));
|
||||
CloseHandle(event);
|
||||
WaitForSingleObject(GetCurrentProcess(), INFINITE);
|
||||
}
|
||||
|
||||
class InstallerFileOpsTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
ASSERT_TRUE(temp_dir_.CreateUniqueTempDir());
|
||||
install_dir_ = temp_dir_.GetPath().Append(kCefSubdirectory);
|
||||
ASSERT_TRUE(base::CreateDirectory(install_dir_));
|
||||
}
|
||||
|
||||
void TearDown() override { SetFileOpsFaultForTesting(FileOpsFault::kNone); }
|
||||
|
||||
// Create a mock source directory with the version metadata file
|
||||
base::FilePath CreateMockSourceDir(const std::string& version_str) {
|
||||
base::FilePath source_dir = temp_dir_.GetPath().Append(
|
||||
L"source_" + base::FilePath::FromUTF8Unsafe(version_str).value());
|
||||
base::CreateDirectory(source_dir);
|
||||
|
||||
// Create the version metadata file
|
||||
base::FilePath metadata_file = source_dir.Append(kVersionMetadataFilename);
|
||||
base::WriteFile(metadata_file, R"({"version":")" + version_str + R"("})");
|
||||
|
||||
return source_dir;
|
||||
}
|
||||
|
||||
// Create a mock installed version
|
||||
void CreateInstalledVersion(const std::string& version_str) {
|
||||
Version version = Version::Parse(version_str);
|
||||
base::FilePath version_dir = GetVersionPath(install_dir_, version);
|
||||
base::CreateDirectory(version_dir);
|
||||
|
||||
// Create the version metadata file
|
||||
base::FilePath metadata_file = version_dir.Append(kVersionMetadataFilename);
|
||||
base::WriteFile(metadata_file, R"({"version":")" + version_str + R"("})");
|
||||
}
|
||||
|
||||
VersionMetadata CreateCompleteDistribution(
|
||||
const base::FilePath& path,
|
||||
const std::string& version_str,
|
||||
const std::string& abi_hash = "abc123def4567890") {
|
||||
EXPECT_TRUE(base::CreateDirectory(path));
|
||||
VersionMetadata metadata;
|
||||
metadata.version = Version::Parse(version_str);
|
||||
metadata.abi_hash = abi_hash;
|
||||
metadata.platform = GetCurrentPlatform();
|
||||
EXPECT_EQ(MetadataError::kSuccess, WriteVersionMetadata(path, metadata));
|
||||
base::FilePath release = path.Append(kReleaseSubdirectory);
|
||||
EXPECT_TRUE(base::CreateDirectory(release));
|
||||
EXPECT_TRUE(base::WriteFile(release.Append(kLibcefFilename), "libcef"));
|
||||
EXPECT_TRUE(base::WriteFile(path.Append(kCatalogFilename), "catalog"));
|
||||
return metadata;
|
||||
}
|
||||
|
||||
base::ScopedTempDir temp_dir_;
|
||||
base::FilePath install_dir_;
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// InstallVersion Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, InstallVersionSuccess) {
|
||||
base::FilePath source_dir = CreateMockSourceDir("137.3.5");
|
||||
Version version = Version::Parse("137.3.5");
|
||||
|
||||
FileOpsError result = InstallVersion(source_dir, install_dir_, version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSuccess, result);
|
||||
|
||||
// Verify the version was installed
|
||||
base::FilePath installed_path = GetVersionPath(install_dir_, version);
|
||||
EXPECT_TRUE(base::DirectoryExists(installed_path));
|
||||
EXPECT_TRUE(
|
||||
base::PathExists(installed_path.Append(kVersionMetadataFilename)));
|
||||
|
||||
// Source should no longer exist (was renamed)
|
||||
EXPECT_FALSE(base::DirectoryExists(source_dir));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, InstallVersionDestExists) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
CreateInstalledVersion("137.3.5");
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(destination.Append(L"old_marker.txt"), "old target"));
|
||||
|
||||
base::FilePath source_dir = CreateMockSourceDir(version_string);
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(source_dir.Append(L"staging_marker.txt"), "staging"));
|
||||
|
||||
FileOpsError result = InstallVersion(source_dir, install_dir_, version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSuccess, result);
|
||||
EXPECT_FALSE(base::PathExists(source_dir));
|
||||
EXPECT_FALSE(base::PathExists(destination.Append(L"old_marker.txt")));
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"staging_marker.txt")));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, InstallVersionSourceMissing) {
|
||||
base::FilePath nonexistent_dir = temp_dir_.GetPath().Append(L"nonexistent");
|
||||
Version version = Version::Parse("137.3.5");
|
||||
|
||||
FileOpsError result = InstallVersion(nonexistent_dir, install_dir_, version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSourceNotFound, result);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, InstallVersionInvalidVersion) {
|
||||
base::FilePath source_dir = CreateMockSourceDir("137.3.5");
|
||||
Version invalid_version; // Default-constructed = invalid
|
||||
|
||||
FileOpsError result =
|
||||
InstallVersion(source_dir, install_dir_, invalid_version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSourceNotFound, result);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, InstallVersionCreatesVersionsDir) {
|
||||
// Use a fresh install dir without Versions subdirectory
|
||||
base::FilePath fresh_install_dir = temp_dir_.GetPath().Append(L"FreshCEF");
|
||||
ASSERT_TRUE(base::CreateDirectory(fresh_install_dir));
|
||||
|
||||
base::FilePath source_dir = CreateMockSourceDir("137.3.5");
|
||||
Version version = Version::Parse("137.3.5");
|
||||
|
||||
FileOpsError result = InstallVersion(source_dir, fresh_install_dir, version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSuccess, result);
|
||||
|
||||
// Versions subdirectory should have been created
|
||||
base::FilePath versions_dir = fresh_install_dir.Append(kVersionsSubdirectory);
|
||||
EXPECT_TRUE(base::DirectoryExists(versions_dir));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ReplacesPartialDestination) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
base::FilePath source = temp_dir_.GetPath().Append(L"replacement_source");
|
||||
VersionMetadata expected = CreateCompleteDistribution(source, version_string);
|
||||
ASSERT_TRUE(base::WriteFile(source.Append(L"staging_marker.txt"), "staging"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::CreateDirectory(destination));
|
||||
ASSERT_TRUE(base::WriteFile(destination.Append(L"partial.txt"), "partial"));
|
||||
|
||||
bool cleanup_deferred = false;
|
||||
EXPECT_EQ(FileOpsError::kSuccess,
|
||||
InstallVersion(source, install_dir_, version, &cleanup_deferred));
|
||||
EXPECT_EQ(DistributionValidation::kComplete,
|
||||
ValidateDistribution(install_dir_, destination, expected));
|
||||
EXPECT_FALSE(base::PathExists(destination.Append(L"partial.txt")));
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"staging_marker.txt")));
|
||||
EXPECT_FALSE(cleanup_deferred);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ReplacesCompleteDestinationWithStaging) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
base::FilePath source = temp_dir_.GetPath().Append(L"verified_source");
|
||||
VersionMetadata expected = CreateCompleteDistribution(source, version_string);
|
||||
ASSERT_TRUE(base::WriteFile(
|
||||
source.Append(kReleaseSubdirectory).Append(kLibcefFilename),
|
||||
"verified staging bytes"));
|
||||
ASSERT_TRUE(base::WriteFile(source.Append(L"staging_marker.txt"), "staging"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
CreateCompleteDistribution(destination, version_string);
|
||||
ASSERT_TRUE(base::WriteFile(
|
||||
destination.Append(kReleaseSubdirectory).Append(kLibcefFilename),
|
||||
"orphan bytes"));
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(destination.Append(L"old_marker.txt"), "old target"));
|
||||
|
||||
bool cleanup_deferred = false;
|
||||
EXPECT_EQ(FileOpsError::kSuccess,
|
||||
InstallVersion(source, install_dir_, version, &cleanup_deferred));
|
||||
EXPECT_FALSE(base::PathExists(source));
|
||||
EXPECT_EQ(DistributionValidation::kComplete,
|
||||
ValidateDistribution(install_dir_, destination, expected));
|
||||
std::string installed_bytes;
|
||||
ASSERT_TRUE(base::ReadFileToString(
|
||||
destination.Append(kReleaseSubdirectory).Append(kLibcefFilename),
|
||||
&installed_bytes));
|
||||
EXPECT_EQ("verified staging bytes", installed_bytes);
|
||||
EXPECT_FALSE(base::PathExists(destination.Append(L"old_marker.txt")));
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"staging_marker.txt")));
|
||||
EXPECT_FALSE(cleanup_deferred);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ReplacesEmptyDirectoryDestination) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
base::FilePath source = temp_dir_.GetPath().Append(L"empty_target_source");
|
||||
CreateCompleteDistribution(source, version_string);
|
||||
ASSERT_TRUE(base::WriteFile(source.Append(L"staging_marker.txt"), "staging"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::CreateDirectory(destination));
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSuccess,
|
||||
InstallVersion(source, install_dir_, version));
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"staging_marker.txt")));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ReplacesRegularFileDestination) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
base::FilePath source = temp_dir_.GetPath().Append(L"file_target_source");
|
||||
CreateCompleteDistribution(source, version_string);
|
||||
ASSERT_TRUE(base::WriteFile(source.Append(L"staging_marker.txt"), "staging"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::CreateDirectory(destination.DirName()));
|
||||
ASSERT_TRUE(base::WriteFile(destination, "old regular file"));
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSuccess,
|
||||
InstallVersion(source, install_dir_, version));
|
||||
EXPECT_TRUE(base::DirectoryExists(destination));
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"staging_marker.txt")));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, RejectsReparsePointDestination) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
base::FilePath source = temp_dir_.GetPath().Append(L"reparse_source");
|
||||
CreateCompleteDistribution(source, version_string);
|
||||
base::FilePath external = temp_dir_.GetPath().Append(L"external_target");
|
||||
ASSERT_TRUE(base::CreateDirectory(external));
|
||||
ASSERT_TRUE(base::WriteFile(external.Append(L"sentinel.txt"), "sentinel"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::CreateDirectory(destination));
|
||||
auto reparse_point =
|
||||
base::test::FilePathReparsePoint::Create(destination, external);
|
||||
ASSERT_TRUE(reparse_point.has_value());
|
||||
|
||||
EXPECT_EQ(FileOpsError::kQuarantineFailed,
|
||||
InstallVersion(source, install_dir_, version));
|
||||
EXPECT_TRUE(base::PathExists(source));
|
||||
EXPECT_TRUE(base::PathExists(destination));
|
||||
EXPECT_TRUE(base::PathExists(external.Append(L"sentinel.txt")));
|
||||
EXPECT_FALSE(base::PathExists(install_dir_.Append(kTrashSubdirectory)));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ReplacementFaultBoundaries) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
|
||||
for (FileOpsFault fault :
|
||||
{FileOpsFault::kQuarantineMove, FileOpsFault::kRepairMove}) {
|
||||
base::FilePath source = temp_dir_.GetPath().Append(
|
||||
fault == FileOpsFault::kQuarantineMove ? L"quarantine_source"
|
||||
: L"repair_move_source");
|
||||
CreateCompleteDistribution(source, version_string);
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(source.Append(L"staging_marker.txt"), "staging"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::CreateDirectory(destination));
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(destination.Append(L"old_marker.txt"), "old target"));
|
||||
|
||||
SetFileOpsFaultForTesting(fault);
|
||||
FileOpsError error = InstallVersion(source, install_dir_, version);
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kNone);
|
||||
EXPECT_EQ(fault == FileOpsFault::kQuarantineMove
|
||||
? FileOpsError::kQuarantineFailed
|
||||
: FileOpsError::kRepairFailed,
|
||||
error);
|
||||
|
||||
EXPECT_TRUE(base::PathExists(source));
|
||||
base::FilePath trash = install_dir_.Append(kTrashSubdirectory);
|
||||
base::FileEnumerator entries(
|
||||
trash, false,
|
||||
base::FileEnumerator::FILES | base::FileEnumerator::DIRECTORIES);
|
||||
base::FilePath quarantined = entries.Next();
|
||||
if (fault == FileOpsFault::kQuarantineMove) {
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"old_marker.txt")));
|
||||
EXPECT_TRUE(quarantined.empty());
|
||||
} else {
|
||||
EXPECT_FALSE(base::PathExists(destination));
|
||||
ASSERT_FALSE(quarantined.empty());
|
||||
EXPECT_TRUE(base::PathExists(quarantined.Append(L"old_marker.txt")));
|
||||
}
|
||||
|
||||
base::DeletePathRecursively(destination);
|
||||
base::DeletePathRecursively(source);
|
||||
base::DeletePathRecursively(trash);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ReplacementCleanupDeferredAndRetryable) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
base::FilePath source = temp_dir_.GetPath().Append(L"deferred_source");
|
||||
CreateCompleteDistribution(source, version_string);
|
||||
ASSERT_TRUE(base::WriteFile(source.Append(L"staging_marker.txt"), "staging"));
|
||||
base::FilePath destination = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::CreateDirectory(destination));
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(destination.Append(L"old_marker.txt"), "old target"));
|
||||
|
||||
bool cleanup_deferred = false;
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kTrashReclaim);
|
||||
EXPECT_EQ(FileOpsError::kSuccess,
|
||||
InstallVersion(source, install_dir_, version, &cleanup_deferred));
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kNone);
|
||||
|
||||
EXPECT_TRUE(cleanup_deferred);
|
||||
EXPECT_TRUE(base::PathExists(destination.Append(L"staging_marker.txt")));
|
||||
EXPECT_FALSE(base::PathExists(destination.Append(L"old_marker.txt")));
|
||||
EXPECT_EQ(1, RetryPendingDeletions(install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, TrashFaultBoundaries) {
|
||||
const std::string version_string = "137.3.5";
|
||||
Version version = Version::Parse(version_string);
|
||||
|
||||
CreateInstalledVersion(version_string);
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kTrashMove);
|
||||
EXPECT_EQ(FileOpsError::kRenameFailed,
|
||||
UninstallVersion(install_dir_, version));
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kNone);
|
||||
EXPECT_TRUE(base::PathExists(GetVersionPath(install_dir_, version)));
|
||||
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kTrashReclaim);
|
||||
EXPECT_EQ(FileOpsError::kInUse, UninstallVersion(install_dir_, version));
|
||||
SetFileOpsFaultForTesting(FileOpsFault::kNone);
|
||||
EXPECT_FALSE(base::PathExists(GetVersionPath(install_dir_, version)));
|
||||
base::FilePath trash = install_dir_.Append(kTrashSubdirectory);
|
||||
base::FileEnumerator entries(trash, false, base::FileEnumerator::DIRECTORIES);
|
||||
EXPECT_FALSE(entries.Next().empty());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// UninstallVersion Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, UninstallVersionSuccess) {
|
||||
CreateInstalledVersion("137.3.5");
|
||||
Version version = Version::Parse("137.3.5");
|
||||
|
||||
// Verify it exists before uninstall
|
||||
base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::DirectoryExists(version_path));
|
||||
|
||||
FileOpsError result = UninstallVersion(install_dir_, version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kSuccess, result);
|
||||
|
||||
// Version directory should be gone
|
||||
EXPECT_FALSE(base::DirectoryExists(version_path));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, UninstallVersionNotFound) {
|
||||
Version version = Version::Parse("999.0.0");
|
||||
|
||||
FileOpsError result = UninstallVersion(install_dir_, version);
|
||||
|
||||
EXPECT_EQ(FileOpsError::kDestinationNotFound, result);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, UninstallVersionInUse) {
|
||||
CreateInstalledVersion("137.3.5");
|
||||
Version version = Version::Parse("137.3.5");
|
||||
base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
|
||||
// Lock a file in the directory to simulate "in use"
|
||||
base::FilePath locked_file = version_path.Append(L"locked.dll");
|
||||
base::WriteFile(locked_file, "test");
|
||||
|
||||
// Open with exclusive access to lock it
|
||||
HANDLE handle =
|
||||
::CreateFileW(locked_file.value().c_str(), GENERIC_READ | GENERIC_WRITE,
|
||||
0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
ASSERT_NE(INVALID_HANDLE_VALUE, handle);
|
||||
|
||||
FileOpsError result = UninstallVersion(install_dir_, version);
|
||||
|
||||
// When a file is locked, different things can happen:
|
||||
// - kInUse: MoveFileEx detects sharing violation
|
||||
// - kAccessDenied: MoveFileEx returns access denied for locked directory
|
||||
// - kSuccess: Rename succeeded but delete failed (kInUse semantics)
|
||||
// All are acceptable outcomes for a locked file scenario
|
||||
EXPECT_TRUE(result == FileOpsError::kInUse ||
|
||||
result == FileOpsError::kAccessDenied ||
|
||||
result == FileOpsError::kSuccess)
|
||||
<< "Unexpected error: " << FileOpsErrorToString(result);
|
||||
|
||||
::CloseHandle(handle);
|
||||
|
||||
// Clean up - retry deletions
|
||||
RetryPendingDeletions(install_dir_);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, UninstallVersionCleansEmptyParent) {
|
||||
CreateInstalledVersion("137.3.5");
|
||||
Version version = Version::Parse("137.3.5");
|
||||
|
||||
// Verify parent version dir exists: Versions/137.3.5/
|
||||
base::FilePath version_parent =
|
||||
install_dir_.Append(kVersionsSubdirectory).Append(L"137.3.5");
|
||||
ASSERT_TRUE(base::DirectoryExists(version_parent));
|
||||
|
||||
// Verify platform dir exists: Versions/137.3.5/<platform>/
|
||||
base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
ASSERT_TRUE(base::DirectoryExists(version_path));
|
||||
|
||||
FileOpsError result = UninstallVersion(install_dir_, version);
|
||||
EXPECT_EQ(FileOpsError::kSuccess, result);
|
||||
|
||||
// Platform directory should be gone
|
||||
EXPECT_FALSE(base::DirectoryExists(version_path));
|
||||
|
||||
// Parent version directory should also be cleaned up since it's now empty
|
||||
EXPECT_FALSE(base::DirectoryExists(version_parent));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VersionLeaseBlocksWholeDirectoryMove) {
|
||||
const Version version = Version::Parse("137.3.5");
|
||||
const base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
CreateCompleteDistribution(version_path, version.ToString());
|
||||
std::unique_ptr<VersionLease> lease;
|
||||
ASSERT_EQ(VersionLeaseError::kSuccess,
|
||||
AcquireVersionLease(install_dir_, version_path, &lease));
|
||||
ASSERT_TRUE(lease);
|
||||
EXPECT_TRUE(lease->IsValid());
|
||||
|
||||
const base::FilePath moved = temp_dir_.GetPath().Append(L"leased_moved");
|
||||
EXPECT_FALSE(MoveFileExW(version_path.value().c_str(), moved.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH));
|
||||
EXPECT_TRUE(base::DirectoryExists(version_path));
|
||||
|
||||
lease.reset();
|
||||
EXPECT_TRUE(MoveFileExW(version_path.value().c_str(), moved.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, MultipleVersionLeaseReaders) {
|
||||
const Version version = Version::Parse("137.3.5");
|
||||
const base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
CreateCompleteDistribution(version_path, version.ToString());
|
||||
std::unique_ptr<VersionLease> first;
|
||||
std::unique_ptr<VersionLease> second;
|
||||
EXPECT_EQ(VersionLeaseError::kSuccess,
|
||||
AcquireVersionLease(install_dir_, version_path, &first));
|
||||
EXPECT_EQ(VersionLeaseError::kSuccess,
|
||||
AcquireVersionLease(install_dir_, version_path, &second));
|
||||
EXPECT_TRUE(first);
|
||||
EXPECT_TRUE(second);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VersionLeaseDetectsLostRemovalRace) {
|
||||
const Version version = Version::Parse("137.3.5");
|
||||
const base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
std::unique_ptr<VersionLease> lease;
|
||||
EXPECT_EQ(VersionLeaseError::kLostRace,
|
||||
AcquireVersionLease(install_dir_, version_path, &lease));
|
||||
EXPECT_FALSE(lease);
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ProcessTerminationReleasesVersionLease) {
|
||||
const Version version = Version::Parse("137.3.5");
|
||||
const base::FilePath version_path = GetVersionPath(install_dir_, version);
|
||||
CreateCompleteDistribution(version_path, version.ToString());
|
||||
std::wstring event_name = std::wstring(L"Local") + static_cast<wchar_t>(92) +
|
||||
L"CEF_Installer_Lease_Test_" +
|
||||
std::to_wstring(GetCurrentProcessId()) + L"_" +
|
||||
std::to_wstring(GetTickCount64());
|
||||
HANDLE event = CreateEventW(nullptr, TRUE, FALSE, event_name.c_str());
|
||||
ASSERT_NE(nullptr, event);
|
||||
|
||||
wchar_t executable[MAX_PATH] = {};
|
||||
ASSERT_GT(GetModuleFileNameW(nullptr, executable, MAX_PATH), 0u);
|
||||
const wchar_t quote = static_cast<wchar_t>(34);
|
||||
std::wstring command_line(1, quote);
|
||||
command_line += executable;
|
||||
command_line += quote;
|
||||
command_line +=
|
||||
L" --single-process-tests "
|
||||
L"--gtest_filter=InstallerVersionLeaseChildTest.HoldUntilKilled "
|
||||
L"--cef-lease-child-root=";
|
||||
command_line += quote;
|
||||
command_line += install_dir_.value();
|
||||
command_line += quote;
|
||||
command_line += L" --cef-lease-child-version=";
|
||||
command_line += quote;
|
||||
command_line += version_path.value();
|
||||
command_line += quote;
|
||||
command_line += L" --cef-lease-child-event=" + event_name;
|
||||
STARTUPINFOW startup = {};
|
||||
startup.cb = sizeof(startup);
|
||||
PROCESS_INFORMATION process = {};
|
||||
ASSERT_TRUE(CreateProcessW(nullptr, command_line.data(), nullptr, nullptr,
|
||||
FALSE, CREATE_NO_WINDOW, nullptr, nullptr,
|
||||
&startup, &process));
|
||||
CloseHandle(process.hThread);
|
||||
ASSERT_EQ(WAIT_OBJECT_0, WaitForSingleObject(event, 10000));
|
||||
CloseHandle(event);
|
||||
|
||||
const base::FilePath moved =
|
||||
temp_dir_.GetPath().Append(L"process_lease_moved");
|
||||
EXPECT_FALSE(MoveFileExW(version_path.value().c_str(), moved.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH));
|
||||
ASSERT_TRUE(TerminateProcess(process.hProcess, 1));
|
||||
ASSERT_EQ(WAIT_OBJECT_0, WaitForSingleObject(process.hProcess, 10000));
|
||||
CloseHandle(process.hProcess);
|
||||
EXPECT_TRUE(MoveFileExW(version_path.value().c_str(), moved.value().c_str(),
|
||||
MOVEFILE_WRITE_THROUGH));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RetryPendingDeletions Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, RetryPendingDeletions) {
|
||||
// Create .trash/ with entries
|
||||
base::FilePath trash_root = install_dir_.Append(L".trash");
|
||||
ASSERT_TRUE(base::CreateDirectory(trash_root));
|
||||
|
||||
base::FilePath trash1 = trash_root.Append(L"137.3.5_ABCDEF1234567890");
|
||||
base::FilePath trash2 = trash_root.Append(L"138.0.0_1234567890ABCDEF");
|
||||
ASSERT_TRUE(base::CreateDirectory(trash1));
|
||||
ASSERT_TRUE(base::CreateDirectory(trash2));
|
||||
|
||||
// Add some files to make them non-empty
|
||||
base::WriteFile(trash1.Append(L"file.txt"), "test");
|
||||
base::WriteFile(trash2.Append(L"file.txt"), "test");
|
||||
|
||||
int deleted = RetryPendingDeletions(install_dir_);
|
||||
|
||||
EXPECT_EQ(2, deleted);
|
||||
EXPECT_FALSE(base::DirectoryExists(trash1));
|
||||
EXPECT_FALSE(base::DirectoryExists(trash2));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, RetryPendingDeletionsPartialSuccess) {
|
||||
// Create .trash/ with two entries
|
||||
base::FilePath trash_root = install_dir_.Append(L".trash");
|
||||
ASSERT_TRUE(base::CreateDirectory(trash_root));
|
||||
|
||||
base::FilePath trash1 = trash_root.Append(L"137.3.5_ABCDEF1234567890");
|
||||
base::FilePath trash2 = trash_root.Append(L"138.0.0_1234567890ABCDEF");
|
||||
ASSERT_TRUE(base::CreateDirectory(trash1));
|
||||
ASSERT_TRUE(base::CreateDirectory(trash2));
|
||||
|
||||
// Lock a file in trash1
|
||||
base::FilePath locked_file = trash1.Append(L"locked.dll");
|
||||
base::WriteFile(locked_file, "test");
|
||||
HANDLE handle =
|
||||
::CreateFileW(locked_file.value().c_str(), GENERIC_READ | GENERIC_WRITE,
|
||||
0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
ASSERT_NE(INVALID_HANDLE_VALUE, handle);
|
||||
|
||||
int deleted = RetryPendingDeletions(install_dir_);
|
||||
|
||||
// trash2 should be deleted, trash1 should remain (locked)
|
||||
EXPECT_EQ(1, deleted);
|
||||
EXPECT_TRUE(base::DirectoryExists(trash1));
|
||||
EXPECT_FALSE(base::DirectoryExists(trash2));
|
||||
|
||||
::CloseHandle(handle);
|
||||
|
||||
// Now retry - should delete the remaining one
|
||||
deleted = RetryPendingDeletions(install_dir_);
|
||||
EXPECT_EQ(1, deleted);
|
||||
EXPECT_FALSE(base::DirectoryExists(trash1));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, RetryPendingDeletionsRegularFileTrash) {
|
||||
base::FilePath trash_root = install_dir_.Append(kTrashSubdirectory);
|
||||
ASSERT_TRUE(base::CreateDirectory(trash_root));
|
||||
base::FilePath trash_file = trash_root.Append(L"137.3.5_file");
|
||||
ASSERT_TRUE(base::WriteFile(trash_file, "old target"));
|
||||
|
||||
EXPECT_EQ(1, RetryPendingDeletions(install_dir_));
|
||||
EXPECT_FALSE(base::PathExists(trash_file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest,
|
||||
RetryPendingDeletionsTrashDoesNotTraverseReparsePoint) {
|
||||
base::FilePath external = temp_dir_.GetPath().Append(L"external_reclaim");
|
||||
ASSERT_TRUE(base::CreateDirectory(external));
|
||||
base::FilePath sentinel = external.Append(L"sentinel.txt");
|
||||
ASSERT_TRUE(base::WriteFile(sentinel, "preserve"));
|
||||
|
||||
base::FilePath trash_root = install_dir_.Append(kTrashSubdirectory);
|
||||
base::FilePath trash_entry = trash_root.Append(L"137.3.5_reparse");
|
||||
base::FilePath nested = trash_entry.Append(L"nested");
|
||||
ASSERT_TRUE(base::CreateDirectory(nested));
|
||||
auto reparse_point =
|
||||
base::test::FilePathReparsePoint::Create(nested, external);
|
||||
ASSERT_TRUE(reparse_point.has_value());
|
||||
ASSERT_TRUE(base::PathExists(nested.Append(L"sentinel.txt")));
|
||||
|
||||
EXPECT_EQ(1, RetryPendingDeletions(install_dir_));
|
||||
EXPECT_FALSE(base::PathExists(trash_entry));
|
||||
EXPECT_TRUE(base::PathExists(sentinel));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FileOpsErrorToString Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, FileOpsErrorToString) {
|
||||
EXPECT_STREQ("Success", FileOpsErrorToString(FileOpsError::kSuccess));
|
||||
EXPECT_STREQ("Source directory not found",
|
||||
FileOpsErrorToString(FileOpsError::kSourceNotFound));
|
||||
EXPECT_STREQ("Destination already exists",
|
||||
FileOpsErrorToString(FileOpsError::kDestinationExists));
|
||||
EXPECT_STREQ("Destination not found",
|
||||
FileOpsErrorToString(FileOpsError::kDestinationNotFound));
|
||||
EXPECT_STREQ("Access denied",
|
||||
FileOpsErrorToString(FileOpsError::kAccessDenied));
|
||||
EXPECT_STREQ("Disk full", FileOpsErrorToString(FileOpsError::kDiskFull));
|
||||
EXPECT_STREQ("Files are in use", FileOpsErrorToString(FileOpsError::kInUse));
|
||||
EXPECT_STREQ("Rename operation failed",
|
||||
FileOpsErrorToString(FileOpsError::kRenameFailed));
|
||||
EXPECT_STREQ("Delete operation failed",
|
||||
FileOpsErrorToString(FileOpsError::kDeleteFailed));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsReparsePointRegularDir) {
|
||||
// A regular directory should not be a reparse point
|
||||
EXPECT_FALSE(IsReparsePoint(temp_dir_.GetPath()));
|
||||
EXPECT_FALSE(IsReparsePoint(install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsReparsePointNonExistent) {
|
||||
// Non-existent path should return false
|
||||
base::FilePath nonexistent = temp_dir_.GetPath().Append(L"does_not_exist");
|
||||
EXPECT_FALSE(IsReparsePoint(nonexistent));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VerifySafeFilePath Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeFilePathRegularFile) {
|
||||
base::FilePath file = temp_dir_.GetPath().Append(L"safe.txt");
|
||||
base::WriteFile(file, "content");
|
||||
EXPECT_TRUE(VerifySafeFilePath(file));
|
||||
// File should still exist when delete_if_exists is false.
|
||||
EXPECT_TRUE(base::PathExists(file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeFilePathNonExistent) {
|
||||
base::FilePath missing = temp_dir_.GetPath().Append(L"missing.txt");
|
||||
EXPECT_TRUE(VerifySafeFilePath(missing));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeFilePathDeleteIfExists) {
|
||||
base::FilePath file = temp_dir_.GetPath().Append(L"to_delete.txt");
|
||||
base::WriteFile(file, "content");
|
||||
EXPECT_TRUE(VerifySafeFilePath(file, /*delete_if_exists=*/true));
|
||||
EXPECT_FALSE(base::PathExists(file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeFilePathDeleteNonExistent) {
|
||||
base::FilePath missing = temp_dir_.GetPath().Append(L"missing.txt");
|
||||
// Should succeed even if file doesn't exist.
|
||||
EXPECT_TRUE(VerifySafeFilePath(missing, /*delete_if_exists=*/true));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// VerifySafeDirectoryPath Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeDirectoryPathRegularDir) {
|
||||
EXPECT_TRUE(VerifySafeDirectoryPath(install_dir_));
|
||||
// Directory should still exist when delete_if_exists is false.
|
||||
EXPECT_TRUE(base::DirectoryExists(install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeDirectoryPathNonExistent) {
|
||||
base::FilePath missing = temp_dir_.GetPath().Append(L"missing_dir");
|
||||
EXPECT_TRUE(VerifySafeDirectoryPath(missing));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeDirectoryPathDeleteIfExists) {
|
||||
base::FilePath dir = temp_dir_.GetPath().Append(L"dir_to_delete");
|
||||
ASSERT_TRUE(base::CreateDirectory(dir));
|
||||
base::WriteFile(dir.Append(L"child.txt"), "data");
|
||||
|
||||
EXPECT_TRUE(VerifySafeDirectoryPath(dir, /*delete_if_exists=*/true));
|
||||
EXPECT_FALSE(base::DirectoryExists(dir));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, VerifySafeDirectoryPathDeleteNonExistent) {
|
||||
base::FilePath missing = temp_dir_.GetPath().Append(L"missing_dir");
|
||||
EXPECT_TRUE(VerifySafeDirectoryPath(missing, /*delete_if_exists=*/true));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IsReadableDirectory Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsReadableDirectorySuccess) {
|
||||
EXPECT_TRUE(IsReadableDirectory(install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsReadableDirectoryNonExistent) {
|
||||
base::FilePath nonexistent = temp_dir_.GetPath().Append(L"does_not_exist");
|
||||
EXPECT_FALSE(IsReadableDirectory(nonexistent));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsReadableDirectoryEmptyPath) {
|
||||
EXPECT_FALSE(IsReadableDirectory(base::FilePath()));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsReadableDirectoryFile) {
|
||||
base::FilePath file_path = temp_dir_.GetPath().Append(L"afile.txt");
|
||||
base::WriteFile(file_path, "content");
|
||||
EXPECT_FALSE(IsReadableDirectory(file_path));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IsPathSafeForLoading Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingNormalHierarchy) {
|
||||
// Create root/a/b/file.dll
|
||||
base::FilePath root = temp_dir_.GetPath().Append(L"root");
|
||||
base::FilePath a = root.Append(L"a");
|
||||
base::FilePath b = a.Append(L"b");
|
||||
ASSERT_TRUE(base::CreateDirectory(b));
|
||||
base::FilePath file = b.Append(L"file.dll");
|
||||
ASSERT_TRUE(base::WriteFile(file, "content"));
|
||||
|
||||
EXPECT_TRUE(IsPathSafeForLoading(root, file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingNonexistentComponent) {
|
||||
base::FilePath root = temp_dir_.GetPath().Append(L"root");
|
||||
ASSERT_TRUE(base::CreateDirectory(root));
|
||||
base::FilePath missing = root.Append(L"missing").Append(L"file.dll");
|
||||
|
||||
EXPECT_FALSE(IsPathSafeForLoading(root, missing));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingPathNotUnderRoot) {
|
||||
base::FilePath root = temp_dir_.GetPath().Append(L"root");
|
||||
base::FilePath other = temp_dir_.GetPath().Append(L"other");
|
||||
ASSERT_TRUE(base::CreateDirectory(root));
|
||||
ASSERT_TRUE(base::CreateDirectory(other));
|
||||
base::FilePath file = other.Append(L"file.dll");
|
||||
ASSERT_TRUE(base::WriteFile(file, "content"));
|
||||
|
||||
EXPECT_FALSE(IsPathSafeForLoading(root, file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingEmptyRoot) {
|
||||
EXPECT_FALSE(IsPathSafeForLoading(base::FilePath(), install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingEmptyPath) {
|
||||
EXPECT_FALSE(IsPathSafeForLoading(install_dir_, base::FilePath()));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingRootEqualsPath) {
|
||||
// Path is not under root (IsParent returns false when they're equal)
|
||||
EXPECT_FALSE(IsPathSafeForLoading(install_dir_, install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingRelativePaths) {
|
||||
EXPECT_FALSE(
|
||||
IsPathSafeForLoading(base::FilePath(L"relative\\root"),
|
||||
base::FilePath(L"relative\\root\\file.dll")));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsPathSafeForLoadingHonorsSoftDeadline) {
|
||||
bool time_limit_reached = false;
|
||||
EXPECT_FALSE(
|
||||
IsPathSafeForLoading(install_dir_, install_dir_.Append(L"file.dll"),
|
||||
base::TimeTicks::Now(), &time_limit_reached));
|
||||
EXPECT_TRUE(time_limit_reached);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IsSameDirectory Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsSameDirectorySamePath) {
|
||||
EXPECT_TRUE(IsSameDirectory(install_dir_, install_dir_));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsSameDirectoryDifferentCase) {
|
||||
// On Windows, paths differing only in case should be the same.
|
||||
base::FilePath upper(L"C:\\WINDOWS\\TEMP");
|
||||
base::FilePath lower(L"C:\\windows\\temp");
|
||||
EXPECT_TRUE(IsSameDirectory(upper, lower));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, IsSameDirectoryDifferentPaths) {
|
||||
base::FilePath dir_a = temp_dir_.GetPath().Append(L"DirA");
|
||||
base::FilePath dir_b = temp_dir_.GetPath().Append(L"DirB");
|
||||
ASSERT_TRUE(base::CreateDirectory(dir_a));
|
||||
ASSERT_TRUE(base::CreateDirectory(dir_b));
|
||||
EXPECT_FALSE(IsSameDirectory(dir_a, dir_b));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ScopedFileDeleter Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedFileDeleterDeletesOnDestruction) {
|
||||
base::FilePath file = temp_dir_.GetPath().Append(L"to_delete.txt");
|
||||
base::WriteFile(file, "content");
|
||||
ASSERT_TRUE(base::PathExists(file));
|
||||
|
||||
{
|
||||
ScopedFileDeleter deleter(file);
|
||||
}
|
||||
|
||||
EXPECT_FALSE(base::PathExists(file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedFileDeleterReleasePreventsDeletion) {
|
||||
base::FilePath file = temp_dir_.GetPath().Append(L"keep.txt");
|
||||
base::WriteFile(file, "content");
|
||||
ASSERT_TRUE(base::PathExists(file));
|
||||
|
||||
{
|
||||
ScopedFileDeleter deleter(file);
|
||||
deleter.Release();
|
||||
}
|
||||
|
||||
EXPECT_TRUE(base::PathExists(file));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedFileDeleterEmptyPathIsNoOp) {
|
||||
// Should not crash when constructed with an empty path.
|
||||
{
|
||||
ScopedFileDeleter deleter{base::FilePath()};
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedFileDeleterNonexistentPathIsNoOp) {
|
||||
base::FilePath missing = temp_dir_.GetPath().Append(L"no_such_file.txt");
|
||||
ASSERT_FALSE(base::PathExists(missing));
|
||||
|
||||
// Should not crash when the file doesn't exist.
|
||||
{
|
||||
ScopedFileDeleter deleter(missing);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ScopedDirectoryDeleter Tests
|
||||
// =============================================================================
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedDirectoryDeleterDeletesOnDestruction) {
|
||||
base::FilePath dir = temp_dir_.GetPath().Append(L"dir_to_delete");
|
||||
ASSERT_TRUE(base::CreateDirectory(dir));
|
||||
base::WriteFile(dir.Append(L"child.txt"), "data");
|
||||
ASSERT_TRUE(base::DirectoryExists(dir));
|
||||
|
||||
{
|
||||
ScopedDirectoryDeleter deleter(dir);
|
||||
}
|
||||
|
||||
EXPECT_FALSE(base::DirectoryExists(dir));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedDirectoryDeleterReleasePreventsDeletion) {
|
||||
base::FilePath dir = temp_dir_.GetPath().Append(L"dir_to_keep");
|
||||
ASSERT_TRUE(base::CreateDirectory(dir));
|
||||
base::WriteFile(dir.Append(L"child.txt"), "data");
|
||||
|
||||
{
|
||||
ScopedDirectoryDeleter deleter(dir);
|
||||
deleter.Release();
|
||||
}
|
||||
|
||||
EXPECT_TRUE(base::DirectoryExists(dir));
|
||||
EXPECT_TRUE(base::PathExists(dir.Append(L"child.txt")));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedDirectoryDeleterDeletesNestedContents) {
|
||||
base::FilePath dir = temp_dir_.GetPath().Append(L"nested_dir");
|
||||
base::FilePath sub = dir.Append(L"sub");
|
||||
ASSERT_TRUE(base::CreateDirectory(sub));
|
||||
base::WriteFile(sub.Append(L"deep.txt"), "deep");
|
||||
|
||||
{
|
||||
ScopedDirectoryDeleter deleter(dir);
|
||||
}
|
||||
|
||||
EXPECT_FALSE(base::DirectoryExists(dir));
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedDirectoryDeleterEmptyPathIsNoOp) {
|
||||
{
|
||||
ScopedDirectoryDeleter deleter{base::FilePath()};
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(InstallerFileOpsTest, ScopedDirectoryDeleterNonexistentPathIsNoOp) {
|
||||
base::FilePath missing = temp_dir_.GetPath().Append(L"no_such_dir");
|
||||
ASSERT_FALSE(base::DirectoryExists(missing));
|
||||
|
||||
{
|
||||
ScopedDirectoryDeleter deleter(missing);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace cef_installer
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,751 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_launch_state.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <tuple>
|
||||
|
||||
#include "base/files/file_enumerator.h"
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/json/json_reader.h"
|
||||
#include "base/json/json_writer.h"
|
||||
#include "base/no_destructor.h"
|
||||
#include "base/process/process.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/string_util.h"
|
||||
#include "base/strings/utf_string_conversions.h"
|
||||
#include "base/values.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_ops.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_lock.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace {
|
||||
|
||||
uint64_t FileTimeToTicks(FILETIME ft) {
|
||||
ULARGE_INTEGER li;
|
||||
li.LowPart = ft.dwLowDateTime;
|
||||
li.HighPart = ft.dwHighDateTime;
|
||||
return li.QuadPart;
|
||||
}
|
||||
|
||||
// Process-global active launch state path. See header for the threading
|
||||
// contract: set once before RunWinMain spawns threads, read-only thereafter.
|
||||
// Thread creation provides the happens-before edge, so no lock is needed.
|
||||
base::FilePath& ActiveLaunchStatePath() {
|
||||
static base::NoDestructor<base::FilePath> path;
|
||||
return *path;
|
||||
}
|
||||
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
base::NoDestructor<RetentionCanonicalObservationHookForTesting>
|
||||
g_retention_canonical_observation_hook;
|
||||
std::optional<uint64_t> g_launch_state_gc_time_for_testing;
|
||||
#endif
|
||||
|
||||
uint64_t GetLaunchStateGcCurrentTime() {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
if (g_launch_state_gc_time_for_testing.has_value()) {
|
||||
return *g_launch_state_gc_time_for_testing;
|
||||
}
|
||||
#endif
|
||||
FILETIME now = {};
|
||||
::GetSystemTimePreciseAsFileTime(&now);
|
||||
return FileTimeToTicks(now);
|
||||
}
|
||||
|
||||
std::optional<base::DictValue> ReadJsonWithIntegrity(
|
||||
const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action) {
|
||||
if (IsReparsePoint(path)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::optional<int64_t> size = base::GetFileSize(path);
|
||||
if (!size || *size > kMaxLaunchStateFileSize) {
|
||||
return std::nullopt;
|
||||
}
|
||||
std::string content;
|
||||
IntegrityResult integrity = ReadFileWithIntegrity(
|
||||
path, &content, mismatch_action, kMaxLaunchStateFileSize);
|
||||
if (integrity != IntegrityResult::kSuccess) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return base::JSONReader::ReadDict(content, base::JSON_PARSE_RFC);
|
||||
}
|
||||
|
||||
std::optional<LivenessRecord> ParseLivenessDict(const base::DictValue& parsed) {
|
||||
const std::string* appid = parsed.FindString("appid");
|
||||
const std::string* platform = parsed.FindString("platform");
|
||||
const std::string* last_launch = parsed.FindString("last_launch");
|
||||
uint64_t timestamp = 0;
|
||||
if (!appid || appid->empty() || appid->size() > kMaxUuidLength || !platform ||
|
||||
platform->empty() || platform->size() > kMaxPlatformLength ||
|
||||
!last_launch || !base::StringToUint64(*last_launch, ×tamp) ||
|
||||
timestamp == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return LivenessRecord{*appid, *platform, timestamp};
|
||||
}
|
||||
|
||||
std::optional<LaunchState> ParseLaunchStateDict(const base::DictValue& parsed) {
|
||||
const std::string* appid = parsed.FindString("appid");
|
||||
std::optional<int> pid_val = parsed.FindInt("pid");
|
||||
const std::string* pid_start_str = parsed.FindString("pid_start_time");
|
||||
const std::string* last_update_str = parsed.FindString("last_update");
|
||||
std::optional<int> failures = parsed.FindInt("consecutive_failures");
|
||||
std::optional<bool> running = parsed.FindBool("running");
|
||||
std::optional<bool> confirmed = parsed.FindBool("confirmed");
|
||||
if (!appid || !pid_val || !pid_start_str || !last_update_str || !failures ||
|
||||
!running || !confirmed || appid->size() > kMaxUuidLength) {
|
||||
return std::nullopt;
|
||||
}
|
||||
uint64_t pid_start_time = 0;
|
||||
uint64_t last_update = 0;
|
||||
if (!base::StringToUint64(*pid_start_str, &pid_start_time) ||
|
||||
!base::StringToUint64(*last_update_str, &last_update) ||
|
||||
last_update == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string* ver = parsed.FindString("version");
|
||||
const std::string* plat = parsed.FindString("platform");
|
||||
if (!ver || ver->empty() || ver->size() > kMaxVersionLength || !plat ||
|
||||
plat->empty() || plat->size() > kMaxPlatformLength) {
|
||||
return std::nullopt;
|
||||
}
|
||||
LaunchState state;
|
||||
state.appid = *appid;
|
||||
state.pid = static_cast<uint32_t>(*pid_val);
|
||||
state.pid_start_time = pid_start_time;
|
||||
state.consecutive_failures = *failures;
|
||||
state.running = *running;
|
||||
state.confirmed = *confirmed;
|
||||
state.version = *ver;
|
||||
state.platform = *plat;
|
||||
state.last_update = last_update;
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::wstring GetAppidHash(const std::string& appid) {
|
||||
return base::UTF8ToWide(internal::ComputeSha1HexPrefix(appid));
|
||||
}
|
||||
|
||||
bool IsProcessAlive(uint32_t pid, uint64_t pid_start_time) {
|
||||
base::Process process = base::Process::Open(pid);
|
||||
if (!process.IsValid()) {
|
||||
return false;
|
||||
}
|
||||
if (!process.IsRunning()) {
|
||||
return false;
|
||||
}
|
||||
return FileTimeToTicks(process.CreationTime().ToFileTime()) == pid_start_time;
|
||||
}
|
||||
|
||||
uint64_t GetCurrentPidStartTime() {
|
||||
return FileTimeToTicks(base::Process::Current().CreationTime().ToFileTime());
|
||||
}
|
||||
|
||||
uint64_t GetCurrentWallTime() {
|
||||
FILETIME now = {};
|
||||
::GetSystemTimePreciseAsFileTime(&now);
|
||||
return FileTimeToTicks(now);
|
||||
}
|
||||
|
||||
LaunchStateGcAge ClassifyLaunchStateGcAge(uint64_t timestamp) {
|
||||
if (timestamp == 0) {
|
||||
return LaunchStateGcAge::kInvalid;
|
||||
}
|
||||
const uint64_t now = GetLaunchStateGcCurrentTime();
|
||||
if (now == 0) {
|
||||
return LaunchStateGcAge::kUnavailable;
|
||||
}
|
||||
if (timestamp > now) {
|
||||
return LaunchStateGcAge::kFresh;
|
||||
}
|
||||
return now - timestamp >= kLaunchStateGcMaxAge ? LaunchStateGcAge::kExpired
|
||||
: LaunchStateGcAge::kFresh;
|
||||
}
|
||||
|
||||
void SetLaunchStateGcTimeForTesting(std::optional<uint64_t> now) {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
g_launch_state_gc_time_for_testing = now;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::optional<uint64_t> GetCurrentBootTime() {
|
||||
FILETIME now_file_time = {};
|
||||
::GetSystemTimePreciseAsFileTime(&now_file_time);
|
||||
const uint64_t now = FileTimeToTicks(now_file_time);
|
||||
constexpr uint64_t kFileTimeTicksPerMillisecond = 10000;
|
||||
const uint64_t uptime = ::GetTickCount64();
|
||||
if (uptime > now / kFileTimeTicksPerMillisecond) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return now - uptime * kFileTimeTicksPerMillisecond;
|
||||
}
|
||||
|
||||
LaunchStateAssessment AssessLaunchState(
|
||||
LaunchHealthMode mode,
|
||||
const LaunchState& state,
|
||||
std::optional<uint64_t> current_boot_time,
|
||||
std::optional<bool> process_alive) {
|
||||
LaunchStateAssessment result;
|
||||
result.consecutive_failures = state.consecutive_failures;
|
||||
if (mode == LaunchHealthMode::kOff) {
|
||||
return result;
|
||||
}
|
||||
if (!state.running) {
|
||||
result.status = state.confirmed && state.consecutive_failures == 0
|
||||
? LaunchStateStatus::kConfirmed
|
||||
: LaunchStateStatus::kNeutral;
|
||||
return result;
|
||||
}
|
||||
if (!current_boot_time || state.pid_start_time < *current_boot_time ||
|
||||
!process_alive) {
|
||||
return result;
|
||||
}
|
||||
if (*process_alive) {
|
||||
result.status = LaunchStateStatus::kRunning;
|
||||
return result;
|
||||
}
|
||||
result.status = LaunchStateStatus::kFailure;
|
||||
result.consecutive_failures = state.consecutive_failures + 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
bool ApplyLaunchExit(LaunchHealthMode mode,
|
||||
bool normal_exit,
|
||||
bool neutral_exit,
|
||||
LaunchState* state) {
|
||||
if (!state || mode == LaunchHealthMode::kOff || !state->running) {
|
||||
return false;
|
||||
}
|
||||
if (mode == LaunchHealthMode::kExplicit) {
|
||||
state->running = false;
|
||||
state->confirmed = false;
|
||||
return true;
|
||||
}
|
||||
if (normal_exit) {
|
||||
state->consecutive_failures = 0;
|
||||
state->running = false;
|
||||
state->confirmed = true;
|
||||
return true;
|
||||
}
|
||||
if (neutral_exit) {
|
||||
state->running = false;
|
||||
state->confirmed = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
AppLaunchHealthEvaluation EvaluateAppLaunchHealth(
|
||||
const base::FilePath& install_dir,
|
||||
const std::string& appid,
|
||||
LaunchHealthMode mode,
|
||||
int failure_threshold) {
|
||||
AppLaunchHealthEvaluation result;
|
||||
if (mode == LaunchHealthMode::kOff || install_dir.empty() || appid.empty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.states = ScanLaunchStates(install_dir, GetAppidHash(appid));
|
||||
const std::optional<uint64_t> boot_time = GetCurrentBootTime();
|
||||
FILETIME now_file_time = {};
|
||||
::GetSystemTimePreciseAsFileTime(&now_file_time);
|
||||
const uint64_t now = FileTimeToTicks(now_file_time);
|
||||
for (const auto& [version, state] : result.states) {
|
||||
std::optional<bool> process_alive;
|
||||
if (boot_time && state.pid_start_time >= *boot_time &&
|
||||
state.pid_start_time <= now) {
|
||||
process_alive = IsProcessAlive(state.pid, state.pid_start_time);
|
||||
}
|
||||
LaunchStateAssessment assessment =
|
||||
AssessLaunchState(mode, state, boot_time, process_alive);
|
||||
result.assessments.emplace(version, assessment);
|
||||
if (assessment.status == LaunchStateStatus::kFailure &&
|
||||
assessment.consecutive_failures >= failure_threshold) {
|
||||
result.disqualified_versions.insert(version);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void SetActiveLaunchStatePath(const base::FilePath& path) {
|
||||
// Set-once contract: called during single-threaded startup before
|
||||
// RunWinMain spawns any threads. Never mutate after startup.
|
||||
ActiveLaunchStatePath() = path;
|
||||
}
|
||||
|
||||
base::FilePath GetActiveLaunchStatePath() {
|
||||
return ActiveLaunchStatePath();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Install-dir-level API (.launch/ directory)
|
||||
// ============================================================================
|
||||
|
||||
base::FilePath GetLaunchStateDir(const base::FilePath& install_dir) {
|
||||
return install_dir.Append(kLaunchStateDirName);
|
||||
}
|
||||
|
||||
base::FilePath GetInstallDirLaunchStatePath(const base::FilePath& install_dir,
|
||||
const std::wstring& appid_hash,
|
||||
const Version& version,
|
||||
const std::string& platform) {
|
||||
std::wstring filename = appid_hash + L"_" +
|
||||
base::UTF8ToWide(version.ToString()) + L"_" +
|
||||
base::UTF8ToWide(platform);
|
||||
return GetLaunchStateDir(install_dir).Append(filename);
|
||||
}
|
||||
|
||||
base::FilePath GetInstallDirLivenessPath(const base::FilePath& install_dir,
|
||||
const std::wstring& appid_hash,
|
||||
const std::string& platform) {
|
||||
std::wstring filename = appid_hash + L"_" + base::UTF8ToWide(platform);
|
||||
return GetLaunchStateDir(install_dir).Append(filename);
|
||||
}
|
||||
|
||||
std::optional<LivenessRecord> ReadLivenessPath(
|
||||
const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action) {
|
||||
std::optional<base::DictValue> parsed =
|
||||
ReadJsonWithIntegrity(path, mismatch_action);
|
||||
if (!parsed) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return ParseLivenessDict(*parsed);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool WriteLivenessPathImpl(const base::FilePath& path,
|
||||
const LivenessRecord& record) {
|
||||
if (record.appid.empty() || record.appid.size() > kMaxUuidLength ||
|
||||
record.platform.empty() || record.platform.size() > kMaxPlatformLength) {
|
||||
return false;
|
||||
}
|
||||
if (record.last_launch == 0) {
|
||||
return false;
|
||||
}
|
||||
base::FilePath dir = path.DirName();
|
||||
if (IsReparsePoint(dir) ||
|
||||
(!base::DirectoryExists(dir) &&
|
||||
(!VerifySafeDirectoryPath(dir) || !base::CreateDirectory(dir))) ||
|
||||
!VerifySafeFilePath(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
base::DictValue dict;
|
||||
dict.Set("appid", record.appid);
|
||||
dict.Set("platform", record.platform);
|
||||
dict.Set("last_launch", base::NumberToString(record.last_launch));
|
||||
std::string json;
|
||||
return base::JSONWriter::Write(dict, &json) &&
|
||||
WriteFileWithIntegrity(path, json);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool WriteLivenessPath(const base::FilePath& path,
|
||||
const LivenessRecord& record) {
|
||||
return WriteLivenessPathImpl(path, record);
|
||||
}
|
||||
|
||||
bool RefreshLivenessPath(const base::FilePath& path,
|
||||
const std::string& appid,
|
||||
const std::string& platform,
|
||||
uint64_t now) {
|
||||
if (now == 0) {
|
||||
return false;
|
||||
}
|
||||
std::optional<LivenessRecord> current = ReadLivenessPath(path);
|
||||
if (current && current->appid == appid && current->platform == platform &&
|
||||
current->last_launch <= now &&
|
||||
now - current->last_launch < kLivenessRefreshIntervalFileTimeTicks) {
|
||||
return true;
|
||||
}
|
||||
return WriteLivenessPathImpl(path, LivenessRecord{appid, platform, now});
|
||||
}
|
||||
|
||||
std::optional<LaunchState> ReadLaunchStatePath(
|
||||
const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action) {
|
||||
if (IsReparsePoint(path)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<base::DictValue> parsed =
|
||||
ReadJsonWithIntegrity(path, mismatch_action);
|
||||
if (!parsed) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return ParseLaunchStateDict(*parsed);
|
||||
}
|
||||
|
||||
std::optional<LaunchRecordSnapshot> ReadLaunchRecordSnapshot(
|
||||
const base::FilePath& path,
|
||||
const base::FilePath& expected_resolved_parent) {
|
||||
if (expected_resolved_parent.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (IsReparsePoint(path)) {
|
||||
LaunchRecordSnapshot snapshot;
|
||||
snapshot.path = path;
|
||||
snapshot.resolved_parent_path = expected_resolved_parent;
|
||||
snapshot.reparse_point = true;
|
||||
return snapshot;
|
||||
}
|
||||
std::string content;
|
||||
std::string raw_content;
|
||||
base::FilePath resolved_path;
|
||||
bool too_large = false;
|
||||
IntegrityResult integrity = ReadFileWithIntegrity(
|
||||
path, &content, IntegrityMismatchAction::kPreserve,
|
||||
kMaxLaunchStateFileSize, &resolved_path, &raw_content, &too_large);
|
||||
const bool bounded_raw_invalid =
|
||||
integrity == IntegrityResult::kReadError && !raw_content.empty();
|
||||
if ((!too_large && !bounded_raw_invalid &&
|
||||
integrity != IntegrityResult::kSuccess &&
|
||||
integrity != IntegrityResult::kSuccessNoFooter &&
|
||||
integrity != IntegrityResult::kIntegrityMismatch) ||
|
||||
resolved_path.empty() ||
|
||||
!base::FilePath::CompareEqualIgnoreCase(
|
||||
resolved_path.DirName().value(), expected_resolved_parent.value())) {
|
||||
return std::nullopt;
|
||||
}
|
||||
LaunchRecordSnapshot snapshot;
|
||||
snapshot.path = path;
|
||||
snapshot.resolved_parent_path = expected_resolved_parent;
|
||||
snapshot.content = std::move(content);
|
||||
snapshot.raw_content = std::move(raw_content);
|
||||
snapshot.oversized = too_large;
|
||||
if (too_large) {
|
||||
return snapshot;
|
||||
}
|
||||
if (integrity != IntegrityResult::kSuccess) {
|
||||
return snapshot;
|
||||
}
|
||||
std::optional<base::DictValue> parsed =
|
||||
base::JSONReader::ReadDict(snapshot.content, base::JSON_PARSE_RFC);
|
||||
if (!parsed) {
|
||||
return snapshot;
|
||||
}
|
||||
if (std::optional<LaunchState> health = ParseLaunchStateDict(*parsed)) {
|
||||
snapshot.kind = LaunchRecordKind::kHealth;
|
||||
snapshot.health = std::move(*health);
|
||||
return snapshot;
|
||||
}
|
||||
if (std::optional<LivenessRecord> liveness = ParseLivenessDict(*parsed)) {
|
||||
snapshot.kind = LaunchRecordKind::kLiveness;
|
||||
snapshot.liveness = std::move(*liveness);
|
||||
return snapshot;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
RetentionEvidenceSnapshot CollectRetentionEvidenceSnapshot(
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<AppEntry>& registrations) {
|
||||
RetentionEvidenceSnapshot snapshot;
|
||||
RetentionEvidenceMap& result = snapshot.evidence;
|
||||
for (const auto& app : registrations) {
|
||||
result[{app.uuid, app.platform}] = {};
|
||||
}
|
||||
|
||||
base::FilePath launch_dir = GetLaunchStateDir(install_dir);
|
||||
if (!base::DirectoryExists(launch_dir)) {
|
||||
return snapshot;
|
||||
}
|
||||
if (IsReparsePoint(launch_dir)) {
|
||||
for (auto& [key, evidence] : result) {
|
||||
evidence.unknown = true;
|
||||
evidence.diagnostic = "unsafe_launch_directory";
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
std::optional<base::FilePath> resolved_launch_dir =
|
||||
GetSafeDirectoryResolvedPath(launch_dir);
|
||||
if (!resolved_launch_dir) {
|
||||
for (auto& [key, evidence] : result) {
|
||||
evidence.unknown = true;
|
||||
evidence.diagnostic = "unsafe_launch_directory";
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
std::set<base::FilePath> canonical_liveness_paths;
|
||||
auto observe_path = [&](const base::FilePath& path) {
|
||||
if (!base::PathExists(path)) {
|
||||
return;
|
||||
}
|
||||
const std::wstring filename = path.BaseName().value();
|
||||
const bool reparse = IsReparsePoint(path);
|
||||
std::string content;
|
||||
base::FilePath resolved_path;
|
||||
IntegrityResult integrity = IntegrityResult::kReadError;
|
||||
if (!reparse) {
|
||||
std::optional<int64_t> size = base::GetFileSize(path);
|
||||
if (size && *size <= kMaxLaunchStateFileSize) {
|
||||
integrity = ReadFileWithIntegrity(
|
||||
path, &content, IntegrityMismatchAction::kPreserve,
|
||||
kMaxLaunchStateFileSize, &resolved_path);
|
||||
}
|
||||
}
|
||||
const bool resolved_parent_matches =
|
||||
!resolved_path.empty() &&
|
||||
base::FilePath::CompareEqualIgnoreCase(resolved_path.DirName().value(),
|
||||
resolved_launch_dir->value());
|
||||
const bool readable =
|
||||
resolved_parent_matches && integrity == IntegrityResult::kSuccess;
|
||||
std::optional<base::DictValue> parsed =
|
||||
readable ? base::JSONReader::ReadDict(content, base::JSON_PARSE_RFC)
|
||||
: std::nullopt;
|
||||
std::optional<LivenessRecord> liveness =
|
||||
parsed ? ParseLivenessDict(*parsed) : std::nullopt;
|
||||
std::optional<LaunchState> health =
|
||||
parsed ? ParseLaunchStateDict(*parsed) : std::nullopt;
|
||||
|
||||
for (const auto& app : registrations) {
|
||||
const std::wstring hash = GetAppidHash(app.uuid);
|
||||
const std::wstring prefix = hash + L"_";
|
||||
const std::wstring platform = base::UTF8ToWide(app.platform);
|
||||
const base::FilePath canonical_liveness =
|
||||
GetInstallDirLivenessPath(install_dir, hash, app.platform);
|
||||
const bool potential =
|
||||
path == canonical_liveness ||
|
||||
(filename.starts_with(prefix) && filename.ends_with(L"_" + platform));
|
||||
const bool liveness_identity = liveness && liveness->appid == app.uuid &&
|
||||
liveness->platform == app.platform;
|
||||
const bool health_identity = health && health->appid == app.uuid &&
|
||||
health->platform == app.platform;
|
||||
if (!potential && !liveness_identity && !health_identity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
RetentionEvidence& evidence = result[{app.uuid, app.platform}];
|
||||
if (reparse) {
|
||||
evidence.unknown = true;
|
||||
evidence.diagnostic = "invalid_or_noncanonical_evidence";
|
||||
continue;
|
||||
}
|
||||
|
||||
uint64_t timestamp = 0;
|
||||
RetentionEvidenceKind kind = RetentionEvidenceKind::kNone;
|
||||
bool canonical = false;
|
||||
if (liveness_identity) {
|
||||
timestamp = liveness->last_launch;
|
||||
kind = RetentionEvidenceKind::kLiveness;
|
||||
canonical = path == canonical_liveness;
|
||||
} else if (health_identity) {
|
||||
Version version = Version::Parse(health->version);
|
||||
if (version.IsValid()) {
|
||||
canonical = path == GetInstallDirLaunchStatePath(
|
||||
install_dir, hash, version, app.platform);
|
||||
}
|
||||
timestamp = health->pid_start_time;
|
||||
kind = RetentionEvidenceKind::kHealthSentinel;
|
||||
}
|
||||
|
||||
if (!canonical || kind == RetentionEvidenceKind::kNone) {
|
||||
evidence.unknown = true;
|
||||
evidence.diagnostic = "invalid_or_noncanonical_evidence";
|
||||
continue;
|
||||
}
|
||||
if (readable) {
|
||||
snapshot.files.push_back({{app.uuid, app.platform},
|
||||
path,
|
||||
*resolved_launch_dir,
|
||||
content,
|
||||
integrity == IntegrityResult::kSuccess});
|
||||
}
|
||||
if (timestamp > evidence.timestamp) {
|
||||
evidence.timestamp = timestamp;
|
||||
evidence.kind = kind;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Read each registration's canonical liveness path directly. This read is
|
||||
// that registration's launch-intent ordering point; a later directory scan
|
||||
// must not reinterpret a replacement as pre-cutoff evidence.
|
||||
for (const auto& app : registrations) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir, GetAppidHash(app.uuid), app.platform);
|
||||
canonical_liveness_paths.insert(path);
|
||||
observe_path(path);
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
if (*g_retention_canonical_observation_hook) {
|
||||
g_retention_canonical_observation_hook->Run(path);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
base::FileEnumerator enumerator(launch_dir, /*recursive=*/false,
|
||||
base::FileEnumerator::FILES);
|
||||
for (base::FilePath path = enumerator.Next(); !path.empty();
|
||||
path = enumerator.Next()) {
|
||||
if (!canonical_liveness_paths.contains(path)) {
|
||||
observe_path(path);
|
||||
}
|
||||
}
|
||||
std::sort(snapshot.files.begin(), snapshot.files.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
return std::tie(a.key, a.path) < std::tie(b.key, b.path);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
void SetRetentionCanonicalObservationHookForTesting(
|
||||
RetentionCanonicalObservationHookForTesting callback) {
|
||||
#if !(defined(OFFICIAL_BUILD) && defined(NDEBUG))
|
||||
*g_retention_canonical_observation_hook = std::move(callback);
|
||||
#endif
|
||||
}
|
||||
|
||||
RetentionEvidenceMap CollectRetentionEvidence(
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<AppEntry>& registrations) {
|
||||
return CollectRetentionEvidenceSnapshot(install_dir, registrations).evidence;
|
||||
}
|
||||
|
||||
std::set<VersionKey> CollectConfirmedVersionProtection(
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<AppEntry>& registrations) {
|
||||
std::set<VersionKey> result;
|
||||
std::set<RetentionRegistrationKey> registered;
|
||||
for (const auto& app : registrations) {
|
||||
registered.insert({app.uuid, app.platform});
|
||||
}
|
||||
base::FilePath launch_dir = GetLaunchStateDir(install_dir);
|
||||
if (!base::DirectoryExists(launch_dir) || IsReparsePoint(launch_dir)) {
|
||||
return result;
|
||||
}
|
||||
base::FileEnumerator enumerator(launch_dir, /*recursive=*/false,
|
||||
base::FileEnumerator::FILES);
|
||||
for (base::FilePath path = enumerator.Next(); !path.empty();
|
||||
path = enumerator.Next()) {
|
||||
if (IsReparsePoint(path)) {
|
||||
continue;
|
||||
}
|
||||
std::optional<LaunchState> health =
|
||||
ReadLaunchStatePath(path, IntegrityMismatchAction::kPreserve);
|
||||
if (!health || health->running || !health->confirmed ||
|
||||
health->consecutive_failures != 0 ||
|
||||
!registered.contains({health->appid, health->platform})) {
|
||||
continue;
|
||||
}
|
||||
Version version = Version::Parse(health->version);
|
||||
if (!version.IsValid() ||
|
||||
path != GetInstallDirLaunchStatePath(install_dir,
|
||||
GetAppidHash(health->appid),
|
||||
version, health->platform)) {
|
||||
continue;
|
||||
}
|
||||
result.insert({version, health->platform});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
bool WriteLaunchStatePathImpl(const base::FilePath& path,
|
||||
const LaunchState& state) {
|
||||
const uint64_t last_update = GetLaunchStateGcCurrentTime();
|
||||
if (last_update == 0) {
|
||||
return false;
|
||||
}
|
||||
base::FilePath dir = path.DirName();
|
||||
if (IsReparsePoint(dir)) {
|
||||
return false;
|
||||
}
|
||||
if (!base::DirectoryExists(dir)) {
|
||||
if (!VerifySafeDirectoryPath(dir)) {
|
||||
return false;
|
||||
}
|
||||
if (!base::CreateDirectory(dir)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!VerifySafeFilePath(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (state.appid.empty() || state.version.empty() || state.platform.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
base::DictValue dict;
|
||||
dict.Set("appid", state.appid);
|
||||
dict.Set("pid", static_cast<int>(state.pid));
|
||||
dict.Set("pid_start_time", base::NumberToString(state.pid_start_time));
|
||||
dict.Set("consecutive_failures", state.consecutive_failures);
|
||||
dict.Set("running", state.running);
|
||||
dict.Set("confirmed", state.confirmed);
|
||||
dict.Set("version", state.version);
|
||||
dict.Set("platform", state.platform);
|
||||
dict.Set("last_update", base::NumberToString(last_update));
|
||||
|
||||
std::string json;
|
||||
if (!base::JSONWriter::Write(dict, &json)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return WriteFileWithIntegrity(path, json);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool WriteLaunchStatePath(const base::FilePath& path,
|
||||
const LaunchState& state) {
|
||||
return WriteLaunchStatePathImpl(path, state);
|
||||
}
|
||||
|
||||
std::map<Version, LaunchState> ScanLaunchStates(
|
||||
const base::FilePath& install_dir,
|
||||
const std::wstring& appid_hash) {
|
||||
std::map<Version, LaunchState> result;
|
||||
|
||||
base::FilePath launch_dir = GetLaunchStateDir(install_dir);
|
||||
if (!base::DirectoryExists(launch_dir)) {
|
||||
return result;
|
||||
}
|
||||
if (IsReparsePoint(launch_dir)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string current_platform = GetCurrentPlatform();
|
||||
|
||||
base::FileEnumerator enumerator(launch_dir, /*recursive=*/false,
|
||||
base::FileEnumerator::FILES,
|
||||
appid_hash + L"_*");
|
||||
|
||||
for (base::FilePath path = enumerator.Next(); !path.empty();
|
||||
path = enumerator.Next()) {
|
||||
auto ls = ReadLaunchStatePath(path);
|
||||
if (!ls) {
|
||||
continue;
|
||||
}
|
||||
if (ls->platform != current_platform) {
|
||||
continue;
|
||||
}
|
||||
Version ver = Version::Parse(ls->version);
|
||||
if (!ver.IsValid()) {
|
||||
continue;
|
||||
}
|
||||
result.emplace(ver, *ls);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#ifndef CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_LAUNCH_STATE_H_
|
||||
#define CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_LAUNCH_STATE_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/files/file_path.h"
|
||||
#include "base/functional/callback_forward.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_config.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_retention.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_version.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
struct LaunchState {
|
||||
std::string appid;
|
||||
uint32_t pid = 0;
|
||||
uint64_t pid_start_time = 0;
|
||||
int consecutive_failures = 0;
|
||||
bool running = false;
|
||||
// True only after explicit confirmation or a successful exit-code-mode
|
||||
// launch.
|
||||
bool confirmed = false;
|
||||
std::string version;
|
||||
std::string platform;
|
||||
// Writer-owned publication time used only for launch-record garbage
|
||||
// collection. Callers cannot preserve or choose this value.
|
||||
uint64_t last_update = 0;
|
||||
};
|
||||
|
||||
struct LivenessRecord {
|
||||
std::string appid;
|
||||
std::string platform;
|
||||
uint64_t last_launch = 0;
|
||||
};
|
||||
|
||||
enum class LaunchRecordKind {
|
||||
kInvalid,
|
||||
kHealth,
|
||||
kLiveness,
|
||||
};
|
||||
|
||||
// One bounded, no-follow observation of a launch record. Valid records expose
|
||||
// their integrity-verified payload; all conclusive observations expose the
|
||||
// exact raw publication for race-safe repair.
|
||||
struct LaunchRecordSnapshot {
|
||||
LaunchRecordKind kind = LaunchRecordKind::kInvalid;
|
||||
LaunchState health;
|
||||
LivenessRecord liveness;
|
||||
base::FilePath path;
|
||||
base::FilePath resolved_parent_path;
|
||||
std::string content;
|
||||
std::string raw_content;
|
||||
bool oversized = false;
|
||||
bool reparse_point = false;
|
||||
};
|
||||
|
||||
enum class LaunchStateGcAge {
|
||||
kInvalid,
|
||||
kUnavailable,
|
||||
kFresh,
|
||||
kExpired,
|
||||
};
|
||||
|
||||
// Exact canonical evidence file observed while building a retention plan.
|
||||
// |content| is the integrity-verified payload without the integrity footer.
|
||||
// |resolved_parent_path| binds later compare-before-delete cleanup to the
|
||||
// observed directory location after logical publication.
|
||||
struct RetentionEvidenceFileSnapshot {
|
||||
RetentionRegistrationKey key;
|
||||
base::FilePath path;
|
||||
base::FilePath resolved_parent_path;
|
||||
std::string content;
|
||||
bool integrity_protected = false;
|
||||
};
|
||||
|
||||
struct RetentionEvidenceSnapshot {
|
||||
RetentionEvidenceMap evidence;
|
||||
std::vector<RetentionEvidenceFileSnapshot> files;
|
||||
};
|
||||
|
||||
enum class LaunchStateStatus {
|
||||
kConfirmed,
|
||||
kNeutral,
|
||||
kRunning,
|
||||
kFailure,
|
||||
kIndeterminate,
|
||||
};
|
||||
|
||||
struct LaunchStateAssessment {
|
||||
LaunchStateStatus status = LaunchStateStatus::kIndeterminate;
|
||||
int consecutive_failures = 0;
|
||||
};
|
||||
|
||||
// Selection-policy-free evaluation for one app and the current platform.
|
||||
// Off mode returns an empty result without enumerating .launch/. Callers pass
|
||||
// only |disqualified_versions| to the shared selector and may use |states| and
|
||||
// |assessments| to seed the selected version's next sentinel.
|
||||
struct AppLaunchHealthEvaluation {
|
||||
std::map<Version, LaunchState> states;
|
||||
std::map<Version, LaunchStateAssessment> assessments;
|
||||
std::set<Version> disqualified_versions;
|
||||
};
|
||||
|
||||
// Estimate the current Windows boot boundary in FILETIME ticks using the
|
||||
// system wall clock minus GetTickCount64(). Returns nullopt if the conversion
|
||||
// is internally inconsistent. Clock changes can make the estimate conservative
|
||||
// (indeterminate) but never turn a known pre-boot record into a confirmed run.
|
||||
std::optional<uint64_t> GetCurrentBootTime();
|
||||
|
||||
// Pure classification for an existing record. |process_alive| is consulted
|
||||
// only for a running record from the current boot. A missing boot boundary is
|
||||
// indeterminate, and a start time before the boundary never counts as failure.
|
||||
LaunchStateAssessment AssessLaunchState(
|
||||
LaunchHealthMode mode,
|
||||
const LaunchState& state,
|
||||
std::optional<uint64_t> current_boot_time,
|
||||
std::optional<bool> process_alive);
|
||||
|
||||
// Apply a completed RunWinMain return to |state|. Returns true when the state
|
||||
// should be persisted. Explicit mode treats every ordinary return as neutral;
|
||||
// exit-code mode confirms zero, preserves neutral codes, and leaves abnormal
|
||||
// exits running for failure classification on the next launch.
|
||||
bool ApplyLaunchExit(LaunchHealthMode mode,
|
||||
bool normal_exit,
|
||||
bool neutral_exit,
|
||||
LaunchState* state);
|
||||
|
||||
AppLaunchHealthEvaluation EvaluateAppLaunchHealth(
|
||||
const base::FilePath& install_dir,
|
||||
const std::string& appid,
|
||||
LaunchHealthMode mode,
|
||||
int failure_threshold);
|
||||
|
||||
// Key for identifying a confirmed version (version + platform).
|
||||
// Defined here to avoid depending on VersionKey from
|
||||
// installer_version_resolver.
|
||||
struct ConfirmedVersionKey {
|
||||
Version version;
|
||||
std::string platform;
|
||||
|
||||
bool operator<(const ConfirmedVersionKey& other) const {
|
||||
if (version != other.version) {
|
||||
return version < other.version;
|
||||
}
|
||||
return platform < other.platform;
|
||||
}
|
||||
|
||||
bool operator==(const ConfirmedVersionKey& other) const {
|
||||
return version == other.version && platform == other.platform;
|
||||
}
|
||||
};
|
||||
|
||||
// Compute the appid hash: first 16 hex chars of SHA-1(appid).
|
||||
std::wstring GetAppidHash(const std::string& appid);
|
||||
|
||||
// Check if a process with the given PID and creation time is still alive.
|
||||
// Uses OpenProcess + GetProcessTimes to guard against PID reuse.
|
||||
bool IsProcessAlive(uint32_t pid, uint64_t pid_start_time);
|
||||
|
||||
// Return the creation time of the current process as a uint64 FILETIME.
|
||||
uint64_t GetCurrentPidStartTime();
|
||||
uint64_t GetCurrentWallTime();
|
||||
|
||||
// Classify a nonzero CRC-verified content timestamp against the dedicated
|
||||
// launch-GC clock. Future timestamps are fresh and unavailable time preserves
|
||||
// the record.
|
||||
LaunchStateGcAge ClassifyLaunchStateGcAge(uint64_t timestamp);
|
||||
|
||||
// Process-global test seam shared only by launch publication and launch GC.
|
||||
// Passing nullopt restores the production wall clock. Tests must reset it.
|
||||
void SetLaunchStateGcTimeForTesting(std::optional<uint64_t> now);
|
||||
|
||||
// Returns install_dir/.launch/.
|
||||
base::FilePath GetLaunchStateDir(const base::FilePath& install_dir);
|
||||
|
||||
// Returns install_dir/.launch/<appid_hash>_<version>_<platform>.
|
||||
base::FilePath GetInstallDirLaunchStatePath(const base::FilePath& install_dir,
|
||||
const std::wstring& appid_hash,
|
||||
const Version& version,
|
||||
const std::string& platform);
|
||||
|
||||
// Returns install_dir/.launch/<appid_hash>_<platform>. The version-less
|
||||
// two-segment shape cannot collide with a health sentinel.
|
||||
base::FilePath GetInstallDirLivenessPath(const base::FilePath& install_dir,
|
||||
const std::wstring& appid_hash,
|
||||
const std::string& platform);
|
||||
|
||||
// Read a current-schema liveness record with a valid integrity footer and a
|
||||
// nonzero last_launch. The default preserves integrity mismatches.
|
||||
std::optional<LivenessRecord> ReadLivenessPath(
|
||||
const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action =
|
||||
IntegrityMismatchAction::kPreserve);
|
||||
// Write an integrity-protected liveness record. Zero last_launch is rejected.
|
||||
bool WriteLivenessPath(const base::FilePath& path,
|
||||
const LivenessRecord& record);
|
||||
|
||||
// Refresh only when missing/malformed or at least 30 days old. Future content
|
||||
// timestamps are treated as fresh so wall-clock rollback does not cause a
|
||||
// write on every launch.
|
||||
bool RefreshLivenessPath(const base::FilePath& path,
|
||||
const std::string& appid,
|
||||
const std::string& platform,
|
||||
uint64_t now);
|
||||
|
||||
// Read a current-schema launch state from a full path (install-dir-level
|
||||
// format). A valid integrity footer and nonzero last_update are required.
|
||||
// Includes version and platform fields from the JSON body. The default
|
||||
// preserves an integrity mismatch for read-only callers.
|
||||
std::optional<LaunchState> ReadLaunchStatePath(
|
||||
const base::FilePath& path,
|
||||
IntegrityMismatchAction mismatch_action =
|
||||
IntegrityMismatchAction::kPreserve);
|
||||
|
||||
// Observe exactly one bounded publication. Returns kInvalid for conclusively
|
||||
// corrupt, footerless, schema-invalid, or final-component reparse publication
|
||||
// so a writer can conditionally repair that exact publication. I/O and
|
||||
// path-identity uncertainty returns nullopt. The resolved parent must match
|
||||
// |expected_resolved_parent|.
|
||||
std::optional<LaunchRecordSnapshot> ReadLaunchRecordSnapshot(
|
||||
const base::FilePath& path,
|
||||
const base::FilePath& expected_resolved_parent);
|
||||
|
||||
// Collect the newest canonical health-sentinel pid_start_time or liveness-only
|
||||
// last_launch for each durable registration. This is a strictly read-only
|
||||
// retention snapshot: it never repairs, deletes, creates, or rewrites files.
|
||||
// A malformed, reparse, mismatched, or noncanonical file that could belong to
|
||||
// a registration marks that registration's evidence unknown/protected.
|
||||
RetentionEvidenceMap CollectRetentionEvidence(
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<AppEntry>& registrations);
|
||||
|
||||
// Collect evidence plus the exact canonical file payloads that produced it.
|
||||
// This is side-effect-free and is used by apply for final validation and
|
||||
// compare-before-delete cleanup.
|
||||
RetentionEvidenceSnapshot CollectRetentionEvidenceSnapshot(
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<AppEntry>& registrations);
|
||||
|
||||
using RetentionCanonicalObservationHookForTesting =
|
||||
base::RepeatingCallback<void(const base::FilePath&)>;
|
||||
void SetRetentionCanonicalObservationHookForTesting(
|
||||
RetentionCanonicalObservationHookForTesting callback);
|
||||
|
||||
// Side-effect-free collection of canonical, confirmed health sentinels for
|
||||
// registrations that remain in the supplied durable database snapshot.
|
||||
std::set<VersionKey> CollectConfirmedVersionProtection(
|
||||
const base::FilePath& install_dir,
|
||||
const std::vector<AppEntry>& registrations);
|
||||
|
||||
// Write a launch state file to a full path (install-dir-level format).
|
||||
// Creates the .launch/ directory if it doesn't exist and replaces any
|
||||
// caller-supplied last_update with the current launch-GC clock.
|
||||
bool WriteLaunchStatePath(const base::FilePath& path, const LaunchState& state);
|
||||
|
||||
// Process-global active launch state path.
|
||||
//
|
||||
// Set once by the bootstrap before RunWinMain (during single-threaded
|
||||
// startup), and read thereafter by HandleLaunchSuccess (e.g. on a background
|
||||
// task) to resolve the sentinel file for the current launch. No lock is
|
||||
// needed: the set-once-before-threads contract gives the happens-before edge.
|
||||
|
||||
// Set the active launch state path. Called exactly once by the bootstrap
|
||||
// before RunWinMain spawns any threads.
|
||||
void SetActiveLaunchStatePath(const base::FilePath& path);
|
||||
|
||||
// Return the active launch state path, or an empty path if never set (e.g.
|
||||
// not running under the bootstrap, or a bundled/unchecked path that does not
|
||||
// participate in launch health).
|
||||
base::FilePath GetActiveLaunchStatePath();
|
||||
|
||||
// Scan .launch/<appid_hash>_* files for a specific appid.
|
||||
// Filters to the current platform. Returns map keyed by version.
|
||||
// Returns empty map if .launch/ doesn't exist.
|
||||
std::map<Version, LaunchState> ScanLaunchStates(
|
||||
const base::FilePath& install_dir,
|
||||
const std::wstring& appid_hash);
|
||||
|
||||
} // namespace cef_installer
|
||||
|
||||
#endif // CEF_LIBCEF_DLL_BOOTSTRAP_INSTALLER_INSTALLER_LAUNCH_STATE_H_
|
||||
@@ -0,0 +1,837 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_launch_state.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "base/files/file_util.h"
|
||||
#include "base/files/scoped_temp_dir.h"
|
||||
#include "base/functional/bind.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/utf_string_conversions.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_file_integrity.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_lock.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_paths.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
class LaunchStateTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override { ASSERT_TRUE(temp_dir_.CreateUniqueTempDir()); }
|
||||
void TearDown() override {
|
||||
SetRetentionCanonicalObservationHookForTesting({});
|
||||
SetLaunchStateGcTimeForTesting(std::nullopt);
|
||||
}
|
||||
|
||||
base::FilePath install_dir() { return temp_dir_.GetPath(); }
|
||||
|
||||
base::ScopedTempDir temp_dir_;
|
||||
};
|
||||
|
||||
TEST_F(LaunchStateTest, GetAppidHash_Deterministic) {
|
||||
std::string appid = "A3B9C4D5-E6F7-4A8B-9C0D-E1F2A3B4C5D6";
|
||||
std::wstring hash1 = GetAppidHash(appid);
|
||||
std::wstring hash2 = GetAppidHash(appid);
|
||||
EXPECT_EQ(hash1, hash2);
|
||||
EXPECT_EQ(16u, hash1.size());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, GetAppidHash_DifferentAppids) {
|
||||
std::wstring hash1 = GetAppidHash("A3B9C4D5-E6F7-4A8B-9C0D-E1F2A3B4C5D6");
|
||||
std::wstring hash2 = GetAppidHash("B4C0D5E6-F7A8-5B9C-0D1E-F2A3B4C5D6E7");
|
||||
EXPECT_NE(hash1, hash2);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, IsProcessAlive_CurrentProcess) {
|
||||
uint32_t pid = GetCurrentProcessId();
|
||||
uint64_t start_time = GetCurrentPidStartTime();
|
||||
EXPECT_TRUE(IsProcessAlive(pid, start_time));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, IsProcessAlive_DeadProcess) {
|
||||
EXPECT_FALSE(IsProcessAlive(99999, 1));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, IsProcessAlive_WrongStartTime) {
|
||||
uint32_t pid = GetCurrentProcessId();
|
||||
EXPECT_FALSE(IsProcessAlive(pid, 1));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, AssessLaunchStateModeAndBootMatrix) {
|
||||
LaunchState state;
|
||||
state.running = true;
|
||||
state.pid_start_time = 200;
|
||||
state.consecutive_failures = 2;
|
||||
|
||||
EXPECT_EQ(
|
||||
LaunchStateStatus::kIndeterminate,
|
||||
AssessLaunchState(LaunchHealthMode::kOff, state, 100, false).status);
|
||||
EXPECT_EQ(LaunchStateStatus::kIndeterminate,
|
||||
AssessLaunchState(LaunchHealthMode::kExplicit, state, std::nullopt,
|
||||
std::nullopt)
|
||||
.status);
|
||||
EXPECT_EQ(
|
||||
LaunchStateStatus::kIndeterminate,
|
||||
AssessLaunchState(LaunchHealthMode::kExitCode, state, 201, std::nullopt)
|
||||
.status);
|
||||
EXPECT_EQ(
|
||||
LaunchStateStatus::kRunning,
|
||||
AssessLaunchState(LaunchHealthMode::kExplicit, state, 200, true).status);
|
||||
const LaunchStateAssessment failure =
|
||||
AssessLaunchState(LaunchHealthMode::kExplicit, state, 200, false);
|
||||
EXPECT_EQ(LaunchStateStatus::kFailure, failure.status);
|
||||
EXPECT_EQ(3, failure.consecutive_failures);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, AssessLaunchStateConfirmedAndNeutral) {
|
||||
LaunchState state;
|
||||
state.running = false;
|
||||
state.consecutive_failures = 0;
|
||||
state.confirmed = true;
|
||||
EXPECT_EQ(LaunchStateStatus::kConfirmed,
|
||||
AssessLaunchState(LaunchHealthMode::kExplicit, state, std::nullopt,
|
||||
std::nullopt)
|
||||
.status);
|
||||
state.consecutive_failures = 2;
|
||||
EXPECT_EQ(LaunchStateStatus::kNeutral,
|
||||
AssessLaunchState(LaunchHealthMode::kExitCode, state, std::nullopt,
|
||||
std::nullopt)
|
||||
.status);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ApplyLaunchExitModeMatrix) {
|
||||
LaunchState state;
|
||||
state.running = true;
|
||||
state.consecutive_failures = 2;
|
||||
EXPECT_FALSE(ApplyLaunchExit(LaunchHealthMode::kOff, true, false, &state));
|
||||
EXPECT_TRUE(state.running);
|
||||
|
||||
EXPECT_TRUE(
|
||||
ApplyLaunchExit(LaunchHealthMode::kExplicit, false, false, &state));
|
||||
EXPECT_FALSE(state.running);
|
||||
EXPECT_EQ(2, state.consecutive_failures);
|
||||
|
||||
state.running = true;
|
||||
EXPECT_TRUE(
|
||||
ApplyLaunchExit(LaunchHealthMode::kExitCode, true, false, &state));
|
||||
EXPECT_FALSE(state.running);
|
||||
EXPECT_EQ(0, state.consecutive_failures);
|
||||
|
||||
state.running = true;
|
||||
state.consecutive_failures = 2;
|
||||
EXPECT_TRUE(
|
||||
ApplyLaunchExit(LaunchHealthMode::kExitCode, false, true, &state));
|
||||
EXPECT_FALSE(state.running);
|
||||
EXPECT_EQ(2, state.consecutive_failures);
|
||||
|
||||
state.running = true;
|
||||
EXPECT_FALSE(
|
||||
ApplyLaunchExit(LaunchHealthMode::kExitCode, false, false, &state));
|
||||
EXPECT_TRUE(state.running);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest,
|
||||
EvaluateAppLaunchHealthOffSkipsAndExplicitDisqualifies) {
|
||||
const std::string appid = "550e8400-e29b-41d4-a716-446655440000";
|
||||
const Version version = Version::Parse("150.1.0");
|
||||
LaunchState state;
|
||||
state.appid = appid;
|
||||
state.pid = 99999;
|
||||
state.pid_start_time = GetCurrentPidStartTime();
|
||||
state.consecutive_failures = 2;
|
||||
state.running = true;
|
||||
state.confirmed = false;
|
||||
state.version = version.ToString();
|
||||
state.platform = GetCurrentPlatform();
|
||||
ASSERT_TRUE(WriteLaunchStatePath(
|
||||
GetInstallDirLaunchStatePath(install_dir(), GetAppidHash(appid), version,
|
||||
GetCurrentPlatform()),
|
||||
state));
|
||||
|
||||
AppLaunchHealthEvaluation off =
|
||||
EvaluateAppLaunchHealth(install_dir(), appid, LaunchHealthMode::kOff, 3);
|
||||
EXPECT_TRUE(off.states.empty());
|
||||
EXPECT_TRUE(off.disqualified_versions.empty());
|
||||
|
||||
AppLaunchHealthEvaluation explicit_mode = EvaluateAppLaunchHealth(
|
||||
install_dir(), appid, LaunchHealthMode::kExplicit, 3);
|
||||
EXPECT_EQ(1u, explicit_mode.states.size());
|
||||
EXPECT_EQ(1u, explicit_mode.disqualified_versions.count(version));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, MissingConfirmedFieldIsRejected) {
|
||||
base::FilePath path =
|
||||
GetLaunchStateDir(install_dir()).AppendASCII("legacy_state");
|
||||
ASSERT_TRUE(base::CreateDirectory(path.DirName()));
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(path, R"({"appid":"legacy","pid":1,"pid_start_time":"1",)"
|
||||
R"("consecutive_failures":0,"running":false,)"
|
||||
R"("version":"150.1.0","platform":"windows64"})"));
|
||||
|
||||
std::optional<LaunchState> state = ReadLaunchStatePath(path);
|
||||
EXPECT_FALSE(state);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Process-global active launch state path
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(LaunchStateTest, ActiveLaunchStatePath_DefaultsEmpty) {
|
||||
// Defaults to empty before any set. RoundTrip restores empty so this holds
|
||||
// regardless of test execution order.
|
||||
EXPECT_TRUE(GetActiveLaunchStatePath().empty());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ActiveLaunchStatePath_RoundTrip) {
|
||||
base::FilePath path =
|
||||
install_dir().Append(L".launch").Append(L"abc_150.1.0_windows64");
|
||||
SetActiveLaunchStatePath(path);
|
||||
EXPECT_EQ(path, GetActiveLaunchStatePath());
|
||||
|
||||
// Restore the empty default so other tests (and DefaultsEmpty) see a clean
|
||||
// process-global.
|
||||
SetActiveLaunchStatePath(base::FilePath());
|
||||
EXPECT_TRUE(GetActiveLaunchStatePath().empty());
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Install-dir-level API tests (.launch/ directory)
|
||||
// ============================================================================
|
||||
|
||||
TEST_F(LaunchStateTest, GetLaunchStateDir_ReturnsCorrectPath) {
|
||||
base::FilePath dir = GetLaunchStateDir(install_dir());
|
||||
EXPECT_EQ(dir, install_dir().Append(L".launch"));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, GetInstallDirLaunchStatePath_Format) {
|
||||
Version ver = Version::Parse("150.1.0");
|
||||
base::FilePath path = GetInstallDirLaunchStatePath(
|
||||
install_dir(), L"abc123def4567890", ver, "windows64");
|
||||
EXPECT_EQ(path, install_dir()
|
||||
.Append(L".launch")
|
||||
.Append(L"abc123def4567890_150.1.0_windows64"));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ReadWriteLaunchStatePath_RoundTrip) {
|
||||
base::FilePath launch_dir = install_dir().Append(L".launch");
|
||||
base::FilePath path =
|
||||
launch_dir.Append(L"abc123def4567890_150.1.0_windows64");
|
||||
|
||||
LaunchState state;
|
||||
state.appid = "test-app-uuid";
|
||||
state.pid = 42;
|
||||
state.pid_start_time = 133612345678901234ULL;
|
||||
state.consecutive_failures = 1;
|
||||
state.running = true;
|
||||
state.version = "150.1.0";
|
||||
state.platform = "windows64";
|
||||
|
||||
constexpr uint64_t kPublicationTime = 123456789;
|
||||
SetLaunchStateGcTimeForTesting(kPublicationTime);
|
||||
state.last_update = 1;
|
||||
ASSERT_TRUE(WriteLaunchStatePath(path, state));
|
||||
|
||||
auto read = ReadLaunchStatePath(path);
|
||||
ASSERT_TRUE(read.has_value());
|
||||
EXPECT_EQ(state.appid, read->appid);
|
||||
EXPECT_EQ(state.pid, read->pid);
|
||||
EXPECT_EQ(state.pid_start_time, read->pid_start_time);
|
||||
EXPECT_EQ(state.consecutive_failures, read->consecutive_failures);
|
||||
EXPECT_EQ(state.running, read->running);
|
||||
EXPECT_EQ(state.version, read->version);
|
||||
EXPECT_EQ(state.platform, read->platform);
|
||||
EXPECT_EQ(kPublicationTime, read->last_update);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, HealthWriterOwnsAndRefreshesLastUpdate) {
|
||||
base::FilePath path = GetInstallDirLaunchStatePath(
|
||||
install_dir(), L"abc123def4567890", Version::Parse("150.1.0"),
|
||||
GetCurrentPlatform());
|
||||
LaunchState state;
|
||||
state.appid = "test-app";
|
||||
state.pid_start_time = 1;
|
||||
state.version = "150.1.0";
|
||||
state.platform = GetCurrentPlatform();
|
||||
state.last_update = 7;
|
||||
|
||||
SetLaunchStateGcTimeForTesting(100);
|
||||
ASSERT_TRUE(WriteLaunchStatePath(path, state));
|
||||
ASSERT_EQ(100u, ReadLaunchStatePath(path)->last_update);
|
||||
SetLaunchStateGcTimeForTesting(200);
|
||||
ASSERT_TRUE(WriteLaunchStatePath(path, state));
|
||||
EXPECT_EQ(200u, ReadLaunchStatePath(path)->last_update);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ZeroPublicationClockPreservesPriorHealthState) {
|
||||
base::FilePath path = GetInstallDirLaunchStatePath(
|
||||
install_dir(), L"abc123def4567890", Version::Parse("150.1.0"),
|
||||
GetCurrentPlatform());
|
||||
LaunchState state;
|
||||
state.appid = "test-app";
|
||||
state.pid_start_time = 1;
|
||||
state.version = "150.1.0";
|
||||
state.platform = GetCurrentPlatform();
|
||||
SetLaunchStateGcTimeForTesting(100);
|
||||
ASSERT_TRUE(WriteLaunchStatePath(path, state));
|
||||
std::string before;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &before));
|
||||
|
||||
SetLaunchStateGcTimeForTesting(0);
|
||||
EXPECT_FALSE(WriteLaunchStatePath(path, state));
|
||||
std::string after;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &after));
|
||||
EXPECT_EQ(before, after);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LaunchStateGcAgeBoundaries) {
|
||||
constexpr uint64_t kNow = kLaunchStateGcMaxAge + 100;
|
||||
SetLaunchStateGcTimeForTesting(kNow);
|
||||
EXPECT_EQ(LaunchStateGcAge::kInvalid, ClassifyLaunchStateGcAge(0));
|
||||
EXPECT_EQ(LaunchStateGcAge::kFresh,
|
||||
ClassifyLaunchStateGcAge(kNow - kLaunchStateGcMaxAge + 1));
|
||||
EXPECT_EQ(LaunchStateGcAge::kExpired,
|
||||
ClassifyLaunchStateGcAge(kNow - kLaunchStateGcMaxAge));
|
||||
EXPECT_EQ(LaunchStateGcAge::kFresh, ClassifyLaunchStateGcAge(kNow + 1));
|
||||
SetLaunchStateGcTimeForTesting(0);
|
||||
EXPECT_EQ(LaunchStateGcAge::kUnavailable, ClassifyLaunchStateGcAge(kNow));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, OldSchemaAndFooterlessHealthAreRejectedAndPreserved) {
|
||||
base::FilePath dir = GetLaunchStateDir(install_dir());
|
||||
ASSERT_TRUE(base::CreateDirectory(dir));
|
||||
const std::string old_schema =
|
||||
R"({"appid":"test-app","pid":1,"pid_start_time":"1",)"
|
||||
R"("consecutive_failures":0,"running":false,"confirmed":false,)"
|
||||
R"("version":"150.1.0","platform":"windows64"})";
|
||||
base::FilePath old_path = dir.Append(L"old-schema");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(old_path, old_schema));
|
||||
EXPECT_FALSE(ReadLaunchStatePath(old_path));
|
||||
EXPECT_TRUE(base::PathExists(old_path));
|
||||
|
||||
const std::string current_schema =
|
||||
R"({"appid":"test-app","pid":1,"pid_start_time":"1",)"
|
||||
R"("consecutive_failures":0,"running":false,"confirmed":false,)"
|
||||
R"("version":"150.1.0","platform":"windows64",)"
|
||||
R"("last_update":"1"})";
|
||||
base::FilePath footerless_path = dir.Append(L"footerless");
|
||||
ASSERT_TRUE(base::WriteFile(footerless_path, current_schema));
|
||||
EXPECT_FALSE(ReadLaunchStatePath(footerless_path));
|
||||
EXPECT_TRUE(base::PathExists(footerless_path));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, InvalidHealthTimestampsAreRejected) {
|
||||
base::FilePath dir = GetLaunchStateDir(install_dir());
|
||||
ASSERT_TRUE(base::CreateDirectory(dir));
|
||||
const char quote = 34;
|
||||
const std::vector<std::string> values = {
|
||||
std::string() + quote + "0" + quote, "-1",
|
||||
std::string() + quote + "-1" + quote,
|
||||
std::string() + quote + "18446744073709551616" + quote,
|
||||
std::string() + quote + "not-a-time" + quote};
|
||||
for (size_t i = 0; i < values.size(); ++i) {
|
||||
const std::string json =
|
||||
R"({"appid":"test-app","pid":1,"pid_start_time":"1",)"
|
||||
R"("consecutive_failures":0,"running":false,"confirmed":false,)"
|
||||
R"("version":"150.1.0","platform":"windows64","last_update":)" +
|
||||
values[i] + "}";
|
||||
base::FilePath path =
|
||||
dir.AppendASCII("invalid-time-" + base::NumberToString(i));
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, json));
|
||||
EXPECT_FALSE(ReadLaunchStatePath(path)) << values[i];
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, WriteLaunchStatePath_RejectsEmptyAppid) {
|
||||
base::FilePath path =
|
||||
install_dir().Append(L".launch").Append(L"abc123def4567890_1.0.0_win");
|
||||
LaunchState state;
|
||||
state.pid = 1;
|
||||
state.pid_start_time = 1;
|
||||
state.version = "1.0.0";
|
||||
state.platform = "win";
|
||||
EXPECT_FALSE(WriteLaunchStatePath(path, state));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, WriteLaunchStatePath_RejectsEmptyVersion) {
|
||||
base::FilePath path =
|
||||
install_dir().Append(L".launch").Append(L"abc123def4567890_1.0.0_win");
|
||||
LaunchState state;
|
||||
state.appid = "test";
|
||||
state.pid = 1;
|
||||
state.pid_start_time = 1;
|
||||
state.platform = "win";
|
||||
EXPECT_FALSE(WriteLaunchStatePath(path, state));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, WriteLaunchStatePath_RejectsEmptyPlatform) {
|
||||
base::FilePath path =
|
||||
install_dir().Append(L".launch").Append(L"abc123def4567890_1.0.0_win");
|
||||
LaunchState state;
|
||||
state.appid = "test";
|
||||
state.pid = 1;
|
||||
state.pid_start_time = 1;
|
||||
state.version = "1.0.0";
|
||||
EXPECT_FALSE(WriteLaunchStatePath(path, state));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ReadLaunchStatePath_MissingFile) {
|
||||
base::FilePath path =
|
||||
install_dir().Append(L".launch").Append(L"abc123def4567890_1.0_win");
|
||||
auto read = ReadLaunchStatePath(path);
|
||||
EXPECT_FALSE(read.has_value());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ReadLaunchStatePath_CorruptFile) {
|
||||
base::FilePath launch_dir = install_dir().Append(L".launch");
|
||||
ASSERT_TRUE(base::CreateDirectory(launch_dir));
|
||||
base::FilePath path = launch_dir.Append(L"abc123def4567890_1.0_win");
|
||||
ASSERT_TRUE(base::WriteFile(path, "corrupt garbage data!!"));
|
||||
|
||||
auto read = ReadLaunchStatePath(path);
|
||||
EXPECT_FALSE(read.has_value());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ScanLaunchStates_MultipleVersions) {
|
||||
base::FilePath install = install_dir();
|
||||
std::wstring hash = L"abc123def4567890";
|
||||
|
||||
for (const char* ver : {"150.1.0", "150.2.0", "150.3.0"}) {
|
||||
LaunchState ls;
|
||||
ls.appid = "test-app";
|
||||
ls.pid = 1;
|
||||
ls.pid_start_time = 1;
|
||||
ls.consecutive_failures = 0;
|
||||
ls.running = false;
|
||||
ls.version = ver;
|
||||
ls.platform = GetCurrentPlatform();
|
||||
base::FilePath path = GetInstallDirLaunchStatePath(
|
||||
install, hash, Version::Parse(ver), GetCurrentPlatform());
|
||||
ASSERT_TRUE(WriteLaunchStatePath(path, ls));
|
||||
}
|
||||
|
||||
auto states = ScanLaunchStates(install, hash);
|
||||
ASSERT_EQ(3u, states.size());
|
||||
EXPECT_TRUE(states.count(Version::Parse("150.1.0")));
|
||||
EXPECT_TRUE(states.count(Version::Parse("150.2.0")));
|
||||
EXPECT_TRUE(states.count(Version::Parse("150.3.0")));
|
||||
EXPECT_EQ("test-app", states.at(Version::Parse("150.1.0")).appid);
|
||||
EXPECT_EQ("150.2.0", states.at(Version::Parse("150.2.0")).version);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ScanLaunchStates_FiltersAppidHash) {
|
||||
base::FilePath install = install_dir();
|
||||
std::wstring hash1 = L"aaaa000000000000";
|
||||
std::wstring hash2 = L"bbbb000000000000";
|
||||
std::string platform = GetCurrentPlatform();
|
||||
|
||||
LaunchState ls;
|
||||
ls.appid = "app1";
|
||||
ls.pid = 1;
|
||||
ls.pid_start_time = 1;
|
||||
ls.version = "150.1.0";
|
||||
ls.platform = platform;
|
||||
ASSERT_TRUE(WriteLaunchStatePath(
|
||||
GetInstallDirLaunchStatePath(install, hash1, Version::Parse("150.1.0"),
|
||||
platform),
|
||||
ls));
|
||||
|
||||
ls.appid = "app2";
|
||||
ls.version = "150.2.0";
|
||||
ASSERT_TRUE(WriteLaunchStatePath(
|
||||
GetInstallDirLaunchStatePath(install, hash2, Version::Parse("150.2.0"),
|
||||
platform),
|
||||
ls));
|
||||
|
||||
auto states = ScanLaunchStates(install, hash1);
|
||||
ASSERT_EQ(1u, states.size());
|
||||
EXPECT_TRUE(states.count(Version::Parse("150.1.0")));
|
||||
EXPECT_EQ("app1", states.at(Version::Parse("150.1.0")).appid);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ScanLaunchStates_SkipsMalformed) {
|
||||
base::FilePath install = install_dir();
|
||||
std::wstring hash = L"abc123def4567890";
|
||||
std::string platform = GetCurrentPlatform();
|
||||
|
||||
LaunchState ls;
|
||||
ls.appid = "test-app";
|
||||
ls.pid = 1;
|
||||
ls.pid_start_time = 1;
|
||||
ls.version = "150.1.0";
|
||||
ls.platform = platform;
|
||||
ASSERT_TRUE(WriteLaunchStatePath(
|
||||
GetInstallDirLaunchStatePath(install, hash, Version::Parse("150.1.0"),
|
||||
platform),
|
||||
ls));
|
||||
|
||||
// Write a garbage file with the right prefix.
|
||||
base::FilePath launch_dir = GetLaunchStateDir(install);
|
||||
base::FilePath garbage = launch_dir.Append(hash + L"_garbage");
|
||||
ASSERT_TRUE(base::WriteFile(garbage, "not a valid file"));
|
||||
|
||||
auto states = ScanLaunchStates(install, hash);
|
||||
ASSERT_EQ(1u, states.size());
|
||||
EXPECT_TRUE(states.count(Version::Parse("150.1.0")));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ScanLaunchStates_EmptyDir) {
|
||||
base::FilePath install = install_dir();
|
||||
ASSERT_TRUE(base::CreateDirectory(GetLaunchStateDir(install)));
|
||||
|
||||
auto states = ScanLaunchStates(install, L"abc123def4567890");
|
||||
EXPECT_TRUE(states.empty());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, ScanLaunchStates_NoDir) {
|
||||
auto states = ScanLaunchStates(install_dir(), L"abc123def4567890");
|
||||
EXPECT_TRUE(states.empty());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LivenessFilenameCannotCollideWithHealthSentinel) {
|
||||
std::wstring hash = L"abc123def4567890";
|
||||
std::string platform = GetCurrentPlatform();
|
||||
base::FilePath liveness =
|
||||
GetInstallDirLivenessPath(install_dir(), hash, platform);
|
||||
base::FilePath health = GetInstallDirLaunchStatePath(
|
||||
install_dir(), hash, Version::Parse("150.1.0"), platform);
|
||||
EXPECT_NE(liveness, health);
|
||||
EXPECT_EQ(hash + L"_" + base::UTF8ToWide(platform),
|
||||
liveness.BaseName().value());
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LivenessRoundTrip) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir(), L"abc123def4567890", GetCurrentPlatform());
|
||||
LivenessRecord record{"test-app", GetCurrentPlatform(), 123456};
|
||||
ASSERT_TRUE(WriteLivenessPath(path, record));
|
||||
std::optional<LivenessRecord> read = ReadLivenessPath(path);
|
||||
ASSERT_TRUE(read);
|
||||
EXPECT_EQ(record.appid, read->appid);
|
||||
EXPECT_EQ(record.platform, read->platform);
|
||||
EXPECT_EQ(record.last_launch, read->last_launch);
|
||||
EXPECT_FALSE(ReadLaunchStatePath(path));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LivenessRejectsZeroAndFooterlessRecords) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir(), L"abc123def4567890", GetCurrentPlatform());
|
||||
EXPECT_FALSE(WriteLivenessPath(path, {"test-app", GetCurrentPlatform(), 0}));
|
||||
EXPECT_FALSE(RefreshLivenessPath(path, "test-app", GetCurrentPlatform(), 0));
|
||||
ASSERT_TRUE(base::CreateDirectory(path.DirName()));
|
||||
ASSERT_TRUE(base::WriteFile(
|
||||
path,
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":"1"})"));
|
||||
EXPECT_FALSE(ReadLivenessPath(path));
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, InvalidLivenessTimestampsAreRejectedAndPreserved) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir(), L"abc123def4567890", GetCurrentPlatform());
|
||||
ASSERT_TRUE(base::CreateDirectory(path.DirName()));
|
||||
const std::vector<std::string> invalid_records = {
|
||||
R"({"appid":"test-app","platform":"windows64"})",
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":1})",
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":"-1"})",
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":"18446744073709551616"})",
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":"invalid"})",
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":"0"})",
|
||||
};
|
||||
for (const auto& json : invalid_records) {
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, json));
|
||||
EXPECT_FALSE(ReadLivenessPath(path)) << json;
|
||||
EXPECT_TRUE(base::PathExists(path)) << json;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LaunchRecordSnapshotBindsExactPublication) {
|
||||
const std::string appid = "test-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetInstallDirLivenessPath(install_dir(), GetAppidHash(appid), platform);
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {appid, platform, 100}));
|
||||
std::optional<base::FilePath> resolved =
|
||||
GetSafeDirectoryResolvedPath(path.DirName());
|
||||
ASSERT_TRUE(resolved);
|
||||
std::optional<LaunchRecordSnapshot> snapshot =
|
||||
ReadLaunchRecordSnapshot(path, *resolved);
|
||||
ASSERT_TRUE(snapshot);
|
||||
EXPECT_EQ(LaunchRecordKind::kLiveness, snapshot->kind);
|
||||
EXPECT_EQ(100u, snapshot->liveness.last_launch);
|
||||
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {appid, platform, 200}));
|
||||
EXPECT_EQ(
|
||||
ConditionalDeleteResult::kChanged,
|
||||
DeleteFileWithIntegrityIfMatching(snapshot->path, snapshot->content,
|
||||
/*expected_integrity_protected=*/true,
|
||||
snapshot->resolved_parent_path));
|
||||
EXPECT_EQ(200u, ReadLivenessPath(path)->last_launch);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LaunchRecordSnapshotClassifiesInvalidPublications) {
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetInstallDirLivenessPath(install_dir(), L"abc123def4567890", platform);
|
||||
ASSERT_TRUE(base::CreateDirectory(path.DirName()));
|
||||
std::optional<base::FilePath> resolved =
|
||||
GetSafeDirectoryResolvedPath(path.DirName());
|
||||
ASSERT_TRUE(resolved);
|
||||
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(path, "{}"));
|
||||
std::optional<LaunchRecordSnapshot> malformed =
|
||||
ReadLaunchRecordSnapshot(path, *resolved);
|
||||
ASSERT_TRUE(malformed);
|
||||
EXPECT_EQ(LaunchRecordKind::kInvalid, malformed->kind);
|
||||
EXPECT_FALSE(malformed->raw_content.empty());
|
||||
EXPECT_FALSE(malformed->oversized);
|
||||
|
||||
const std::string footerless =
|
||||
R"({"appid":"test-app","platform":"windows64","last_launch":"1"})";
|
||||
ASSERT_TRUE(base::WriteFile(path, footerless));
|
||||
std::optional<LaunchRecordSnapshot> no_footer =
|
||||
ReadLaunchRecordSnapshot(path, *resolved);
|
||||
ASSERT_TRUE(no_footer);
|
||||
EXPECT_EQ(LaunchRecordKind::kInvalid, no_footer->kind);
|
||||
EXPECT_EQ(footerless, no_footer->raw_content);
|
||||
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {"test-app", platform, 1}));
|
||||
std::string corrupt;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &corrupt));
|
||||
ASSERT_FALSE(corrupt.empty());
|
||||
corrupt[0] ^= 1;
|
||||
ASSERT_TRUE(base::WriteFile(path, corrupt));
|
||||
std::optional<LaunchRecordSnapshot> crc_mismatch =
|
||||
ReadLaunchRecordSnapshot(path, *resolved);
|
||||
ASSERT_TRUE(crc_mismatch);
|
||||
EXPECT_EQ(LaunchRecordKind::kInvalid, crc_mismatch->kind);
|
||||
EXPECT_EQ(corrupt, crc_mismatch->raw_content);
|
||||
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(path, std::string(kMaxLaunchStateFileSize + 17, 'x')));
|
||||
std::optional<LaunchRecordSnapshot> oversized =
|
||||
ReadLaunchRecordSnapshot(path, *resolved);
|
||||
ASSERT_TRUE(oversized);
|
||||
EXPECT_EQ(LaunchRecordKind::kInvalid, oversized->kind);
|
||||
EXPECT_TRUE(oversized->oversized);
|
||||
|
||||
std::optional<base::FilePath> wrong_parent =
|
||||
GetSafeDirectoryResolvedPath(install_dir());
|
||||
ASSERT_TRUE(wrong_parent);
|
||||
EXPECT_FALSE(ReadLaunchRecordSnapshot(path, *wrong_parent));
|
||||
|
||||
ASSERT_TRUE(base::DeleteFile(path));
|
||||
base::FilePath target = install_dir().Append(L"reparse-target");
|
||||
ASSERT_TRUE(WriteFileWithIntegrity(target, "target"));
|
||||
constexpr DWORD kAllowUnprivilegedCreate = 0x2;
|
||||
if (!::CreateSymbolicLinkW(path.value().c_str(), target.value().c_str(),
|
||||
kAllowUnprivilegedCreate)) {
|
||||
GTEST_SKIP() << "Symbolic-link creation is unavailable";
|
||||
}
|
||||
std::optional<LaunchRecordSnapshot> reparse =
|
||||
ReadLaunchRecordSnapshot(path, *resolved);
|
||||
ASSERT_TRUE(reparse);
|
||||
EXPECT_EQ(LaunchRecordKind::kInvalid, reparse->kind);
|
||||
EXPECT_TRUE(reparse->reparse_point);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LivenessRefreshUsesContentTimestamp) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir(), L"abc123def4567890", GetCurrentPlatform());
|
||||
constexpr uint64_t now = 100 * kLivenessRefreshIntervalFileTimeTicks;
|
||||
ASSERT_TRUE(WriteLivenessPath(
|
||||
path, {"test-app", GetCurrentPlatform(),
|
||||
now - kLivenessRefreshIntervalFileTimeTicks + 1}));
|
||||
ASSERT_TRUE(RefreshLivenessPath(path, "test-app", GetCurrentPlatform(), now));
|
||||
ASSERT_EQ(now - kLivenessRefreshIntervalFileTimeTicks + 1,
|
||||
ReadLivenessPath(path)->last_launch);
|
||||
|
||||
ASSERT_TRUE(
|
||||
WriteLivenessPath(path, {"test-app", GetCurrentPlatform(),
|
||||
now - kLivenessRefreshIntervalFileTimeTicks}));
|
||||
ASSERT_TRUE(RefreshLivenessPath(path, "test-app", GetCurrentPlatform(), now));
|
||||
EXPECT_EQ(now, ReadLivenessPath(path)->last_launch);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LivenessFutureTimestampIsReplacedAfterClockRollback) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir(), L"abc123def4567890", GetCurrentPlatform());
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {"test-app", GetCurrentPlatform(), 200}));
|
||||
ASSERT_TRUE(RefreshLivenessPath(path, "test-app", GetCurrentPlatform(), 100));
|
||||
EXPECT_EQ(100u, ReadLivenessPath(path)->last_launch);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, LivenessMalformedOrOversizedIsReplaced) {
|
||||
base::FilePath path = GetInstallDirLivenessPath(
|
||||
install_dir(), L"abc123def4567890", GetCurrentPlatform());
|
||||
ASSERT_TRUE(base::CreateDirectory(path.DirName()));
|
||||
ASSERT_TRUE(
|
||||
base::WriteFile(path, std::string(kMaxLaunchStateFileSize + 1, 'x')));
|
||||
ASSERT_TRUE(RefreshLivenessPath(path, "test-app", GetCurrentPlatform(), 123));
|
||||
ASSERT_EQ(123u, ReadLivenessPath(path)->last_launch);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorChoosesNewestEvidence) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
const std::wstring hash = GetAppidHash(appid);
|
||||
ASSERT_TRUE(WriteLivenessPath(
|
||||
GetInstallDirLivenessPath(install_dir(), hash, platform),
|
||||
{appid, platform, 100}));
|
||||
LaunchState older_health;
|
||||
older_health.appid = appid;
|
||||
older_health.pid_start_time = 90;
|
||||
older_health.version = "150.1.0";
|
||||
older_health.platform = platform;
|
||||
ASSERT_TRUE(WriteLaunchStatePath(
|
||||
GetInstallDirLaunchStatePath(
|
||||
install_dir(), hash, Version::Parse(older_health.version), platform),
|
||||
older_health));
|
||||
LaunchState newest_health = older_health;
|
||||
newest_health.pid_start_time = 120;
|
||||
newest_health.version = "151.1.0";
|
||||
ASSERT_TRUE(WriteLaunchStatePath(
|
||||
GetInstallDirLaunchStatePath(
|
||||
install_dir(), hash, Version::Parse(newest_health.version), platform),
|
||||
newest_health));
|
||||
|
||||
RetentionEvidenceMap evidence =
|
||||
CollectRetentionEvidence(install_dir(), {{"retention-app", platform}});
|
||||
|
||||
const RetentionEvidence& result = evidence.at({appid, platform});
|
||||
EXPECT_FALSE(result.unknown);
|
||||
EXPECT_EQ(RetentionEvidenceKind::kHealthSentinel, result.kind);
|
||||
EXPECT_EQ(120u, result.timestamp);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorIsPlatformScoped) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::wstring hash = GetAppidHash(appid);
|
||||
ASSERT_TRUE(WriteLivenessPath(
|
||||
GetInstallDirLivenessPath(install_dir(), hash, "windows64"),
|
||||
{appid, "windows64", 100}));
|
||||
ASSERT_TRUE(WriteLivenessPath(
|
||||
GetInstallDirLivenessPath(install_dir(), hash, "windows32"),
|
||||
{appid, "windows32", 200}));
|
||||
|
||||
RetentionEvidenceMap evidence = CollectRetentionEvidence(
|
||||
install_dir(), {{appid, "windows64"}, {appid, "windows32"}});
|
||||
|
||||
EXPECT_EQ(100u, evidence.at({appid, "windows64"}).timestamp);
|
||||
EXPECT_EQ(200u, evidence.at({appid, "windows32"}).timestamp);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest,
|
||||
RetentionSnapshotUsesOneCanonicalObservationForPlanAndCleanup) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetInstallDirLivenessPath(install_dir(), GetAppidHash(appid), platform);
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {appid, platform, 100}));
|
||||
SetRetentionCanonicalObservationHookForTesting(base::BindRepeating(
|
||||
[](const std::string& hook_appid, const std::string& hook_platform,
|
||||
const base::FilePath& hook_path) {
|
||||
ASSERT_TRUE(
|
||||
WriteLivenessPath(hook_path, {hook_appid, hook_platform, 200}));
|
||||
},
|
||||
appid, platform));
|
||||
|
||||
RetentionEvidenceSnapshot snapshot =
|
||||
CollectRetentionEvidenceSnapshot(install_dir(), {{appid, platform}});
|
||||
|
||||
ASSERT_EQ(1u, snapshot.files.size());
|
||||
EXPECT_EQ(100u, snapshot.evidence.at({appid, platform}).timestamp);
|
||||
EXPECT_EQ(ConditionalDeleteResult::kChanged,
|
||||
DeleteFileWithIntegrityIfMatching(
|
||||
path, snapshot.files[0].content,
|
||||
snapshot.files[0].integrity_protected,
|
||||
snapshot.files[0].resolved_parent_path));
|
||||
EXPECT_EQ(200u, ReadLivenessPath(path)->last_launch);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorProtectsNoncanonicalEvidence) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetLaunchStateDir(install_dir()).Append(L"noncanonical");
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {appid, platform, 100}));
|
||||
|
||||
RetentionEvidenceMap evidence =
|
||||
CollectRetentionEvidence(install_dir(), {{appid, platform}});
|
||||
|
||||
EXPECT_TRUE(evidence.at({appid, platform}).unknown);
|
||||
EXPECT_EQ("invalid_or_noncanonical_evidence",
|
||||
evidence.at({appid, platform}).diagnostic);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorProtectsMalformedCanonicalEvidence) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetInstallDirLivenessPath(install_dir(), GetAppidHash(appid), platform);
|
||||
ASSERT_TRUE(base::CreateDirectory(path.DirName()));
|
||||
ASSERT_TRUE(base::WriteFile(path, "malformed"));
|
||||
|
||||
RetentionEvidenceMap evidence =
|
||||
CollectRetentionEvidence(install_dir(), {{appid, platform}});
|
||||
|
||||
EXPECT_TRUE(evidence.at({appid, platform}).unknown);
|
||||
EXPECT_EQ("invalid_or_noncanonical_evidence",
|
||||
evidence.at({appid, platform}).diagnostic);
|
||||
EXPECT_TRUE(base::PathExists(path));
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorPreservesIntegrityMismatch) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetInstallDirLivenessPath(install_dir(), GetAppidHash(appid), platform);
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {appid, platform, 100}));
|
||||
std::string corrupt;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &corrupt));
|
||||
ASSERT_GT(corrupt.size(), 20u);
|
||||
corrupt[5] ^= 0xFF;
|
||||
ASSERT_TRUE(base::WriteFile(path, corrupt));
|
||||
|
||||
RetentionEvidenceMap evidence =
|
||||
CollectRetentionEvidence(install_dir(), {{appid, platform}});
|
||||
|
||||
EXPECT_TRUE(evidence.at({appid, platform}).unknown);
|
||||
std::string after;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &after));
|
||||
EXPECT_EQ(corrupt, after);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorMissingDirectoryIsProtected) {
|
||||
RetentionEvidenceMap evidence = CollectRetentionEvidence(
|
||||
install_dir(), {{"retention-app", GetCurrentPlatform()}});
|
||||
|
||||
ASSERT_EQ(1u, evidence.size());
|
||||
const RetentionEvidence& result =
|
||||
evidence.at({"retention-app", GetCurrentPlatform()});
|
||||
EXPECT_EQ(RetentionEvidenceKind::kNone, result.kind);
|
||||
EXPECT_EQ(0u, result.timestamp);
|
||||
EXPECT_FALSE(result.unknown);
|
||||
}
|
||||
|
||||
TEST_F(LaunchStateTest, RetentionCollectorDoesNotWrite) {
|
||||
const std::string appid = "retention-app";
|
||||
const std::string platform = GetCurrentPlatform();
|
||||
base::FilePath path =
|
||||
GetInstallDirLivenessPath(install_dir(), GetAppidHash(appid), platform);
|
||||
ASSERT_TRUE(WriteLivenessPath(path, {appid, platform, 100}));
|
||||
std::string before;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &before));
|
||||
|
||||
CollectRetentionEvidence(install_dir(), {{appid, platform}});
|
||||
|
||||
std::string after;
|
||||
ASSERT_TRUE(base::ReadFileToString(path, &after));
|
||||
EXPECT_EQ(before, after);
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
@@ -0,0 +1,348 @@
|
||||
// Copyright 2026 The Chromium Embedded Framework Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_lifecycle.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <limits>
|
||||
#include <type_traits>
|
||||
|
||||
#include "base/containers/span.h"
|
||||
#include "base/json/json_writer.h"
|
||||
#include "base/rand_util.h"
|
||||
#include "base/strings/string_number_conversions.h"
|
||||
#include "base/strings/string_util.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_constants.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_logger.h"
|
||||
#include "cef/libcef_dll/bootstrap/installer/installer_result_json.h"
|
||||
|
||||
namespace cef_installer {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kRelaunchStartedEvent[] = "relaunch_started";
|
||||
constexpr char kOperationResultEvent[] = "operation_result";
|
||||
constexpr char kDiagnosticFallback[] = "Diagnostic unavailable";
|
||||
|
||||
static_assert(std::numeric_limits<DWORD>::max() <= UINT64_C(9007199254740991));
|
||||
static_assert(std::numeric_limits<int>::digits <= 53);
|
||||
static_assert(std::is_same_v<decltype(COPYDATASTRUCT::dwData), ULONG_PTR>);
|
||||
static_assert(sizeof(kWmCopyDataInstallerLifecycle) <=
|
||||
sizeof(COPYDATASTRUCT::dwData));
|
||||
|
||||
bool SerializeDictionary(const base::DictValue& dict, std::string* json) {
|
||||
return base::JSONWriter::Write(dict, json) &&
|
||||
json->size() + 1 <= kInstallerLifecycleMaxPayloadSize;
|
||||
}
|
||||
|
||||
bool FitsWithTruncationFlag(const base::DictValue& dict) {
|
||||
base::DictValue candidate = dict.Clone();
|
||||
candidate.Set("diagnostics_truncated", true);
|
||||
std::string json;
|
||||
return SerializeDictionary(candidate, &json);
|
||||
}
|
||||
|
||||
std::string BoundDiagnostic(std::string_view input,
|
||||
size_t max_size,
|
||||
bool* truncated) {
|
||||
if (!base::IsStringUTF8(input)) {
|
||||
*truncated = true;
|
||||
return kDiagnosticFallback;
|
||||
}
|
||||
std::string_view bounded = base::TruncateUTF8ToByteSize(input, max_size);
|
||||
if (bounded.size() != input.size()) {
|
||||
*truncated = true;
|
||||
}
|
||||
return std::string(bounded);
|
||||
}
|
||||
|
||||
std::optional<std::string> SerializeLifecycleDictionary(base::DictValue dict,
|
||||
const Result* result) {
|
||||
bool diagnostics_truncated = false;
|
||||
std::string error_message;
|
||||
std::vector<std::string> warnings;
|
||||
if (result && !result->success) {
|
||||
error_message = BoundDiagnostic(result->error_message,
|
||||
kInstallerLifecycleMaxErrorMessageSize,
|
||||
&diagnostics_truncated);
|
||||
}
|
||||
if (result) {
|
||||
const size_t count =
|
||||
std::min(result->warnings.size(),
|
||||
static_cast<size_t>(kInstallerLifecycleMaxWarnings));
|
||||
diagnostics_truncated |= count != result->warnings.size();
|
||||
warnings.reserve(count);
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
warnings.push_back(BoundDiagnostic(result->warnings[i],
|
||||
kInstallerLifecycleMaxWarningSize,
|
||||
&diagnostics_truncated));
|
||||
}
|
||||
}
|
||||
|
||||
dict.Remove("error_message");
|
||||
dict.Remove("warnings");
|
||||
if (result && !result->success) {
|
||||
dict.Set("error_message", error_message);
|
||||
if (!FitsWithTruncationFlag(dict)) {
|
||||
diagnostics_truncated = true;
|
||||
size_t low = 0;
|
||||
size_t high = error_message.size();
|
||||
while (low < high) {
|
||||
const size_t middle = low + (high - low + 1) / 2;
|
||||
base::DictValue candidate = dict.Clone();
|
||||
candidate.Set("error_message", std::string(base::TruncateUTF8ToByteSize(
|
||||
error_message, middle)));
|
||||
if (FitsWithTruncationFlag(candidate)) {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
dict.Set("error_message",
|
||||
std::string(base::TruncateUTF8ToByteSize(error_message, low)));
|
||||
if (!FitsWithTruncationFlag(dict)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
} else if (!FitsWithTruncationFlag(dict)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
base::ListValue included_warnings;
|
||||
for (const auto& warning : warnings) {
|
||||
base::ListValue candidate_warnings = included_warnings.Clone();
|
||||
candidate_warnings.Append(warning);
|
||||
base::DictValue candidate = dict.Clone();
|
||||
candidate.Set("warnings", std::move(candidate_warnings));
|
||||
if (FitsWithTruncationFlag(candidate)) {
|
||||
included_warnings.Append(warning);
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics_truncated = true;
|
||||
size_t low = 0;
|
||||
size_t high = warning.size();
|
||||
while (low < high) {
|
||||
const size_t middle = low + (high - low + 1) / 2;
|
||||
base::ListValue shortened = included_warnings.Clone();
|
||||
shortened.Append(
|
||||
std::string(base::TruncateUTF8ToByteSize(warning, middle)));
|
||||
base::DictValue shortened_candidate = dict.Clone();
|
||||
shortened_candidate.Set("warnings", std::move(shortened));
|
||||
if (FitsWithTruncationFlag(shortened_candidate)) {
|
||||
low = middle;
|
||||
} else {
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
const std::string shortened_warning(
|
||||
base::TruncateUTF8ToByteSize(warning, low));
|
||||
if (!shortened_warning.empty()) {
|
||||
included_warnings.Append(shortened_warning);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (included_warnings.size() != warnings.size()) {
|
||||
diagnostics_truncated = true;
|
||||
}
|
||||
if (!included_warnings.empty()) {
|
||||
dict.Set("warnings", std::move(included_warnings));
|
||||
}
|
||||
if (diagnostics_truncated) {
|
||||
dict.Set("diagnostics_truncated", true);
|
||||
}
|
||||
|
||||
std::string json;
|
||||
if (!SerializeDictionary(dict, &json)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool UninstallLifecycleContext::valid() const {
|
||||
return parent_window && IsValidInstallerLifecycleOperationId(operation_id);
|
||||
}
|
||||
|
||||
std::string GenerateInstallerLifecycleOperationId() {
|
||||
std::array<uint8_t, 16> random = {};
|
||||
base::RandBytes(random);
|
||||
return base::ToLowerASCII(base::HexEncode(random));
|
||||
}
|
||||
|
||||
bool IsValidInstallerLifecycleOperationId(std::string_view operation_id) {
|
||||
if (operation_id.size() != 32) {
|
||||
return false;
|
||||
}
|
||||
return std::all_of(operation_id.begin(), operation_id.end(), [](char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
});
|
||||
}
|
||||
|
||||
bool IsValidInstallerLifecycleTerminalErrorCode(int exit_code) {
|
||||
switch (exit_code) {
|
||||
case kExitCodeConfigError:
|
||||
case kExitCodeNetworkError:
|
||||
case kExitCodeSignatureError:
|
||||
case kExitCodeNoMatchingVersion:
|
||||
case kExitCodeExtractionError:
|
||||
case kExitCodeInstallError:
|
||||
case kExitCodeDatabaseError:
|
||||
case kExitCodeLockTimeout:
|
||||
case kExitCodeCancelled:
|
||||
case kExitCodeNoSentinel:
|
||||
case kExitCodeSentinelReadError:
|
||||
case kExitCodeSentinelOwnerMismatch:
|
||||
case kExitCodePolicyDenied:
|
||||
case kExitCodeIndexError:
|
||||
case kExitCodeRecoveryError:
|
||||
case kExitCodeRepairError:
|
||||
case kExitCodeQuarantineError:
|
||||
case kExitCodeRetentionSnapshotChanged:
|
||||
case kExitCodePolicyError:
|
||||
case kExitCodeUnknownError:
|
||||
return true;
|
||||
case kExitCodeSuccess:
|
||||
case kExitCodeRelaunched:
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::optional<std::string> SerializeInstallerRelaunchStarted(
|
||||
std::string_view operation_id,
|
||||
DWORD child_pid) {
|
||||
if (!IsValidInstallerLifecycleOperationId(operation_id) || child_pid == 0) {
|
||||
return std::nullopt;
|
||||
}
|
||||
base::DictValue dict;
|
||||
dict.Set("protocol_version", kInstallerLifecycleProtocolVersion);
|
||||
dict.Set("event", kRelaunchStartedEvent);
|
||||
dict.Set("operation_id", operation_id);
|
||||
dict.Set("child_pid", static_cast<double>(child_pid));
|
||||
return SerializeLifecycleDictionary(std::move(dict), nullptr);
|
||||
}
|
||||
|
||||
std::optional<std::string> SerializeInstallerOperationResult(
|
||||
std::string_view operation_id,
|
||||
const Result& result,
|
||||
int exit_code) {
|
||||
if (!IsValidInstallerLifecycleOperationId(operation_id) ||
|
||||
exit_code != ResultToExitCode(result) ||
|
||||
(!result.success &&
|
||||
!IsValidInstallerLifecycleTerminalErrorCode(exit_code))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
// base::Value requires valid UTF-8 at construction. Substitute invalid
|
||||
// diagnostics before building the shared dictionary; the bounded helper
|
||||
// below uses the original Result so it can also set diagnostics_truncated.
|
||||
Result serializable_result = result;
|
||||
if (!serializable_result.success &&
|
||||
!base::IsStringUTF8(serializable_result.error_message)) {
|
||||
serializable_result.error_message = kDiagnosticFallback;
|
||||
}
|
||||
for (std::string& warning : serializable_result.warnings) {
|
||||
if (!base::IsStringUTF8(warning)) {
|
||||
warning = kDiagnosticFallback;
|
||||
}
|
||||
}
|
||||
base::DictValue dict = internal::BuildResultJsonValue(serializable_result);
|
||||
dict.Set("protocol_version", kInstallerLifecycleProtocolVersion);
|
||||
dict.Set("event", kOperationResultEvent);
|
||||
dict.Set("operation_id", operation_id);
|
||||
dict.Set("command", "uninstall");
|
||||
dict.Set("exit_code", exit_code);
|
||||
return SerializeLifecycleDictionary(std::move(dict), &result);
|
||||
}
|
||||
|
||||
InstallerLifecycleSendStatus SendInstallerLifecycleMessage(
|
||||
HWND parent_window,
|
||||
std::string_view json) {
|
||||
if (!parent_window || !::IsWindow(parent_window)) {
|
||||
return InstallerLifecycleSendStatus::kInvalidWindow;
|
||||
}
|
||||
if (json.empty() || !base::IsStringUTF8(json) ||
|
||||
json.find('\0') != std::string_view::npos ||
|
||||
json.size() + 1 > kInstallerLifecycleMaxPayloadSize) {
|
||||
return InstallerLifecycleSendStatus::kSerializationError;
|
||||
}
|
||||
const std::string payload(json);
|
||||
COPYDATASTRUCT data = {};
|
||||
data.dwData = kWmCopyDataInstallerLifecycle;
|
||||
data.cbData = static_cast<DWORD>(json.size() + 1);
|
||||
data.lpData = const_cast<char*>(payload.c_str());
|
||||
DWORD_PTR receiver_result = 0;
|
||||
::SetLastError(ERROR_SUCCESS);
|
||||
const LRESULT sent = ::SendMessageTimeoutW(
|
||||
parent_window, WM_COPYDATA, 0, reinterpret_cast<LPARAM>(&data),
|
||||
SMTO_ABORTIFHUNG, kInstallerLifecycleSendTimeoutMs, &receiver_result);
|
||||
if (sent != 0) {
|
||||
return InstallerLifecycleSendStatus::kDelivered;
|
||||
}
|
||||
return ::GetLastError() == ERROR_TIMEOUT
|
||||
? InstallerLifecycleSendStatus::kTimeout
|
||||
: InstallerLifecycleSendStatus::kError;
|
||||
}
|
||||
|
||||
int FinalizeUninstallLifecycle(
|
||||
const std::optional<UninstallLifecycleContext>& context,
|
||||
const Result& result) {
|
||||
const int exit_code = ResultToExitCode(result);
|
||||
if (!context || !context->valid()) {
|
||||
return exit_code;
|
||||
}
|
||||
std::optional<std::string> json = SerializeInstallerOperationResult(
|
||||
context->operation_id, result, exit_code);
|
||||
const InstallerLifecycleSendStatus status =
|
||||
json ? SendInstallerLifecycleMessage(context->parent_window, *json)
|
||||
: InstallerLifecycleSendStatus::kSerializationError;
|
||||
if (status != InstallerLifecycleSendStatus::kDelivered) {
|
||||
Logger::GetInstance().Warning(
|
||||
"Uninstall lifecycle terminal delivery was not confirmed");
|
||||
}
|
||||
return exit_code;
|
||||
}
|
||||
|
||||
int FinalizeControlledUninstallFailure(
|
||||
const std::optional<UninstallLifecycleContext>& context,
|
||||
ControlledUninstallFailure failure,
|
||||
int error_code) {
|
||||
const char* diagnostic = nullptr;
|
||||
switch (failure) {
|
||||
case ControlledUninstallFailure::kInvalidRetentionOptions:
|
||||
diagnostic = "Invalid retention option usage";
|
||||
break;
|
||||
case ControlledUninstallFailure::kInvalidRetentionAge:
|
||||
diagnostic = "Invalid retention age";
|
||||
break;
|
||||
case ControlledUninstallFailure::kConfigLoad:
|
||||
diagnostic = "Failed to load installer configuration";
|
||||
break;
|
||||
case ControlledUninstallFailure::kConfigNotFound:
|
||||
diagnostic = "Installer configuration was not found";
|
||||
break;
|
||||
case ControlledUninstallFailure::kExplicitModeDisabled:
|
||||
diagnostic = "Explicit installer command is not enabled";
|
||||
break;
|
||||
case ControlledUninstallFailure::kPreflightRejected:
|
||||
diagnostic = "Relaunched uninstall preflight was rejected";
|
||||
break;
|
||||
}
|
||||
return FinalizeUninstallLifecycle(context,
|
||||
Result::Error(error_code, diagnostic));
|
||||
}
|
||||
|
||||
int FinalizeUninstallLifecycleAfterProgressUi(
|
||||
const std::optional<UninstallLifecycleContext>& context,
|
||||
const Result& result,
|
||||
base::OnceClosure close_progress_ui) {
|
||||
if (close_progress_ui) {
|
||||
std::move(close_progress_ui).Run();
|
||||
}
|
||||
return FinalizeUninstallLifecycle(context, result);
|
||||
}
|
||||
|
||||
} // namespace cef_installer
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user