fix: the version writer recognizes every spelling its reader accepts

FileVersion reads the live config with the YAML parser, which understands
`"version":` and `'version':` exactly like the bare key. WriteVersion matched
lines by the literal prefix `version`, so a legal quoted-key config was not
recognized as already carrying the field at all: instead of replacing the line,
the writer prepended a second `version:` key. The file then failed to load
("mapping key \"version\" already defined"), i.e. the version sync could leave
an installation that no longer starts - while reporting success through the
real entry point (before=v1.0.0 -> after=v1.7.22, err=nil).

The writer now matches all three spellings, top-level only, first occurrence
only, and before writing it re-reads the new text with the same YAML parser: if
the result does not parse, or the top-level version does not read back as the
target, the write is refused and the original file is left byte-for-byte alone
(the update reports the skip and stands - a stale version number is a display
problem, a config that does not load is an installation that does not start).
Forms a line-based writer cannot rewrite, like JSON one-liners and block
scalars, are refused rather than corrupted.

The tarball path's writer in upgrade.sh gets the same rule set: top-level
three-spelling matching, first match only, a refusal for block/multi-line
scalars and for duplicate keys, plus the value validation the Go side applies -
a tag with a quote or newline in it can no longer reach the live config.

Regression tests: the config package covers the three quoted spellings and the
three refused forms (JSON one-liner, block scalar, duplicate key - file must
stay untouched), and the update package drives a quoted-key live config through
a real Apply and Rollback; the latter fails on the pre-fix tree with the
duplicate key it used to produce.
This commit is contained in:
Sycun committed 2026-10-08 16:16:21 +08:00
1 parent 9a68a17cec
commit 81bd5f1a6e
4 files changed
+172 -25

No files matched your search

