diff --git a/README.md b/README.md index 6ec4e22..2cf4312 100644 --- a/README.md +++ b/README.md @@ -130,17 +130,20 @@ ## API ### `safeTag(value: unknown): string` (default export) - - - **Returns:** A string in the form `[object Type]`. - - **Exception handling:** Guaranteed not to throw. For hostile objects (like revoked proxies), returns `"[object Object]"`. - - **Behavior:** Returns the tag as the engine sees it. Respects `Symbol.toStringTag` masks and never mutates the input. - - ### `unmaskTag(value: unknown): string` - - Advanced API that attempts to reveal the underlying tag by temporarily mutating the object's own `Symbol.toStringTag` descriptor. - - - **Risk:** May cause V8 to de-optimize the object (hidden class changes) due to descriptor mutation. - - **Side effects:** Temporarily mutates `Symbol.toStringTag` on the object during the read, but is designed to restore the original descriptor and **never throws**. Falls back to `safeTag(value)` if unmasking fails. + +- **Returns:** A string in the form `[object Type]`. +- **Performance:** High. Does not mutate inputs and avoids V8 de-optimizations. +- **Exception handling:** Guaranteed not to throw. For hostile objects (like revoked proxies), returns `"[object Object]"`. +- **Behavior:** Returns the tag as the engine sees it. Respects `Symbol.toStringTag` masks. **Recommended for 99% of use cases.** + +### `unmaskTag(value: unknown): string` + +Advanced API that attempts to reveal the underlying innate tag, even if it has been spoofed using an own `Symbol.toStringTag`. + +- **Risk:** May cause V8 to de-optimize the object (hidden class changes) due to temporary descriptor mutation. +- **Usage:** **Should be used only when you need to bypass spoofed tags (e.g., in security-sensitive code).** Not needed for normal type checks. +- **Innate detection:** Includes a fast, non-mutating path for common built-ins (Array, Date, RegExp, Map, Set, Promise, Function, Error) to avoid de-optimization when possible. +- **Side effects:** Temporarily mutates `Symbol.toStringTag` on the object during the read if the innate fast-path is not available. Designed to restore the original descriptor and **never throws**. Falls back to `safeTag(value)` if unmasking fails. ### Performance variants (`safe-tag/fast`) diff --git a/bench/index.js b/bench/index.js index 0b54787..29372a8 100644 --- a/bench/index.js +++ b/bench/index.js @@ -1,13 +1,13 @@ // Benchmark for safe-tag const { performance } = require("perf_hooks"); -const { default: safeTag } = require("../dist/index.js"); +const { default: safeTag, unmaskTag } = require("../dist/index.js"); +const { fastTag, ultraFastTag, cachedTag } = require("../dist/fast.js"); const ITERATIONS = 100000; console.log("=== safe-tag Benchmarks ==="); console.log(`Running ${ITERATIONS} iterations per test...\n`); -// Test data const testCases = [ { name: "null", value: null }, { name: "undefined", value: undefined }, @@ -19,36 +19,32 @@ const testCases = [ { name: "Date", value: new Date() }, ]; -// Native Object.prototype.toString for comparison const nativeToString = Object.prototype.toString; -console.log("--- Baseline: Object.prototype.toString ---"); -for (const testCase of testCases) { - const start = performance.now(); - for (let i = 0; i < ITERATIONS; i++) { - try { - nativeToString.call(testCase.value); - } catch (e) { - // Baseline may throw on hostile objects +function runBench(name, fn, cases) { + console.log(`--- ${name} ---`); + for (const testCase of cases) { + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + try { + fn(testCase.value); + } catch (e) {} } + const end = performance.now(); + const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0); + console.log(`${testCase.name.padEnd(25)} ${opsPerSec.padStart(12)} ops/sec`); } - const end = performance.now(); - const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0); - console.log(`${testCase.name.padEnd(20)} ${opsPerSec.padStart(10)} ops/sec`); + console.log(""); } -console.log("\n--- safeTag (safe wrapper) ---"); -for (const testCase of testCases) { - const start = performance.now(); - for (let i = 0; i < ITERATIONS; i++) { - safeTag(testCase.value); - } - const end = performance.now(); - const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0); - console.log(`${testCase.name.padEnd(20)} ${opsPerSec.padStart(10)} ops/sec`); -} +runBench("Baseline: Object.prototype.toString", (v) => nativeToString.call(v), testCases); +runBench("safeTag (safe wrapper)", safeTag, testCases); +runBench("unmaskTag (advanced revelation)", unmaskTag, testCases); +runBench("fastTag (minimal wrapper)", fastTag, testCases); +runBench("ultraFastTag (direct call)", ultraFastTag, testCases); +runBench("cachedTag (WeakMap cache)", cachedTag, testCases.filter(c => c.value && typeof c.value === 'object')); -console.log("\n--- Hostile Object Tests ---"); +console.log("--- Hostile Object Tests (safeTag) ---"); const revokedProxy = (() => { const { proxy, revoke } = Proxy.revocable({}, {}); revoke(); @@ -75,7 +71,7 @@ for (const testCase of hostileTests) { } const end = performance.now(); const opsPerSec = ((ITERATIONS / (end - start)) * 1000).toFixed(0); - console.log(`${testCase.name.padEnd(20)} ${opsPerSec.padStart(10)} ops/sec`); + console.log(`${testCase.name.padEnd(25)} ${opsPerSec.padStart(12)} ops/sec`); } console.log("\n=== Benchmark Complete ==="); diff --git a/src/fast.ts b/src/fast.ts index 2393db3..22f0c35 100644 --- a/src/fast.ts +++ b/src/fast.ts @@ -32,8 +32,8 @@ export function fastTag(value: unknown): string { * @param value - Any value to tag. * @returns The native "[object Type]" tag. */ -export function ultraFastTag(value: any): string { - return nativeToString.call(value); +export function ultraFastTag(value: unknown): string { + return nativeToString.call(value as object); } const tagCache = new WeakMap(); diff --git a/src/index.ts b/src/index.ts index 73c8090..1d3e1a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,13 +29,33 @@ export default function safeTag(value: unknown): string { } } +/** + * Detects the innate tag of common built-in objects without mutation. + * This is a fast, safe path that avoids V8 de-optimization. + */ +function getInnateTag(value: object): string | undefined { + if (Array.isArray(value)) return "[object Array]"; + if (value instanceof Date) return "[object Date]"; + if (value instanceof RegExp) return "[object RegExp]"; + if (value instanceof Map) return "[object Map]"; + if (value instanceof Set) return "[object Set]"; + if (value instanceof Promise) return "[object Promise]"; + if (typeof value === "function") return "[object Function]"; + if (value instanceof Error) return "[object Error]"; + return undefined; +} + /** * Explicitly attempts to reveal the underlying tag by temporarily disabling an * own Symbol.toStringTag, if present and configurable. * - * This operation mutates the target's property descriptor but guarantees that it - * will never throw. If unmasking fails for any reason, it falls back to - * safeTag(value). + * PERFORMANCE WARNING: This operation mutates the target's property descriptor + * which may cause V8 hidden class de-optimizations. Use only when you must + * bypass spoofed tags in security-sensitive contexts. For normal type checks, + * use safeTag() or check innate types directly. + * + * This operation is guaranteed that it will never throw. If unmasking fails + * for any reason, it falls back to safeTag(value). */ export function unmaskTag(value: unknown): string { if (value === null || (typeof value !== "object" && typeof value !== "function")) { @@ -46,6 +66,16 @@ export function unmaskTag(value: unknown): string { return safeTag(value); } + // Try non-mutating path first for common built-ins. + // We wrap this in a try/catch because built-in checks like Array.isArray + // can throw on revoked proxies in some engines/environments. + try { + const innate = getInnateTag(value as object); + if (innate) return innate; + } catch { + // Fall through to standard unmasking logic if innate check fails. + } + try { const descriptor = getOwnDescriptor(value, symToStringTag);