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
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8854-record-hidden-key.ts"
],
"diags": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/hidden-key-boundary/main.ts"
],
"diags": []
}
25 changes: 21 additions & 4 deletions packages/runtime/src/scr_decl_adopt.c
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)";
Comment thread
vercel[bot] marked this conversation as resolved.
Resolved
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) {
Expand Down Expand Up @@ -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;
}
Expand Down
46 changes: 40 additions & 6 deletions packages/runtime/src/scr_json.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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);
Expand Down
53 changes: 53 additions & 0 deletions packages/runtime/src/scr_json.test.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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++) {
Expand Down
97 changes: 97 additions & 0 deletions tests/corpus/8854-record-hidden-key.ts
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading