diff --git a/docs/content/limitations/record-identity-and-property-order.mdx b/docs/content/limitations/record-identity-and-property-order.mdx index bdbf8993da0..55ad9e18feb 100644 --- a/docs/content/limitations/record-identity-and-property-order.mdx +++ b/docs/content/limitations/record-identity-and-property-order.mdx @@ -27,6 +27,12 @@ Calling `valueOf` or `isPrototypeOf` by name on a record type that such an objec `Object.values` and `Object.entries` check those values against their static result type and throw a `TypeError` if one does not fit. For a record converted from an object with more properties, these surfaces read the object's other properties when they run, so later writes to those properties are visible; the declared fields are a copy taken at the conversion. Copies of such a record made by object spread, `Object.assign` or rest destructuring contain only the declared fields; a conversion to another record type keeps the properties too. +A record converted from an object keeps a property non-enumerable when the object held it as a non-enumerable data property that is writable and configurable, whether or not the record's type declares it. +`Object.keys`, `Object.values`, `Object.entries`, `for...in`, `JSON.stringify`, `util.inspect`, and untyped views of the record leave the property out, `in`, `Object.hasOwn`, and `Object.getOwnPropertyNames` include it, and its descriptor read through an untyped view reports `enumerable: false`. +A copy of such a record made by object spread, rest destructuring, or `structuredClone` holds a declared non-enumerable property as an ordinary enumerable one, where Node.js leaves it out of the copy, and `Object.assign` from the record throws a `TypeError` when the result's type requires the property. +`propertyIsEnumerable` called through the record's type answers `true` for a declared property that is not enumerable. +A conversion does not keep a declared property's read-only or non-configurable attribute or its accessor: it reads the property once, and typed writes to the field succeed. +A value that a compiled npm package returns into a type its declarations give it is refused with a `TypeError` when the object has an accessor, a read-only or non-configurable property, symbol-keyed properties, or a prototype, or when it is frozen, sealed, or not extensible, or when it has a non-enumerable property and the type keeps its keys without property attributes (an index-signature record). Index-signature records list declared fields before the other keys. A rest binding over an index-signature source copies its source's key order, so it lists the remaining declared fields before the other keys even when the source object created the other keys first. diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8854-record-hidden-key.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8854-record-hidden-key.ts.json new file mode 100644 index 00000000000..541b8f2fe42 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8854-record-hidden-key.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8854-record-hidden-key.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hidden-key-boundary/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hidden-key-boundary/main.ts.json new file mode 100644 index 00000000000..bc766fd2f70 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hidden-key-boundary/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/hidden-key-boundary/main.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_decl_adopt.c b/packages/runtime/src/scr_decl_adopt.c index ffac1f80283..d7ccc1512f8 100644 --- a/packages/runtime/src/scr_decl_adopt.c +++ b/packages/runtime/src/scr_decl_adopt.c @@ -51,7 +51,18 @@ static void scr_decl_adopt_push(ScrDeclAdoptList *list, ScrDyn *node, ScrDyn *ca list->len++; } -static bool scr_decl_adopt_plain(const ScrDyn *n, const ScrDynPath *path, bool lenient) { +/* Whether the value built from a node holds a view of its source: the one + * place a record has for a property attribute its layout cannot carry + * (scr_record_source_note keeps a source for a non-enumerable declared + * key). A layout without one, such as an index-signature record, which + * captures undeclared keys into a map that holds no attributes, would list + * such a key as an ordinary one. */ +static bool scr_decl_adopt_keeps_attributes(const ScrDyn *capsule) { + return capsule->kind == SCR_DYN_TYPED_REF && scr_record_source_has(capsule->v.typed_ref.ptr); +} + +static bool scr_decl_adopt_plain(const ScrDyn *n, const ScrDyn *capsule, const ScrDynPath *path, + bool lenient) { const char *want = NULL; if (n->kind == SCR_DYN_OBJ) { if (n->prototype || n->null_proto || n->v.obj.class_name) @@ -63,11 +74,17 @@ static bool scr_decl_adopt_plain(const ScrDyn *n, const ScrDynPath *path, bool l else if (n->v.obj.source_access && !n->v.obj.source_identity) want = "a plain object"; else { + const bool keeps_attributes = scr_decl_adopt_keeps_attributes(capsule); for (size_t i = 0; i < scr_dyn_obj_count(n) && !want; i++) { ScrDynProp e = scr_dyn_obj_prop_at(n, i); + /* A non-enumerable data property stays one only where the built + * record keeps a view of its source; a read-only or + * non-configurable one has no native form yet. */ if (e.accessor) want = "a plain object (this value has accessor properties)"; - else if (!e.writable || !e.enumerable || !e.configurable) - want = "a plain object (this value has non-default property attributes)"; + else if (!e.writable || !e.configurable) + want = "a plain object (this value has read-only or non-configurable properties)"; + else if (!e.enumerable && !keeps_attributes) + want = "a plain object (this value has a non-enumerable key its record type cannot keep)"; } } } else if (n->kind == SCR_DYN_ARR) { @@ -105,7 +122,7 @@ static bool scr_decl_adopt_walk(ScrDeclAdoptList *list, ScrDyn *n, ScrDyn *capsu scr_dyn_release(capsule); return true; } - if (!scr_decl_adopt_plain(n, path, list->lenient)) { + if (!scr_decl_adopt_plain(n, capsule, path, list->lenient)) { scr_dyn_release(capsule); return list->lenient; } diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 57bb1b61a7f..7e0cb1c5e81 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4405,11 +4405,30 @@ static ScrDyn *scr_dyn_own_descriptor_or_null(const ScrDyn *source, const ScrStr return descriptor; } +/* A declared key the checked source held as a non-enumerable data property + * stays non-enumerable in the record's view: its value lives in the record, + * and this entry attribute is the only place the record has for "not + * enumerable". Every rebuild of the view carries it (scr_record_source_merge) + * and the record's serializer skips the key (scr_jb_try_record_source), so + * Object.keys, for-in and JSON.stringify leave the key out and its + * descriptor reports it, as on the one object Node has. The writable and + * configurable attributes of a declared key are not kept, and a typed copy + * of the record (a spread, a rest binding) copies the field as any other. + * Out of line: the callers are linked by most programs and run this for + * none of their records. */ +static __attribute__((noinline)) void scr_record_source_hide(ScrDyn *view, const char *key, + size_t key_len) { + ScrDynEntry *entry = scr_dyn_find_entry(view, key, key_len); + if (entry) entry->enumerable = false; +} + /* Called by the checked record builder after it filled `rec` from `src`; * `names` lists the shape's fields in declaration order. Keeps nothing - * unless the source has an own property the shape does not declare, or - * holds the declared ones in another order than the record serializes - * them. Borrows both. */ + * unless the source has an own property the shape does not declare, holds + * the declared ones in another order than the record serializes them, or + * holds a declared one as a non-enumerable data property: the record has + * no place for that attribute, so the view's entry for the key keeps it + * (scr_record_source_hide). Borrows both. */ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *names, const size_t *lens, size_t count) { if (!rec || !src || src->kind != SCR_DYN_OBJ || src->v.obj.source_identity) return; @@ -4421,7 +4440,7 @@ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *nam const ScrDynProp entry_prop = scr_dyn_obj_prop_at(src, i); const ScrDynProp *entry = &entry_prop; size_t index = scr_record_source_index(&probe, entry->key, entry->key_len); - keep = index == count || index < next; + keep = index == count || index < next || (!entry->enumerable && !entry->accessor); next = index + 1; } if (!keep) return; @@ -4431,6 +4450,8 @@ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *nam const ScrDynProp *entry = &entry_prop; if (scr_record_source_declared(&probe, entry->key, entry->key_len)) { scr_dyn_obj_set(view, entry->key, entry->key_len, scr_dyn_retain(scr_dyn_undefined())); + if (!entry->enumerable && !entry->accessor) + scr_record_source_hide(view, entry->key, entry->key_len); } else if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) { scr_dyn_obj_set(view, entry->key, entry->key_len, scr_dyn_retain(entry->value)); } else { @@ -4649,6 +4670,10 @@ static ScrDyn *scr_record_source_merge(const ScrRecordSource *source, const ScrD ScrDyn *value = scr_dyn_obj_get(fresh, entry->key, entry->key_len); if (value) { scr_dyn_obj_set(out, entry->key, entry->key_len, scr_dyn_retain(value)); + /* A hidden declared key stays hidden across refreshes. (The fresh + * snapshot holds the record's fields, so a key it has is declared.) */ + if (!entry->enumerable && !entry->accessor) + scr_record_source_hide(out, entry->key, entry->key_len); continue; } if (scr_record_source_declared(source, entry->key, entry->key_len)) continue; @@ -4808,7 +4833,9 @@ bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, const ScrDynProp *entry = &entry_prop; size_t index = scr_record_source_index(&source, entry->key, entry->key_len); if (index < count) { - if (!seen[index] && scr_jb_put_source_field(b, rec, &source, index, first, on_stack, field)) + /* A hidden declared key (scr_record_source_hide) is not serialized. */ + if (!seen[index] && (entry->enumerable || entry->accessor) && + scr_jb_put_source_field(b, rec, &source, index, first, on_stack, field)) first = false; seen[index] = true; continue; @@ -4827,7 +4854,14 @@ bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, for (size_t j = i; j < view->v.obj.len; j++) { const ScrDynProp entry_prop = scr_dyn_obj_prop_at(view, order ? order[j] : j); const ScrDynProp *entry = &entry_prop; - if (!entry->enumerable && !scr_record_source_declared(&source, entry->key, entry->key_len)) continue; + if (!entry->enumerable) { + size_t hidden = entry->accessor + ? count + : scr_record_source_index(&source, entry->key, entry->key_len); + if (hidden < count) seen[hidden] = true; + if (hidden < count || !scr_record_source_declared(&source, entry->key, entry->key_len)) + continue; + } ScrStr *name = scr_str_new(entry->key, entry->key_len); scr_dyn_arr_push(rest, scr_dyn_new_str(name)); scr_str_release(name); diff --git a/packages/runtime/src/scr_json.test.c b/packages/runtime/src/scr_json.test.c index b35b5c7ed17..7741c3b4f53 100644 --- a/packages/runtime/src/scr_json.test.c +++ b/packages/runtime/src/scr_json.test.c @@ -878,6 +878,58 @@ static void record_source_class_member(void) { scr_str_release(member); } +/* A declared key the source held as a non-enumerable data property stays + * non-enumerable: the conversion keeps a view for that alone, the view's + * entry for the key carries the attribute, and a refresh of the view from + * the record's values keeps it. An enumerable source in declaration order + * still keeps nothing. */ +static void record_source_hidden_key(void) { + static const char *const names[] = {"kind", "type"}; + static const size_t lens[] = {4, 4}; + ScrStr *text = scr_str_new("t", 1); + + ScrDyn *plain = scr_dyn_new_obj(); + scr_dyn_obj_set(plain, "kind", 4, scr_dyn_new_str(text)); + scr_dyn_obj_set(plain, "type", 4, scr_dyn_new_str(text)); + size_t ordinary[2] = {1, 0}; + scr_record_source_note(ordinary, plain, names, lens, 2); + assert(!scr_record_source_has(ordinary)); + + ScrDyn *source = scr_dyn_new_obj(); + scr_dyn_obj_set(source, "kind", 4, scr_dyn_new_str(text)); + scr_dyn_obj_set(source, "type", 4, scr_dyn_new_str(text)); + scr_dyn_obj_set_attrs_at(source, scr_dyn_obj_index_of(source, "kind", 4), true, false, true); + size_t record[2] = {1, 0}; + scr_record_source_note(record, source, names, lens, 2); + assert(!scr_exc_pending() && scr_record_source_has(record)); + const ScrDyn *view = scr_record_source_view(record); + assert(view && view->kind == SCR_DYN_OBJ && view->v.obj.len == 2); + ScrDynProp kind = scr_dyn_obj_prop_at(view, scr_dyn_obj_index_of(view, "kind", 4)); + assert(!kind.enumerable && kind.writable && kind.configurable && !kind.accessor); + ScrDynProp type = scr_dyn_obj_prop_at(view, scr_dyn_obj_index_of(view, "type", 4)); + assert(type.enumerable && type.writable && type.configurable); + + /* The record's snapshot lists every field as an ordinary property; the + * merge through the kept view hides the key again. */ + ScrDyn *snapshot = scr_dyn_new_obj(); + scr_dyn_obj_set(snapshot, "kind", 4, scr_dyn_new_str(text)); + scr_dyn_obj_set(snapshot, "type", 4, scr_dyn_new_str(text)); + ScrDyn *merged = scr_record_source_apply(snapshot, record); + assert(merged && merged->kind == SCR_DYN_OBJ && merged->v.obj.len == 2); + kind = scr_dyn_obj_prop_at(merged, scr_dyn_obj_index_of(merged, "kind", 4)); + assert(!kind.enumerable && kind.writable && kind.configurable); + assert(kind.value && kind.value->kind == SCR_DYN_STR); + type = scr_dyn_obj_prop_at(merged, scr_dyn_obj_index_of(merged, "type", 4)); + assert(type.enumerable); + scr_dyn_release(merged); + + scr_weak_dispose(record); + assert(!scr_record_source_has(record)); + scr_dyn_release(source); + scr_dyn_release(plain); + scr_str_release(text); +} + /* Freezing a record sets the frozen bit beside its count; an immortal * record keeps its word, which does not read as frozen. */ static void record_frozen_bit(void) { @@ -905,6 +957,7 @@ int main(void) { json_schema_special_names(); json_schema_union_members(); record_source_class_member(); + record_source_hidden_key(); /* Native Set boxes may own headerless scalar/string maps. Collecting an * enclosing cycle must neither trace those leaves nor skip their release. */ for (int i = 0; i < 2000; i++) { diff --git a/tests/corpus/8854-record-hidden-key.ts b/tests/corpus/8854-record-hidden-key.ts new file mode 100644 index 00000000000..b0dd0c83271 --- /dev/null +++ b/tests/corpus/8854-record-hidden-key.ts @@ -0,0 +1,97 @@ +// A checked record keeps a declared key non-enumerable when the object it +// was converted from held the key that way. The record has no place for +// the attribute, so its kept-source view holds it: Object.keys, values, +// entries, for...in, JSON.stringify, console.log and untyped views leave +// the key out, while reads, writes, `in`, hasOwn and getOwnPropertyNames +// still see it, and its descriptor reports the attribute. + +interface Schema { + kind: string; + type: string; + note?: string; +} + +function hide(target: any, key: string, value: unknown): void { + Object.defineProperty(target, key, { enumerable: false, writable: true, configurable: true, value }); +} + +// `extra` adds a key the type does not declare, so the record keeps a view +// for two reasons at once. +function make(extra: boolean): Schema { + const o: any = JSON.parse('{"type":"t"}'); + if (extra) o.description = "d"; + hide(o, "kind", "K"); + return o as Schema; +} + +const s = make(false); +const x = make(true); + +console.log("read", s.kind, s.type, x.kind, x.type); +console.log("keys", Object.keys(s).join(","), "|", Object.keys(x).join(",")); +console.log("values", Object.values(s).join(","), "|", Object.values(x).join(",")); +console.log("entries", JSON.stringify(Object.entries(s)), JSON.stringify(Object.entries(x))); +for (const k in s) console.log("for-in s", k); +for (const k in x) console.log("for-in x", k); +console.log("json", JSON.stringify(s), JSON.stringify(x)); +console.log("json nested", JSON.stringify({ wrap: s, list: [x] })); +console.log(JSON.stringify(s, null, 2)); +console.log("has", "kind" in s, Object.hasOwn(s, "kind"), "kind" in x, Object.hasOwn(x, "type")); +console.log("names", Object.getOwnPropertyNames(s).join(","), "|", Object.getOwnPropertyNames(x).join(",")); +console.log(s); +console.log(x); +console.log([s]); + +// A write through the type keeps the attribute. +s.kind = "K2"; +x.kind = "K3"; +console.log("written", s.kind, Object.keys(s).join(","), JSON.stringify(s)); +console.log("written", x.kind, Object.keys(x).join(","), JSON.stringify(x)); + +// Untyped views of the same records. +const a: any = s; +const b: any = x; +console.log("view", Object.keys(a).join(","), JSON.stringify(a), a.kind); +console.log("view", JSON.stringify(Object.getOwnPropertyDescriptor(a, "kind"))); +console.log("view", Object.keys(b).join(","), JSON.stringify(b), b.kind); +console.log("view", JSON.stringify(Object.getOwnPropertyDescriptor(b, "kind"))); +console.log("view", JSON.stringify(Object.getOwnPropertyDescriptor(b, "type"))); +a.kind = "W"; +console.log("view write", s.kind, Object.keys(s).join(","), JSON.stringify(s)); +console.log("view write", JSON.stringify(Object.getOwnPropertyDescriptor(a, "kind"))); + +// Back through a checked cast: the same record, the key still hidden. +const back = a as Schema; +console.log("back", back === s, Object.keys(back).join(","), JSON.stringify(back)); + +// A conversion to another record type keeps it hidden too. +const narrow: { type: string } = x; +console.log("narrow", Object.keys(narrow).join(","), JSON.stringify(narrow)); +const other = b as { type: string; description: string }; +console.log("other", Object.keys(other).join(","), JSON.stringify(other)); + +// Records inside records and arrays. +interface Outer { + inner: Schema; + list: Schema[]; +} +const outer: Outer = { inner: s, list: [x, make(false)] }; +console.log("outer", JSON.stringify(outer), Object.keys(outer.inner).join(",")); +const untypedOuter: any = outer; +console.log("outer view", JSON.stringify(untypedOuter), Object.keys(untypedOuter.list[1]).join(",")); +console.log("each", outer.list.map((v) => Object.keys(v).join("+")).join(" ")); +console.log("from entries", JSON.stringify(Object.fromEntries(Object.entries(x)))); + +// Making the key enumerable again through the view is seen through the type. +Object.defineProperty(b, "kind", { enumerable: true }); +console.log("shown", Object.keys(x).join(","), JSON.stringify(x)); + +// A hidden key the type does not declare stays hidden as well. +function makeUndeclared(): { type: string } { + const o: any = JSON.parse('{"type":"t"}'); + hide(o, "secret", 1); + return o as { type: string }; +} +const u = makeUndeclared(); +const uv: any = u; +console.log("undeclared", Object.keys(u).join(","), JSON.stringify(u), uv.secret, "secret" in u); diff --git a/tests/fixtures/npm/cases/hidden-key-boundary/main.ts b/tests/fixtures/npm/cases/hidden-key-boundary/main.ts new file mode 100644 index 00000000000..2a5e4ad21af --- /dev/null +++ b/tests/fixtures/npm/cases/hidden-key-boundary/main.ts @@ -0,0 +1,42 @@ +// A package whose values carry non-enumerable keys, typed by its own +// declarations: the schemas cross into typed code as native records and +// keep their hidden keys hidden, on the typed side and when they go back +// into the package. +import { text, count, optional, list, shape, kindOf, isKind, describe, check } from "kindkit"; + +const name = text({ description: "a name" }); +const size = count(); +const tags = list(text()); +const entry = shape({ name, size: optional(size), tags }, { title: "Entry" }); + +console.log("json", JSON.stringify(entry)); +console.log("keys", Object.keys(entry).join(","), "|", Object.keys(name).join(","), "|", Object.keys(size).join(",")); +console.log("properties", Object.keys(entry.properties).join(",")); +console.log("reads", name["~kind"], name.type, entry["~kind"], entry.type, entry.required.join(",")); +console.log("nested", entry.properties.tags["~kind"], entry.properties.tags.items["~kind"], entry.properties.size["~optional"]); +for (const key in name) console.log("for-in", key); +console.log("values", Object.values(name).join(",")); +console.log("entries", JSON.stringify(Object.entries(tags))); +console.log("has", "~kind" in name, Object.hasOwn(name, "~kind"), Object.getOwnPropertyNames(name).join(",")); +console.log(name); +console.log(entry); + +// The package reads the hidden keys of the values typed code hands back. +console.log("kinds", kindOf(name), kindOf(size), kindOf(tags), kindOf(entry), isKind(entry.properties.size, "Count")); +console.log(describe(name)); +console.log(describe(entry)); +console.log(describe(entry.properties.size)); +console.log( + "check", + check(entry, { name: "a", tags: ["x", "y"] }), + check(entry, { name: "a", size: 2, tags: [] }), + check(entry, { name: 1, tags: [] }), + check(entry, { name: "a", size: 1.5, tags: [] }), + check(entry, { tags: [] }), +); + +// A table of schemas, as a program with many commands builds at start. +const table = [name, text(), text({ title: "third" })]; +console.log("table", JSON.stringify(table), table.map((schema) => Object.keys(schema).length).join(",")); +const untyped: unknown = entry; +console.log("untyped", JSON.stringify(untyped), kindOf(untyped)); diff --git a/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/index.d.ts b/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/index.d.ts new file mode 100644 index 00000000000..5e813110f3f --- /dev/null +++ b/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/index.d.ts @@ -0,0 +1,33 @@ +export interface Options { + description?: string; + title?: string; +} +export interface Text { + "~kind": "Text"; + type: "string"; +} +export interface Count { + "~kind": "Count"; + type: "integer"; +} +export interface List { + "~kind": "List"; + type: "array"; + items: Items; +} +export interface Shape { + "~kind": "Shape"; + type: "object"; + required: string[]; + properties: Properties; +} +export type Optional = Schema & { "~optional": true }; +export declare function text(options?: Options): Text; +export declare function count(options?: Options): Count; +export declare function optional(schema: Schema): Optional; +export declare function list(items: Items, options?: Options): List; +export declare function shape(properties: Properties, options?: Options): Shape; +export declare function kindOf(schema: unknown): string | undefined; +export declare function isKind(schema: unknown, kind: string): boolean; +export declare function describe(schema: unknown): string; +export declare function check(schema: unknown, value: unknown): boolean; diff --git a/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/index.js b/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/index.js new file mode 100644 index 00000000000..c2396d26c16 --- /dev/null +++ b/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/index.js @@ -0,0 +1,89 @@ +// A schema builder that keeps its bookkeeping keys out of enumeration: +// every schema is a plain object whose `~kind` (and `~optional`) are +// non-enumerable data properties, so JSON.stringify of a schema is the +// JSON Schema document and nothing else. +function withHidden(target, hidden) { + for (const key of Object.keys(hidden)) { + Object.defineProperty(target, key, { + configurable: true, + writable: true, + enumerable: false, + value: hidden[key], + }); + } + return target; +} + +function create(hidden, visible, options = {}) { + return withHidden({ ...visible, ...options }, hidden); +} + +// A copy that keeps each key's enumerability, descriptor by descriptor. +function clone(schema) { + const result = {}; + for (const key of Object.getOwnPropertyNames(schema)) { + const descriptor = Object.getOwnPropertyDescriptor(schema, key); + if (descriptor.enumerable === true) result[key] = descriptor.value; + else Object.defineProperty(result, key, descriptor); + } + return result; +} + +export function text(options) { + return create({ "~kind": "Text" }, { type: "string" }, options); +} + +export function count(options) { + return create({ "~kind": "Count" }, { type: "integer" }, options); +} + +export function optional(schema) { + return withHidden(clone(schema), { "~optional": true }); +} + +export function list(items, options) { + return create({ "~kind": "List" }, { type: "array", items }, options); +} + +export function shape(properties, options) { + const required = Object.keys(properties).filter((key) => properties[key]["~optional"] !== true); + return create({ "~kind": "Shape" }, { type: "object", required, properties }, options); +} + +export function kindOf(schema) { + return typeof schema === "object" && schema !== null ? schema["~kind"] : undefined; +} + +export function isKind(schema, kind) { + return kindOf(schema) === kind; +} + +export function describe(schema) { + const hidden = Object.getOwnPropertyNames(schema).filter( + (key) => !Object.prototype.propertyIsEnumerable.call(schema, key), + ); + return `${kindOf(schema)} visible=${Object.keys(schema).join(",")} hidden=${hidden.join(",")}`; +} + +export function check(schema, value) { + switch (kindOf(schema)) { + case "Text": + return typeof value === "string"; + case "Count": + return typeof value === "number" && value % 1 === 0; + case "List": + return Array.isArray(value) && value.every((item) => check(schema.items, item)); + case "Shape": { + if (typeof value !== "object" || value === null) return false; + for (const key of Object.keys(schema.properties)) { + const property = schema.properties[key]; + if (!(key in value)) { + if (property["~optional"] !== true) return false; + } else if (!check(property, value[key])) return false; + } + return true; + } + default: + return false; + } +} diff --git a/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/package.json b/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/package.json new file mode 100644 index 00000000000..64c32acd019 --- /dev/null +++ b/tests/fixtures/npm/cases/hidden-key-boundary/node_modules/kindkit/package.json @@ -0,0 +1 @@ +{ "name": "kindkit", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/hidden-key-boundary/package.json b/tests/fixtures/npm/cases/hidden-key-boundary/package.json new file mode 100644 index 00000000000..fb592aed51b --- /dev/null +++ b/tests/fixtures/npm/cases/hidden-key-boundary/package.json @@ -0,0 +1 @@ +{ "name": "hidden-key-boundary-case", "private": true, "type": "module" } diff --git a/tests/fixtures/npm/cases/zod3-boundary/lying.ts b/tests/fixtures/npm/cases/zod3-boundary/lying.ts index 107dda7d819..b64c3a62543 100644 --- a/tests/fixtures/npm/cases/zod3-boundary/lying.ts +++ b/tests/fixtures/npm/cases/zod3-boundary/lying.ts @@ -17,3 +17,39 @@ try { } catch (e) { console.log("frozen threw", (e as Error).message); } +// A non-enumerable key crosses and stays non-enumerable; a read-only key +// and an accessor have no native form yet and are refused by name. +const Tagged = z.custom<{ id: string; n: number }>(); +function tagged(descriptor: PropertyDescriptor): unknown { + const o = JSON.parse('{"n":1}'); + Object.defineProperty(o, "id", descriptor); + return o; +} +try { + const h = Tagged.parse(tagged({ value: "a", enumerable: false, writable: true, configurable: true })); + console.log("hidden", h.id, Object.keys(h).join(","), JSON.stringify(h)); +} catch (e) { + console.log("hidden threw", (e as Error).message); +} +try { + const r = Tagged.parse(tagged({ value: "a", enumerable: true, writable: false, configurable: true })); + console.log("read-only", r.id); +} catch (e) { + console.log("read-only threw", (e as Error).message); +} +try { + const g = Tagged.parse(tagged({ get: () => "a", enumerable: true, configurable: true })); + console.log("accessor", g.id); +} catch (e) { + console.log("accessor threw", (e as Error).message); +} +// An index-signature record keeps the keys its type does not declare in a +// map, which holds no attributes: a non-enumerable key has no place there +// and the boundary refuses it rather than list it as an ordinary one. +const Bag = z.custom<{ [key: string]: number }>(); +try { + const b = Bag.parse(tagged({ value: 2, enumerable: false, writable: true, configurable: true })); + console.log("index", JSON.stringify(b), Object.keys(b).join(",")); +} catch (e) { + console.log("index threw", (e as Error).message); +} diff --git a/tests/harness/corpus-costs/8854-record-hidden-key.ts.json b/tests/harness/corpus-costs/8854-record-hidden-key.ts.json new file mode 100644 index 00000000000..ae647d7965a --- /dev/null +++ b/tests/harness/corpus-costs/8854-record-hidden-key.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 9000 } diff --git a/tests/harness/decl-boundary.test.ts b/tests/harness/decl-boundary.test.ts index 8a45c015b30..f0f0309a7b9 100644 --- a/tests/harness/decl-boundary.test.ts +++ b/tests/harness/decl-boundary.test.ts @@ -1,7 +1,9 @@ /* The declaration-typed boundary's checks (decl-projection.ts): a value * zod's declarations type wrongly fails the boundary cast with a TypeError * naming the path, and a value the native record cannot represent (a - * frozen object) fails with the reason. Node does not check, so this is a + * frozen object, a read-only key, an accessor, a non-enumerable key of an + * index-signature record) fails with the reason, while a non-enumerable key + * of a declared-field record crosses. Node does not check, so this is a * native-only test; npm.test.ts runs the differential cases * (zod3-boundary, zod3-adoption, zod3-infer, zod4-boundary, * hono-zod-validator). The hono test checks that `c.req.valid()` is such a @@ -36,7 +38,11 @@ test( const { stdout } = await pending; expect(stdout).toBe( "liar threw true expected number at $.n, got string\n" + - "frozen threw expected a plain object (this value is frozen, sealed or not extensible) at $, got object\n", + "frozen threw expected a plain object (this value is frozen, sealed or not extensible) at $, got object\n" + + 'hidden a n {"n":1}\n' + + "read-only threw expected a plain object (this value has read-only or non-configurable properties) at $, got object\n" + + "accessor threw expected a plain object (this value has accessor properties) at $, got object\n" + + "index threw expected a plain object (this value has a non-enumerable key its record type cannot keep) at $, got object\n", ); }, );