diff --git a/package-lock.json b/package-lock.json index 48e7c0bd..37784410 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/slothlet", - "version": "3.14.0", + "version": "3.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/slothlet", - "version": "3.14.0", + "version": "3.14.1", "license": "Apache-2.0", "bin": { "slothlet": "bin/slothlet.mjs" diff --git a/package.json b/package.json index 2d67ae88..6cc2f293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/slothlet", - "version": "3.14.0", + "version": "3.14.1", "moduleVersions": { "lazy": "3.0.0", "eager": "3.0.0", diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index 0b365986..011bc156 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -56,6 +56,16 @@ import { isFrameworkInternal } from "#handlers/framework-internals"; // the filesystem-backed add/reload/remove paths are Node-only and never run in browser mode. import { fsp, path } from "@cldmv/slothlet/helpers/platform"; +/** + * Mount-path segment names that must never be written through to the object graph. + * Assigning to any of these while walking `current[segment] = …` mutates `Object.prototype` + * (or `Function.prototype`) globally — classic prototype pollution — instead of the api tree. + * They are refused at path-normalization time in any segment position (#302). + * @type {ReadonlySet} + * @private + */ +const UNSAFE_PATH_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]); + /** * Manages runtime API component lifecycle (add/remove/reload). * @class ApiManager @@ -165,6 +175,18 @@ export class ApiManager extends ComponentBase { }); } + // Prototype-pollution guard: refuse a __proto__/constructor/prototype segment in any position (#302). + const unsafeArrayIndex = apiPath.findIndex((segment) => UNSAFE_PATH_SEGMENTS.has(segment)); + if (unsafeArrayIndex !== -1) { + throw new this.SlothletError("INVALID_CONFIG_API_PATH_INVALID", { + apiPath, + reason: translate("API_PATH_REASON_UNSAFE_SEGMENT"), + index: unsafeArrayIndex, + segment: apiPath[unsafeArrayIndex], + validationError: true + }); + } + return { apiPath: apiPath.join("."), parts: apiPath }; } @@ -206,6 +228,18 @@ export class ApiManager extends ComponentBase { }); } + // Prototype-pollution guard: refuse a __proto__/constructor/prototype segment in any position (#302). + const unsafeIndex = parts.findIndex((segment) => UNSAFE_PATH_SEGMENTS.has(segment)); + if (unsafeIndex !== -1) { + throw new this.SlothletError("INVALID_CONFIG_API_PATH_INVALID", { + apiPath: normalized, + reason: translate("API_PATH_REASON_UNSAFE_SEGMENT"), + index: unsafeIndex, + segment: parts[unsafeIndex], + validationError: true + }); + } + return { apiPath: normalized, parts }; } @@ -443,6 +477,26 @@ export class ApiManager extends ComponentBase { * @public */ setOwnedProperty(apiPath, value, callerWrapper) { + // Reject prototype-pollution segments BEFORE the canonical normalizer runs. This surface has + // its own dedicated LOOSE_SET_RESERVED_KEY error; `normalizeApiPath` now also blocks these + // segments, but with a generic INVALID_CONFIG_API_PATH_INVALID, so the check must run first to + // keep the loose-set error. `self.__proto__ = obj` would assign onto the API root's prototype + // chain; `self.a.__proto__ = obj` (via the dotted form) would do the same on the wrapper at `a`. + // Reuses the module-level UNSAFE_PATH_SEGMENTS (same set the add-path guard uses). `String()` + // coerces without a branch and without throwing on a Symbol (unlike a template literal) — callers + // always pass `String(prop)`, and null/undefined stringify to a non-reserved token that falls + // through to the empty-path guard below. + const coercedPath = String(apiPath); + for (const segment of coercedPath.split(".")) { + if (UNSAFE_PATH_SEGMENTS.has(segment)) { + throw new this.SlothletError("LOOSE_SET_RESERVED_KEY", { + apiPath: coercedPath, + segment, + validationError: true + }); + } + } + // Delegate parsing to the canonical normalizer used by api.add / api.remove: // it rejects empty segments (e.g. "a..b") AND reserved root names (slothlet, // shutdown, destroy) so a runtime `self.slothlet = …` can't overwrite the @@ -463,21 +517,6 @@ export class ApiManager extends ComponentBase { }); } - // Reject prototype-pollution segments at any position. `self.__proto__ = obj` - // would assign onto the API root's prototype chain; `self.a.__proto__ = obj` - // (via dotted form) would do the same on the wrapper at `a`. Mirrors the same - // blocked set used by metadata.mjs and api_builder.mjs. - const RESERVED = new Set(["__proto__", "prototype", "constructor"]); - for (const segment of parts) { - if (RESERVED.has(segment)) { - throw new this.SlothletError("LOOSE_SET_RESERVED_KEY", { - apiPath: parts.join("."), - segment, - validationError: true - }); - } - } - // Ownership root = the caller module's MOUNT POINT, not its function-level apiPath. // The wrapper for `lib.config.foo` may belong to a module whose entire mount // point is `lib.config` — that whole subtree is the module's domain. We look diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index b4d2b332..f0f15154 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -3677,6 +3677,16 @@ export class UnifiedWrapper extends ComponentBase { return value; } + // A callable leaf's built-in function surface — inherited Function.prototype/Object.prototype + // members (apply, call, bind, constructor, …) and the non-enumerable own `prototype` slot — are + // the function's OWN properties, not child endpoints. Return them directly: wrapping one + // registered a phantom child and flipped the leaf's record from function to namespace on a mere + // read (#304). Only a user-added ENUMERABLE own property of the impl materializes as a child. + const currentImpl = wrapper.____slothletInternal.impl; + if (typeof currentImpl === "function" && !Object.prototype.propertyIsEnumerable.call(currentImpl, prop)) { + return value; + } + const wrapped = wrapper.___createChildWrapper(prop, value); // ___createChildWrapper always returns a wrapper for every value type seen in tests; null is never returned. /* v8 ignore next */ diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 702a617a..88868a66 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "Array-Elemente müssen Strings sein", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "Array enthält leere String-Segmente", "API_PATH_REASON_RESERVED_NAME": "kollidiert mit reservierten Namen (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "enthält ein unsicheres Segment (__proto__, constructor, prototype), das die Prototypenkette verunreinigen würde", "API_PATH_REASON_INVALID_TYPE": "muss ein String, ein Array von Strings, ein leerer String (Root) oder null/undefined (Root) sein", "API_PATH_REASON_EMPTY_SEGMENTS": "enthält leere Pfad-Segmente", "API_PATH_REASON_COLLISION_ERROR": "Pfad existiert bereits und Kollisionsmodus ist 'error'", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index 00d718cf..873919c9 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "array elements must be strings", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "array contains empty string segments", "API_PATH_REASON_RESERVED_NAME": "conflicts with reserved names (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contains an unsafe segment (__proto__, constructor, prototype) that would pollute the prototype chain", "API_PATH_REASON_INVALID_TYPE": "must be a string, array of strings, empty string (root), or null/undefined (root)", "API_PATH_REASON_EMPTY_SEGMENTS": "contains empty path segments", "API_PATH_REASON_COLLISION_ERROR": "path already exists and collision mode is 'error'", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index 2ba93b98..3e887866 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "array elements must be strings", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "array contains empty string segments", "API_PATH_REASON_RESERVED_NAME": "conflicts with reserved names (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contains an unsafe segment (__proto__, constructor, prototype) that would pollute the prototype chain", "API_PATH_REASON_INVALID_TYPE": "must be a string, array of strings, empty string (root), or null/undefined (root)", "API_PATH_REASON_EMPTY_SEGMENTS": "contains empty path segments", "API_PATH_REASON_COLLISION_ERROR": "path already exists and collision mode is 'error'", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index 45f266b9..1fc944b0 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "los elementos del array deben ser cadenas", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "el array contiene segmentos de cadena vacíos", "API_PATH_REASON_RESERVED_NAME": "entra en conflicto con nombres reservados (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contiene un segmento no seguro (__proto__, constructor, prototype) que contaminaría la cadena de prototipos", "API_PATH_REASON_INVALID_TYPE": "debe ser una cadena, un array de cadenas, cadena vacía (raíz) o null/undefined (raíz)", "API_PATH_REASON_EMPTY_SEGMENTS": "contiene segmentos de ruta vacíos", "API_PATH_REASON_COLLISION_ERROR": "la ruta ya existe y el modo de colisión es 'error'", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index ae1b78d8..bafa14c7 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "los elementos del array deben ser cadenas", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "el array contiene segmentos de cadena vacíos", "API_PATH_REASON_RESERVED_NAME": "entra en conflicto con nombres reservados (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contiene un segmento no seguro (__proto__, constructor, prototype) que contaminaría la cadena de prototipos", "API_PATH_REASON_INVALID_TYPE": "debe ser una cadena, un array de cadenas, cadena vacía (raíz) o null/undefined (raíz)", "API_PATH_REASON_EMPTY_SEGMENTS": "contiene segmentos de ruta vacíos", "API_PATH_REASON_COLLISION_ERROR": "la ruta ya existe y el modo de colisión es 'error'", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index 73a4a57f..77cf3f99 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "les éléments du tableau doivent être des chaînes", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "le tableau contient des segments de chaînes vides", "API_PATH_REASON_RESERVED_NAME": "entre en conflit avec les noms réservés (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contient un segment non sécurisé (__proto__, constructor, prototype) qui polluerait la chaîne de prototypes", "API_PATH_REASON_INVALID_TYPE": "doit être une chaîne, un tableau de chaînes, une chaîne vide (racine) ou null/undefined (racine)", "API_PATH_REASON_EMPTY_SEGMENTS": "contient des segments de chemin vides", "API_PATH_REASON_COLLISION_ERROR": "le chemin existe déjà et le mode de collision est 'error'", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index c1acee29..bbdd801d 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "एरे तत्व स्ट्रिंग्स होने चाहिए", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "एरे में खाली स्ट्रिंग खंड हैं", "API_PATH_REASON_RESERVED_NAME": "आरक्षित नामों (slothlet, shutdown, destroy) के साथ संघर्ष", + "API_PATH_REASON_UNSAFE_SEGMENT": "एक असुरक्षित सेगमेंट (__proto__, constructor, prototype) शामिल है जो प्रोटोटाइप श्रृंखला को दूषित करेगा", "API_PATH_REASON_INVALID_TYPE": "स्ट्रिंग, स्ट्रिंग का एरे, खाली स्ट्रिंग (रूट), या null/undefined (रूट) होना चाहिए", "API_PATH_REASON_EMPTY_SEGMENTS": "इसमें खाली पथ खंड हैं", "API_PATH_REASON_COLLISION_ERROR": "पथ पहले से मौजूद है और टकराव मोड 'error' है", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index dc6d515a..67df20ab 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "配列の要素は文字列である必要があります", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "配列に空の文字列セグメントが含まれています", "API_PATH_REASON_RESERVED_NAME": "予約名 (slothlet, shutdown, destroy) と衝突しています", + "API_PATH_REASON_UNSAFE_SEGMENT": "プロトタイプチェーンを汚染する安全でないセグメント (__proto__, constructor, prototype) が含まれています", "API_PATH_REASON_INVALID_TYPE": "文字列、文字列の配列、空の文字列 (ルート)、または null/undefined (ルート) である必要があります", "API_PATH_REASON_EMPTY_SEGMENTS": "空のパスセグメントが含まれています", "API_PATH_REASON_COLLISION_ERROR": "パスが既に存在し、衝突モードが 'error' です", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index 24d9d22d..df234588 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "배열 요소는 문자열이어야 함", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "배열에 빈 문자열 세그먼트가 포함되어 있음", "API_PATH_REASON_RESERVED_NAME": "예약어(slothlet, shutdown, destroy)와 충돌함", + "API_PATH_REASON_UNSAFE_SEGMENT": "프로토타입 체인을 오염시킬 수 있는 안전하지 않은 세그먼트(__proto__, constructor, prototype)를 포함함", "API_PATH_REASON_INVALID_TYPE": "문자열, 문자열 배열, 빈 문자열(루트), 또는 null/undefined(루트)여야 함", "API_PATH_REASON_EMPTY_SEGMENTS": "빈 경로 세그먼트를 포함하고 있음", "API_PATH_REASON_COLLISION_ERROR": "경로가 이미 존재하고 충돌 모드가 'error'임", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index ced94f40..9591db42 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "elementos do array devem ser strings", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "o array contém segmentos de string vazios", "API_PATH_REASON_RESERVED_NAME": "conflita com nomes reservados (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "contém um segmento inseguro (__proto__, constructor, prototype) que poluiria a cadeia de protótipos", "API_PATH_REASON_INVALID_TYPE": "deve ser uma string, array de strings, string vazia (raiz) ou null/undefined (raiz)", "API_PATH_REASON_EMPTY_SEGMENTS": "contém segmentos de caminho vazios", "API_PATH_REASON_COLLISION_ERROR": "o caminho já existe e o modo de colisão é 'error'", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index 50375723..5aed1931 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "Элементы массива должны быть строками", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "Массив содержит пустые сегменты", "API_PATH_REASON_RESERVED_NAME": "Конфликт с зарезервированными именами (slothlet, shutdown, destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "Содержит небезопасный сегмент (__proto__, constructor, prototype), который загрязнил бы цепочку прототипов", "API_PATH_REASON_INVALID_TYPE": "Должно быть строкой, массивом строк, пустой строкой (root) или null/undefined (root)", "API_PATH_REASON_EMPTY_SEGMENTS": "Содержит пустые сегменты пути", "API_PATH_REASON_COLLISION_ERROR": "Путь уже существует и режим столкновения 'error'", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index 9136c9ab..6d737424 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -376,6 +376,7 @@ "API_PATH_REASON_ARRAY_ELEMENTS": "数组元素必须为字符串", "API_PATH_REASON_ARRAY_EMPTY_SEGMENTS": "数组包含空字符串片段", "API_PATH_REASON_RESERVED_NAME": "与保留名称冲突(slothlet、shutdown、destroy)", + "API_PATH_REASON_UNSAFE_SEGMENT": "包含会污染原型链的不安全段(__proto__、constructor、prototype)", "API_PATH_REASON_INVALID_TYPE": "必须是字符串、字符串数组、空字符串(根)或 null/undefined(根)", "API_PATH_REASON_EMPTY_SEGMENTS": "包含空的路径段", "API_PATH_REASON_COLLISION_ERROR": "路径已存在且冲突模式为 'error'", diff --git a/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs index 5e5bebe4..e141c64c 100644 --- a/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs +++ b/tests/vitests/suites/api-manager/api-manager-reserved-paths.test.vitest.mjs @@ -97,3 +97,46 @@ describe.each(CONFIGS)("normalizeApiPath reserved names — $name", ({ config }) await expect(api.slothlet.api.add("a..b", TEST_DIRS.API_TEST)).rejects.toMatchObject({ code: "INVALID_CONFIG_API_PATH_INVALID" }); }); }); + +// #302 – a mount-path segment of __proto__/constructor/prototype must be refused, never +// written through to the real prototype chain (prototype pollution). Any segment position +// is dangerous, and both the dotted-string and array path forms funnel through normalizeApiPath. +describe.each(CONFIGS)("normalizeApiPath prototype-pollution guard — $name", ({ config }) => { + let api; + + afterEach(async () => { + // Fail loudly if any assertion below leaked a write onto Object.prototype. + delete Object.prototype.x; + delete Object.prototype.pwn; + if (api) { + await api.shutdown(); + api = null; + } + }); + + const UNSAFE = [ + { label: "__proto__ (string)", path: "__proto__.x", probe: "x" }, + { label: "constructor.prototype (string)", path: "constructor.prototype.pwn", probe: "pwn" }, + { label: "__proto__ (array)", path: ["__proto__", "x"], probe: "x" }, + { label: "prototype at a deeper segment (array)", path: ["safe", "prototype", "pwn"], probe: "pwn" } + ]; + + it.each(UNSAFE)("rejects a $label mount path and does not pollute Object.prototype", async ({ path, probe }) => { + api = await makeApi(config); + await expect(api.slothlet.api.add(path, () => "polluted", { moduleID: "proto-guard" })).rejects.toMatchObject({ + code: "INVALID_CONFIG_API_PATH_INVALID" + }); + // The write must not have reached the prototype chain. + expect(Object.prototype[probe]).toBeUndefined(); + expect({}[probe]).toBeUndefined(); + }); + + it("accepts a __-prefixed / reserved-substring segment (precise guard, not a blanket '__' ban)", async () => { + // Only the exact segments __proto__/constructor/prototype are refused. A name that merely + // starts with "__" (the module-private convention) or contains a reserved word as a substring + // must still mount — the guard matches whole segments, not prefixes or substrings. + api = await makeApi(config); + await expect(api.slothlet.api.add("__config.value", () => 1, { moduleID: "safe-underscore" })).resolves.toBeDefined(); + await expect(api.slothlet.api.add(["prototypeName", "leaf"], () => 2, { moduleID: "safe-substring" })).resolves.toBeDefined(); + }); +}); diff --git a/tests/vitests/suites/unified-wrapper/leaf-function-prototype-read.test.vitest.mjs b/tests/vitests/suites/unified-wrapper/leaf-function-prototype-read.test.vitest.mjs new file mode 100644 index 00000000..eec49867 --- /dev/null +++ b/tests/vitests/suites/unified-wrapper/leaf-function-prototype-read.test.vitest.mjs @@ -0,0 +1,104 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/unified-wrapper/leaf-function-prototype-read.test.vitest.mjs + * @Date: 2026-08-24T00:00:00-08:00 (1756022400) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-08-24T00:00:00-08:00 (1756022400) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Reading a Function.prototype member off a callable leaf must not mutate its record (#304). + * + * @description + * `leaf.apply(thisArg, args)` is a common forwarding idiom. The wrapper's get trap used to treat + * every function-valued property — including the leaf's own inherited `apply`/`call`/`bind`/`constructor` + * and its non-enumerable `prototype` — as a child endpoint, wrapping it and registering a phantom child. + * A mere READ therefore flipped the leaf from `kind: "function"` to `kind: "namespace"` and grew a phantom + * `leaf.apply` record. Built-in function members must return the function's own property untouched; only a + * user-added enumerable own property materializes as a child. Verified in eager and lazy modes. + * @module tests/vitests/suites/unified-wrapper/leaf-function-prototype-read + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import slothlet from "@cldmv/slothlet"; + +const ROOT = resolve("tmp", `slothlet-leaffnproto-${Date.now()}-${Math.random().toString(36).slice(2)}`); + +beforeAll(async () => { + await mkdir(ROOT, { recursive: true }); + await writeFile(join(ROOT, "tool.mjs"), `export function ping() { return "pong"; }\n`); + // A callable that ALSO carries a real, user-added enumerable own child (`version`): the built-in + // members must still return native, but the genuine child must still materialize. + await writeFile( + join(ROOT, "greet.mjs"), + `export function greet() { return "hi"; }\ngreet.version = function version() { return "v1"; };\n` + ); +}); + +afterAll(async () => { + await rm(ROOT, { recursive: true, force: true }); +}); + +const MODES = ["eager", "lazy"]; +// The leaf function's OWN built-in surface, not api children: inherited callable members and the +// non-enumerable `prototype` slot. Reading any of them must not mutate the record. +const CALLABLE_MEMBERS = ["apply", "call", "bind", "constructor"]; +const isPhantom = (p) => /\.(apply|call|bind|constructor|prototype)$/.test(p.path); + +describe.each(MODES)("Function.prototype reads on a callable leaf leave the record intact — %s", (mode) => { + let api; + + afterEach(async () => { + if (api?.shutdown) await api.shutdown(); + api = null; + }); + + it("does not turn a function leaf into a namespace or grow phantom children", async () => { + api = await slothlet({ base: ROOT, silent: true, mode }); + + const before = await api.slothlet.api.leaves(".", { details: true, includePrivate: true }); + expect(before.find((p) => p.path === "tool.ping")?.kind).toBe("function"); + expect(before.filter(isPhantom)).toHaveLength(0); + + // Read every built-in member — this must not mutate the record. + for (const member of CALLABLE_MEMBERS) { + expect(typeof api.tool.ping[member]).toBe("function"); + } + expect(typeof api.tool.ping.prototype).toBe("object"); + expect(api.tool.ping()).toBe("pong"); + + const after = await api.slothlet.api.leaves(".", { details: true, includePrivate: true }); + // The leaf stays a function, and no phantom `tool.ping.apply` / `.call` / `.bind` / … appeared. + expect(after.find((p) => p.path === "tool.ping")?.kind).toBe("function"); + expect(after.filter(isPhantom)).toHaveLength(0); + expect(after.some((p) => p.path.startsWith("tool.ping."))).toBe(false); + }); + + it("leaf.apply / leaf.call still invoke the leaf through slothlet", async () => { + api = await slothlet({ base: ROOT, silent: true, mode }); + expect(api.tool.ping.apply(null, [])).toBe("pong"); + expect(api.tool.ping.call(null)).toBe("pong"); + const bound = api.tool.ping.bind(null); + expect(bound()).toBe("pong"); + }); + + it("a genuine user-added enumerable own child on a callable still materializes", async () => { + api = await slothlet({ base: ROOT, silent: true, mode }); + // `greet.version` is a real enumerable own property — it IS a child endpoint and must resolve, + // while `greet.apply` (built-in) returns the function's own member and is not a child. + expect(api.greet()).toBe("hi"); + expect(api.greet.version()).toBe("v1"); + expect(typeof api.greet.apply).toBe("function"); + + const details = await api.slothlet.api.leaves(".", { details: true, includePrivate: true }); + expect(details.find((p) => p.path === "greet.version")?.kind).toBe("function"); + expect(details.some((p) => isPhantom(p) && p.path.startsWith("greet."))).toBe(false); + }); +}); diff --git a/types/src/lib/handlers/api-manager.d.mts.map b/types/src/lib/handlers/api-manager.d.mts.map index 41831cb6..9e07d25e 100644 --- a/types/src/lib/handlers/api-manager.d.mts.map +++ b/types/src/lib/handlers/api-manager.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"api-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/api-manager.mjs"],"names":[],"mappings":"AA0DA;;;;;;;;;;;;;;GAcG;AACH;IACC,gCAAuC;IAEvC;;;;;;;;;;;OAWG;IACH,sBAVW,MAAM,EAuBhB;IAXA,sIAAsI;IACtI,OADW;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,aAAa,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC;QAAC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;KAAE,CAUjI;IAGF;;;;;;;;;;;;;;;;;;OAkBG;IACH,yBAuFC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBA4CC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BA4BC;IAED;;;;;;;;;;;;OAYG;IACH,6BAIC;IAED;;;;;;;;;;;;OAYG;IACH,uBASC;IAED;;;;;;;;;;;;;OAaG;IACH,yBAkCC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,iCAbW,MAAM,SACN,OAAO,iBACP,MAAM,GAAC,IAAI,GACT,IAAI,CAgHhB;IAED;;;;;;;;;;;;;OAaG;IACH,uBAEC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBAkOC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,8BAiCC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,uBAkGC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,uBAgJC;IAED;;;;;;;;;;;;OAYG;IACH,mBAmGC;IACD;;;;;;;;;;;;OAYG;IACH,uBAwEC;IA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkDG;IACH,wBAhDG;QAAuB,OAAO,EAAtB,MAAM;QACmD,UAAU,EAAnE,MAAM,GAAC,MAAM,EAAE,cAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;QACf,OAAO;KAChD,GAAU,OAAO,CAAC,MAAM,GAAC,MAAM,EAAE,CAAC,CA+xBpC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,yEATG;QAAqB,QAAQ,EAArB,MAAM;QACO,aAAa,EAA1B,MAAM;QACO,cAAc,EAA3B,MAAM;KACd,GAAU,OAAO,CAAC,IAAI,CAAC,CAgDzB;IA8BD;;;;;;;;;;;OAWG;IACH,mCAeC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,mCAfW,MAAM,iBACJ,OAAO,CAAC,IAAI,CAAC,CA+ezB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,2BAdG;QAAwB,OAAO,EAAtB,MAAM,OAAA;QACS,QAAQ,EAAvB,MAAM,OAAA;KACf,GAAU,OAAO,CAAC,IAAI,CAAC,CAuCzB;IAED;;;;;;;;;;OAUG;IACH,0BAuEC;IAED;;;;;;;;;;;;;;OAcG;IACH,yBAsFC;IAED;;;;;;;;;;;;;OAaG;IACH,4BA0FC;IAED;;;;;;;;OAQG;IACH,iCAuDC;IAED;;;;;OAKG;IACH,iCAeC;IAED;;;;;;;;;;;;;OAaG;IACH,wBAoUC;;CACD;8BAl+G6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"api-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/api-manager.mjs"],"names":[],"mappings":"AAoEA;;;;;;;;;;;;;;GAcG;AACH;IACC,gCAAuC;IAEvC;;;;;;;;;;;OAWG;IACH,sBAVW,MAAM,EAuBhB;IAXA,sIAAsI;IACtI,OADW;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,aAAa,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC;QAAC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;KAAE,CAUjI;IAGF;;;;;;;;;;;;;;;;;;OAkBG;IACH,yBA+GC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBA4CC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BA4BC;IAED;;;;;;;;;;;;OAYG;IACH,6BAIC;IAED;;;;;;;;;;;;OAYG;IACH,uBASC;IAED;;;;;;;;;;;;;OAaG;IACH,yBAkCC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,iCAbW,MAAM,SACN,OAAO,iBACP,MAAM,GAAC,IAAI,GACT,IAAI,CAqHhB;IAED;;;;;;;;;;;;;OAaG;IACH,uBAEC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBAkOC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,8BAiCC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,uBAkGC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,uBAgJC;IAED;;;;;;;;;;;;OAYG;IACH,mBAmGC;IACD;;;;;;;;;;;;OAYG;IACH,uBAwEC;IA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkDG;IACH,wBAhDG;QAAuB,OAAO,EAAtB,MAAM;QACmD,UAAU,EAAnE,MAAM,GAAC,MAAM,EAAE,cAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;QACf,OAAO;KAChD,GAAU,OAAO,CAAC,MAAM,GAAC,MAAM,EAAE,CAAC,CA+xBpC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,yEATG;QAAqB,QAAQ,EAArB,MAAM;QACO,aAAa,EAA1B,MAAM;QACO,cAAc,EAA3B,MAAM;KACd,GAAU,OAAO,CAAC,IAAI,CAAC,CAgDzB;IA8BD;;;;;;;;;;;OAWG;IACH,mCAeC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,mCAfW,MAAM,iBACJ,OAAO,CAAC,IAAI,CAAC,CA+ezB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,2BAdG;QAAwB,OAAO,EAAtB,MAAM,OAAA;QACS,QAAQ,EAAvB,MAAM,OAAA;KACf,GAAU,OAAO,CAAC,IAAI,CAAC,CAuCzB;IAED;;;;;;;;;;OAUG;IACH,0BAuEC;IAED;;;;;;;;;;;;;;OAcG;IACH,yBAsFC;IAED;;;;;;;;;;;;;OAaG;IACH,4BA0FC;IAED;;;;;;;;OAQG;IACH,iCAuDC;IAED;;;;;OAKG;IACH,iCAeC;IAED;;;;;;;;;;;;;OAaG;IACH,wBAoUC;;CACD;8BAzgH6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/unified-wrapper.d.mts.map b/types/src/lib/handlers/unified-wrapper.d.mts.map index 90b69580..e34377ee 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts.map +++ b/types/src/lib/handlers/unified-wrapper.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA6nBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAg0HD;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAz3HD;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAyQC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IA3XD;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,sBAxBW,MAAM,sHAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;KAErC,EA6KF;IA3MD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IA8LD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBA6CC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA+FC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA2bC;IAED;;;;;;;;;;;OAWG;IACH,8BA4IC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAy3BC;IAED;;;;;;OAMG;IACH,uCAyiDC;;CACD;8BAx6I6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA6nBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AA00HD;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAn4HD;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAyQC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IA3XD;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,sBAxBW,MAAM,sHAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;KAErC,EA6KF;IA3MD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IA8LD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBA6CC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA+FC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA2bC;IAED;;;;;;;;;;;OAWG;IACH,8BA4IC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAy3BC;IAED;;;;;;OAMG;IACH,uCAmjDC;;CACD;8BAl7I6B,2BAA2B"} \ No newline at end of file