mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-10-18 21:47:17 +02:00
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:
1 parent
9a68a17cec
commit
81bd5f1a6e
4 files changed
+172
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user