M63(M60 审计 P0-1/P0-2)。作用域 ≠ 敏感度:`scope='user'` 是共享域,里 面却装着住址(花溪 64 条 active)/私称(叶酱 63)/作息(早睡 38),于是受限车 道(群聊/陌生人)把共享域整锅注入上下文 —— 2026-10-29 群 531082168 的住址泄漏 即此路径。本片给 memory_items 加 `sensitive` 列,把「这条归谁」(scope)与 「这条能不能外说」(sensitive)拆成两根正交的轴。 - 迁移 0028:加列(INTEGER NOT NULL DEFAULT 0)+ **同迁移幂等回填**(精确短语 白名单 花溪/叶酱/早睡,`AND sensitive=0`,不改 updated_at),down 只撤结构; memory 包内 `ensureSensitiveSchema` 同形前滚(任意 *sql.DB 自举)。 - 受限判据:新增 `MemoryItem.SharedVisible()` = 共享域 且 !sensitive,成为 restricted 侧唯一起点;`loop.FilterSharedVisible` 与复用它的 knowledge 工具 自动跟随。主人车道不过这道判据,照常读到全部记忆(sensitive 不是权限系统)。 - 提取器:prompt 追加 sensitive 位(住址/私人称呼/家人/健康/作息与行程 → true, 口味/兴趣/项目约定 → false,含正反例);产出侧与落库侧共用 `resolveSensitive`(模型的类目判断 ∨ 已实证短语白名单),只加标不撤标。 - 回填入口 `BackfillSensitive(ctx, db, dryRun)`:幂等、可 dry-run、返回候选池 前后规模与逐短语计数(生产快照实测 3116 → 2948,标 725 行)。 - 读路径并到 15 列(scanItem + 7 处 SELECT);store.go 拆分 store_insert.go 守住 500 行上限。 验收:go build/vet/test 全绿(memory + loop 单包复跑绿);删实现三处变异分别打红 (sensitiveColumn 恒 0 / SharedVisible 忽略 Sensitive / Extractor 跳过白名单); 生产库快照上跑 0028 后 Go 侧回填 marked=0(迁移 SQL 与 privateFacts 等价)。
189 lines
7.8 KiB
Go
189 lines
7.8 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"slices"
|
||
"testing"
|
||
)
|
||
|
||
// 本文件钉住**迁移 0028(`memory_sensitive`)与 Store 自举 DDL 的同形、可回滚、
|
||
// 以及「迁移里的白名单回填」与 Go 侧回填判据等价**。手法照 retention_migration_test.go
|
||
// 对 0026、temporal_migration_test.go 对 0027 的那一套(同一批 helpers)。
|
||
//
|
||
// 真实启动顺序是「先 MigrateUp、后 NewStore」(cmd/cyrene/wire.go),因此新列有两份
|
||
// 真源:迁移(权威)与 NewStore 的幂等前滚(sensitive_schema.go)。少一列就是
|
||
// 「新库有、老库无」的漂移,因此三条判据都必须有:
|
||
//
|
||
// 1. TestMigration0028AddsColumnAndBackfills —— 迁移落在**已有数据**上:行不丢、
|
||
// 列补上、白名单命中的历史行被标成 sensitive=1、非白名单行保持 0;
|
||
// 2. TestMigration0028DownDropsColumnOnly —— down 只撤结构(行数与其它数据不变);
|
||
// 3. TestMigration0028ShapeMatchesStoreBootstrap —— 两份 DDL 的列集合与列形状同形,
|
||
// 且自举侧尾序是 `… temporal, sensitive`。
|
||
//
|
||
// 第 1 条同时是**白名单等价性**的证据:迁移里那份短语清单与 Go 的 privateFacts
|
||
// 若不一致,`BackfillSensitive` 在迁移之后重跑会标出额外的行(Marked > 0)——
|
||
// 那条断言把「两份实现悄悄分叉」变成一次红灯。
|
||
|
||
// sensitiveMigrationID 是 0028 在 schema_migrations 里的记账 id(= 文件名去掉 .up.sql)。
|
||
const sensitiveMigrationID = "0028_memory_sensitive"
|
||
|
||
// TestMigration0028AddsColumnAndBackfills 造出「0028 之前的既有部署」再升级:
|
||
// 历史行一行不丢、白名单命中的被标、没命中的不被标。
|
||
func TestMigration0028AddsColumnAndBackfills(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
d := openRawMemoryDB(t)
|
||
migrateUp(t, d)
|
||
if !hasMemoryColumn(t, d, sensitiveColumn) {
|
||
t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn)
|
||
}
|
||
|
||
// 回到 0028 之前,按旧形态写四行(花溪 / 叶酱 / 早睡 / 习惯)。
|
||
migrateDownToBefore(t, d, sensitiveMigrationID)
|
||
if hasMemoryColumn(t, d, sensitiveColumn) {
|
||
t.Fatalf("0028 回滚后 %s 应已消失", sensitiveColumn)
|
||
}
|
||
seedLegacySensitiveRow(t, d, "legacy-1", string(ScopeUser), "用户居住在花溪(贵阳花溪区)")
|
||
seedLegacySensitiveRow(t, d, "legacy-2", string(ScopePersona), "主人希望被称作「叶酱」")
|
||
seedLegacySensitiveRow(t, d, "legacy-3", string(ScopeProject), "主人常熬夜,助手提醒早睡")
|
||
seedLegacySensitiveRow(t, d, "legacy-4", string(ScopeUser), "用户喜欢冰美式")
|
||
|
||
// 升级:0028 落在**已有数据**上(建列 + 回填)。
|
||
migrateUp(t, d)
|
||
if !hasMemoryColumn(t, d, sensitiveColumn) {
|
||
t.Fatalf("迁移后 memory_items 应带 %s 列", sensitiveColumn)
|
||
}
|
||
var rows int
|
||
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
|
||
t.Fatalf("count rows: %v", err)
|
||
}
|
||
if rows != 4 {
|
||
t.Fatalf("迁移后行数 = %d, want 4(既有行一行都不能丢)", rows)
|
||
}
|
||
for _, id := range []string{"legacy-1", "legacy-2", "legacy-3"} {
|
||
if got := readSensitiveCell(t, d, id); got != 1 {
|
||
t.Fatalf("%s 回填后 sensitive = %d, want 1(白名单命中必须被标)", id, got)
|
||
}
|
||
}
|
||
if got := readSensitiveCell(t, d, "legacy-4"); got != 0 {
|
||
t.Fatalf("legacy-4(习惯类)被标成敏感 = %d —— 迁移把「用户习惯可查」推翻了", got)
|
||
}
|
||
|
||
// 白名单等价性:迁移已经标完,Go 侧回填不该再标出一行。
|
||
report, err := BackfillSensitive(ctx, d, false)
|
||
if err != nil {
|
||
t.Fatalf("BackfillSensitive(迁移之后): %v", err)
|
||
}
|
||
if report.Marked != 0 {
|
||
t.Fatalf("迁移之后 Go 侧回填又标了 %d 行 —— 迁移里的短语清单与 privateFacts 已经分叉",
|
||
report.Marked)
|
||
}
|
||
if report.Matched != 3 {
|
||
t.Fatalf("Matched = %d, want 3(两条实现的判据必须一致)", report.Matched)
|
||
}
|
||
}
|
||
|
||
// TestMigration0028DownDropsColumnOnly 钉住 down 的唯一语义:只撤结构。
|
||
func TestMigration0028DownDropsColumnOnly(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
d := openRawMemoryDB(t)
|
||
migrateUp(t, d)
|
||
seedLegacySensitiveRow(t, d, "keep-1", string(ScopeUser), "用户居住在花溪")
|
||
if _, err := d.ExecContext(ctx,
|
||
`UPDATE memory_items SET sensitive = 1 WHERE id = 'keep-1'`); err != nil {
|
||
t.Fatalf("mark row: %v", err)
|
||
}
|
||
|
||
migrateDownToBefore(t, d, sensitiveMigrationID)
|
||
if hasMemoryColumn(t, d, sensitiveColumn) {
|
||
t.Fatalf("回滚后 %s 应已消失", sensitiveColumn)
|
||
}
|
||
var rows int
|
||
if err := d.QueryRowContext(ctx, `SELECT COUNT(*) FROM memory_items`).Scan(&rows); err != nil {
|
||
t.Fatalf("count rows after down: %v", err)
|
||
}
|
||
if rows != 1 {
|
||
t.Fatalf("回滚后行数 = %d, want 1(down 只撤结构,不删行)", rows)
|
||
}
|
||
var content, scope string
|
||
if err := d.QueryRowContext(ctx,
|
||
`SELECT content, scope FROM memory_items WHERE id = 'keep-1'`).Scan(&content, &scope); err != nil {
|
||
t.Fatalf("read row after down: %v", err)
|
||
}
|
||
if content != "用户居住在花溪" || scope != string(ScopeUser) {
|
||
t.Fatalf("down 改写了行数据 = (%q, %q) —— 回滚只撤结构", content, scope)
|
||
}
|
||
|
||
// 再 up:列以默认 0 重建,回填重新按白名单标上。
|
||
migrateUp(t, d)
|
||
if !hasMemoryColumn(t, d, sensitiveColumn) {
|
||
t.Fatalf("再次迁移后应重建 %s 列", sensitiveColumn)
|
||
}
|
||
if got := readSensitiveCell(t, d, "keep-1"); got != 1 {
|
||
t.Fatalf("再 up 之后 sensitive = %d, want 1(回填随迁移重跑)", got)
|
||
}
|
||
}
|
||
|
||
// TestMigration0028ShapeMatchesStoreBootstrap 是**漂移护栏**:迁移路径与自举路径
|
||
// 建出的 memory_items 列集合必须一致,且 `sensitive` 在两边的形状逐字相同
|
||
// (INTEGER NOT NULL DEFAULT 0)。
|
||
func TestMigration0028ShapeMatchesStoreBootstrap(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
|
||
migrated := openRawMemoryDB(t)
|
||
migrateUp(t, migrated)
|
||
if _, err := NewStore(ctx, migrated, testLogger()); err != nil {
|
||
t.Fatalf("NewStore(migrated): %v", err)
|
||
}
|
||
|
||
bootstrapped := openRawMemoryDB(t)
|
||
if _, err := NewStore(ctx, bootstrapped, testLogger()); err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
|
||
gotCols := sortedColumns(tableColumns(t, migrated, "memory_items"))
|
||
wantCols := sortedColumns(tableColumns(t, bootstrapped, "memory_items"))
|
||
if !slices.Equal(gotCols, wantCols) {
|
||
t.Fatalf("迁移与自举的 memory_items 列集合不一致(两份 DDL 必须同形):\n迁移=%v\n自举=%v",
|
||
gotCols, wantCols)
|
||
}
|
||
for name, cols := range map[string][]string{"迁移": gotCols, "自举": wantCols} {
|
||
if !slices.Contains(cols, sensitiveColumn+"|INTEGER|1|0|0") {
|
||
t.Fatalf("%s 侧的 %s 列形状不是 INTEGER NOT NULL DEFAULT 0:%v", name, sensitiveColumn, cols)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestMigration0028ThenStoreBootstrapIsARegularPath 覆盖真实启动顺序:迁移先建列
|
||
// (含回填),随后 Store 自举走到 "duplicate column name" —— 预期路径必须无害,
|
||
// 且写入路径可用(敏感位随同一笔 INSERT 落下)。
|
||
func TestMigration0028ThenStoreBootstrapIsARegularPath(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
d := openRawMemoryDB(t)
|
||
migrateUp(t, d)
|
||
|
||
store, err := NewStore(ctx, d, testLogger())
|
||
if err != nil {
|
||
t.Fatalf("NewStore(列已由迁移建好): %v", err)
|
||
}
|
||
after := tableColumns(t, d, "memory_items")
|
||
if _, err := NewStore(ctx, d, testLogger()); err != nil {
|
||
t.Fatalf("NewStore(第二次): %v", err)
|
||
}
|
||
if got := tableColumns(t, d, "memory_items"); !slices.Equal(got, after) {
|
||
t.Fatalf("第二次自举改变了表结构:\n got=%v\nwant=%v", got, after)
|
||
}
|
||
|
||
item := habitItem("主人现居贵阳花溪区", tidyTestTime(t), 0.9)
|
||
got, err := store.Add(ctx, item)
|
||
if err != nil {
|
||
t.Fatalf("Add: %v", err)
|
||
}
|
||
if cell := readSensitiveCell(t, store.db, got.ID); cell != 1 {
|
||
t.Fatalf("迁移建列后经 Store 落库的 sensitive = %d, want 1(写入侧兜底没跑)", cell)
|
||
}
|
||
}
|