117 changes: 65 additions & 52 deletions Source/Core/UpdaterCommon/UpdaterCommon.cpp
Expand Up @@ -34,13 +34,28 @@

// Refer to docs/autoupdate_overview.md for a detailed overview of the autoupdate process

// Where to log updater output.
static FILE* log_fp = stderr;

// Public key used to verify update manifests.
const std::array<u8, 32> UPDATE_PUB_KEY = {
0x2a, 0xb3, 0xd1, 0xdc, 0x6e, 0xf5, 0x07, 0xf6, 0xa0, 0x6c, 0x7c, 0x54, 0xdf, 0x54, 0xf4, 0x42,
0x80, 0xa6, 0x28, 0x8b, 0x6d, 0x70, 0x14, 0xb5, 0x4c, 0x34, 0x95, 0x20, 0x4d, 0xd4, 0xd3, 0x5d};
// The private key for UPDATE_PUB_KEY_TEST is in Tools/test-updater.py
const std::array<u8, 32> UPDATE_PUB_KEY_TEST = {
0x0c, 0x5f, 0xdc, 0xd1, 0x15, 0x71, 0xfb, 0x86, 0x4f, 0x9e, 0x6d, 0xe6, 0x65, 0x39, 0x43, 0xe1,
0x9e, 0xe0, 0x9b, 0x28, 0xc9, 0x1a, 0x60, 0xb7, 0x67, 0x1c, 0xf3, 0xf6, 0xca, 0x1b, 0xdd, 0x1a};

// Where to log updater output.
static FILE* log_fp = stderr;

void LogToFile(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);

vfprintf(log_fp, fmt, args);
fflush(log_fp);

va_end(args);
}

bool ProgressCallback(double total, double now, double, double)
{
Expand Down Expand Up @@ -120,7 +135,7 @@ std::optional<std::string> GzipInflate(const std::string& data)

if (ret != Z_STREAM_END)
{
fprintf(log_fp, "Could not read the data as gzip: error %d.\n", ret);
LogToFile("Could not read the data as gzip: error %d.\n", ret);
return {};
}

Expand Down Expand Up @@ -148,12 +163,13 @@ bool VerifySignature(const std::string& data, const std::string& b64_signature)
b64_signature.size()) ||
sig_size != sizeof(signature))
{
fprintf(log_fp, "Invalid base64: %s\n", b64_signature.c_str());
LogToFile("Invalid base64: %s\n", b64_signature.c_str());
return false;
}

const auto& pub_key = UI::IsTestMode() ? UPDATE_PUB_KEY_TEST : UPDATE_PUB_KEY;
return ed25519_verify(signature, reinterpret_cast<const u8*>(data.data()), data.size(),
UPDATE_PUB_KEY.data());
pub_key.data());
}

