Files
HomeAgent/internal/systemd/sync_test.go
JianFeeeee 40de84facc feat(systemd,webui,security): 数据目录一致性接线 + 内嵌主密钥注入
## 1. 数据目录一致性(原为死代码)

`internal/systemd/sync.go` 能编译但**从未接线** —— 没有任何地方调用它。

内核有**两处**数据目录来源且不等价:

	-data <dir>            ← systemd 单元 → memory/graph.db、插件数据根
	core.daemon.data_dir   ← 数据库     → memory/text、documents、knowledge

不一致时数据**分裂**:图记忆在 A、文本/知识在 B,检索跨目录、备份会漏。
生产当前两者一致所以没暴露,但 WebUI 允许改 DB 里的值而**无任何提示**。

接线三处:

 · cmd/homed/main.go 启动时检查,不一致则打告警 + 内联修复命令
 · GET /api/v1/system/data-dir 返回诊断(只在真不一致时给 sync_command)
 · 判据 6 条(InSync/Clean/SyncCommand 内联 sed/备份/daemon-reload/drop-in)

★ 只告警**不改**:静默改 systemd 单元比不提示更危险 —— 改错了服务
起不来,而用户不知道是谁改的。命令里已内联 sed 替换,人复制执行即可。

## 2. 内嵌主密钥(方案 C)

build.sh 支持 `HA_EMBEDDED_KEY_HEX`(64 位 hex)经 -ldflags 注入。
从**环境变量**读而不是写进文件:本仓有两个公开远端,64 位 hex 一旦
写进被跟踪的文件就永久留在 git 历史里。

未设时不注入、不报错:开发机与 CI 本来就不该带生产密钥。
设了但格式不对则**中断构建**(`return 1`)—— 静默产出一个没有内嵌钥的
二进制,会让"防丢失"这个目的悄悄失效。

实测(真机):磁盘上**没有** security.key 时,仅靠 embedded 密钥即可
Submit + 二次加载解密成功;日志确认
`密钥来源 embedded (构建期注入,指纹 0db8e9c5)`。

## 判据第一版的两个错误(记下来)

 · 没给 UnitPath 就断言"必须含 sed -i" —— 实际 sed 行只在 UnitPath
   非空时生成,判据误报。
 · 断言 `/` 必须转义 —— 但 sed 分隔符是 `|` 不是 `/`。`/` 无需转义;
   真正必须转义的是 `&`(sed 里表示整个匹配)与 `|`(分隔符)。
2026-10-18 13:51:00 +08:00

