Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/search-auto-excluded-types-disjointness-pin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@objectstack/spec": patch
---

fix(spec): 把 `$search` 自动默认集三个类型词表的「互不相交」钉成受检事实 (#6934)

`packages/spec/src/data/search-fields.ts` 的 `autoDefaultFields` 先测一遍
`SEARCH_AUTO_EXCLUDED_TYPES` 的**否定**守卫,紧接着才是
`SEARCHABLE_TEXTUAL_TYPES` / `SEARCHABLE_ENUM_TYPES` 的**肯定**白名单。三个集合
今天两两不相交,所以那行否定守卫改变不了任何一次判定 —— 在完整的 56 个字段类型
域(`FieldType` 枚举 ∪ 三个词表 ∪ 域外探针)× 9 种调用形态 = 504 次解析上逐一比对,
删掉它与保留它的结果**逐字节相同**。本次改动因此**不改变任何行为**。

**保留该行,而不是退休它。** 判据是构造出来实测的,不是判断的:给
`SEARCHABLE_TEXTUAL_TYPES` 补一个已在排除集里的类型(`json`),两种形态给出的是
**方向相反、同样沉默**的两种解决 —— 有守卫时该类型被悄悄踢出扫描(fail closed),
没守卫时肯定列表获胜,该类型不仅进入自动默认集,还顺带进入上一层 #4254 的 ingress
allow-list,于是 `$searchFields=<该字段>` 从「拒绝」翻成「接受」,正是 #4483 为 `id`
关掉的那类放宽。排除集里写着 `secret` / `password` / `encrypted` / `vector`,
fail open 意味着对被脱敏或重量级列做 `$contains` 子串扫描。所以那行不是安全网
(两个方向都不出声),但它是**更安全的那个平局裁决**。

**真正堵住这一类的是钉子。** `search-fields.test.ts` 新增:三个词表两两不相交的
断言,加上两个方向的行为断言(排除集成员必被拒、白名单成员必被纳)。任一方向出现
重叠都会变红,且没有任何单点放宽能让它变成空转 —— 已用临时重叠实测两种形态各自
变红。同时补充:两个肯定列表之间也必须不相交,因为引擎侧
`fieldClausesForTerm` 先分支到 `SEARCHABLE_ENUM_TYPES`,同时命中的类型只会走
option label 映射、永远不会按原文 `$contains` 检索。

守卫处的注释如实写明它是 redundant-by-construction、不承重、以及保留它买到的是
哪个方向,避免下一位作者把它读成「新增可搜索类型时必须同步维护排除集」的规则。
69 changes: 69 additions & 0 deletions packages/spec/src/data/search-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import {
resolveSearchFieldResolution,
resolveSearchFields,
SEARCH_AUTO_EXCLUDED_FIELDS,
SEARCH_AUTO_EXCLUDED_TYPES,
SEARCHABLE_ENUM_TYPES,
SEARCHABLE_TEXTUAL_TYPES,
} from './search-fields';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -118,3 +121,69 @@ describe('[#4483] $search auto field set — lead orders, never admits', () => {
.toEqual([]);
});
});