void FlushLog()
Expand All @@ -166,22 +182,22 @@ void TodoList::Log() const
{
if (to_update.size())
{
fprintf(log_fp, "Updating:\n");
LogToFile("Updating:\n");
for (const auto& op : to_update)
{
std::string old_desc =
op.old_hash ? HexEncode(op.old_hash->data(), op.old_hash->size()) : "(new)";
fprintf(log_fp, " - %s: %s -> %s\n", op.filename.c_str(), old_desc.c_str(),
HexEncode(op.new_hash.data(), op.new_hash.size()).c_str());
LogToFile(" - %s: %s -> %s\n", op.filename.c_str(), old_desc.c_str(),
HexEncode(op.new_hash.data(), op.new_hash.size()).c_str());
}
}
if (to_delete.size())
{
fprintf(log_fp, "Deleting:\n");
LogToFile("Deleting:\n");
for (const auto& op : to_delete)
{
fprintf(log_fp, " - %s (%s)\n", op.filename.c_str(),
HexEncode(op.old_hash.data(), op.old_hash.size()).c_str());
LogToFile(" - %s (%s)\n", op.filename.c_str(),
HexEncode(op.old_hash.data(), op.old_hash.size()).c_str());
}
}
}
Expand Down Expand Up @@ -215,7 +231,7 @@ bool DownloadContent(const std::vector<TodoList::DownloadOp>& to_download,
content_store_path.insert(2, "/");

std::string url = content_base_url + content_store_path;
fprintf(log_fp, "Downloading %s ...\n", url.c_str());
LogToFile("Downloading %s ...\n", url.c_str());

auto resp = req.Get(url);
if (!resp)
Expand All @@ -234,14 +250,14 @@ bool DownloadContent(const std::vector<TodoList::DownloadOp>& to_download,
Manifest::Hash contents_hash = ComputeHash(decompressed);
if (contents_hash != download.hash)
{
fprintf(log_fp, "Wrong hash on downloaded content %s.\n", url.c_str());
LogToFile("Wrong hash on downloaded content %s.\n", url.c_str());
return false;
}

const std::string out = temp_path + DIR_SEP + hash_filename;
if (!File::WriteStringToFile(out, decompressed))
{
fprintf(log_fp, "Could not write cache file %s.\n", out.c_str());
LogToFile("Could not write cache file %s.\n", out.c_str());
return false;
}
}
Expand All @@ -252,7 +268,7 @@ bool PlatformVersionCheck(const std::vector<TodoList::UpdateOp>& to_update,
const std::string& install_base_path, const std::string& temp_dir)
{
UI::SetDescription("Checking platform...");
return Platform::VersionCheck(to_update, install_base_path, temp_dir, log_fp);
return Platform::VersionCheck(to_update, install_base_path, temp_dir);
}

TodoList ComputeActionsToDo(Manifest this_manifest, Manifest next_manifest)
Expand Down Expand Up @@ -310,10 +326,10 @@ void CleanUpTempDir(const std::string& temp_dir, const TodoList& todo)
bool BackupFile(const std::string& path)
{
std::string backup_path = path + ".bak";
fprintf(log_fp, "Backing up existing %s to .bak.\n", path.c_str());
LogToFile("Backing up existing %s to .bak.\n", path.c_str());
if (!File::Rename(path, backup_path))
{
fprintf(log_fp, "Cound not rename %s to %s for backup.\n", path.c_str(), backup_path.c_str());
LogToFile("Cound not rename %s to %s for backup.\n", path.c_str(), backup_path.c_str());
return false;
}
return true;
Expand All @@ -328,15 +344,15 @@ bool DeleteObsoleteFiles(const std::vector<TodoList::DeleteOp>& to_delete,

if (!File::Exists(path))
{
fprintf(log_fp, "File %s is already missing.\n", op.filename.c_str());
LogToFile("File %s is already missing.\n", op.filename.c_str());
continue;
}
else
{
std::string contents;
if (!File::ReadFileToString(path, contents))
{
fprintf(log_fp, "Could not read file planned for deletion: %s.\n", op.filename.c_str());
LogToFile("Could not read file planned for deletion: %s.\n", op.filename.c_str());
return false;
}
Manifest::Hash contents_hash = ComputeHash(contents);
Expand Down Expand Up @@ -365,7 +381,7 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,
std::string path = install_base_path + DIR_SEP + op.filename;
if (!File::CreateFullPath(path))
{
fprintf(log_fp, "Could not create directory structure for %s.\n", op.filename.c_str());
LogToFile("Could not create directory structure for %s.\n", op.filename.c_str());
return false;
}

Expand All @@ -385,7 +401,7 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,

if (S_ISLNK(file_stats.st_mode))
{
fprintf(log_fp, "%s is symlink, skipping\n", path.c_str());
LogToFile("%s is symlink, skipping\n", path.c_str());
continue;
}

Expand All @@ -408,13 +424,13 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,
std::string contents;
if (!File::ReadFileToString(path, contents))
{
fprintf(log_fp, "Could not read existing file %s.\n", op.filename.c_str());
LogToFile("Could not read existing file %s.\n", op.filename.c_str());
return false;
}
Manifest::Hash contents_hash = ComputeHash(contents);
if (contents_hash == op.new_hash)
{
fprintf(log_fp, "File %s was already up to date. Partial update?\n", op.filename.c_str());
LogToFile("File %s was already up to date. Partial update?\n", op.filename.c_str());
continue;
}
else if (!op.old_hash || contents_hash != *op.old_hash || is_self)
Expand All @@ -426,8 +442,8 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,

// Now we can safely move the new contents to the location.
std::string content_filename = HexEncode(op.new_hash.data(), op.new_hash.size());
fprintf(log_fp, "Updating file %s from content %s...\n", op.filename.c_str(),
content_filename.c_str());
LogToFile("Updating file %s from content %s...\n", op.filename.c_str(),
content_filename.c_str());
#ifdef __APPLE__
// macOS caches the code signature of Mach-O executables when they're first loaded.
// Unfortunately, there is a quirk in the kernel with how it handles the cache: if the file is
Expand All @@ -440,8 +456,7 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,
const std::string temporary_file = temp_path + DIR_SEP + "temporary_file";
if (!File::CopyRegularFile(temp_path + DIR_SEP + content_filename, temporary_file))
{
fprintf(log_fp, "Could not copy %s to %s.\n", content_filename.c_str(),
temporary_file.c_str());
LogToFile("Could not copy %s to %s.\n", content_filename.c_str(), temporary_file.c_str());
return false;
}

Expand All @@ -450,7 +465,7 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,
if (!File::CopyRegularFile(temp_path + DIR_SEP + content_filename, path))
#endif
{
fprintf(log_fp, "Could not update file %s.\n", op.filename.c_str());
LogToFile("Could not update file %s.\n", op.filename.c_str());
return false;
}

Expand All @@ -465,32 +480,32 @@ bool UpdateFiles(const std::vector<TodoList::UpdateOp>& to_update,
bool PerformUpdate(const TodoList& todo, const std::string& install_base_path,
const std::string& content_base_url, const std::string& temp_path)
{
fprintf(log_fp, "Starting download step...\n");
LogToFile("Starting download step...\n");
if (!DownloadContent(todo.to_download, content_base_url, temp_path))
return false;
fprintf(log_fp, "Download step completed.\n");
LogToFile("Download step completed.\n");

fprintf(log_fp, "Starting platform version check step...\n");
LogToFile("Starting platform version check step...\n");
if (!PlatformVersionCheck(todo.to_update, install_base_path, temp_path))
return false;
fprintf(log_fp, "Platform version check step completed.\n");
LogToFile("Platform version check step completed.\n");

fprintf(log_fp, "Starting update step...\n");
LogToFile("Starting update step...\n");
if (!UpdateFiles(todo.to_update, install_base_path, temp_path))
return false;
fprintf(log_fp, "Update step completed.\n");
LogToFile("Update step completed.\n");

fprintf(log_fp, "Starting deletion step...\n");
LogToFile("Starting deletion step...\n");
if (!DeleteObsoleteFiles(todo.to_delete, install_base_path))
return false;
fprintf(log_fp, "Deletion step completed.\n");
LogToFile("Deletion step completed.\n");

return true;
}

void FatalError(const std::string& message)
{
fprintf(log_fp, "%s\n", message.c_str());
LogToFile("%s\n", message.c_str());

UI::SetVisible(true);
UI::Error(message);
Expand All @@ -506,30 +521,28 @@ std::optional<Manifest> ParseManifest(const std::string& manifest)
size_t filename_end_pos = manifest.find('\t', pos);
if (filename_end_pos == std::string::npos)
{
fprintf(log_fp, "Manifest entry %zu: could not find filename end.\n", parsed.entries.size());
LogToFile("Manifest entry %zu: could not find filename end.\n", parsed.entries.size());
return {};
}
size_t hash_end_pos = manifest.find('\n', filename_end_pos);
if (hash_end_pos == std::string::npos)
{
fprintf(log_fp, "Manifest entry %zu: could not find hash end.\n", parsed.entries.size());
LogToFile("Manifest entry %zu: could not find hash end.\n", parsed.entries.size());
return {};
}

std::string filename = manifest.substr(pos, filename_end_pos - pos);
std::string hash = manifest.substr(filename_end_pos + 1, hash_end_pos - filename_end_pos - 1);
if (hash.size() != 32)
{
fprintf(log_fp, "Manifest entry %zu: invalid hash: \"%s\".\n", parsed.entries.size(),
hash.c_str());
LogToFile("Manifest entry %zu: invalid hash: \"%s\".\n", parsed.entries.size(), hash.c_str());
return {};
}

Manifest::Hash decoded_hash;
if (!HexDecode(hash, decoded_hash.data(), decoded_hash.size()))
{
fprintf(log_fp, "Manifest entry %zu: invalid hash: \"%s\".\n", parsed.entries.size(),
hash.c_str());
LogToFile("Manifest entry %zu: invalid hash: \"%s\".\n", parsed.entries.size(), hash.c_str());
return {};
}

Expand All @@ -548,7 +561,7 @@ std::optional<Manifest> FetchAndParseManifest(const std::string& url)
Common::HttpRequest::Response resp = http.Get(url);
if (!resp)
{
fprintf(log_fp, "Manifest download failed.\n");
LogToFile("Manifest download failed.\n");
return {};
}

Expand All @@ -562,7 +575,7 @@ std::optional<Manifest> FetchAndParseManifest(const std::string& url)
size_t boundary = decompressed.rfind("\n\n");
if (boundary == std::string::npos)
{
fprintf(log_fp, "No signature was found in manifest.\n");
LogToFile("No signature was found in manifest.\n");
return {};
}

Expand All @@ -581,7 +594,7 @@ std::optional<Manifest> FetchAndParseManifest(const std::string& url)
}
if (!found_valid_signature)
{
fprintf(log_fp, "Could not verify signature of the manifest.\n");
LogToFile("Could not verify signature of the manifest.\n");
return {};
}

Expand Down Expand Up @@ -692,9 +705,9 @@ bool RunUpdater(std::vector<std::string> args)
atexit(FlushLog);
}

fprintf(log_fp, "Updating from: %s\n", opts.this_manifest_url.c_str());
fprintf(log_fp, "Updating to: %s\n", opts.next_manifest_url.c_str());
fprintf(log_fp, "Install path: %s\n", opts.install_base_path.c_str());
LogToFile("Updating from: %s\n", opts.this_manifest_url.c_str());
LogToFile("Updating to: %s\n", opts.next_manifest_url.c_str());
LogToFile("Install path: %s\n", opts.install_base_path.c_str());

if (!File::IsDirectory(opts.install_base_path))
{
Expand All @@ -704,13 +717,13 @@ bool RunUpdater(std::vector<std::string> args)

if (opts.parent_pid)
{
fprintf(log_fp, "Waiting for parent PID %d to complete...\n", *opts.parent_pid);
LogToFile("Waiting for parent PID %d to complete...\n", *opts.parent_pid);

auto pid = opts.parent_pid.value();

UI::WaitForPID(static_cast<u32>(pid));

fprintf(log_fp, "Completed! Proceeding with update.\n");
LogToFile("Completed! Proceeding with update.\n");
}

UI::SetVisible(true);
Expand Down
1 change: 1 addition & 0 deletions Source/Core/UpdaterCommon/UpdaterCommon.h
Expand Up @@ -49,6 +49,7 @@ struct TodoList
void Log() const;
};

void LogToFile(const char* fmt, ...);
std::string HexEncode(const u8* buffer, size_t size);
Manifest::Hash ComputeHash(const std::string& contents);
bool RunUpdater(std::vector<std::string> args);
38 changes: 25 additions & 13 deletions Source/Core/WinUpdater/Platform.cpp
Expand Up @@ -103,7 +103,10 @@ class BuildInfo
auto key_it = map.find(key);
if (key_it == map.end())
continue;
key_it->second = line.substr(equals_index + 1);
auto val_start = equals_index + 1;
auto eol = line.find('\r', val_start);
auto val_size = (eol == line.npos) ? line.npos : eol - val_start;
key_it->second = line.substr(val_start, val_size);
}
}
Map map;
Expand Down Expand Up @@ -194,9 +197,12 @@ static bool VCRuntimeUpdate(const BuildInfo& build_info)

Common::ScopeGuard redist_deleter([&] { File::Delete(redist_path_u8); });

// The installer also supports /passive and /quiet. We pass neither to allow the user to see and
// interact with the installer.
// The installer also supports /passive and /quiet. We normally pass neither (the
// exception being test automation) to allow the user to see and interact with the installer.
std::wstring cmdline = redist_path.filename().wstring() + L" /install /norestart";
if (UI::IsTestMode())
cmdline += L" /passive /quiet";

STARTUPINFOW startup_info{.cb = sizeof(startup_info)};
PROCESS_INFORMATION process_info;
if (!CreateProcessW(redist_path.c_str(), cmdline.data(), nullptr, nullptr, TRUE, 0, nullptr,
Expand All @@ -213,7 +219,8 @@ static bool VCRuntimeUpdate(const BuildInfo& build_info)
CloseHandle(process_info.hProcess);
// NOTE: Some nonzero exit codes can still be considered success (e.g. if installation was
// bypassed because the same version already installed).
return has_exit_code && exit_code == EXIT_SUCCESS;
return has_exit_code &&
(exit_code == ERROR_SUCCESS || exit_code == ERROR_SUCCESS_REBOOT_REQUIRED);
}

static BuildVersion CurrentOSVersion()
Expand Down Expand Up @@ -241,7 +248,7 @@ static VersionCheckResult OSVersionCheck(const BuildInfo& build_info)

std::optional<BuildInfos> InitBuildInfos(const std::vector<TodoList::UpdateOp>& to_update,
const std::string& install_base_path,
const std::string& temp_dir, FILE* log_fp)
const std::string& temp_dir)
{
const auto op_it = std::find_if(to_update.cbegin(), to_update.cend(),
[&](const auto& op) { return op.filename == "build_info.txt"; });
Expand All @@ -255,7 +262,7 @@ std::optional<BuildInfos> InitBuildInfos(const std::vector<TodoList::UpdateOp>&
if (!File::ReadFileToString(build_info_path, build_info_content) ||
op.new_hash != ComputeHash(build_info_content))
{
fprintf(log_fp, "Failed to read %s\n.", build_info_path.c_str());
LogToFile("Failed to read %s\n.", build_info_path.c_str());
return {};
}
BuildInfos build_infos;
Expand All @@ -266,7 +273,7 @@ std::optional<BuildInfos> InitBuildInfos(const std::vector<TodoList::UpdateOp>&
if (File::ReadFileToString(build_info_path, build_info_content))
{
if (op.old_hash != ComputeHash(build_info_content))
fprintf(log_fp, "Using modified existing BuildInfo %s.\n", build_info_path.c_str());
LogToFile("Using modified existing BuildInfo %s.\n", build_info_path.c_str());
build_infos.current = Platform::BuildInfo(build_info_content);
}
return build_infos;
Expand All @@ -287,11 +294,16 @@ bool CheckBuildInfo(const BuildInfos& build_infos)
// Check if application being launched needs more recent version of VC Redist. If so, download
// latest updater and execute it.
auto vc_check = VCRuntimeVersionCheck(build_infos);
if (vc_check.status != VersionCheckStatus::NothingToDo)
const auto is_test_mode = UI::IsTestMode();
if (vc_check.status != VersionCheckStatus::NothingToDo || is_test_mode)
{
// Don't bother checking status of the install itself, just check if we actually see the new
// version.
VCRuntimeUpdate(build_infos.next);
auto update_ok = VCRuntimeUpdate(build_infos.next);
if (!update_ok && is_test_mode)
{
// For now, only check return value when test automation is running.
// The vc_redist exe may return other non-zero status that we don't check for, yet.
return false;
}
vc_check = VCRuntimeVersionCheck(build_infos);
if (vc_check.status == VersionCheckStatus::UpdateRequired)
{
Expand All @@ -305,9 +317,9 @@ bool CheckBuildInfo(const BuildInfos& build_infos)
}

bool VersionCheck(const std::vector<TodoList::UpdateOp>& to_update,
const std::string& install_base_path, const std::string& temp_dir, FILE* log_fp)
const std::string& install_base_path, const std::string& temp_dir)
{
auto build_infos = InitBuildInfos(to_update, install_base_path, temp_dir, log_fp);
auto build_infos = InitBuildInfos(to_update, install_base_path, temp_dir);
// If there's no build info, it means the check should be skipped.
if (!build_infos.has_value())
{
Expand Down
31 changes: 28 additions & 3 deletions Source/Core/WinUpdater/WinUI.cpp
Expand Up @@ -3,12 +3,14 @@

#include "UpdaterCommon/UI.h"

#include <cstdlib>
#include <string>
#include <thread>

#include <Windows.h>
#include <CommCtrl.h>
#include <ShObjIdl.h>
#include <ShlObj.h>
#include <shellapi.h>
#include <wrl/client.h>

Expand Down Expand Up @@ -253,11 +255,34 @@ void Stop()
ui_thread.join();
}

bool IsTestMode()
{
return std::getenv("DOLPHIN_UPDATE_SERVER_URL") != nullptr;
}

void LaunchApplication(std::string path)
{
// Indirectly start the application via explorer. This effectively drops admin priviliges because
// explorer is running as current user.
ShellExecuteW(nullptr, nullptr, L"explorer.exe", UTF8ToWString(path).c_str(), nullptr, SW_SHOW);
const auto wpath = UTF8ToWString(path);
if (IsUserAnAdmin())
{
// Indirectly start the application via explorer. This effectively drops admin privileges
// because explorer is running as current user.
ShellExecuteW(nullptr, nullptr, L"explorer.exe", wpath.c_str(), nullptr, SW_SHOW);
}
else
{
std::wstring cmdline = wpath;
STARTUPINFOW startup_info{.cb = sizeof(startup_info)};
PROCESS_INFORMATION process_info;
if (IsTestMode())
SetEnvironmentVariableA("DOLPHIN_UPDATE_TEST_DONE", "1");
if (CreateProcessW(wpath.c_str(), cmdline.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr,
&startup_info, &process_info))
{
CloseHandle(process_info.hThread);
CloseHandle(process_info.hProcess);
}
}
}

void Sleep(int sleep)
Expand Down
200 changes: 200 additions & 0 deletions Tools/test-updater.py
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
# requirements: pycryptodome
from Crypto.PublicKey import ECC
from Crypto.Signature import eddsa
from hashlib import sha256
from pathlib import Path
import base64
import configparser
import gzip
import http.server
import json
import os
import shutil
import socketserver
import subprocess
import sys
import tempfile
import threading
import time

UPDATE_KEY_TEST = ECC.construct(
curve="Ed25519",
seed=bytes.fromhex(
"543a581db60008bbb978a464e136d686dbc9d594119e928b5276bece3d583d81"
),
)

HTTP_SERVER_ADDR = ("localhost", 8042)
DOLPHIN_UPDATE_SERVER_URL = f"http://{HTTP_SERVER_ADDR[0]}:{HTTP_SERVER_ADDR[1]}"


class Manifest:
def __init__(self, path: Path):
self.path = path
self.entries = {}
for p in self.path.glob("**/*.*"):
if not p.is_file():
continue
digest = sha256(p.read_bytes()).digest()[:0x10].hex()
self.entries[digest] = p.relative_to(self.path).as_posix()

def get_signed(self):
manifest = "".join(
f"{name}\t{digest}\n" for digest, name in self.entries.items()
)
manifest = manifest.encode("utf-8")
sig = eddsa.new(UPDATE_KEY_TEST, "rfc8032").sign(manifest)
manifest += b"\n" + base64.b64encode(sig) + b"\n"
return gzip.compress(manifest)

def get_path(self, digest):
return self.path.joinpath(self.entries.get(digest))


class HTTPRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/update/check/v1/updater-test"):
self.send_response(200)
self.end_headers()
self.wfile.write(
bytes(
json.dumps(
{
"status": "outdated",
"content-store": DOLPHIN_UPDATE_SERVER_URL + "/content/",
"changelog": [],
"old": {"manifest": DOLPHIN_UPDATE_SERVER_URL + "/old"},
"new": {
"manifest": DOLPHIN_UPDATE_SERVER_URL + "/new",
"name": "updater-test",
"hash": bytes(range(32)).hex(),
},
}
),
"utf-8",
)
)
elif self.path == "/old":
self.send_response(200)
self.end_headers()
self.wfile.write(self.current.get_signed())
elif self.path == "/new":
self.send_response(200)
self.end_headers()
self.wfile.write(self.next.get_signed())
elif self.path.startswith("/content/"):
self.send_response(200)
self.end_headers()
digest = "".join(self.path[len("/content/") :].split("/"))
path = self.next.get_path(digest)
self.wfile.write(gzip.compress(path.read_bytes()))
elif self.path.startswith("/update-test-done/"):
self.send_response(200)
self.end_headers()
HTTPRequestHandler.dolphin_pid = int(self.path[len("/update-test-done/") :])
self.done.set()


def http_server():
with socketserver.TCPServer(HTTP_SERVER_ADDR, HTTPRequestHandler) as httpd:
httpd.serve_forever()


def create_entries_in_ini(ini_path: Path, entries: dict):
config = configparser.ConfigParser()
if ini_path.exists():
config.read(ini_path)
else:
ini_path.parent.mkdir(parents=True, exist_ok=True)

for section, options in entries.items():
if not config.has_section(section):
config.add_section(section)
for option, value in options.items():
config.set(section, option, value)

with ini_path.open("w") as f:
config.write(f)


if __name__ == "__main__":
dolphin_bin_path = Path(sys.argv[1])

threading.Thread(target=http_server, daemon=True).start()

with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir)

tmp_dolphin = tmp_dir.joinpath("dolphin")
print(f"install to {tmp_dolphin}")
shutil.copytree(dolphin_bin_path.parent, tmp_dolphin)
tmp_dolphin.joinpath("portable.txt").touch()
create_entries_in_ini(
tmp_dolphin.joinpath("User/Config/Dolphin.ini"),
{
"Analytics": {"Enabled": "False", "PermissionAsked": "True"},
"AutoUpdate": {"UpdateTrack": "updater-test"},
},
)

tmp_dolphin_next = tmp_dir.joinpath("dolphin_next")
print(f"install next to {tmp_dolphin_next}")
# XXX copies from just-created dir so Dolphin.ini is kept
shutil.copytree(tmp_dolphin, tmp_dolphin_next)
tmp_dolphin_next.joinpath("updater-test-file").write_text("test")
with tmp_dolphin_next.joinpath("build_info.txt").open("a") as f:
print("test", file=f)
for ext in ("exe", "dll"):
for path in tmp_dolphin_next.glob("**/*." + ext):
data = bytearray(path.read_bytes())
richpos = data[:0x200].find(b"Rich")
if richpos < 0:
continue
data[richpos : richpos + 4] = b"DOLP"
path.write_bytes(data)

HTTPRequestHandler.current = Manifest(tmp_dolphin)
HTTPRequestHandler.next = Manifest(tmp_dolphin_next)
HTTPRequestHandler.done = threading.Event()

tmp_env = os.environ
tmp_env.update({"DOLPHIN_UPDATE_SERVER_URL": DOLPHIN_UPDATE_SERVER_URL})
tmp_dolphin_bin = tmp_dolphin.joinpath(dolphin_bin_path.name)
result = subprocess.run(tmp_dolphin_bin, env=tmp_env)
assert result.returncode == 0

assert HTTPRequestHandler.done.wait(60 * 2)
# works fine but raises exceptions...
try:
os.kill(HTTPRequestHandler.dolphin_pid, 0)
except:
pass
try:
os.waitpid(HTTPRequestHandler.dolphin_pid, 0)
except:
pass

failed = False
for path in tmp_dolphin_next.glob("**/*.*"):
if not path.is_file():
continue
path_rel = path.relative_to(tmp_dolphin_next)
if path_rel.parts[0] == "User":
continue
new_path = tmp_dolphin.joinpath(path_rel)
if not new_path.exists():
print(f"missing: {new_path}")
failed = True
continue
if (
sha256(new_path.read_bytes()).digest()
!= sha256(path.read_bytes()).digest()
):
print(f"bad digest: {new_path} {path}")
failed = True
continue
assert not failed

print(tmp_dolphin.joinpath("User/Logs/Updater.log").read_text())
# while True: time.sleep(1)