mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Comments, doc comments, UI labels, log and error messages, LLM prompt strings, markdown docs, test data and assertions. Identifiers, enum keys, routes, placeholders and code structure are unchanged. Notes: - docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md - tests that exercised CJK specific handling (GBK zip names, Chinese full text search) now use ASCII equivalents; go build passes and the test suite matches the pre translation baseline (remaining failures need a PostgreSQL DSN and fail identically on the original) - frontend mention syntax now uses English kinds (finding, asset, ...) matching the backend, chat mentions tests pass
460 lines
14 KiB
Go
460 lines
14 KiB
Go
package selfupdate
|
|
|
|
import (
|
|
"archive/zip"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// testPaths creates an isolated upgrade directory. ResolvePaths() cannot be used directly — it would point at the test binary itself,
|
|
// and running it once would rename the go test executable.
|
|
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 writes an executable shell script impersonating artex. smokeTest merely launches it with -h and checks the exit code,
|
|
// so a script is entirely sufficient and much faster than compiling a real binary.
|
|
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("write fake binary %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
return string(rune('0' + n))
|
|
}
|
|
|
|
// stage arranges bin to look "staged and ready to swap": it writes artex.new and its checksum.
|
|
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("compute checksum: %v", err)
|
|
}
|
|
if err := os.WriteFile(p.Sum, []byte(sum), 0o644); err != nil {
|
|
t.Fatalf("write checksum: %v", err)
|
|
}
|
|
}
|
|
|
|
func readAll(t *testing.T, path string) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", path, err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func requireUnix(t *testing.T) {
|
|
t.Helper()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("the fake binary is an sh script and cannot run on 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 strips the v while tags carry it; both sides must be accepted
|
|
{"0.3.7", "v0.3.7", 0, true},
|
|
{"0.9.0", "0.10.0", -1, true}, // compare numerically, not lexicographically
|
|
{"1.0.0", "0.99.99", 1, true},
|
|
// Dev builds must be judged non-comparable, otherwise a release overwrites uncommitted changes.
|
|
{"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, want %v", c.a, c.b, ok, c.comparable)
|
|
continue
|
|
}
|
|
if ok && got != c.want {
|
|
t.Errorf("CompareVersions(%q,%q)=%d, want %d", c.a, c.b, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePathsNaming(t *testing.T) {
|
|
p, err := ResolvePaths()
|
|
if err != nil {
|
|
t.Fatalf("ResolvePaths: %v", err)
|
|
}
|
|
// Key invariant: every upgrade file sits next to the executable. Landing in CWD would break the swap completely when running as a service
|
|
// (the working directory may be /).
|
|
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 is not in the executable's directory: %s (want %s)", name, path, p.Dir)
|
|
}
|
|
}
|
|
// On Windows .new/.old must keep .exe, otherwise the smoke test and post-swap execution both fail.
|
|
if runtime.GOOS == "windows" {
|
|
if !strings.HasSuffix(p.New, ".exe") || !strings.HasSuffix(p.Old, ".exe") {
|
|
t.Errorf("on Windows .new/.old must end with .exe: new=%s old=%s", p.New, p.Old)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestVerifyStagedRejectsTamperedBinary(t *testing.T) {
|
|
requireUnix(t)
|
|
p := testPaths(t)
|
|
stage(t, p, "new", 0)
|
|
|
|
// Modify the file after its checksum is written, simulating a corrupted / tampered download.
|
|
fakeBin(t, p.New, "tampered", 0)
|
|
if err := verifyStaged(p); err == nil {
|
|
t.Fatal("expected the SHA256 mismatch to be rejected, but it passed")
|
|
}
|
|
}
|
|
|
|
func TestVerifyStagedRejectsUnrunnableBinary(t *testing.T) {
|
|
requireUnix(t)
|
|
p := testPaths(t)
|
|
stage(t, p, "broken", 1) // runs but exits non-zero
|
|
|
|
if err := verifyStaged(p); err == nil {
|
|
t.Fatal("expected the smoke-test failure to be rejected, but it passed")
|
|
}
|
|
}
|
|
|
|
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("write marker: %v", err)
|
|
}
|
|
|
|
action, st := applyStaged(p)
|
|
if action != Restart {
|
|
t.Fatalf("want Restart, got %v", action)
|
|
}
|
|
if !st.Pending {
|
|
t.Error("status should be Pending after the swap")
|
|
}
|
|
if !strings.Contains(readAll(t, p.Current), "new") {
|
|
t.Error("artex should have been replaced with the new version")
|
|
}
|
|
if !strings.Contains(readAll(t, p.Old), "old") {
|
|
t.Error("the old version should be backed up to artex.old")
|
|
}
|
|
if _, err := os.Stat(p.New); !os.IsNotExist(err) {
|
|
t.Error("artex.new should be gone after the swap")
|
|
}
|
|
if _, err := os.Stat(p.Sum); !os.IsNotExist(err) {
|
|
t.Error("the checksum file should be cleaned up after the swap")
|
|
}
|
|
// The marker must stay: the next start (running the new version) counts on it and rolls back if necessary.
|
|
if _, ok := readMarker(p.Marker); !ok {
|
|
t.Error("the upgrade marker should be kept after the swap")
|
|
}
|
|
}
|
|
|
|
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) // break the checksum
|
|
|
|
action, st := applyStaged(p)
|
|
if action != Continue {
|
|
t.Fatalf("want Continue on verification failure, got %v", action)
|
|
}
|
|
if !st.FailedStage {
|
|
t.Error("status should be marked FailedStage")
|
|
}
|
|
if !strings.Contains(readAll(t, p.Current), "old") {
|
|
t.Fatal("the current version must never be touched on verification failure")
|
|
}
|
|
if _, err := os.Stat(p.New); !os.IsNotExist(err) {
|
|
t.Error("the staged file of a failed verification should be cleaned up, otherwise the next start retries it")
|
|
}
|
|
}
|
|
|
|
func TestSwapOverwritesPreviousBackup(t *testing.T) {
|
|
requireUnix(t)
|
|
p := testPaths(t)
|
|
fakeBin(t, p.Current, "v2", 0)
|
|
fakeBin(t, p.Old, "v1", 0) // backup left by a previous upgrade
|
|
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("should have swapped to v3")
|
|
}
|
|
if !strings.Contains(readAll(t, p.Old), "v2") {
|
|
t.Error("the backup should be updated to the just-swapped-out 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"}
|
|
|
|
// The first maxAttempts starts only increment the counter, giving the new version a chance to settle.
|
|
for i := 1; i <= maxAttempts; i++ {
|
|
action, st := confirmOrRollback(p, m)
|
|
if action != Continue {
|
|
t.Fatalf("attempt %d: want Continue, got %v", i, action)
|
|
}
|
|
if !st.Pending {
|
|
t.Errorf("attempt %d: status should be Pending", i)
|
|
}
|
|
got, ok := readMarker(p.Marker)
|
|
if !ok || got.Attempts != i {
|
|
t.Fatalf("after attempt %d, attempts=%d (ok=%v), want %d", i, got.Attempts, ok, i)
|
|
}
|
|
m = got
|
|
}
|
|
|
|
// One more crash exceeds the limit; the old version is swapped back automatically.
|
|
action, st := confirmOrRollback(p, m)
|
|
if action != Restart {
|
|
t.Fatalf("want Restart once the attempt limit is exceeded, got %v", action)
|
|
}
|
|
if !st.RolledBack {
|
|
t.Error("status should be marked RolledBack")
|
|
}
|
|
if !strings.Contains(readAll(t, p.Current), "good-old") {
|
|
t.Fatal("should have rolled back to the old version")
|
|
}
|
|
if _, err := os.Stat(p.Marker); !os.IsNotExist(err) {
|
|
t.Error("the marker should be cleared after rollback, otherwise rollback loops forever")
|
|
}
|
|
// The version that cannot start is kept for diagnosis, not deleted outright.
|
|
if _, err := os.Stat(p.Current + ".failed"); err != nil {
|
|
t.Error("the failed version should be kept as .failed for diagnosis")
|
|
}
|
|
}
|
|
|
|
func TestManualRollbackIsReversible(t *testing.T) {
|
|
requireUnix(t)
|
|
p := testPaths(t)
|
|
fakeBin(t, p.Current, "v2", 0)
|
|
fakeBin(t, p.Old, "v1", 0)
|
|
|
|
// Rollback() goes through ResolvePaths(); this tests the underlying swap semantics directly.
|
|
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("after rollback the current version should be v1")
|
|
}
|
|
if !strings.Contains(readAll(t, p.Old), "v2") {
|
|
t.Error("after rollback the backup should become v2, so it can roll back again")
|
|
}
|
|
}
|
|
|
|
func TestParseSums(t *testing.T) {
|
|
const (
|
|
linuxSum = "1111111111111111111111111111111111111111111111111111111111111111"
|
|
winSum = "ABCDEF0000000000000000000000000000000000000000000000000000000000"
|
|
)
|
|
// sha256sum output is double-space separated; shasum -a 256 in binary mode prefixes the filename with *.
|
|
raw := linuxSum + " artex-0.3.8-linux-amd64.zip\n" +
|
|
winSum + " *artex-0.3.8-windows-amd64.zip\n" +
|
|
"\n" +
|
|
"garbage line\n" + // exactly two fields, but the first is not a digest
|
|
"deadbeef artex-0.3.8-darwin-arm64.zip\n" // wrong digest length
|
|
|
|
out := parseSums(raw)
|
|
if out["artex-0.3.8-linux-amd64.zip"] != linuxSum {
|
|
t.Errorf("linux entry parsed wrong: %v", out)
|
|
}
|
|
// Digests are normalized to lowercase so case differences cannot be misjudged as mismatches.
|
|
if got := out["artex-0.3.8-windows-amd64.zip"]; got != strings.ToLower(winSum) {
|
|
t.Errorf("windows entry wrong (* prefix should be stripped, digest lowercased): %q", got)
|
|
}
|
|
if len(out) != 2 {
|
|
t.Errorf("blank lines, non-digest lines, and wrong-length lines should be ignored, got %v", out)
|
|
}
|
|
}
|
|
|
|
func TestExtractBinaryFindsNestedEntry(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("the in-archive basename is artex.exe on Windows; this case is built with Unix naming")
|
|
}
|
|
dir := t.TempDir()
|
|
zipPath := filepath.Join(dir, "release.zip")
|
|
|
|
f, err := os.Create(zipPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zw := zip.NewWriter(f)
|
|
// Real release archive layout: artex-<version>-<os>-<arch>/artex, plus a few distractor files.
|
|
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("what was extracted is not the artex executable: %q", got)
|
|
}
|
|
info, err := os.Stat(dst)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if info.Mode().Perm()&0o111 == 0 {
|
|
t.Error("the extracted binary must carry the executable bit")
|
|
}
|
|
}
|
|
|
|
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("should error when the archive has no executable")
|
|
}
|
|
}
|
|
|
|
func TestCheckURLRejectsNonGitHub(t *testing.T) {
|
|
bad := []string{
|
|
"http://github.com/x", // not HTTPS
|
|
"https://evil.com/artex.zip", // domain not on the allowlist
|
|
"https://github.com.evil.com/x", // suffix spoofing
|
|
"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) should reject", raw)
|
|
}
|
|
}
|
|
good := []string{
|
|
"https://api.github.com/repos/x/releases/latest",
|
|
"https://objects.githubusercontent.com/blah",
|
|
"https://GitHub.com/x", // domain matching is case-insensitive
|
|
}
|
|
for _, raw := range good {
|
|
u := mustParse(t, raw)
|
|
if err := checkURL(u); err != nil {
|
|
t.Errorf("checkURL(%q) should allow, but errored: %v", raw, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAssetNameMatchesBuildScript(t *testing.T) {
|
|
// build.sh's package_binary uses artex-<version>-<os>-<arch>.zip with the v prefix
|
|
// stripped from the version. One wrong character here and one-click update finds no asset on any platform.
|
|
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("parse %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("the upgrade marker must be cleared once stability is confirmed")
|
|
}
|
|
// With the marker gone, later normal restarts neither count attempts nor trigger a spurious rollback.
|
|
if _, ok := readMarker(p.Marker); ok {
|
|
t.Error("reading the marker should fail")
|
|
}
|
|
// The backup stays so the user can still roll back manually.
|
|
if _, err := os.Stat(p.Old); err != nil {
|
|
t.Error("the previous version's backup should still be kept once stability is confirmed")
|
|
}
|
|
}
|
|
|
|
func TestSettleIsNoopWithoutMarker(t *testing.T) {
|
|
requireUnix(t)
|
|
p := testPaths(t)
|
|
fakeBin(t, p.Current, "cur", 0)
|
|
settle(p) // the plain start path: must not panic or touch any file
|
|
if _, err := os.Stat(p.Current); err != nil {
|
|
t.Error("settle must not affect any file when there is no marker")
|
|
}
|
|
}
|