diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 591cf6c..fbfdef5 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -31,6 +31,9 @@ jobs: steps: - uses: actions/checkout@v4 + - name: installer release fallback tests + run: python3 scripts/test-release-fallback.py + # ---------- Rust 存储 ---------- - name: Rust toolchain uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64a1f87..0c02614 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ # 装的人拿到的就是编好的二进制,不需要 GHC / Rust: # curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh \ # | sh -s -- --component web -# 也可下载 ChuRepo 脚本(发行包仍默认取自 GitHub): +# 也可下载 ChuRepo 脚本(发行包下载失败时自动回退): # curl -fsSL https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.sh -o install.sh # sh install.sh --component web # 两站发行包与源码安装入口见 docs/install.md。 diff --git a/README.md b/README.md index e22932f..87b2df8 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ ChuSQL 是采用 Haskell SQL 引擎与 Rust 存储层的数据库项目,提供 Linux / macOS: ```sh -curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh | sh -s -- --component web +(curl -fsSL --connect-timeout 10 \ + https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh -o install.sh || + curl -fsSL --connect-timeout 10 \ + https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.sh -o install.sh) && +sh install.sh --component web ``` 也可从 ChuRepo 下载后执行: @@ -35,7 +39,11 @@ sh install.sh --component web Windows(PowerShell 里下下来再跑;也有下载 zip 解压的包内安装): ```powershell -irm https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.ps1 -OutFile "$env:TEMP\install.ps1" +try { + irm https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.ps1 -OutFile "$env:TEMP\install.ps1" -TimeoutSec 60 -ErrorAction Stop +} catch { + irm https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.ps1 -OutFile "$env:TEMP\install.ps1" -TimeoutSec 60 -ErrorAction Stop +} & "$env:TEMP\install.ps1" -Component web ``` @@ -46,7 +54,7 @@ irm https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.ps1 - & "$env:TEMP\install.ps1" -Component web ``` -以上在线安装脚本默认从 GitHub 获取发行包,脚本下载来源不会改变发行包来源。GitHub 不可用时,参见[包内安装](docs/install.md#包内安装)或[从源码安装](docs/install.md#从源码安装)。 +上述命令下载脚本时优先 GitHub,失败后尝试 ChuRepo。脚本安装和更新时也会自动回退:版本查询或发行包下载失败时尝试 ChuRepo;已选定的版本保持不变,校验失败直接报错。两站均无对应发行包时需使用包内安装或源码安装。显式指定其他仓库或归档 URL 时不会回退到官方仓库。 ### 启动 diff --git a/docs/install.md b/docs/install.md index d03cbed..78dba93 100644 --- a/docs/install.md +++ b/docs/install.md @@ -13,7 +13,11 @@ Linux / macOS: ```sh -curl -fsSL https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh | sh -s -- --component web +(curl -fsSL --connect-timeout 10 \ + https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.sh -o install.sh || + curl -fsSL --connect-timeout 10 \ + https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.sh -o install.sh) && +sh install.sh --component web ``` 也可从 ChuRepo 下载后执行: @@ -28,7 +32,11 @@ sh install.sh --component web Windows PowerShell: ```powershell -irm https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.ps1 -OutFile "$env:TEMP\install.ps1" +try { + irm https://raw.githubusercontent.com/TheBadRoger/ChuSQL/main/scripts/install.ps1 -OutFile "$env:TEMP\install.ps1" -TimeoutSec 60 -ErrorAction Stop +} catch { + irm https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.ps1 -OutFile "$env:TEMP\install.ps1" -TimeoutSec 60 -ErrorAction Stop +} & "$env:TEMP\install.ps1" -Component web ``` @@ -39,7 +47,7 @@ irm https://repo.chunana.cn/ChuNa7a/ChuSQL/raw/branch/main/scripts/install.ps1 - & "$env:TEMP\install.ps1" -Component web ``` -以上在线安装脚本默认从 GitHub 获取发行包,脚本下载来源不会改变发行包来源。GitHub 不可用时,参见[包内安装](#包内安装)或[从源码安装](#从源码安装)。 +上述命令下载脚本时优先 GitHub,失败后尝试 ChuRepo。脚本安装和更新时也会自动回退:版本查询或发行包下载失败时尝试 ChuRepo;已选定的版本保持不变,校验失败直接报错。两站均无对应发行包时需使用包内安装或源码安装。显式指定其他仓库或归档 URL 时不会回退到官方仓库。 ## 安装方式 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b20c15a..fc71123 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -37,6 +37,8 @@ function Note([string]$msg) { Write-Host " note: $msg" } # ---- 发行站、平台标签 ---- $githubBase = if ($env:CHUSQL_GITHUB_BASE) { $env:CHUSQL_GITHUB_BASE.TrimEnd('/') } else { 'https://github.com' } +$churepoBase = if ($env:CHUSQL_CHUREPO_BASE) { $env:CHUSQL_CHUREPO_BASE.TrimEnd('/') } else { 'https://repo.chunana.cn' } +$churepoRepo = 'ChuNa7a/ChuSQL' $apiBase = if ($githubBase -match '^https?://github\.com$') { 'https://api.github.com' } else { "$githubBase/api/v3" } # 查版本走 API,未认证只有 60 次/小时;给了 token 就带上(CI 里 GITHUB_TOKEN 一般就有) $githubToken = if ($env:CHUSQL_GITHUB_TOKEN) { $env:CHUSQL_GITHUB_TOKEN } elseif ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } else { '' } @@ -69,18 +71,22 @@ function Select-Tag([bool]$stableOnly, [bool]$needPackage, $releases) { # 取 URL 的文本内容(字节数组先转字符串)。 function Get-ResponseText([string]$url) { - $response = Invoke-WebRequest -Uri $url -Headers $apiHeaders -UseBasicParsing + $headers = if ($url.StartsWith("$churepoBase/")) { @{ 'User-Agent' = 'chusql-install' } } else { $apiHeaders } + $response = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -TimeoutSec 300 if ($response.Content -is [byte[]]) { return [System.Text.Encoding]::UTF8.GetString($response.Content) } return [string]$response.Content } # 拉取 release 列表并逐个输出。 function Get-Releases { - try { $text = Get-ResponseText "$apiBase/repos/$Repo/releases?per_page=100" } catch { return } - try { $parsed = ConvertFrom-Json -InputObject $text } catch { return } - if ($null -eq $parsed) { return } - foreach ($release in @($parsed)) { - if ($null -ne $release) { Write-Output $release } + $urls = @("$apiBase/repos/$Repo/releases?per_page=100") + if ($Repo -eq 'TheBadRoger/ChuSQL') { $urls += "$churepoBase/api/v1/repos/$churepoRepo/releases?limit=100" } + foreach ($url in $urls) { + try { + $parsed = @(ConvertFrom-Json -InputObject (Get-ResponseText $url)) + $releases = @($parsed | Where-Object { $null -ne $_ -and $_.tag_name }) + if ($releases.Count -gt 0) { return $releases } + } catch { Note "release API unavailable: $url" } } } @@ -241,13 +247,27 @@ if (-not (Test-Path (Join-Path $pack 'bin\chusql_core_storage.dll'))) { $tryUrl = if ($Version -eq 'latest') { "$githubBase/$Repo/releases/latest/download/$candidate" } else { "$githubBase/$Repo/releases/download/$Version/$candidate" } try { - Invoke-WebRequest -Uri $tryUrl -Headers $apiHeaders -OutFile $zip -UseBasicParsing + Invoke-WebRequest -Uri $tryUrl -Headers $apiHeaders -OutFile $zip -UseBasicParsing -TimeoutSec 300 $asset = $candidate $assetUrl = $tryUrl break - } catch { - continue - } + } catch { Note "asset unavailable: $tryUrl" } + if ($Repo -ne 'TheBadRoger/ChuSQL') { continue } + try { + $fallbackVersion = $Version + if ($fallbackVersion -eq 'latest') { + $latest = ConvertFrom-Json -InputObject (Get-ResponseText "$churepoBase/api/v1/repos/$churepoRepo/releases/latest") + $fallbackVersion = $latest.tag_name + if (-not $fallbackVersion) { throw 'ChuRepo returned no release tag' } + } + $tryUrl = "$churepoBase/$churepoRepo/releases/download/$fallbackVersion/$candidate" + Note "trying ChuRepo: $candidate ($fallbackVersion)" + Invoke-WebRequest -Uri $tryUrl -OutFile $zip -UseBasicParsing -TimeoutSec 300 + $asset = $candidate + $assetUrl = $tryUrl + $Version = $fallbackVersion + break + } catch { Note "ChuRepo asset unavailable: $candidate" } } if (-not $asset) { Fail "no package for windows-$arch in $Repo ($Version); try -ListVersions, or install from an archive you downloaded yourself" diff --git a/scripts/install.sh b/scripts/install.sh index e7e3a29..c046af1 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -17,6 +17,9 @@ esac # 查版本走 API,未认证只有 60 次/小时;给了 token 就带上(CI 里 GITHUB_TOKEN 一般就有) github_token="${CHUSQL_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}" +churepo_base="${CHUSQL_CHUREPO_BASE:-https://repo.chunana.cn}" +churepo_base="${churepo_base%/}" +churepo_repo='ChuNa7a/ChuSQL' # 打印用法、选项与环境变量说明。 usage() { @@ -44,7 +47,7 @@ usage: ./install.sh [--component web|cli|both] [options] --no-service skip Linux systemd registration and boot autostart where the files come from: - (default) a package next to this script, else the newest GitHub release + (default) a local package, else GitHub releases with ChuRepo fallback --url URL|PATH install from this archive (URL, file:// URL or local path) --repo OWNER/NAME GitHub repository (default: TheBadRoger/ChuSQL) --version TAG release tag to download / git ref to build @@ -59,6 +62,8 @@ environment: --password CHUSQL_GITHUB_BASE GitHub base URL: GitHub Enterprise, a mirror, or a local test fixture (default: https://github.com) + CHUSQL_CHUREPO_BASE fallback base URL (default: https://repo.chunana.cn) + fallback only applies to the default repository CHUSQL_GITHUB_TOKEN token for the version API (or GITHUB_TOKEN); without one the API allows 60 requests per hour EOF @@ -276,15 +281,15 @@ new_tmp() { download() { if command -v curl >/dev/null 2>&1; then if [ -n "${3:-}" ]; then - curl -fsSL -H "$3" -H 'Accept: application/vnd.github+json' -o "$2" "$1" || return 1 + curl -fsSL --connect-timeout 10 --max-time 300 -H "$3" -H 'Accept: application/vnd.github+json' -o "$2" "$1" || return 1 else - curl -fsSL -o "$2" "$1" || return 1 + curl -fsSL --connect-timeout 10 --max-time 300 -o "$2" "$1" || return 1 fi elif command -v wget >/dev/null 2>&1; then if [ -n "${3:-}" ]; then - wget -q --header="$3" --header='Accept: application/vnd.github+json' -O "$2" "$1" || return 1 + wget -q --timeout=30 --tries=1 --header="$3" --header='Accept: application/vnd.github+json' -O "$2" "$1" || return 1 else - wget -q -O "$2" "$1" || return 1 + wget -q --timeout=30 --tries=1 -O "$2" "$1" || return 1 fi else fail 'need curl or wget to download the package' @@ -342,18 +347,41 @@ detect_platform() { } # ---- 发行 tag:问 GitHub API(不依赖 jq,用 awk 读 JSON)---- -# 请求 GitHub API 并落盘。 +# 请求发行列表,默认仓库失败或无版本时回退。 api_get() { if [ -n "$github_token" ]; then - download "$api_base$1" "$2" "Authorization: Bearer $github_token" + download "$api_base$1" "$2" "Authorization: Bearer $github_token" && + grep -q '"tag_name"' "$2" && return 0 else - download "$api_base$1" "$2" + download "$api_base$1" "$2" && grep -q '"tag_name"' "$2" && return 0 fi + [ "$repo" = "$default_repo" ] || return 1 + note 'release API unavailable or empty; trying ChuRepo' >&2 + download "$churepo_base/api/v1/repos/$churepo_repo/releases?limit=100" "$2" +} + +# 将紧凑 JSON 按字符串外的分隔符换行。 +json_lines() { + awk ' + { + for (i = 1; i <= length($0); i++) { + c = substr($0, i, 1) + printf "%s", c + if (quoted) { + if (escaped) escaped = 0 + else if (c == "\\") escaped = 1 + else if (c == "\"") quoted = 0 + } else if (c == "\"") quoted = 1 + else if (c ~ /[][{},]/) printf "\n" + } + printf "\n" + } + ' "$1" } # 把 release JSON 解析成 tag 与资源行。 parse_releases() { - awk ' + json_lines "$1" | awk ' in_assets { if ($0 ~ /^[[:space:]]*\]/) { in_assets = 0; next } if ($0 ~ /"name":/) { @@ -372,7 +400,7 @@ parse_releases() { } /"prerelease":/ { pre = ($0 ~ /true/) ? 1 : 0; next } END { if (tag != "") { print tag "\t" pre "\t" assets } } - ' "$1" + ' } # 这份 release 里有没有本平台的包。 @@ -468,6 +496,19 @@ fetch_asset() { note "asset $asset" return 0 fi + [ "$repo" = "$default_repo" ] || return 1 + fallback_version="$version" + if [ "$fallback_version" = 'latest' ]; then + download "$churepo_base/api/v1/repos/$churepo_repo/releases/latest" "$tmp/latest.json" 2>/dev/null || return 1 + fallback_version=$(parse_releases "$tmp/latest.json" | cut -f1) + [ -n "$fallback_version" ] || return 1 + fi + url="$churepo_base/$churepo_repo/releases/download/$fallback_version/$asset" + note "trying ChuRepo: $asset ($fallback_version)" + if download "$url" "$archive" 2>/dev/null; then + version="$fallback_version" + return 0 + fi return 1 } diff --git a/scripts/test-release-fallback.py b/scripts/test-release-fallback.py new file mode 100644 index 0000000..d095a5c --- /dev/null +++ b/scripts/test-release-fallback.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +import hashlib +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import threading +import unittest + +# 用本机 HTTP 夹具验证发行源回退、版本固定与令牌隔离。 + + +class ReleaseFallback(unittest.TestCase): + # 启动只响应测试路由的发行站。 + def setUp(self): + self.routes = {} + self.requests = [] + fixture = self + + class Handler(BaseHTTPRequestHandler): + # 按夹具返回响应并记录认证头。 + def do_GET(self): + fixture.requests.append((self.path, self.headers.get("Authorization"))) + status, body = fixture.routes.get(self.path, (404, b"missing")) + self.send_response(status) + self.end_headers() + self.wfile.write(body) + + # 隐藏 HTTP 访问日志。 + def log_message(self, *args): + pass + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.base = f"http://127.0.0.1:{self.server.server_port}" + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + source = (Path(__file__).parent / "install.sh").read_text() + self.functions = "\n".join(re.findall(r"^[a-z_]+\(\) \{[^\n]*\}$|^[a-z_]+\(\) \{\n.*?^\}", source, re.M | re.S)) + self.setup = f"""set -eu +{self.functions} +default_repo=TheBadRoger/ChuSQL +repo=$default_repo +github_base={self.base}/github +api_base=$github_base/api/v3 +churepo_base={self.base}/churepo +churepo_repo=ChuNa7a/ChuSQL +github_token=secret-test-token +tmp={self.root} +version=v-test +labels=linux-x86_64 +os=linux +arch=x86_64 +ext=tar.gz +""" + + # 停止夹具并清理临时文件。 + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + self.temp.cleanup() + + # 执行真实安装脚本中的发行函数。 + def shell(self, command): + return subprocess.run(["sh", "-c", self.setup + command], capture_output=True, text=True, timeout=15) + + # 验证紧凑 API、附件回退、同站校验与认证隔离。 + def test_api_and_asset_fallback(self): + releases = [{"tag_name": "v-test", "prerelease": False, + "body": 'text with [brackets], \"quotes\"', + "assets": [{"name": "chusql-linux-x86_64.tar.gz"}]}] + api = "/churepo/api/v1/repos/ChuNa7a/ChuSQL/releases?limit=100" + self.routes[api] = (200, json.dumps(releases).encode()) + asset = "/churepo/ChuNa7a/ChuSQL/releases/download/v-test/chusql-linux-x86_64.tar.gz" + self.routes[asset] = (200, b"release-package") + digest = hashlib.sha256(b"release-package").hexdigest() + self.routes[asset + ".sha256"] = (200, digest.encode()) + result = self.shell('version=latest; resolve_version; [ "$version" = v-test ]; fetch_asset chusql-linux-x86_64.tar.gz; verify_checksum "$url" "$archive"') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("(ok)", result.stdout) + self.assertEqual((self.root / "chusql-linux-x86_64.tar.gz").read_bytes(), b"release-package") + self.assertTrue(any(auth for path, auth in self.requests if path.startswith("/github/"))) + self.assertTrue(all(auth is None for path, auth in self.requests if path.startswith("/churepo/"))) + + # 列表不可用时从备用站解析 latest 的具体标签。 + def test_latest_redirect_fallback(self): + self.routes["/churepo/api/v1/repos/ChuNa7a/ChuSQL/releases/latest"] = ( + 200, json.dumps({"tag_name": "v-latest", "assets": []}).encode()) + asset = "/churepo/ChuNa7a/ChuSQL/releases/download/v-latest/package.tar.gz" + self.routes[asset] = (200, b"latest-package") + result = self.shell('version=latest; fetch_asset package.tar.gz; [ "$version" = v-latest ]') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + # 两站缺少指定版本时不得静默换版。 + def test_missing_pinned_version_fails(self): + result = self.shell('fetch_asset chusql-linux-x86_64.tar.gz') + self.assertNotEqual(result.returncode, 0) + self.assertTrue(all("latest" not in path for path, _ in self.requests)) + + # 主站可用时无需访问备用站。 + def test_primary_success(self): + path = "/github/TheBadRoger/ChuSQL/releases/download/v-test/package.tar.gz" + self.routes[path] = (200, b"primary") + result = self.shell('fetch_asset package.tar.gz') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requests, [(path, None)]) + + # 显式仓库不回退至官方仓库。 + def test_custom_repo_does_not_fallback(self): + result = self.shell('repo=custom/project; fetch_asset package.tar.gz') + self.assertNotEqual(result.returncode, 0) + self.assertTrue(all(path.startswith("/github/") for path, _ in self.requests)) + + # 校验失败须中止,不能当作网络失败。 + def test_corrupt_checksum_fails(self): + path = "/churepo/ChuNa7a/ChuSQL/releases/download/v-test/package.tar.gz" + self.routes[path] = (200, b"corrupt") + self.routes[path + ".sha256"] = (200, b"0" * 64) + result = self.shell('fetch_asset package.tar.gz; verify_checksum "$url" "$archive"') + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum mismatch", result.stderr) + + # 执行 PowerShell 的真实版本查询和下载校验代码。 + def powershell(self, command, download=False): + if not shutil.which("pwsh"): + self.skipTest("pwsh is required for PowerShell fallback tests") + source = (Path(__file__).parent / "install.ps1").resolve() + script = f"""$ErrorActionPreference = 'Stop' +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile('{source}', [ref]$tokens, [ref]$errors) +if ($errors.Count) {{ throw ($errors | Out-String) }} +$ast.FindAll({{ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }}, $true) | ForEach-Object {{ Invoke-Expression $_.Extent.Text }} +$Repo = 'TheBadRoger/ChuSQL' +$githubBase = '{self.base}/github' +$apiBase = \"$githubBase/api/v3\" +$churepoBase = '{self.base}/churepo' +$churepoRepo = 'ChuNa7a/ChuSQL' +$apiHeaders = @{{ Authorization = 'Bearer secret-test-token' }} +$Version = 'v-test' +$arch = 'x86_64' +$labels = @('windows-x86_64') +$tempDir = '{self.root}' +""" + if download: + text = source.read_text() + script += text[text.index(' # 一个平台一个包:'):text.index(" Step 'Unpacking the package'")] + script += "\n" + command + self.root.mkdir(exist_ok=True) + path = self.root / "test.ps1" + path.write_text(script) + return subprocess.run(["pwsh", "-NoProfile", "-File", str(path)], capture_output=True, text=True, timeout=20) + + # Windows 下载回退并校验,备用站不接收主站令牌。 + def test_powershell_fallback(self): + api = "/churepo/api/v1/repos/ChuNa7a/ChuSQL/releases?limit=100" + self.routes[api] = (200, json.dumps([{"tag_name": "v-test", "assets": []}]).encode()) + asset = "/churepo/ChuNa7a/ChuSQL/releases/download/v-test/chusql-windows-x86_64.zip" + self.routes[asset] = (200, b"windows-package") + digest = hashlib.sha256(b"windows-package").hexdigest() + self.routes[asset + ".sha256"] = (200, digest.encode()) + result = self.powershell('if (@(Get-Releases)[0].tag_name -ne "v-test") { throw "wrong release" }') + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = self.powershell('if ($Version -ne "v-test") { throw "version changed" }', download=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("(ok)", result.stdout) + self.assertTrue(all(auth is None for path, auth in self.requests if path.startswith("/churepo/"))) + + # Windows 双站缺包与校验错误均须失败。 + def test_powershell_errors(self): + result = self.powershell('', download=True) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(all("latest" not in path for path, _ in self.requests)) + asset = "/churepo/ChuNa7a/ChuSQL/releases/download/v-test/chusql-windows-x86_64.zip" + self.routes[asset] = (200, b"corrupt") + self.routes[asset + ".sha256"] = (200, b"0" * 64) + result = self.powershell('', download=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("checksum mismatch", result.stdout) + + +if __name__ == "__main__": + unittest.main()