Files
artex/selfupdate/selfupdate_test.go
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

460 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package selfupdate
import (
"archive/zip"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// testPaths 造一个隔离的升级目录。不能直接用 ResolvePaths()——那会指向测试
// 二进制本身,一跑就把 go test 的可执行文件改名了。
func testPaths(t *testing.T) Paths {
t.Helper()
dir := t.TempDir()
return Paths{
Dir: dir,
Current: filepath.Join(dir, "artex"),
New: filepath.Join(dir, "artex.new"),
Sum: filepath.Join(dir, "artex.new.sha256"),
Old: filepath.Join(dir, "artex.old"),
Marker: filepath.Join(dir, "artex.upgrade.json"),
}
}
// fakeBin 写一个可执行的壳脚本冒充 artex。smokeTest 只是用 -h 拉起它看退出码,
// 脚本完全够用,而且比编译一个真二进制快得多。
func fakeBin(t *testing.T, path, marker string, exitCode int) {
t.Helper()
script := "#!/bin/sh\necho " + marker + "\nexit " + itoa(exitCode) + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("写入假二进制 %s: %v", path, err)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
return string(rune('0' + n))
}
// stage 把 bin 布置成"已暂存待换装"的样子:写好 artex.new 和它的校验和。
func stage(t *testing.T, p Paths, marker string, exitCode int) {
t.Helper()
fakeBin(t, p.New, marker, exitCode)
sum, err := fileSHA256(p.New)
if err != nil {
t.Fatalf("计算校验和: %v", err)
}
if err := os.WriteFile(p.Sum, []byte(sum), 0o644); err != nil {
t.Fatalf("写入校验和: %v", err)
}
}
func readAll(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读取 %s: %v", path, err)
}
return string(b)
}
func requireUnix(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("假二进制用的是 sh 脚本,Windows 上跑不了")
}
}
func TestCompareVersions(t *testing.T) {
cases := []struct {
a, b string
want int
comparable bool
}{
{"0.3.7", "0.3.8", -1, true},
{"0.3.8", "0.3.7", 1, true},
{"0.3.7", "0.3.7", 0, true},
{"v0.3.7", "0.3.8", -1, true}, // build.sh 去掉 v,tag 带 v,两边都要认
{"0.3.7", "v0.3.7", 0, true},
{"0.9.0", "0.10.0", -1, true}, // 按数字比而不是字典序
{"1.0.0", "0.99.99", 1, true},
// 开发构建必须判为不可比较,否则会被正式版覆盖掉未提交的改动。
{"dev", "0.3.8", 0, false},
{"0.3.7-2-gabc1234", "0.3.8", 0, false},
{"0.3.7-dirty", "0.3.8", 0, false},
{"0.3", "0.3.8", 0, false},
{"", "0.3.8", 0, false},
}
for _, c := range cases {
got, ok := CompareVersions(c.a, c.b)
if ok != c.comparable {
t.Errorf("CompareVersions(%q,%q) comparable=%v, 期望 %v", c.a, c.b, ok, c.comparable)
continue
}
if ok && got != c.want {
t.Errorf("CompareVersions(%q,%q)=%d, 期望 %d", c.a, c.b, got, c.want)
}
}
}
func TestResolvePathsNaming(t *testing.T) {
p, err := ResolvePaths()
if err != nil {
t.Fatalf("ResolvePaths: %v", err)
}
// 关键不变量:所有升级文件都和可执行文件同目录。落到 CWD 会让服务化运行
// (工作目录可能是 /)时的换装彻底失效。
for name, path := range map[string]string{"New": p.New, "Sum": p.Sum, "Old": p.Old, "Marker": p.Marker} {
if filepath.Dir(path) != p.Dir {
t.Errorf("%s 不在可执行文件目录下: %s (期望 %s)", name, path, p.Dir)
}
}
// Windows 上 .new/.old 必须保留 .exe,否则冒烟测试和换装后的执行都会失败。
if runtime.GOOS == "windows" {
if !strings.HasSuffix(p.New, ".exe") || !strings.HasSuffix(p.Old, ".exe") {
t.Errorf("Windows 上 .new/.old 必须以 .exe 结尾: new=%s old=%s", p.New, p.Old)
}
}
}
func TestVerifyStagedRejectsTamperedBinary(t *testing.T) {
requireUnix(t)
p := testPaths(t)
stage(t, p, "new", 0)
// 校验和写好之后再改动文件,模拟下载损坏 / 被掉包。
fakeBin(t, p.New, "tampered", 0)
if err := verifyStaged(p); err == nil {
t.Fatal("期望 SHA256 不匹配被拒绝,却通过了")
}
}
func TestVerifyStagedRejectsUnrunnableBinary(t *testing.T) {
requireUnix(t)
p := testPaths(t)
stage(t, p, "broken", 1) // 能执行但退出码非 0
if err := verifyStaged(p); err == nil {
t.Fatal("期望冒烟测试失败被拒绝,却通过了")
}
}
func TestApplyStagedHappyPath(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "old", 0)
stage(t, p, "new", 0)
if err := writeMarker(p.Marker, marker{From: "0.3.7", To: "0.3.8"}); err != nil {
t.Fatalf("写标记: %v", err)
}
action, st := applyStaged(p)
if action != Restart {
t.Fatalf("期望 Restart,得到 %v", action)
}
if !st.Pending {
t.Error("换装后状态应为 Pending")
}
if !strings.Contains(readAll(t, p.Current), "new") {
t.Error("artex 应已被替换为新版本")
}
if !strings.Contains(readAll(t, p.Old), "old") {
t.Error("旧版本应备份到 artex.old")
}
if _, err := os.Stat(p.New); !os.IsNotExist(err) {
t.Error("换装后 artex.new 应已消失")
}
if _, err := os.Stat(p.Sum); !os.IsNotExist(err) {
t.Error("换装后校验和文件应已清理")
}
// 标记必须留着,下一次启动(跑的是新版)靠它计数、必要时回滚。
if _, ok := readMarker(p.Marker); !ok {
t.Error("换装后升级标记应保留")
}
}
func TestApplyStagedKeepsCurrentWhenVerifyFails(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "old", 0)
stage(t, p, "new", 0)
fakeBin(t, p.New, "tampered", 0) // 破坏校验和
action, st := applyStaged(p)
if action != Continue {
t.Fatalf("校验失败时期望 Continue,得到 %v", action)
}
if !st.FailedStage {
t.Error("状态应标记为 FailedStage")
}
if !strings.Contains(readAll(t, p.Current), "old") {
t.Fatal("校验失败时绝不能动当前版本")
}
if _, err := os.Stat(p.New); !os.IsNotExist(err) {
t.Error("校验失败的暂存件应被清理,否则下次启动会再试一遍")
}
}
func TestSwapOverwritesPreviousBackup(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "v2", 0)
fakeBin(t, p.Old, "v1", 0) // 上一轮升级留下的备份
stage(t, p, "v3", 0)
if err := swap(p); err != nil {
t.Fatalf("swap: %v", err)
}
if !strings.Contains(readAll(t, p.Current), "v3") {
t.Error("应换装到 v3")
}
if !strings.Contains(readAll(t, p.Old), "v2") {
t.Error("备份应更新为刚被换下的 v2")
}
}
func TestConfirmCountsAttemptsThenRollsBack(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "broken-new", 0)
fakeBin(t, p.Old, "good-old", 0)
m := marker{From: "0.3.7", To: "0.3.8"}
// 前 maxAttempts 次启动只累计计数,让新版有机会自己站稳。
for i := 1; i <= maxAttempts; i++ {
action, st := confirmOrRollback(p, m)
if action != Continue {
t.Fatalf("第 %d 次尝试期望 Continue,得到 %v", i, action)
}
if !st.Pending {
t.Errorf("第 %d 次尝试状态应为 Pending", i)
}
got, ok := readMarker(p.Marker)
if !ok || got.Attempts != i {
t.Fatalf("第 %d 次尝试后 attempts=%d(ok=%v),期望 %d", i, got.Attempts, ok, i)
}
m = got
}
// 再崩一次就超限,自动把旧版换回来。
action, st := confirmOrRollback(p, m)
if action != Restart {
t.Fatalf("超过尝试上限时期望 Restart,得到 %v", action)
}
if !st.RolledBack {
t.Error("状态应标记为 RolledBack")
}
if !strings.Contains(readAll(t, p.Current), "good-old") {
t.Fatal("应已回滚到旧版本")
}
if _, err := os.Stat(p.Marker); !os.IsNotExist(err) {
t.Error("回滚后标记应清除,否则会无限回滚")
}
// 起不来的那个版本留作排查,不直接删。
if _, err := os.Stat(p.Current + ".failed"); err != nil {
t.Error("失败的版本应保留为 .failed 供排查")
}
}
func TestManualRollbackIsReversible(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "v2", 0)
fakeBin(t, p.Old, "v1", 0)
// Rollback() 走 ResolvePaths(),这里直接测底层的交换语义。
tmp := p.Current + ".swap"
if err := os.Rename(p.Current, tmp); err != nil {
t.Fatal(err)
}
if err := os.Rename(p.Old, p.Current); err != nil {
t.Fatal(err)
}
if err := os.Rename(tmp, p.Old); err != nil {
t.Fatal(err)
}
if !strings.Contains(readAll(t, p.Current), "v1") {
t.Error("回滚后当前版本应是 v1")
}
if !strings.Contains(readAll(t, p.Old), "v2") {
t.Error("回滚后备份应变成 v2,这样还能再滚回去")
}
}
func TestParseSums(t *testing.T) {
const (
linuxSum = "1111111111111111111111111111111111111111111111111111111111111111"
winSum = "ABCDEF0000000000000000000000000000000000000000000000000000000000"
)
// sha256sum 输出是双空格分隔;shasum -a 256 在二进制模式下会给文件名加 *。
raw := linuxSum + " artex-0.3.8-linux-amd64.zip\n" +
winSum + " *artex-0.3.8-windows-amd64.zip\n" +
"\n" +
"garbage line\n" + // 恰好两个字段,但第一个不是摘要
"deadbeef artex-0.3.8-darwin-arm64.zip\n" // 摘要长度不对
out := parseSums(raw)
if out["artex-0.3.8-linux-amd64.zip"] != linuxSum {
t.Errorf("linux 条目解析错误: %v", out)
}
// 摘要统一小写,比对时才不会因大小写误判为不匹配。
if got := out["artex-0.3.8-windows-amd64.zip"]; got != strings.ToLower(winSum) {
t.Errorf("windows 条目错误(* 前缀应剥离、摘要应转小写): %q", got)
}
if len(out) != 2 {
t.Errorf("应忽略空行、非摘要行和长度不对的行,得到 %v", out)
}
}
func TestExtractBinaryFindsNestedEntry(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("包内基名在 Windows 上是 artex.exe,此用例按 Unix 命名构造")
}
dir := t.TempDir()
zipPath := filepath.Join(dir, "release.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
// 真实发布包的结构:artex-<版本>-<os>-<arch>/artex,外加若干干扰文件。
for name, body := range map[string]string{
"artex-0.3.8-linux-amd64/README.md": "readme",
"artex-0.3.8-linux-amd64/skills/a.md": "skill",
"artex-0.3.8-linux-amd64/artex": "#!/bin/sh\nexit 0\n",
"artex-0.3.8-linux-amd64/config.example.json": "{}",
} {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
f.Close()
dst := filepath.Join(dir, "out")
if err := extractBinary(zipPath, dst); err != nil {
t.Fatalf("extractBinary: %v", err)
}
if got := readAll(t, dst); !strings.Contains(got, "exit 0") {
t.Errorf("解压出来的不是 artex 可执行文件: %q", got)
}
info, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Error("解压出的二进制必须带执行位")
}
}
func TestExtractBinaryMissingEntry(t *testing.T) {
dir := t.TempDir()
zipPath := filepath.Join(dir, "release.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
zw := zip.NewWriter(f)
w, _ := zw.Create("artex-0.3.8-linux-amd64/README.md")
_, _ = w.Write([]byte("readme"))
_ = zw.Close()
f.Close()
if err := extractBinary(zipPath, filepath.Join(dir, "out")); err == nil {
t.Fatal("包内没有可执行文件时应报错")
}
}
func TestCheckURLRejectsNonGitHub(t *testing.T) {
bad := []string{
"http://github.com/x", // 非 HTTPS
"https://evil.com/artex.zip", // 域名不在白名单
"https://github.com.evil.com/x", // 后缀伪装
"https://raw.githubusercontent.com.evil.com/x",
}
for _, raw := range bad {
u := mustParse(t, raw)
if err := checkURL(u); err == nil {
t.Errorf("checkURL(%q) 应当拒绝", raw)
}
}
good := []string{
"https://api.github.com/repos/x/releases/latest",
"https://objects.githubusercontent.com/blah",
"https://GitHub.com/x", // 域名大小写不敏感
}
for _, raw := range good {
u := mustParse(t, raw)
if err := checkURL(u); err != nil {
t.Errorf("checkURL(%q) 应当放行,却报错: %v", raw, err)
}
}
}
func TestAssetNameMatchesBuildScript(t *testing.T) {
// build.sh 的 package_binary 用的是 artex-<版本>-<os>-<arch>.zip,且版本号
// 去掉了 v 前缀。这里对错一个字符,所有平台的一键更新都会找不到资产。
if got := AssetName("v0.3.8", "linux", "amd64"); got != "artex-0.3.8-linux-amd64.zip" {
t.Errorf("AssetName = %q", got)
}
if got := AssetName("0.3.8", "windows", "amd64"); got != "artex-0.3.8-windows-amd64.zip" {
t.Errorf("AssetName = %q", got)
}
}
func mustParse(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("解析 %q: %v", raw, err)
}
return u
}
func TestSettleClearsMarkerAndStopsRollback(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "new", 0)
fakeBin(t, p.Old, "old", 0)
if err := writeMarker(p.Marker, marker{From: "0.3.7", To: "0.3.8", Attempts: 2}); err != nil {
t.Fatal(err)
}
settle(p)
if _, err := os.Stat(p.Marker); !os.IsNotExist(err) {
t.Fatal("确认稳定后升级标记必须清除")
}
// 标记没了,后续正常重启就不会再累计次数、也不会误触发回滚。
if _, ok := readMarker(p.Marker); ok {
t.Error("标记读取应失败")
}
// 备份要留着,用户还能手动回滚。
if _, err := os.Stat(p.Old); err != nil {
t.Error("确认稳定后仍应保留上一版本备份")
}
}
func TestSettleIsNoopWithoutMarker(t *testing.T) {
requireUnix(t)
p := testPaths(t)
fakeBin(t, p.Current, "cur", 0)
settle(p) // 普通启动路径,不该 panic 也不该动任何文件
if _, err := os.Stat(p.Current); err != nil {
t.Error("无标记时 settle 不应影响任何文件")
}
}