// ---------------------------------------------------------------------------
// [#6934] The three search type vocabularies stay PAIRWISE DISJOINT.
//
// `autoDefaultFields` tests `SEARCH_AUTO_EXCLUDED_TYPES` immediately before the
// positive `SEARCHABLE_TEXTUAL_TYPES` / `SEARCHABLE_ENUM_TYPES` allow-list. With
// the sets disjoint that guard cannot change an outcome — measured over the full
// field-type domain, deleting it moves not one resolution. It is kept as the
// fail-closed tiebreak (reasons at its site); these tests are what make the
// disjointness a CHECKED fact instead of a coincidence.
//
// Why both halves are here. A type declared in the excluded set AND in a
// positive list is a contradiction that resolves SILENTLY in either shape:
// dropped from the scan with the guard, admitted to the scan — and to the #4254
// ingress allow-list — without it. Neither is a diagnostic the author ever sees.
// These assertions are. The set assertions name the contradiction directly; the
// two resolution assertions below go red on an overlap independently of them,
// and in opposite directions, so no single relaxation can make this pin vacuous.
// ---------------------------------------------------------------------------
describe('[#6934] search type vocabularies are pairwise disjoint', () => {
const overlap = (a: ReadonlySet<string>, b: ReadonlySet<string>) =>
[...a].filter((t) => b.has(t)).sort();

it('no type is both auto-excluded and on a positive allow-list', () => {
expect(
overlap(SEARCH_AUTO_EXCLUDED_TYPES, SEARCHABLE_TEXTUAL_TYPES),
'a type cannot be both auto-excluded and searchable-textual',
).toEqual([]);
expect(
overlap(SEARCH_AUTO_EXCLUDED_TYPES, SEARCHABLE_ENUM_TYPES),
'a type cannot be both auto-excluded and searchable-enum',
).toEqual([]);
});

it('the two positive allow-lists share no type either', () => {
// Not cosmetic: the ENGINE branches on `SEARCHABLE_ENUM_TYPES` FIRST
// (`fieldClausesForTerm`, objectql `search-filter.ts`), so a type in both
// would be searched by option-label mapping and never as raw `$contains`.
expect(
overlap(SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES),
'an enum type is searched by label mapping, a textual one by $contains',
).toEqual([]);
});

it('every auto-excluded type is REJECTED by the auto-default', () => {
// Goes red if an overlap resolves the positive way (i.e. if the guard is
// dropped while the sets intersect).
for (const t of SEARCH_AUTO_EXCLUDED_TYPES) {
expect(
resolveSearchFieldResolution({ fields: { probe: { type: t } } }),
`'${t}' is auto-excluded but the auto-default admitted it`,
).toEqual({ allowed: [], source: 'auto' });
}
});

it('every searchable type is ADMITTED by the auto-default', () => {
// Goes red if an overlap resolves the negative way (the guard silently
// dropping a type an author has just declared searchable).
for (const t of [...SEARCHABLE_TEXTUAL_TYPES, ...SEARCHABLE_ENUM_TYPES]) {
expect(
resolveSearchFieldResolution({ fields: { probe: { type: t } } }),
`'${t}' is declared searchable but the auto-default rejected it`,
).toEqual({ allowed: ['probe'], source: 'auto' });
}
});
});
24 changes: 24 additions & 0 deletions packages/spec/src/data/search-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ function autoDefaultFields(fields: Record<string, SearchFieldMeta>, displayField
if (!meta || meta.hidden) return false;
const t = meta.type;
if (!t) return false;
// Redundant BY CONSTRUCTION, and kept deliberately (#6934).
//
// `SEARCH_AUTO_EXCLUDED_TYPES` is disjoint from both positive lists, so
// every type it names already falls through the `return` below as `false`
// — identically to a type in none of the three sets (`number`, `date`, …).
// Measured over the full 56-type domain (`FieldType` ∪ all three
// vocabularies), deleting this line moves not one resolution. So it is NOT
// load-bearing: adding a type to `SEARCHABLE_TEXTUAL_TYPES` does not also
// require keeping it out of this set for the auto-default to reject it.
//
// What it buys is the DIRECTION the two vocabularies resolve in should that
// disjointness ever break. Both directions are silent — the guard is no
// safety net, it is a second tiebreak — but they are not equally bad. WITH
// it, an overlapping type is dropped from the scan (fail closed). WITHOUT
// it the positive list wins and the type enters the auto-default AND, one
// layer up, the #4254 ingress allow-list — so `$searchFields=<that field>`
// flips from refused to ACCEPTED, the same widening #4483 closed for `id`.
// This set names `secret`, `password`, `encrypted` and `vector`: failing
// open there means a `$contains` scan over a masked or heavy column.
//
// The disjointness is not left to coincidence. `search-fields.test.ts` pins
// all three vocabularies pairwise disjoint AND pins both resolution
// directions, so an overlap is a red test rather than a silent tiebreak
// whichever way it lands.
if (SEARCH_AUTO_EXCLUDED_TYPES.has(t)) return false;
return SEARCHABLE_TEXTUAL_TYPES.has(t) || SEARCHABLE_ENUM_TYPES.has(t);
});
Expand Down
Loading