fix: harden mirrored downloads and first-run upgrades
This commit is contained in:
1 parent
df0a7dc37a
commit
53c5680260
10 files changed
+437
-160
No files matched your search
@@ -22,7 +22,10 @@ pytestmark = pytest.mark.skipif(
|
||||
def powershell(tmp_path, script, **env):
|
||||
path = tmp_path / "test.ps1"
|
||||
path.write_text(
|
||||
"$ErrorActionPreference = 'Stop'\n. $env:DOWNLOAD_SCRIPT\n" + script, encoding="ascii"
|
||||
"$ErrorActionPreference = 'Stop'\n"
|
||||
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
|
||||
". $env:DOWNLOAD_SCRIPT\n" + script,
|
||||
encoding="ascii",
|
||||
)
|
||||
clean_env = {
|
||||
key: value
|
||||
@@ -38,7 +41,7 @@ def powershell(tmp_path, script, **env):
|
||||
**env,
|
||||
},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=45,
|
||||
)
|
||||
|
||||
@@ -58,7 +61,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw '
|
||||
if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' }
|
||||
if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' }
|
||||
if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' }
|
||||
if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' }
|
||||
if (([Uri]$ArtemisDownloads.scrcpy_url).Host -eq 'github.com') { throw 'China scrcpy still uses GitHub' }
|
||||
if ($ArtemisDownloads.scrcpy_urls.Count -lt 2) { throw 'China scrcpy has no fallback' }
|
||||
if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' }
|
||||
Remove-Item Env:npm_config_registry
|
||||
Remove-Item Env:UV_PYTHON_INSTALL_MIRROR
|
||||
@@ -71,11 +75,153 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry'
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_scrcpy_archive_is_checked_before_extraction():
|
||||
launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig")
|
||||
assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index(
|
||||
"Remove-Item $extractDir -Recurse"
|
||||
@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"])
|
||||
def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure):
|
||||
archive = tmp_path / "fixture.zip"
|
||||
with zipfile.ZipFile(archive, "w") as package:
|
||||
package.writestr("scrcpy-win64-v4.1/scrcpy.exe", b"verified executable")
|
||||
package.writestr("scrcpy-win64-v4.1/scrcpy-server", b"verified server")
|
||||
digest = hashlib.sha256(archive.read_bytes()).hexdigest()
|
||||
install_dir = tmp_path / "user/Programs/scrcpy"
|
||||
install_dir.mkdir(parents=True)
|
||||
(install_dir / "keep.txt").write_text("user file")
|
||||
temp_dir = tmp_path / "temporary"
|
||||
temp_dir.mkdir()
|
||||
result = powershell(
|
||||
tmp_path,
|
||||
"""
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors)
|
||||
if ($errors) { throw 'Launcher parse error' }
|
||||
$installer = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Install-PortableScrcpy' }, $true)
|
||||
. ([scriptblock]::Create($installer.Extent.Text))
|
||||
function Get-LaunchText { param($English, $Chinese); return $English }
|
||||
$script:attempts = @()
|
||||
function Invoke-DownloadFile {
|
||||
param($Uri, $OutFile)
|
||||
$script:attempts += $Uri
|
||||
if ($Uri -eq 'https://first.example/scrcpy.zip' -or $env:FIRST_FAILURE -eq 'all') {
|
||||
Set-Content -LiteralPath $OutFile -Value 'rejected content'
|
||||
if ($env:FIRST_FAILURE -eq 'hash') { return $true }
|
||||
$script:ArtemisDownloadError = if ($env:FIRST_FAILURE -eq 'timeout') { 'Download timed out' } else { 'HTTP 404' }
|
||||
return $false
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'fixture.zip') -Destination $OutFile -Force
|
||||
return $true
|
||||
}
|
||||
$script:ArtemisDownloads = [pscustomobject]@{
|
||||
scrcpy_urls = @('https://first.example/scrcpy.zip', 'https://second.example/scrcpy.zip')
|
||||
scrcpy_sha256 = $env:FIXTURE_HASH
|
||||
}
|
||||
$installed = Install-PortableScrcpy
|
||||
Write-Host "INSTALLED=$installed"
|
||||
Write-Host ('ATTEMPTS=' + ($script:attempts -join ','))
|
||||
""",
|
||||
LOCALAPPDATA=str(tmp_path / "user"),
|
||||
TEMP=str(temp_dir),
|
||||
TMP=str(temp_dir),
|
||||
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
|
||||
FIRST_FAILURE=first_failure,
|
||||
FIXTURE_HASH=digest,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
assert "ATTEMPTS=https://first.example/scrcpy.zip,https://second.example/scrcpy.zip" in (
|
||||
result.stdout
|
||||
)
|
||||
assert (install_dir / "keep.txt").read_text() == "user file"
|
||||
assert not list(temp_dir.iterdir()), "Installer must clean its temporary download directory"
|
||||
if first_failure == "all":
|
||||
assert "INSTALLED=False" in result.stdout
|
||||
assert "HTTP 404" in result.stdout
|
||||
assert not (install_dir / "scrcpy.exe").exists()
|
||||
else:
|
||||
assert "INSTALLED=True" in result.stdout
|
||||
assert (install_dir / "scrcpy.exe").read_bytes() == b"verified executable"
|
||||
assert (install_dir / "scrcpy-server").read_bytes() == b"verified server"
|
||||
expected_error = {
|
||||
"http": "HTTP 404",
|
||||
"timeout": "Download timed out",
|
||||
"hash": "SHA256 verification failed",
|
||||
}[first_failure]
|
||||
assert expected_error in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("curl_exit", [22, 28])
|
||||
def test_download_reports_http_or_timeout_without_repeating_request(tmp_path, curl_exit):
|
||||
result = powershell(
|
||||
tmp_path,
|
||||
"""
|
||||
function curl.exe {
|
||||
$target = $args[[Array]::IndexOf($args, '-o') + 1]
|
||||
Set-Content -LiteralPath $target -Value 'partial download'
|
||||
$global:LASTEXITCODE = [int]$env:CURL_EXIT
|
||||
return '404'
|
||||
}
|
||||
function Invoke-WebRequest { throw 'HTTP rejection/timeout must not be retried with a second client' }
|
||||
$destination = Join-Path $env:TEST_ROOT 'download.zip'
|
||||
$downloaded = Invoke-DownloadFile -Uri 'https://missing.example/file.zip' -OutFile $destination -TimeoutSec 10
|
||||
Write-Host "DOWNLOADED=$downloaded"
|
||||
Write-Host "REASON=$script:ArtemisDownloadError"
|
||||
if (Test-Path -LiteralPath $destination) { throw 'Partial download was left behind' }
|
||||
""",
|
||||
CURL_EXIT=str(curl_exit),
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "DOWNLOADED=False" in result.stdout
|
||||
assert ("HTTP 404" if curl_exit == 22 else "timed out after 10 seconds") in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fallbacks,valid",
|
||||
[
|
||||
(["https://backup.example/scrcpy.zip"], True),
|
||||
([], True),
|
||||
(["http://insecure.example/scrcpy.zip"], False),
|
||||
("https://not-an-array.example/scrcpy.zip", False),
|
||||
([None], False),
|
||||
],
|
||||
)
|
||||
def test_scrcpy_custom_sources_validate_urls_and_preserve_order(tmp_path, fallbacks, valid):
|
||||
config = json.loads((ROOT / "config/downloads.json").read_text())
|
||||
config["overrides"]["scrcpy_url"] = "https://custom.example/scrcpy.zip"
|
||||
config["overrides"]["scrcpy_fallback_urls"] = fallbacks
|
||||
(tmp_path / "config").mkdir()
|
||||
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
|
||||
result = powershell(
|
||||
tmp_path,
|
||||
"""
|
||||
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
|
||||
Write-Host ('SOURCES=' + ($ArtemisDownloads.scrcpy_urls -join ','))
|
||||
""",
|
||||
)
|
||||
if valid:
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
assert "SOURCES=" + ",".join(["https://custom.example/scrcpy.zip", *fallbacks]) in (
|
||||
result.stdout
|
||||
)
|
||||
else:
|
||||
assert result.returncode != 0
|
||||
assert "HTTPS URLs" in result.stderr
|
||||
|
||||
|
||||
def test_china_sources_never_fall_back_to_direct_github(tmp_path):
|
||||
config = json.loads((ROOT / "config/downloads.json").read_text())
|
||||
config["overrides"]["scrcpy_fallback_urls"] = [
|
||||
"https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip",
|
||||
config["china"]["scrcpy_url"], # Duplicate should be downloaded only once.
|
||||
]
|
||||
(tmp_path / "config").mkdir()
|
||||
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
|
||||
result = powershell(
|
||||
tmp_path,
|
||||
"""
|
||||
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China
|
||||
if ($ArtemisDownloads.scrcpy_urls.Count -ne 1) { throw 'Invalid China sources' }
|
||||
if (([Uri]$ArtemisDownloads.scrcpy_urls[0]).Host -eq 'github.com') { throw 'Direct GitHub fallback' }
|
||||
""",
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("corrupt", [False, True])
|
||||
|
||||
@@ -11,6 +11,15 @@ import time
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
UPGRADE_FILES = (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
"windows_tools.ps1",
|
||||
"download_config.ps1",
|
||||
"bootstrap_python.ps1",
|
||||
)
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"),
|
||||
reason="Windows PowerShell and uv required",
|
||||
@@ -138,20 +147,22 @@ def test_real_project_process_tree_stops_but_unrelated_process_survives(tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prepare_exit", [0, 7])
|
||||
@pytest.mark.parametrize("initialized", [True, False])
|
||||
def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
|
||||
tmp_path, prepare_exit
|
||||
tmp_path, prepare_exit, initialized
|
||||
):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
author = tmp_path / "author"
|
||||
(author / "scripts").mkdir(parents=True)
|
||||
for name in (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
):
|
||||
for name in UPGRADE_FILES:
|
||||
shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name)
|
||||
(author / "config").mkdir()
|
||||
shutil.copyfile(ROOT / "config/downloads.json", author / "config/downloads.json")
|
||||
(author / "pyproject.toml").write_text(
|
||||
'[project]\nname="upgrade-fixture"\nversion="0.1.0"\nrequires-python=">=3.12"\ndependencies=[]\n'
|
||||
)
|
||||
(author / "uv.lock").write_text('version = 1\nrequires-python = ">=3.12"\n')
|
||||
for name in ("update.bat", "update-cn.bat"):
|
||||
shutil.copyfile(ROOT / name, author / name)
|
||||
(author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n")
|
||||
@@ -161,15 +172,18 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
|
||||
git(author, "config", "user.email", "fixture@example.test")
|
||||
git(author, "add", ".")
|
||||
git(author, "commit", "-m", "old version")
|
||||
installed = tmp_path / "installed with spaces!"
|
||||
installed = tmp_path / "installed 中文 with spaces!"
|
||||
git(tmp_path, "clone", "-b", "main", str(author), str(installed))
|
||||
make_venv(installed)
|
||||
if initialized:
|
||||
make_venv(installed)
|
||||
(installed / ".env").write_text("secret stays local")
|
||||
# A running file AND both BAT wrappers are replaced during the merge.
|
||||
(author / "scripts/upgrade.ps1").write_text("throw 'new updater must not run in this turn'\n")
|
||||
(author / "scripts/upgrade_processes.ps1").write_text(
|
||||
"throw 'new process helper must not run'\n"
|
||||
)
|
||||
for name in ("windows_tools.ps1", "download_config.ps1", "bootstrap_python.ps1"):
|
||||
(author / "scripts" / name).write_text("throw 'new bootstrap helper must not run'\n")
|
||||
(author / "update.bat").write_text("@echo off\nexit /b 33\n")
|
||||
(author / "update-cn.bat").write_text("@echo off\nexit /b 34\n")
|
||||
(author / "scripts/start.ps1").write_text(
|
||||
@@ -184,17 +198,25 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure(
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", "update-cn.bat"],
|
||||
cwd=installed,
|
||||
env={**os.environ, "UV_OFFLINE": "1", "ARTEMIS_NO_PAUSE": "1"},
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=45,
|
||||
)
|
||||
assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr
|
||||
assert result.stderr == ""
|
||||
if initialized:
|
||||
assert result.stderr == ""
|
||||
else:
|
||||
# uv prints normal venv creation progress to stderr.
|
||||
assert "Creating virtual environment" in result.stderr
|
||||
assert (installed / "prepared.txt").read_text().strip() == "new version prepared"
|
||||
assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD")
|
||||
assert (installed / ".env").read_text() == "secret stays local"
|
||||
assert ("升级完成" in result.stdout) == (prepare_exit == 0)
|
||||
assert "new updater must not run" not in result.stdout
|
||||
assert "new bootstrap helper must not run" not in result.stdout
|
||||
assert ("首次准备" in result.stdout) == (not initialized)
|
||||
assert (installed / ".venv/Scripts/python.exe").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"])
|
||||
@@ -262,7 +284,7 @@ def test_installation_lock_blocks_same_checkout_and_recovers_after_owner_crash(t
|
||||
def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path):
|
||||
root = tmp_path / "prepared project"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in ("start.ps1", "installation_lock.ps1"):
|
||||
for name in ("start.ps1", "installation_lock.ps1", "windows_tools.ps1"):
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
(root / "scripts/download_config.ps1").write_text(
|
||||
"function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n",
|
||||
@@ -314,17 +336,13 @@ def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_pat
|
||||
assert (root / ".env").read_text() == "keep credentials"
|
||||
|
||||
|
||||
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
|
||||
@pytest.mark.parametrize("initialized", [True, False])
|
||||
def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path, initialized):
|
||||
if not shutil.which("git"):
|
||||
pytest.skip("Git required")
|
||||
root = tmp_path / "dirty installation"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in (
|
||||
"upgrade.ps1",
|
||||
"upgrade_processes.ps1",
|
||||
"installation_lock.ps1",
|
||||
"update_installation.py",
|
||||
):
|
||||
for name in UPGRADE_FILES:
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
(root / ".gitignore").write_text(".venv/\n")
|
||||
source = root / "user.txt"
|
||||
@@ -334,7 +352,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
|
||||
git(root, "config", "user.email", "fixture@example.test")
|
||||
git(root, "add", ".")
|
||||
git(root, "commit", "-m", "initial")
|
||||
python = make_venv(root)
|
||||
python = make_venv(root) if initialized else sys.executable
|
||||
source.write_text("user changes")
|
||||
worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"])
|
||||
try:
|
||||
@@ -344,6 +362,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
|
||||
assert "[STOP]" not in result.stdout
|
||||
assert worker.poll() is None
|
||||
assert source.read_text() == "user changes"
|
||||
assert (root / ".venv").exists() == initialized
|
||||
finally:
|
||||
# Windows venv redirectors can have a second interpreter process.
|
||||
import psutil
|
||||
@@ -355,3 +374,32 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path):
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
worker.wait(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("launcher", ["update.bat", "update-cn.bat"])
|
||||
def test_fresh_non_git_directory_reports_error_without_bootstrapping_or_hanging(tmp_path, launcher):
|
||||
root = tmp_path / "未初始化 source with spaces!"
|
||||
(root / "scripts").mkdir(parents=True)
|
||||
for name in UPGRADE_FILES:
|
||||
shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name)
|
||||
for name in ("update.bat", "update-cn.bat"):
|
||||
shutil.copyfile(ROOT / name, root / name)
|
||||
(root / ".env").write_text("existing config must stay local")
|
||||
result = subprocess.run(
|
||||
["cmd.exe", "/d", "/c", launcher],
|
||||
cwd=root,
|
||||
# Redirected input must not wait for a key, even without ARTEMIS_NO_PAUSE.
|
||||
env={key: value for key, value in os.environ.items() if key != "ARTEMIS_NO_PAUSE"},
|
||||
input="",
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
timeout=15,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert result.stderr == ""
|
||||
assert "[升级失败]" in result.stdout
|
||||
assert "此目录不含 Git 信息" in result.stdout
|
||||
assert "首次准备" not in result.stdout
|
||||
assert "�" not in result.stdout
|
||||
assert not (root / ".venv").exists()
|
||||
assert (root / ".env").read_text() == "existing config must stay local"
|
||||
Reference in new issue
Block a user