+30 -5
View File
@@ -1761,11 +1761,22 @@ func FileVersion(path string) (string, error) {
}
return "", fmt.Errorf("读取配置文件失败: %w", err)
}
version, err := parseTopVersion(data)
if err != nil {
return "", fmt.Errorf("解析配置文件失败: %w", err)
}
return version, nil
}
// parseTopVersion 从配置文本里取顶层 version。走 YAML 解析,version / "version" / 'version'
// 三种写法一视同仁——读取认得的写法,写入也必须认得,否则就会在合法的引号键配置上追加出
// 第二个 version 键(见 WriteVersion)。
func parseTopVersion(data []byte) (string, error) {
var thin struct {
Version string `yaml:"version"`
}
if err := yaml.Unmarshal(data, &thin); err != nil {
return "", fmt.Errorf("解析配置文件失败: %w", err)
return "", err
}
return strings.TrimSpace(thin.Version), nil
}
@@ -1774,9 +1785,22 @@ func FileVersion(path string) (string, error) {
// 配置上,带引号或换行的值会把 YAML 写坏。
var versionValuePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]*$`)
// topVersionKey 判断一行是不是顶层的 version 键,三种 YAML 都认的写法都在内:version:、
// "version":、'version':。缩进的同名键属于别的段落,不动。
func topVersionKey(line string) bool {
for _, key := range []string{"version", `"version"`, "'version'"} {
if strings.HasPrefix(line, key) {
return strings.HasPrefix(strings.TrimSpace(line[len(key):]), ":")
}
}
return false
}
// WriteVersion 就地替换配置文件的顶层 version 字段,保留其它每一行的原样(tarball 升级
// 路径一直这么做);没有这个字段就插到文件开头。返回是否真的改了。写盘走临时文件 + 原子
// rename——这是运维者的活配置,不能留半截。
// rename——这是运维者的活配置,不能留半截。替换后先把新文本解析回验一遍:行级替换够不着的
// 写法(JSON 单行、多行标量……)解析失败或读不回目标值就拒绝写、原文件不动。版本号停在旧值
// 只是页头显示问题,写坏配置是起不来。
func WriteVersion(path, version string) (bool, error) {
version = strings.TrimSpace(version)
if version == "" {
@@ -1796,9 +1820,7 @@ func WriteVersion(path, version string) (bool, error) {
lines := strings.Split(string(data), "\n")
replaced := false
for i, line := range lines {
// 只认顶层(顶格的)version 键;缩进里同名键属于别的段落,不动。
rest := strings.TrimSpace(strings.TrimPrefix(line, "version"))
if strings.HasPrefix(line, "version") && strings.HasPrefix(rest, ":") {
if topVersionKey(line) {
lines[i] = `version: "` + version + `"`
replaced = true
break
@@ -1811,6 +1833,9 @@ func WriteVersion(path, version string) (bool, error) {
if out == string(data) {
return false, nil
}
if got, err := parseTopVersion([]byte(out)); err != nil || got != version {
return false, fmt.Errorf("version 的写法无法就地替换(改后配置解析失败或读回为 %q),已放弃写入,原文件未改动", got)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(out), info.Mode().Perm()); err != nil {
return false, err
+56
View File
@@ -425,6 +425,62 @@ func TestFileVersionAndWriteVersion(t *testing.T) {
}
}
// FileVersion 用 YAML 解析(引号键认得出),WriteVersion 若只认顶格裸键,合法配置就会被追加
// 出第二个 version 键、整个文件随后 Load 失败。这条测试钉死两者口径一致,以及"改不了就拒绝写"。
func TestWriteVersionRewritesQuotedKeysAndRefusesWhatItCannot(t *testing.T) {
dir := t.TempDir()
// Legal spellings of the same top-level key: replaced in place, exactly one key left.
for _, original := range []string{
"\"version\": \"v1.0.0\"\nserver:\n port: 8088\n",
"'version': v1.0.0\nserver:\n port: 8088\n",
"\"version\": v1.0.0\nserver:\n port: 8088\n",
} {
path := filepath.Join(dir, "quoted.yaml")
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if v, err := FileVersion(path); err != nil || v != "v1.0.0" {
t.Fatalf("FileVersion(%q) = %q, %v", original, v, err)
}
changed, err := WriteVersion(path, "v1.7.22")
if err != nil || !changed {
t.Fatalf("WriteVersion(%q): changed=%v err=%v", original, changed, err)
}
got := readTestFile(t, path)
if strings.Count(got, "version") != 1 {
t.Fatalf("writing %q left %d version keys:\n%q", original, strings.Count(got, "version"), got)
}
if v, err := FileVersion(path); err != nil || v != "v1.7.22" {
t.Fatalf("after writing %q: FileVersion = %q, %v (file %q)", original, v, err, got)
}
if !strings.Contains(got, "port: 8088") {
t.Fatalf("the rest of the file must stay: %q", got)
}
}
// Forms a line-based replacement cannot rewrite: refused with the file untouched. A
// stale version number is a display artifact; a config that no longer loads is an
// installation that no longer starts.
for _, original := range []string{
"{\"version\": \"v1.0.0\", \"server\": {\"port\": 8088}}\n",
"version: >-\n v1.0.0\nserver:\n port: 8088\n",
"\"version\": \"v9.9.9\"\n\"version\": \"v1.0.0\"\n",
} {
path := filepath.Join(dir, "unwritable.yaml")
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
changed, err := WriteVersion(path, "v1.7.22")
if err == nil {
t.Fatalf("WriteVersion(%q) must refuse instead of writing: changed=%v file=%q", original, changed, readTestFile(t, path))
}
if got := readTestFile(t, path); got != original {
t.Fatalf("a refused write must not touch the file:\nbefore %q\nafter %q", original, got)
}
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
+43
View File
@@ -2,6 +2,7 @@ package update
import (
"context"
"cyberstrike-ai/internal/config"
"os"
"os/exec"
"path/filepath"
@@ -456,6 +457,48 @@ func TestApplySyncsTheReleaseVersionIntoTheLiveConfig(t *testing.T) {
}
}
// The same sync end to end, with the key spelled the way issue #4 was filed about: a legal
// quoted key must be replaced in place (not appended next to), and the rollback must still
// find a config it can read.
func TestApplySyncsTheReleaseVersionIntoAQuotedKeyConfig(t *testing.T) {
requireGit(t)
tr := newTree(t)
live := filepath.Join(tr.install, "config.yaml")
writeFile(t, live, "\"version\": \"v1.0.0\"\nserver:\n port: 8088\n")
tr.upstreamCommit(t, "release: bump version", map[string]string{
"config.example.yaml": "version: \"v1.1.0\"\n",
"internal_service.go": "package service\n\nconst Version = \"2\"\n",
})
if _, err := Apply(context.Background(), opts(tr), nil); err != nil {
t.Fatalf("apply failed: %v", err)
}
got := readFile(t, live)
if strings.Count(got, "version") != 1 {
t.Fatalf("live config gained a duplicate version key:\n%q", got)
}
if v, err := config.FileVersion(live); err != nil || v != "v1.1.0" {
t.Fatalf("live config after apply reads %q (%v), want v1.1.0:\n%q", v, err, got)
}
if !strings.Contains(got, "port: 8088") {
t.Fatalf("the rest of the config must stay:\n%q", got)
}
bin := filepath.Join(tr.install, Binary)
writeFile(t, bin, "new binary bytes")
writeFile(t, bin+".prev", "old binary bytes")
if _, err := Rollback(context.Background(), Options{Root: tr.install}); err != nil {
t.Fatalf("rollback failed: %v", err)
}
got = readFile(t, live)
if strings.Count(got, "version") != 1 {
t.Fatalf("rollback left a broken config:\n%q", got)
}
if v, err := config.FileVersion(live); err != nil || v != "v1.0.0" {
t.Fatalf("live config after rollback reads %q (%v), want v1.0.0:\n%q", v, err, got)
}
}
func TestRollbackDoesNotClobberAManuallyChangedVersion(t *testing.T) {
requireGit(t)
tr := newTree(t)
+43 -20
View File
@@ -502,30 +502,53 @@ check_tarball_install() {
}
update_config_version() {
# Replace config.yaml's version: ... with the specified tag.
# Replace config.yaml's top-level version with the specified tag. The line matching
# mirrors the Go writer (internal/config.WriteVersion): top-level key only, in every
# spelling YAML accepts (version / "version" / 'version'), first occurrence only - a
# nested key or a later duplicate belongs to the file's own structure and must not be
# rewritten. A value the line cannot replace safely (block scalar / multi-line scalar)
# leaves the file alone on purpose: a stale version number is a display problem, a
# config that no longer loads is an installation that no longer starts.
local new_tag="$1"
python3 - "$CONFIG_FILE" "$new_tag" <<PY
import re, sys
path=sys.argv[1]
tag=sys.argv[2]
python3 - "$CONFIG_FILE" "$new_tag" <<'PY'
import os, re, sys
path = sys.argv[1]
tag = sys.argv[2]
# Same value rule as the Go writer (internal/config.versionValuePattern): what lands here
# is a live config, and a tag with a quote or newline in it must never reach it.
if not re.match(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$", tag):
sys.stderr.write("版本号 %r 不合法(只允许字母、数字与 . _ + -),未写入 config.yaml\n" % tag)
sys.exit(0)
with open(path, "r", encoding="utf-8") as f:
lines=f.readlines()
lines = f.readlines()
out=[]
replaced=False
for line in lines:
if re.match(r'^\s*version\s*:', line):
out.append(f'version: "{tag}"\n')
replaced=True
else:
out.append(line)
key = re.compile(r"^(['\"]?version['\"]?)\s*:(.*)$")
replaced = False
refused = False
out = []
for i, line in enumerate(lines):
if not replaced:
m = key.match(line)
if m:
value = m.group(2).strip()
nxt = lines[i + 1] if i + 1 < len(lines) else ""
if value[:1] in ("|", ">") or (value == "" and nxt[:1] in (" ", "\t")):
sys.stderr.write("版本号未写入:config.yaml 的 version 是多行/块标量写法,无法就地替换(原文件未改动)\n")
refused = True
break
out.append('version: "%s"\n' % tag)
replaced = True
continue
out.append(line)
if not replaced:
# If no version field is found, insert at the beginning (near the top).
out.insert(0, f'version: "{tag}"\n')
with open(path, "w", encoding="utf-8") as f:
f.writelines(out)
if not refused:
if not replaced:
out.insert(0, 'version: "%s"\n' % tag)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.writelines(out)
os.replace(tmp, path)
PY
}