diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 40dd7e0..9cd009d 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1376,3 +1376,12 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是 - 原因:注册安全码错误与错误 API 令牌按 IP 建条目,轮换地址会使 map 只增不减。 - 备选方案:一并改 `internal/admin/memlock.go`(否决,本波只改 locks.go;admin 测试用内存锁若仍独立注入需后续对齐)。未改对话密码锁键(U-03)。 - 影响:过期未锁定条目会被回收;极端并发失败时最早的非锁定计数可能被挤出。 + +### 复审修复 U-05 + +1. **目录 LIKE 转义;注册拒绝编号 inline** + - 原条款:DEVELOPMENT 6.5 按编号前缀或名称包含匹配;issue #43。 + - 实际做法:目录查询对 `\`、`%`、`_` 转义并 `ESCAPE '\'`。注册拒绝编号 `inline`(与 mochi 内联客户端 ClientID 同名)。 + - 原因:未转义时搜 `e_ab` 会命中 `exab…`,搜 `%` 返回全部端。 + - 备选方案:在 `internal/protocol.ValidEndpointID` 加保留字,使开通/导入一并拒绝(否决,本波不改 protocol 与 `internal/admin/endpoints.go`)。后台开通与批量导入仍可能使用 `inline`,留给后续波次。 + - 影响:目录下划线按字面匹配;自助注册不能占用 `inline`。 diff --git a/internal/app/identity/register.go b/internal/app/identity/register.go index 5e0743c..c64cbc7 100644 --- a/internal/app/identity/register.go +++ b/internal/app/identity/register.go @@ -171,6 +171,9 @@ func (h *RegisterHandler) register(ctx context.Context, req *protocol.RegisterRe } return RegisterResult{}, apiErr(http.StatusBadRequest, code, msg) } + if strings.EqualFold(req.ID, "inline") { + return RegisterResult{}, apiErr(http.StatusBadRequest, protocol.CodeBadRequest, "id reserved") + } id := req.ID loginPassword := req.LoginPassword diff --git a/internal/app/identity/register_test.go b/internal/app/identity/register_test.go index 2ad8efe..db51c04 100644 --- a/internal/app/identity/register_test.go +++ b/internal/app/identity/register_test.go @@ -539,6 +539,18 @@ func TestRegister_LogOmitsSecrets(t *testing.T) { } } +func TestRegister_ReservedInlineID(t *testing.T) { + env := openTestEnv(t) + env.setRegistration(t, true, "inline-code1") + code, resp, _ := env.doRegister(t, `{"registration_code":"inline-code1","id":"inline","login_password":"password1"}`) + if code != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != protocol.CodeBadRequest { + t.Fatalf("status=%d resp=%+v", code, resp) + } + if _, _, ok := env.getEndpoint(t, "inline"); ok { + t.Fatal("inline must not be created") + } +} + func TestRegister_NSTPasswordRejected(t *testing.T) { env := openTestEnv(t) env.setRegistration(t, true, "nst-code-01") diff --git a/internal/app/presence/app.go b/internal/app/presence/app.go index f75966f..e5c1ddd 100644 --- a/internal/app/presence/app.go +++ b/internal/app/presence/app.go @@ -122,13 +122,15 @@ WHERE id > ? ORDER BY id ASC LIMIT ?`, cursor, limit+1) } else { - like := "%" + strings.ToLower(query) + "%" - prefix := strings.ToLower(query) + "%" + q := strings.ToLower(query) + esc := escapeLikePattern(q) + like := "%" + esc + "%" + prefix := esc + "%" rows, err = a.db.Read.QueryContext(ctx, ` SELECT id, name, online_since, offline_since, talk_hash FROM endpoints WHERE id > ? - AND (lower(id) LIKE ? OR lower(name) LIKE ?) + AND (lower(id) LIKE ? ESCAPE '\' OR lower(name) LIKE ? ESCAPE '\') ORDER BY id ASC LIMIT ?`, cursor, prefix, like, limit+1) } diff --git a/internal/app/presence/like.go b/internal/app/presence/like.go new file mode 100644 index 0000000..72bec63 --- /dev/null +++ b/internal/app/presence/like.go @@ -0,0 +1,16 @@ +package presence + +import "strings" + +func escapeLikePattern(s string) string { + var b strings.Builder + b.Grow(len(s) + 4) + for _, r := range s { + switch r { + case '\\', '%', '_': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} diff --git a/internal/app/presence/presence_test.go b/internal/app/presence/presence_test.go index a46e5e0..8cafcfc 100644 --- a/internal/app/presence/presence_test.go +++ b/internal/app/presence/presence_test.go @@ -131,6 +131,35 @@ func TestF03PresenceAndDirectory(t *testing.T) { } } +func TestDirectoryLikeEscapesUnderscore(t *testing.T) { + t.Parallel() + app, db, _ := openPresence(t) + ctx := context.Background() + insertEP(t, db, "e_ab1", "underscore") + insertEP(t, db, "exab2", "wildcard") + insertEP(t, db, "pct", "has%percent") + + q, _, err := app.Directory(ctx, &protocol.DirectoryList{ + V: protocol.Version, Type: protocol.TypeDirectoryList, RID: "u1", Query: "e_ab", Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(q) != 1 || q[0].ID != "e_ab1" { + t.Fatalf("want only e_ab1, got %+v", q) + } + + q, _, err = app.Directory(ctx, &protocol.DirectoryList{ + V: protocol.Version, Type: protocol.TypeDirectoryList, RID: "u2", Query: "%", Limit: 10, + }) + if err != nil { + t.Fatal(err) + } + if len(q) != 1 || q[0].ID != "pct" { + t.Fatalf("literal %% should not match all, got %+v", q) + } +} + func TestF04PresenceWatch(t *testing.T) { t.Parallel() app, db, down := openPresence(t)