First Commit
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
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
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// smokeEnv 让被冒烟测试拉起的子进程直接跳过 Bootstrap。
|
||||
//
|
||||
// 严格来说不加也不会出事:子进程的 os.Executable() 是 artex.new,推导出来的
|
||||
// 全部路径都带 .new 前缀,碰不到真正的升级文件。但依赖这种巧合太脆弱,
|
||||
// 显式短路一目了然,也省掉子进程一次无谓的磁盘探测。
|
||||
const smokeEnv = "ARTEX_SELFUPDATE_SMOKE"
|
||||
|
||||
// Action 是 Bootstrap 给 main 的指令。
|
||||
type Action int
|
||||
|
||||
const (
|
||||
// Continue:照常启动 server。
|
||||
Continue Action = iota
|
||||
// Restart:立刻以 ExitRestart 退出,让守护脚本重新拉起。
|
||||
Restart
|
||||
)
|
||||
|
||||
// State 描述本次启动时的升级状态,供 /api/update/check 如实告诉前端
|
||||
// "上一次升级是成功了还是被回滚了"。
|
||||
type State struct {
|
||||
Pending bool // 换装后尚未确认稳定
|
||||
RolledBack bool // 本次启动刚刚执行过自动回滚
|
||||
FailedStage bool // 暂存件校验/冒烟未通过,已丢弃
|
||||
Detail string // 面向用户的一句话说明
|
||||
}
|
||||
|
||||
// Bootstrap 在 main 的最开头运行,必须在任何监听端口、打开数据库之前调用。
|
||||
//
|
||||
// 三种局面:
|
||||
//
|
||||
// ① 存在暂存件 artex.new → 校验 + 冒烟,通过则换装并要求重启;不通过则丢弃继续跑旧版
|
||||
// ② 只剩标记文件 → 说明刚换装完,累计一次尝试;连续失败够多次则回滚
|
||||
// ③ 什么都没有 → 正常启动
|
||||
func Bootstrap() (Action, State) {
|
||||
if os.Getenv(smokeEnv) != "" {
|
||||
return Continue, State{}
|
||||
}
|
||||
p, err := ResolvePaths()
|
||||
if err != nil {
|
||||
log.Printf("[update] 跳过自举:%v", err)
|
||||
return Continue, State{}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(p.New); err == nil {
|
||||
return applyStaged(p)
|
||||
}
|
||||
|
||||
m, ok := readMarker(p.Marker)
|
||||
if !ok {
|
||||
return Continue, State{}
|
||||
}
|
||||
return confirmOrRollback(p, m)
|
||||
}
|
||||
|
||||
// applyStaged 处理"存在暂存件"的局面:校验通过就换装,失败就丢弃。
|
||||
//
|
||||
// 这里是整个升级链路唯一会覆盖可执行文件的地方,也是最后一道闸门——冒烟测试挡掉
|
||||
// 下载损坏、架构选错、动态链接缺失这类问题。一旦放行一个跑不起来的二进制,
|
||||
// 守护脚本会不知疲倦地反复拉起它,而 Go 代码根本没机会运行,自动回滚也就无从谈起。
|
||||
func applyStaged(p Paths) (Action, State) {
|
||||
m, _ := readMarker(p.Marker)
|
||||
|
||||
if err := verifyStaged(p); err != nil {
|
||||
log.Printf("[update] 暂存的新版本未通过校验,已丢弃,继续运行当前版本:%v", err)
|
||||
cleanStaged(p)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Continue, State{FailedStage: true, Detail: "새 버전 검증에 실패해 폐기했습니다: " + err.Error()}
|
||||
}
|
||||
|
||||
if err := swap(p); err != nil {
|
||||
log.Printf("[update] 换装失败,继续运行当前版本:%v", err)
|
||||
cleanStaged(p)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Continue, State{FailedStage: true, Detail: "버전 교체에 실패했습니다: " + err.Error()}
|
||||
}
|
||||
|
||||
// 换装成功。保留标记,交给下一次启动(跑的就是新版)确认是否稳定。
|
||||
m.Attempts = 0
|
||||
if m.StagedAt == 0 {
|
||||
m.StagedAt = time.Now().Unix()
|
||||
}
|
||||
if err := writeMarker(p.Marker, m); err != nil {
|
||||
log.Printf("[update] 写升级标记失败(失去自动回滚能力):%v", err)
|
||||
}
|
||||
log.Printf("[update] 已换装到 %s,退出以重启(exit %d)", orUnknown(m.To), ExitRestart)
|
||||
return Restart, State{Pending: true}
|
||||
}
|
||||
|
||||
// confirmOrRollback 处理"换装后的启动":累计尝试次数,超限则把旧版换回来。
|
||||
//
|
||||
// 计数只在 Go 代码跑起来后才递增,所以它覆盖的是"能执行但初始化时崩溃"
|
||||
// (配置不兼容、端口被占、DB 迁移炸了)这类故障;"根本无法 exec" 由换装前的
|
||||
// 冒烟测试挡住,两者合起来才是完整的。
|
||||
func confirmOrRollback(p Paths, m marker) (Action, State) {
|
||||
m.Attempts++
|
||||
if m.Attempts > maxAttempts {
|
||||
if err := rollback(p); err != nil {
|
||||
// 回滚都失败了就别再重启了,否则会陷入无限重启。清掉标记,
|
||||
// 让进程按当前状态起——起不来的话用户至少能在日志里看到原因。
|
||||
log.Printf("[update] 新版本连续 %d 次启动失败,且回滚失败:%v", maxAttempts, err)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Continue, State{Detail: "새 버전 시작에 실패하고 롤백도 실패했습니다: " + err.Error()}
|
||||
}
|
||||
log.Printf("[update] 新版本连续 %d 次启动失败,已回滚到 %s,退出以重启(exit %d)",
|
||||
maxAttempts, orUnknown(m.From), ExitRestart)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Restart, State{RolledBack: true, Detail: fmt.Sprintf("새 버전 시작에 실패해 이전 버전으로 롤백했습니다(%s)", orUnknown(m.From))}
|
||||
}
|
||||
if err := writeMarker(p.Marker, m); err != nil {
|
||||
log.Printf("[update] 更新升级标记失败:%v", err)
|
||||
}
|
||||
log.Printf("[update] 新版本启动中(第 %d/%d 次尝试),稳定运行后将确认升级",
|
||||
m.Attempts, maxAttempts)
|
||||
return Continue, State{Pending: true}
|
||||
}
|
||||
|
||||
// Settle 确认新版本已稳定运行,清除升级标记。
|
||||
//
|
||||
// 由 main 在 HTTP 监听起来之后延迟调用:活过这段时间才算数,否则标记留在原地,
|
||||
// 下次启动继续累计尝试次数,直到触发回滚。
|
||||
func Settle() {
|
||||
p, err := ResolvePaths()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
settle(p)
|
||||
}
|
||||
|
||||
func settle(p Paths) {
|
||||
if _, ok := readMarker(p.Marker); !ok {
|
||||
return // 不是升级后的启动,无事可做
|
||||
}
|
||||
if err := os.Remove(p.Marker); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
log.Printf("[update] 清除升级标记失败:%v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[update] 新版本运行稳定,升级完成(上一版本保留为 %s)", p.Old)
|
||||
}
|
||||
|
||||
// SettleDelay 是判定"新版本活下来了"所需的运行时长。
|
||||
const SettleDelay = 30 * time.Second
|
||||
|
||||
// verifyStaged 校验暂存件:先比对 SHA256,再真正把它拉起来跑一次。
|
||||
func verifyStaged(p Paths) error {
|
||||
want, err := os.ReadFile(p.Sum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("체크섬 읽기 실패: %w", err)
|
||||
}
|
||||
got, err := fileSHA256(p.New)
|
||||
if err != nil {
|
||||
return fmt.Errorf("체크섬 계산 실패: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(string(want)), got) {
|
||||
return errors.New("SHA256 이 일치하지 않습니다(다운로드가 손상됐거나 변조됨)")
|
||||
}
|
||||
return smokeTest(p.New)
|
||||
}
|
||||
|
||||
// smokeTest 用 -h 拉起新二进制,确认它在当前系统上真的能执行。
|
||||
// 这能挡掉下载截断、架构选错(exec format error)、缺依赖等一大类问题。
|
||||
func smokeTest(bin string) error {
|
||||
if err := os.Chmod(bin, 0o755); err != nil {
|
||||
return fmt.Errorf("실행 권한 부여 실패: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, "-h")
|
||||
cmd.Env = append(os.Environ(), smokeEnv+"=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("스모크 테스트 시간 초과(새 바이너리가 응답하지 않음)")
|
||||
}
|
||||
if err != nil {
|
||||
snippet := strings.TrimSpace(string(out))
|
||||
if len(snippet) > 300 {
|
||||
snippet = snippet[:300] + "…"
|
||||
}
|
||||
return fmt.Errorf("스모크 테스트 실패: %v: %s", err, snippet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// swap 把当前二进制换成暂存的新版本。
|
||||
//
|
||||
// Unix 和 Windows 都允许 rename 一个正在运行的可执行文件(Windows 禁止的是删除和
|
||||
// 覆盖,rename 不在其列),所以这里不需要分平台,也不需要先停掉自己。
|
||||
func swap(p Paths) error {
|
||||
// Windows 的 rename 不会覆盖已存在的目标,上一轮升级留下的 .old 必须先清掉。
|
||||
if err := os.Remove(p.Old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("이전 백업 %s 정리 실패: %w", p.Old, err)
|
||||
}
|
||||
if err := os.Rename(p.Current, p.Old); err != nil {
|
||||
return fmt.Errorf("현재 버전 백업 실패: %w", err)
|
||||
}
|
||||
if err := os.Rename(p.New, p.Current); err != nil {
|
||||
// 换装失败但当前版本已经被挪走了,必须原样放回去,否则下次启动没有可执行文件。
|
||||
if rerr := os.Rename(p.Old, p.Current); rerr != nil {
|
||||
return fmt.Errorf("새 버전 설치 실패(%v), 현재 버전 복구도 실패: %w", err, rerr)
|
||||
}
|
||||
return fmt.Errorf("새 버전 설치 실패: %w", err)
|
||||
}
|
||||
_ = os.Remove(p.Sum)
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollback 把 swap 备份的旧版本换回来。
|
||||
func rollback(p Paths) error {
|
||||
if _, err := os.Stat(p.Old); err != nil {
|
||||
return fmt.Errorf("롤백할 백업이 없습니다(%s): %w", p.Old, err)
|
||||
}
|
||||
// 把起不来的新版挪到 .failed 留作排查,而不是直接删掉。
|
||||
failed := p.Current + ".failed"
|
||||
_ = os.Remove(failed)
|
||||
if err := os.Rename(p.Current, failed); err != nil {
|
||||
return fmt.Errorf("실패한 버전 이동 실패: %w", err)
|
||||
}
|
||||
if err := os.Rename(p.Old, p.Current); err != nil {
|
||||
return fmt.Errorf("이전 버전 복구 실패: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rollback 是 /api/update/rollback 的实现:主动退回上一版本。
|
||||
// 只做换装,重启同样交给守护脚本(调用方随后以 ExitRestart 退出)。
|
||||
func Rollback() error {
|
||||
p, err := ResolvePaths()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(p.Old); err != nil {
|
||||
return errors.New("롤백할 이전 버전이 없습니다(" + p.Old + " 없음)")
|
||||
}
|
||||
cleanStaged(p)
|
||||
if err := smokeTest(p.Old); err != nil {
|
||||
return fmt.Errorf("이전 버전을 실행할 수 없어 롤백을 거부합니다: %w", err)
|
||||
}
|
||||
// 交换当前与备份:回滚之后还能再滚回来。
|
||||
tmp := p.Current + ".swap"
|
||||
_ = os.Remove(tmp)
|
||||
if err := os.Rename(p.Current, tmp); err != nil {
|
||||
return fmt.Errorf("현재 버전 이동 실패: %w", err)
|
||||
}
|
||||
if err := os.Rename(p.Old, p.Current); err != nil {
|
||||
_ = os.Rename(tmp, p.Current)
|
||||
return fmt.Errorf("이전 버전 설치 실패: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, p.Old); err != nil {
|
||||
log.Printf("[update] 回滚后整理备份失败(不影响运行):%v", err)
|
||||
}
|
||||
_ = os.Remove(p.Marker)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasBackup 报告是否存在可回滚的上一版本,供前端决定要不要显示回滚按钮。
|
||||
func HasBackup() bool {
|
||||
p, err := ResolvePaths()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = os.Stat(p.Old)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "알 수 없는 버전"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Repo 是发布源。写死而不是做成配置项:更新源可配等于给任何能改配置的人一条
|
||||
// 远程代码执行通道,对一个渗透测试平台来说这个口子开不得。
|
||||
const Repo = "Autumn-27/artex"
|
||||
|
||||
// latestURL 是 GitHub 的"最新正式版"接口。它会自动跳过 prerelease 和 draft。
|
||||
const latestURL = "https://api.github.com/repos/" + Repo + "/releases/latest"
|
||||
|
||||
// allowedHosts 限定升级链路能访问的域名。配合下面的 checkRedirect,
|
||||
// 任何一跳被重定向到名单外的主机都会直接失败——这是防止 DNS 污染 / 中间人
|
||||
// 把二进制换掉的第一道闸门,第二道是 SHA256SUMS 比对。
|
||||
var allowedHosts = map[string]bool{
|
||||
"api.github.com": true,
|
||||
"github.com": true,
|
||||
"objects.githubusercontent.com": true, // release 资产实际落地的对象存储
|
||||
"release-assets.githubusercontent.com": true,
|
||||
"raw.githubusercontent.com": true,
|
||||
}
|
||||
|
||||
// Release 是 GitHub Release 里我们关心的字段。
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Assets []Asset `json:"assets"`
|
||||
}
|
||||
|
||||
// Asset 是 Release 上挂的一个文件。
|
||||
type Asset struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"browser_download_url"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// NewClient 构造一个只认 GitHub 域名的 HTTP 客户端。proxy 为空则直连。
|
||||
//
|
||||
// 刻意不复用默认 Transport:升级链路必须强制走 TLS 且校验证书,不能被别处
|
||||
// 设置的 InsecureSkipVerify 之类影响到。
|
||||
func NewClient(proxy string) *http.Client {
|
||||
tr := &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
}
|
||||
if p := strings.TrimSpace(proxy); p != "" {
|
||||
if pu, err := url.Parse(p); err == nil {
|
||||
tr.Proxy = http.ProxyURL(pu)
|
||||
}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: 30 * time.Minute, // 下载整包,不能按请求级超时卡死
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("리다이렉트 횟수가 너무 많습니다")
|
||||
}
|
||||
return checkURL(req.URL)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// checkURL 强制 https + 域名白名单。
|
||||
func checkURL(u *url.URL) error {
|
||||
if u.Scheme != "https" {
|
||||
return fmt.Errorf("HTTPS 가 아닌 주소를 거부합니다: %s", u.Scheme+"://"+u.Host)
|
||||
}
|
||||
if !allowedHosts[strings.ToLower(u.Hostname())] {
|
||||
return fmt.Errorf("GitHub 도메인이 아닌 주소를 거부합니다: %s", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchLatest 查询最新正式版。
|
||||
func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkURL(req.URL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "artex-selfupdate")
|
||||
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GitHub 에 접속하지 못했습니다(시스템 설정에서 전역 프록시를 설정할 수 있습니다): %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusForbidden, resp.StatusCode == http.StatusTooManyRequests:
|
||||
// 未认证的 GitHub API 是每 IP 每小时 60 次,共用出口 IP 时很容易撞上。
|
||||
return nil, fmt.Errorf("GitHub API 요청 제한에 걸렸습니다(시간당 60회). 잠시 후 다시 시도하세요")
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, fmt.Errorf("저장소 %s 에 아직 정식 릴리스가 없습니다", Repo)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("GitHub 응답 코드 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rel Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, fmt.Errorf("Release 해석 실패: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(rel.TagName) == "" {
|
||||
return nil, fmt.Errorf("Release 에 tag 가 없습니다")
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
// AssetName 返回当前平台对应的发布包名,与 build.sh 的 package_binary 保持一致:
|
||||
// artex-<版本>-<os>-<arch>.zip(版本号不带 v 前缀)。
|
||||
func AssetName(tag, goos, goarch string) string {
|
||||
return fmt.Sprintf("artex-%s-%s-%s.zip", strings.TrimPrefix(tag, "v"), goos, goarch)
|
||||
}
|
||||
|
||||
// FindAsset 在 Release 里按名字找资产。
|
||||
func (r *Release) FindAsset(name string) (Asset, bool) {
|
||||
for _, a := range r.Assets {
|
||||
if strings.EqualFold(a.Name, name) {
|
||||
return a, true
|
||||
}
|
||||
}
|
||||
return Asset{}, false
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package selfupdate implements ARTEX 的页面一键更新:从 GitHub Release 拉取新版
|
||||
// 二进制、校验、暂存,并在下次启动时原子换装。
|
||||
//
|
||||
// 整体分工(见 start.sh / start.bat):
|
||||
//
|
||||
// 启动脚本 = 傻瓜守护循环,只负责"进程退出后按退出码决定是否再拉起"
|
||||
// 本包 = 全部易错逻辑(下载 / SHA256 校验 / 冒烟 / 换装 / 失败回滚)
|
||||
//
|
||||
// 之所以把换装放在 Go 而不是脚本里,是因为 sha256 校验和冒烟测试在 sh 和 bat 上
|
||||
// 要写两套(sha256sum / shasum / certutil),而这恰恰是最不能出错的一环——换上一个
|
||||
// 跑不起来的二进制,守护进程会忠实地反复拉起它,用户只能上机器手工救。
|
||||
//
|
||||
// 一次完整升级经过三次进程启动:
|
||||
//
|
||||
// ① 旧版 server 收到 /api/update/apply → 下载校验 → 暂存 artex.new → exit 75
|
||||
// ② 脚本重新拉起旧版 → Bootstrap 发现 artex.new → 校验+冒烟 → 换装 → exit 75
|
||||
// ③ 脚本重新拉起,此时已是新版 → Bootstrap 记一次尝试 → 启动成功后清除标记
|
||||
//
|
||||
// 任何一步失败都退回旧版:② 校验不过就删掉暂存件继续跑旧版;③ 连续 3 次没活到
|
||||
// 清除标记(起不来就崩)则自动把 artex.old 换回去。
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExitRestart 是"请守护进程重新拉起我"的退出码(EX_TEMPFAIL)。启动脚本看到它
|
||||
// 就立刻重跑,不计入崩溃退避。0 表示用户正常停止(脚本退出循环),其余均视为崩溃。
|
||||
const ExitRestart = 75
|
||||
|
||||
// maxAttempts 是换装后允许的启动尝试次数。新版每次启动都会把计数 +1,活过
|
||||
// settleDelay 则清除标记;连崩 maxAttempts 次说明新版根本起不来,自动回滚。
|
||||
const maxAttempts = 3
|
||||
|
||||
// Paths 是一次升级涉及的全部文件,统一挂在**可执行文件所在目录**下。
|
||||
// 刻意不用 CWD:服务化运行时工作目录可能是 / 或任意路径,用 CWD 会让暂存件落到
|
||||
// 别处,换装逻辑直接失效。
|
||||
type Paths struct {
|
||||
Dir string // 可执行文件所在目录
|
||||
Current string // 当前运行的二进制 artex / artex.exe
|
||||
New string // 暂存的新版本 artex.new / artex.new.exe
|
||||
Sum string // 新版本的 sha256(hex) artex.new.sha256 / artex.new.exe.sha256
|
||||
Old string // 换装前备份的旧版本 artex.old / artex.old.exe
|
||||
Marker string // 升级状态标记 artex.upgrade.json
|
||||
}
|
||||
|
||||
// ResolvePaths 按当前可执行文件推导全部升级路径。
|
||||
//
|
||||
// Windows 上 .new/.old 也必须带 .exe 后缀,否则冒烟测试和换装后的执行都会失败,
|
||||
// 所以先把后缀摘掉再拼,两个平台的命名才对称。
|
||||
func ResolvePaths() (Paths, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return Paths{}, fmt.Errorf("실행 파일 위치 확인 실패: %w", err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
name := filepath.Base(exe)
|
||||
ext := filepath.Ext(name) // Windows 上是 ".exe",Unix 上通常为空
|
||||
stem := strings.TrimSuffix(name, ext)
|
||||
|
||||
join := func(suffix string) string { return filepath.Join(dir, stem+suffix+ext) }
|
||||
return Paths{
|
||||
Dir: dir,
|
||||
Current: exe,
|
||||
New: join(".new"),
|
||||
Sum: join(".new") + ".sha256",
|
||||
Old: join(".old"),
|
||||
Marker: filepath.Join(dir, stem+".upgrade.json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// marker 记录一次换装的进度,用来在新版起不来时触发自动回滚。
|
||||
type marker struct {
|
||||
From string `json:"from"` // 升级前的版本
|
||||
To string `json:"to"` // 目标版本
|
||||
Attempts int `json:"attempts"` // 换装后已尝试启动的次数
|
||||
StagedAt int64 `json:"staged_at"`
|
||||
}
|
||||
|
||||
func readMarker(path string) (marker, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return marker{}, false
|
||||
}
|
||||
var m marker
|
||||
if json.Unmarshal(b, &m) != nil {
|
||||
return marker{}, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
|
||||
func writeMarker(path string, m marker) error {
|
||||
b, err := json.MarshalIndent(m, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
// cleanStaged 清掉暂存件。换装成功、校验失败、用户取消都走它,避免残留的
|
||||
// artex.new 在下次启动时被重新尝试。
|
||||
func cleanStaged(p Paths) {
|
||||
_ = os.Remove(p.New)
|
||||
_ = os.Remove(p.Sum)
|
||||
}
|
||||
|
||||
// CompareVersions 比较两个版本号,返回 -1/0/1(a<b / a==b / a>b)。
|
||||
// ok=false 表示至少一边不是可比较的版本号(例如本地开发构建的 "dev" 或
|
||||
// git describe 产出的 "0.3.7-2-gabc1234-dirty"),此时调用方应禁用一键更新,
|
||||
// 否则会把开发中的构建"升级"成正式版、覆盖掉未提交的改动。
|
||||
func CompareVersions(a, b string) (int, bool) {
|
||||
av, aok := parseVersion(a)
|
||||
bv, bok := parseVersion(b)
|
||||
if !aok || !bok {
|
||||
return 0, false
|
||||
}
|
||||
for i := range 3 {
|
||||
if av[i] != bv[i] {
|
||||
if av[i] < bv[i] {
|
||||
return -1, true
|
||||
}
|
||||
return 1, true
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
// parseVersion 解析 "v0.3.7" / "0.3.7" 形式的版本号为 [3]int。
|
||||
//
|
||||
// 只接受纯净的三段式:build.sh 在非 tag 构建时用 git describe 产出
|
||||
// "0.3.7-2-gabc1234" 这类带后缀的版本,它们必须被判为不可比较,而不是被当成
|
||||
// 0.3.7 —— 否则开发构建会被误判为"已是最新"或被正式版覆盖。
|
||||
func parseVersion(s string) ([3]int, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
if s == "" {
|
||||
return [3]int{}, false
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) != 3 {
|
||||
return [3]int{}, false
|
||||
}
|
||||
var out [3]int
|
||||
for i, p := range parts {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n < 0 {
|
||||
return [3]int{}, false
|
||||
}
|
||||
out[i] = n
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// InDocker 报告进程是否跑在容器里。Docker 下换装写的是容器可写层,
|
||||
// `docker compose up -d` 重建容器会退回镜像自带的版本——这是预期行为
|
||||
// (那时用户本来就在拉新镜像),但前端要能据此把话说清楚。
|
||||
func InDocker() bool {
|
||||
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||
return true
|
||||
}
|
||||
b, err := os.ReadFile("/proc/1/cgroup")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s := string(b)
|
||||
return strings.Contains(s, "docker") || strings.Contains(s, "containerd")
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package selfupdate
|
||||
|
||||
// 셀프 업데이트 경로의 사용자 노출 진행·오류 문구가 한국어로 나오는지 고정하는
|
||||
// 회귀 테스트(F20). update.go 의 updateHub 가 Stage 의 진행 콜백 메시지를 SSE
|
||||
// progress.message 로, Stage/FetchLatest/Rollback 의 반환 오류를 progress.error·
|
||||
// writeErr 본문·boot_notice 로 그대로 프런트엔드(update-card)에 노출하므로, 이 문구가
|
||||
// 중국어로 되돌아가면 사용자 화면이 반한반중으로 섞인다. 로그(log.Printf)·주석은 Z2라
|
||||
// 범위 밖이고, 여기서는 "한글이 있고 중국어 한자가 없다"만 단언한다.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func hasHangul(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 0xAC00 && r <= 0xD7A3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasHanzi(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 0x4E00 && r <= 0x9FFF {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// assertKoreanError 는 오류가 존재하고, 한글을 포함하며, 중국어 한자가 없음을 단언한다.
|
||||
// %w 로 감싼 하부 stdlib 오류의 영어는 허용된다(한자만 금지).
|
||||
func assertKoreanError(t *testing.T, label string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s: 오류를 기대했으나 nil 이었습니다", label)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !hasHangul(msg) {
|
||||
t.Errorf("%s: 한글이 없습니다: %q", label, msg)
|
||||
}
|
||||
if hasHanzi(msg) {
|
||||
t.Errorf("%s: 중국어 한자가 남아 있습니다: %q", label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// rtFunc 로 http.Client 에 가짜 응답을 주입한다. GitHub 에 실제로 접속하지 않는다.
|
||||
type rtFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f rtFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func stubClient(status int, body string) *http.Client {
|
||||
return &http.Client{Transport: rtFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
}
|
||||
|
||||
// TestCheckURLErrorsLocalized: 기존 TestCheckURLRejectsNonGitHub 는 거부 여부만 보고
|
||||
// 문구 언어는 보지 않는다. 여기서는 두 거부 경로의 오류가 한국어인지 확인한다.
|
||||
func TestCheckURLErrorsLocalized(t *testing.T) {
|
||||
nonHTTPS := checkURL(mustParse(t, "http://api.github.com/x"))
|
||||
assertKoreanError(t, "non-https", nonHTTPS)
|
||||
if !strings.Contains(nonHTTPS.Error(), "HTTPS") {
|
||||
t.Errorf("non-https 오류에 HTTPS 표기가 없습니다: %q", nonHTTPS.Error())
|
||||
}
|
||||
|
||||
nonGitHub := checkURL(mustParse(t, "https://evil.example.com/x"))
|
||||
assertKoreanError(t, "non-github-host", nonGitHub)
|
||||
if !strings.Contains(nonGitHub.Error(), "GitHub") {
|
||||
t.Errorf("non-github 오류에 GitHub 표기가 없습니다: %q", nonGitHub.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchLatestErrorsLocalized: 버전 확인 경로의 네 가지 오류(요청 제한·미발행·기타
|
||||
// 상태·해석 실패·tag 누락)가 한국어인지 확인한다. 가짜 transport 로 네트워크를 쓰지 않는다.
|
||||
func TestFetchLatestErrorsLocalized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, errRate := FetchLatest(ctx, stubClient(http.StatusForbidden, ""))
|
||||
assertKoreanError(t, "rate-limited", errRate)
|
||||
if !strings.Contains(errRate.Error(), "60") {
|
||||
t.Errorf("요청 제한 오류에 60 표기가 없습니다: %q", errRate.Error())
|
||||
}
|
||||
|
||||
_, errNF := FetchLatest(ctx, stubClient(http.StatusNotFound, ""))
|
||||
assertKoreanError(t, "not-found", errNF)
|
||||
|
||||
_, errStatus := FetchLatest(ctx, stubClient(http.StatusInternalServerError, ""))
|
||||
assertKoreanError(t, "bad-status", errStatus)
|
||||
if !strings.Contains(errStatus.Error(), "GitHub") {
|
||||
t.Errorf("상태 오류에 GitHub 표기가 없습니다: %q", errStatus.Error())
|
||||
}
|
||||
|
||||
_, errJSON := FetchLatest(ctx, stubClient(http.StatusOK, "{not json"))
|
||||
assertKoreanError(t, "bad-json", errJSON)
|
||||
|
||||
_, errTag := FetchLatest(ctx, stubClient(http.StatusOK, "{}"))
|
||||
assertKoreanError(t, "missing-tag", errTag)
|
||||
if !strings.Contains(errTag.Error(), "tag") {
|
||||
t.Errorf("tag 누락 오류에 tag 표기가 없습니다: %q", errTag.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStageMissingAssetLocalized: 릴리스에 현재 플랫폼 패키지가 없으면 Stage 가
|
||||
// 내려받기 전에 한국어 오류로 멈춘다(FindAsset 실패 → 네트워크 접근 없음). 실행 환경에
|
||||
// 따라 checkWritable 가 먼저 막을 수도 있으나 그 오류도 한국어이므로 언어만 단언한다.
|
||||
func TestStageMissingAssetLocalized(t *testing.T) {
|
||||
rel := &Release{TagName: "v9.9.9"} // Assets 비어 있음
|
||||
err := Stage(context.Background(), &http.Client{}, rel, "0.0.1", nil)
|
||||
assertKoreanError(t, "stage-missing-asset", err)
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
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 不应影响任何文件")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
package selfupdate
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sumsAsset 是 release.yml 生成的校验和清单,覆盖 Release 里全部 zip。
|
||||
const sumsAsset = "SHA256SUMS"
|
||||
|
||||
// maxBinarySize 限制解压出来的二进制体积,防止畸形 zip 把磁盘写满。
|
||||
const maxBinarySize = 512 << 20 // 512 MiB
|
||||
|
||||
// Phase 是升级过程中的阶段,直接用作 SSE 事件里的 phase 字段。
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseIdle Phase = "idle"
|
||||
PhaseDownload Phase = "downloading"
|
||||
PhaseVerify Phase = "verifying"
|
||||
PhaseExtract Phase = "extracting"
|
||||
PhaseStaged Phase = "staged"
|
||||
PhaseFailed Phase = "failed"
|
||||
)
|
||||
|
||||
// Progress 由调用方提供,用来把进度推给前端。pct 仅在下载阶段有意义(0-100),
|
||||
// 其余阶段传 -1。
|
||||
type Progress func(ph Phase, pct int, msg string)
|
||||
|
||||
// Stage 下载指定 Release 的当前平台发布包,校验后把新二进制暂存为 artex.new。
|
||||
//
|
||||
// 走的是完整 zip 而不是裸二进制,理由有两个:现有 Release 的 SHA256SUMS 本来就
|
||||
// 只覆盖 zip,走 zip 不需要改 CI,也能兼容已经发布出去的历史版本;zip 里还带着
|
||||
// skills/,为将来同步内置 skill 留了口子。代价只是多下载 skills 那几百 KB。
|
||||
//
|
||||
// 函数返回即代表暂存完成,调用方随后优雅关闭并以 ExitRestart 退出。
|
||||
func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion string, prog Progress) error {
|
||||
if prog == nil {
|
||||
prog = func(Phase, int, string) {}
|
||||
}
|
||||
p, err := ResolvePaths()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkWritable(p.Dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := AssetName(rel.TagName, runtime.GOOS, runtime.GOARCH)
|
||||
asset, ok := rel.FindAsset(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("이 버전은 %s/%s 용 릴리스 패키지를 제공하지 않습니다(%s 누락)", runtime.GOOS, runtime.GOARCH, name)
|
||||
}
|
||||
|
||||
prog(PhaseDownload, 0, "체크섬 목록을 가져오는 중…")
|
||||
sums, err := fetchSums(ctx, c, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want, ok := sums[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 에 %s 항목이 없어 검증되지 않은 바이너리 설치를 거부합니다", sumsAsset, name)
|
||||
}
|
||||
|
||||
// 临时文件全部落在目标目录里,保证最后的 rename 是同一文件系统内的原子操作
|
||||
// (跨设备 rename 会失败,而 /tmp 常常是独立挂载点)。
|
||||
zipPath := p.New + ".zip.part"
|
||||
binPath := p.New + ".part"
|
||||
defer func() {
|
||||
_ = os.Remove(zipPath)
|
||||
_ = os.Remove(binPath)
|
||||
}()
|
||||
|
||||
prog(PhaseDownload, 0, fmt.Sprintf("%s 내려받는 중(%s)…", name, humanSize(asset.Size)))
|
||||
got, err := download(ctx, c, asset, zipPath, prog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prog(PhaseVerify, -1, "SHA256 검증 중…")
|
||||
if !strings.EqualFold(got, want) {
|
||||
return fmt.Errorf("SHA256 이 일치하지 않습니다: 기대값 %s, 실제값 %s(다운로드가 손상됐거나 변조됨)", short(want), short(got))
|
||||
}
|
||||
|
||||
prog(PhaseExtract, -1, "압축을 풀고 스모크 테스트 중…")
|
||||
if err := extractBinary(zipPath, binPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := smokeTest(binPath); err != nil {
|
||||
return fmt.Errorf("새 버전이 현재 시스템에서 실행되지 않습니다: %w", err)
|
||||
}
|
||||
|
||||
// 暂存件自己的 sha256 单独存一份:下次启动换装前还要再校验一次,
|
||||
// 防止暂存后到重启前这段时间里文件被改动或写坏。
|
||||
binSum, err := fileSHA256(binPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("새 바이너리 체크섬 계산 실패: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(p.Sum, []byte(binSum), 0o644); err != nil {
|
||||
return fmt.Errorf("체크섬 기록 실패: %w", err)
|
||||
}
|
||||
if err := os.Rename(binPath, p.New); err != nil {
|
||||
_ = os.Remove(p.Sum)
|
||||
return fmt.Errorf("새 버전 준비 실패: %w", err)
|
||||
}
|
||||
|
||||
if err := writeMarker(p.Marker, marker{
|
||||
From: currentVersion,
|
||||
To: strings.TrimPrefix(rel.TagName, "v"),
|
||||
StagedAt: time.Now().Unix(),
|
||||
}); err != nil {
|
||||
// 标记只影响自动回滚能力,暂存件本身已就位,不因此中断升级。
|
||||
prog(PhaseStaged, -1, "경고: 업데이트 표시 파일 기록에 실패해 이번 업데이트는 자동 롤백 보호를 받지 못합니다")
|
||||
}
|
||||
|
||||
prog(PhaseStaged, 100, "새 버전이 준비되었습니다. 재시작 중…")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchSums 下载并解析 SHA256SUMS,返回 文件名 → 十六进制摘要。
|
||||
func fetchSums(ctx context.Context, c *http.Client, rel *Release) (map[string]string, error) {
|
||||
asset, ok := rel.FindAsset(sumsAsset)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("이 릴리스에 %s 항목이 없어 무결성을 검증할 수 없으므로 업데이트를 거부합니다", sumsAsset)
|
||||
}
|
||||
body, err := get(ctx, c, asset.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 내려받기 실패: %w", sumsAsset, err)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 읽기 실패: %w", sumsAsset, err)
|
||||
}
|
||||
out := parseSums(string(raw))
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s 내용이 비어 있거나 형식을 인식할 수 없습니다", sumsAsset)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseSums 解析 sha256sum 风格的清单,返回 文件名 → 十六进制摘要。
|
||||
//
|
||||
// 第一个字段必须是 64 位十六进制才收录。只按"恰好两个字段"判断是不够的——
|
||||
// 任意一行两个单词的说明文字都会被当成合法条目,把垃圾值塞进摘要表,
|
||||
// 真正的资产反而可能匹配到错误的摘要。
|
||||
func parseSums(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for line := range strings.Lines(raw) {
|
||||
// 格式为 "<sha256> <filename>"(sha256sum 用双空格;shasum 的二进制
|
||||
// 模式会给文件名加 * 前缀)。
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) != 2 || !isHexSHA256(fields[0]) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimPrefix(fields[1], "*")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out[name] = strings.ToLower(fields[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isHexSHA256(s string) bool {
|
||||
if len(s) != 64 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
switch {
|
||||
case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// download 把资产写入 dst,同时计算 SHA256 并按 Content-Length 汇报进度。
|
||||
func download(ctx context.Context, c *http.Client, a Asset, dst string, prog Progress) (string, error) {
|
||||
body, err := get(ctx, c, a.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s 내려받기 실패: %w", a.Name, err)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("임시 파일 생성 실패: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
pw := &progressWriter{total: a.Size, prog: prog, name: a.Name, last: time.Now()}
|
||||
if _, err := io.Copy(io.MultiWriter(f, h, pw), body); err != nil {
|
||||
return "", fmt.Errorf("다운로드가 중단되었습니다: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return "", fmt.Errorf("디스크 기록 실패: %w", err)
|
||||
}
|
||||
if a.Size > 0 && pw.written != a.Size {
|
||||
return "", fmt.Errorf("다운로드가 완전하지 않습니다: 기대 %d바이트, 실제 %d바이트", a.Size, pw.written)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// get 发起一个受白名单约束的 GET,返回响应体。
|
||||
func get(ctx context.Context, c *http.Client, rawURL string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := checkURL(req.URL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "artex-selfupdate")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
// extractBinary 从发布包里取出 artex 可执行文件。
|
||||
//
|
||||
// 包内结构是 artex-<版本>-<os>-<arch>/artex,但这里按**基名**匹配而不是拼完整
|
||||
// 路径:版本号在包名里出现过一次,拼错一个字符就整个升级失败,按基名找更耐改。
|
||||
func extractBinary(zipPath, dst string) error {
|
||||
want := "artex"
|
||||
if runtime.GOOS == "windows" {
|
||||
want = "artex.exe"
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("릴리스 패키지 열기 실패: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, entry := range zr.File {
|
||||
if entry.FileInfo().IsDir() || !strings.EqualFold(path.Base(entry.Name), want) {
|
||||
continue
|
||||
}
|
||||
rc, err := entry.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 읽기 실패: %w", entry.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("새 바이너리 쓰기 실패: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, io.LimitReader(rc, maxBinarySize+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 압축 해제 실패: %w", entry.Name, err)
|
||||
}
|
||||
if n > maxBinarySize {
|
||||
return fmt.Errorf("릴리스 패키지 안의 실행 파일이 %s 보다 커서 압축 해제를 거부합니다", humanSize(maxBinarySize))
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("릴리스 패키지 안의 %s 파일이 비어 있습니다", want)
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
return fmt.Errorf("릴리스 패키지에서 %s 파일을 찾지 못했습니다", want)
|
||||
}
|
||||
|
||||
// checkWritable 提前确认目录可写。没有这一步,非 root 运行、或二进制被放在系统
|
||||
// 目录时,会在下载完几十 MB 之后才在换装那一刻失败。
|
||||
func checkWritable(dir string) error {
|
||||
probe, err := os.CreateTemp(dir, ".artex-update-probe-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("프로그램 디렉터리 %s 에 쓸 수 없어 자동 업데이트를 할 수 없습니다(권한을 확인하거나 수동 업데이트를 사용하세요): %w", dir, err)
|
||||
}
|
||||
name := probe.Name()
|
||||
_ = probe.Close()
|
||||
_ = os.Remove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// progressWriter 统计已写字节并限频汇报,避免每个 32KiB 分块都推一条 SSE。
|
||||
type progressWriter struct {
|
||||
total int64
|
||||
written int64
|
||||
name string
|
||||
prog Progress
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func (w *progressWriter) Write(b []byte) (int, error) {
|
||||
w.written += int64(len(b))
|
||||
if time.Since(w.last) < 300*time.Millisecond {
|
||||
return len(b), nil
|
||||
}
|
||||
w.last = time.Now()
|
||||
pct := -1
|
||||
if w.total > 0 {
|
||||
pct = int(w.written * 100 / w.total)
|
||||
}
|
||||
w.prog(PhaseDownload, pct, fmt.Sprintf("내려받는 중 %s / %s", humanSize(w.written), humanSize(w.total)))
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
func humanSize(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp])
|
||||
}
|
||||
|
||||
func short(sum string) string {
|
||||
if len(sum) > 12 {
|
||||
return sum[:12] + "…"
|
||||
}
|
||||
return sum
|
||||
}
|
||||
Reference in New Issue
Block a user