diff --git a/.changeset/search-auto-excluded-types-disjointness-pin.md b/.changeset/search-auto-excluded-types-disjointness-pin.md new file mode 100644 index 0000000000..06c5c5c376 --- /dev/null +++ b/.changeset/search-auto-excluded-types-disjointness-pin.md @@ -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、不承重、以及保留它买到的是 +哪个方向,避免下一位作者把它读成「新增可搜索类型时必须同步维护排除集」的规则。 diff --git a/packages/spec/src/data/search-fields.test.ts b/packages/spec/src/data/search-fields.test.ts index 8011bee622..98208bbf09 100644 --- a/packages/spec/src/data/search-fields.test.ts +++ b/packages/spec/src/data/search-fields.test.ts @@ -5,6 +5,9 @@ import { resolveSearchFieldResolution, resolveSearchFields, SEARCH_AUTO_EXCLUDED_FIELDS, + SEARCH_AUTO_EXCLUDED_TYPES, + SEARCHABLE_ENUM_TYPES, + SEARCHABLE_TEXTUAL_TYPES, } from './search-fields'; // --------------------------------------------------------------------------- @@ -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, b: ReadonlySet) => + [...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' }); + } + }); +}); diff --git a/packages/spec/src/data/search-fields.ts b/packages/spec/src/data/search-fields.ts index 982d8aecb5..db733c3e4c 100644 --- a/packages/spec/src/data/search-fields.ts +++ b/packages/spec/src/data/search-fields.ts @@ -69,6 +69,30 @@ function autoDefaultFields(fields: Record, 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=` + // 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); });