143 lines
5.4 KiB
Go
Raw 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 systemd
// sync.go 的判据:数据目录一致性检测与修复命令生成。
//
// ## 为什么需要(用户 2026-10-17 指出)
//
// 内核有**两处**数据目录来源,且不等价:
//
// -data <dir> ← systemd 单元,决定 ks.DataDir → memory/graph.db
// core.daemon.data_dir ← 数据库 → memory/text、documents、knowledge
//
// 不一致时**数据分裂**:图记忆在 A、文本/知识/文档在 B,检索跨目录、备份会漏。
//
// 用户还明确要求:「指令应当直接包含替换部分而不是让用户手动修改」
// ⇒ SyncCommand 必须是**可直接粘贴执行的 sed 原地替换**,
// 而不是"请把 -data 改成 xxx"。
//
// 这条要求不是风格偏好:生产用 drop-in 覆盖 ExecStart,
// 手工改主单元**完全无效**,而用户不会知道这一点。
import (
"regexp"
"strings"
"testing"
)
// ★★★ 判据:一致/不一致的判定。
func TestInSync(t *testing.T) {
cases := []struct {
runtime, config string
want bool
why string
}{
{"/a/b", "/a/b", true, "完全相同"},
{"/a/b/", "/a/b", true, "尾斜杠不算不一致(Clean 后相同)"},
{"/a/b", "/a/c", false, "不同目录"},
{"", "/a/b", true, "runtime 未知时不报警(宁可漏,不误报)"},
{"/a/b", "", true, "config 未知时不报警"},
}
for _, c := range cases {
st := State{RuntimeDataDir: c.runtime, ConfigDataDir: c.config}
if got := st.InSync(); got != c.want {
t.Errorf("InSync(%q, %q) = %v,应为 %v(%s)",
c.runtime, c.config, got, c.want, c.why)
}
}
}
// ★★★ 判据:SyncCommand 必须内联替换,不能让用户手工编辑。
func TestSyncCommandInlinesReplacement(t *testing.T) {
// 必须给 UnitPath:真实环境下 Check() 会解析出来。
// 第一版没给,于是 sed 行不生成 —— 判据误报"没有内联替换"。
st := State{
RuntimeDataDir: "/old/dir",
ConfigDataDir: "/new/dir",
UnitPath: "/etc/systemd/system/homeagent.service",
}
cmd := st.SyncCommand("/new/dir")
if cmd == "" {
t.Fatal("不一致时应给出修复命令")
}
// ① 必须含 sed 原地替换(内联目标目录),而不是"请编辑文件"
if !regexp.MustCompile(`sed -i`).MatchString(cmd) {
t.Fatalf("★ 修复命令没有内联 sed -i 替换:\n%s\n"+
"用户明确要求「指令应当直接包含替换部分而不是让用户手动修改」", cmd)
}
if !strings.Contains(cmd, "/new/dir") {
t.Fatalf("★ 修复命令里没有出现目标目录 /new/dir:\n%s", cmd)
}
// ② 必须备份(改坏了要能回滚)
if !strings.Contains(cmd, ".bak") {
t.Fatalf("★ 修复命令没有备份原文件:\n%s", cmd)
}
// ③ 必须 daemon-reload(否则改了单元也不生效)
if !strings.Contains(cmd, "daemon-reload") {
t.Fatalf("★ 修复命令没有 systemctl daemon-reload:\n%s", cmd)
}
// ④ 必须真的重启服务(否则新目录不生效)
if !strings.Contains(cmd, "restart") && !strings.Contains(cmd, "start") {
t.Fatalf("★ 修复命令没有重启服务:\n%s", cmd)
}
}
// ★★★ 判据:drop-in 也必须改(生产用 drop-in 覆盖 ExecStart,改主单元无效)。
func TestSyncCommandCoversDropIns(t *testing.T) {
st := State{
RuntimeDataDir: "/old",
ConfigDataDir: "/new",
UnitPath: "/etc/systemd/system/homeagent.service",
DropIns: []string{"/etc/systemd/system/homeagent.service.d/override.conf"},
}
cmd := st.SyncCommand("/new")
if !strings.Contains(cmd, "override.conf") {
t.Fatalf("★ 修复命令没有处理 drop-in —— 生产用 drop-in 覆盖 ExecStart,"+
"只改主单元**完全无效**,而用户不会知道这一点:\n%s", cmd)
}
}
// ★★★ 判据:目标目录为空时不给命令(避免生成一条把 -data 改成空串的命令)。
func TestSyncCommandEmptyTargetIsNoop(t *testing.T) {
st := State{RuntimeDataDir: "/a", ConfigDataDir: ""}
if got := st.SyncCommand(""); got != "" {
t.Fatalf("目标目录为空时应返回空命令,实际:\n%s", got)
}
}
// ★★★ 判据:sed 替换串里 sed 的**元字符**必须转义,否则生成坏命令。
//
// 注意分隔符是 `|`(见 SyncCommand),所以 `/` **不需**转义。
// 第一版判据断言 `/` 必须转义 —— 那是错的(分隔符是 | 而非 /)。
func TestSedEscapeRepl(t *testing.T) {
// & 在 sed 替换段里表示"整个匹配" ⇒ 必须转义,否则目录里的 & 会被替换成匹配内容
if got := sedEscapeRepl("/a&b"); !strings.Contains(got, `\&`) {
t.Errorf("& 未转义(sed 会把它当成整个匹配):%q", got)
}
// | 是分隔符本身 ⇒ 必须转义
if got := sedEscapeRepl("/a|b"); !strings.Contains(got, `\|`) {
t.Errorf("分隔符 | 未转义:%q", got)
}
// 反斜杠要双写
if got := sedEscapeRepl(`/a\b`); !strings.Contains(got, `\\`) {
t.Errorf("反斜杠未双写:%q", got)
}
}
// ★★★ 判据:Describe 在不一致时给出两个具体路径,一致时为空。
func TestDescribe(t *testing.T) {
ok := State{RuntimeDataDir: "/same", ConfigDataDir: "/same"}
if got := ok.Describe(); got != "" {
t.Errorf("一致时 Describe 应为空,实际:%s", got)
}
bad := State{RuntimeDataDir: "/a", ConfigDataDir: "/b"}
d := bad.Describe()
if !strings.Contains(d, "/a") || !strings.Contains(d, "/b") {
t.Fatalf("Describe 必须给出两个具体路径:\n%s", d)
}
}