Skip to content

Commit c4aca9f

Browse files
committed
util: add private fields preview support
`util.previewValue` may reveal private fields of an object.
1 parent 57aba5e commit c4aca9f

File tree

6 files changed

+348
-2
lines changed

6 files changed

+348
-2
lines changed

doc/api/errors.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,6 +1689,13 @@ object.
16891689
16901690
An attempt was made to `require()` an [ES Module][].
16911691

1692+
<a id="ERR_PREVIEW_FAILURE"></a>
1693+
### `ERR_PREVIEW_FAILURE`
1694+
1695+
> Stability: 1 - Experimental
1696+
1697+
An attempt of previewing a JavaScript value was failed.
1698+
16921699
<a id="ERR_SCRIPT_EXECUTION_INTERRUPTED"></a>
16931700
### `ERR_SCRIPT_EXECUTION_INTERRUPTED`
16941701

doc/api/util.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,49 @@ Otherwise, returns `false`.
885885
See [`assert.deepStrictEqual()`][] for more information about deep strict
886886
equality.
887887

888+
### `util.previewValue(value[, opts])`
889+
<!-- YAML
890+
added: REPLACEME
891+
-->
892+
893+
* `object` {any} Any JavaScript primitive or `Object`.
894+
* `options` {Object}
895+
* `breakLength` {integer} The length at which input values are split across
896+
multiple lines. Set to `Infinity` to format the input as a single line
897+
(in combination with `compact` set to `true` or any number >= `1`).
898+
**Default:** `80`.
899+
* `colors` {boolean} If `true`, the output is styled with ANSI color
900+
codes. Colors are customizable. See [Customizing `util.inspect` colors][].
901+
**Default:** `false`.
902+
* `compact` {boolean|integer} Setting this to `false` causes each object key
903+
to be displayed on a new line. It will also add new lines to text that is
904+
longer than `breakLength`. If set to a number, the most `n` inner elements
905+
are united on a single line as long as all properties fit into
906+
`breakLength`. Short array elements are also grouped together. No
907+
text will be reduced below 16 characters, no matter the `breakLength` size.
908+
For more information, see the example below. **Default:** `3`.
909+
* Returns: {Promise<string>} A promise of the representation of `object`.
910+
911+
> Stability: 1 - Experimental
912+
913+
The `util.previewValue` method returns promise of a string representation
914+
of `object` that is intended for debugging. The output of `util.previewValue`
915+
may change at any time and should not be depended upon programmatically.
916+
Additional `options` may be passed that alter the result. `util.previewValue()`
917+
will use the constructor's name make an identifiable tag for an inspected value.
918+
919+
The difference of this method with `util.inspect` is `util.previewValue` can
920+
reveal target own private fields.
921+
922+
```javascript
923+
class Foo { #bar = 'baz' }
924+
925+
util.previewValue(new Foo())
926+
.then((preview) => {
927+
console.log(preview); // 'Foo { #bar: "baz" }'
928+
});
929+
```
930+
888931
## `util.promisify(original)`
889932
<!-- YAML
890933
added: v8.0.0

lib/internal/errors.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,7 @@ E('ERR_PACKAGE_PATH_NOT_EXPORTED', (pkgPath, subpath, base = undefined) => {
12801280
pkgPath} imported from ${base}`;
12811281
}
12821282
}, Error);
1283+
E('ERR_PREVIEW_FAILURE', 'Preview value failed for reason: "%s"', Error);
12831284
E('ERR_REQUIRE_ESM',
12841285
(filename, parentPath = null, packageJsonPath = null) => {
12851286
let msg = `Must use import to load ES Module: ${filename}`;

lib/internal/util/inspect.js

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const {
3636
ObjectPrototypeHasOwnProperty,
3737
ObjectPrototypePropertyIsEnumerable,
3838
ObjectSeal,
39+
Promise,
3940
RegExp,
4041
RegExpPrototypeToString,
4142
Set,
@@ -73,7 +74,8 @@ const {
7374

7475
const {
7576
codes: {
76-
ERR_INVALID_ARG_TYPE
77+
ERR_INVALID_ARG_TYPE,
78+
ERR_PREVIEW_FAILURE
7779
},
7880
isStackOverflowError
7981
} = require('internal/errors');
@@ -120,6 +122,12 @@ const assert = require('internal/assert');
120122

121123
const { NativeModule } = require('internal/bootstrap/loaders');
122124

125+
const { hasInspector } = internalBinding('config');
126+
127+
let inspector;
128+
let privateInspectionSession;
129+
let privatePreviewSessionCount = 0;
130+
123131
const setSizeGetter = uncurryThis(
124132
ObjectGetOwnPropertyDescriptor(SetPrototype, 'size').get);
125133
const mapSizeGetter = uncurryThis(
@@ -2027,11 +2035,202 @@ function stripVTControlCharacters(str) {
20272035
return str.replace(ansi, '');
20282036
}
20292037

2038+
async function previewValue(target, opts) {
2039+
const ctx = {
2040+
budget: {},
2041+
indentationLvl: 0,
2042+
currentDepth: 0,
2043+
stylize: stylizeNoColor,
2044+
compact: inspectDefaultOptions.compact,
2045+
breakLength: inspectDefaultOptions.breakLength,
2046+
};
2047+
if (opts) {
2048+
const optKeys = ObjectKeys(opts);
2049+
for (const key of optKeys) {
2050+
if (ObjectPrototypeHasOwnProperty(inspectDefaultOptions, key)) {
2051+
ctx[key] = opts[key];
2052+
} else if (ctx.userOptions === undefined) {
2053+
// This is required to pass through the actual user input.
2054+
ctx.userOptions = opts;
2055+
}
2056+
}
2057+
}
2058+
if (ctx.colors) {
2059+
ctx.stylize = stylizeWithColor;
2060+
}
2061+
2062+
return previewRaw(ctx, target);
2063+
}
2064+
2065+
async function previewRaw(ctx, target) {
2066+
if (typeof target !== 'object' &&
2067+
typeof target !== 'function' &&
2068+
!isUndetectableObject(target)) {
2069+
return formatPrimitive(ctx.stylize, target, ctx);
2070+
}
2071+
if (target === null) {
2072+
return ctx.stylize('null', 'null');
2073+
}
2074+
if (typeof target === 'function') {
2075+
return formatRaw(ctx, target);
2076+
}
2077+
2078+
const preview = await getValuePreviewAsync(target);
2079+
if (preview == null) {
2080+
return undefined;
2081+
}
2082+
return formatInspectorPropertyPreview(ctx, target, preview);
2083+
}
2084+
2085+
function previewPrelude() {
2086+
if (!hasInspector) {
2087+
return false;
2088+
}
2089+
if (privateInspectionSession == null) {
2090+
inspector = require('inspector');
2091+
privateInspectionSession = new inspector.Session();
2092+
privateInspectionSession.connect();
2093+
}
2094+
return true;
2095+
}
2096+
2097+
function getValuePreviewAsync(target) {
2098+
if (!previewPrelude()) {
2099+
return undefined;
2100+
}
2101+
if (privatePreviewSessionCount === 0) {
2102+
privateInspectionSession.post('Runtime.enable');
2103+
privateInspectionSession.post('Debugger.enable');
2104+
}
2105+
privatePreviewSessionCount++;
2106+
2107+
return new Promise((resolve, reject) => {
2108+
privateInspectionSession.once('Debugger.paused', (pausedContext) => {
2109+
const callFrames = pausedContext.params.callFrames;
2110+
/** USED */target;
2111+
privateInspectionSession.post(
2112+
'Debugger.evaluateOnCallFrame', {
2113+
/**
2114+
* 0. inspector.dispatch,
2115+
* 1. inspector.post,
2116+
* 2. new Promise,
2117+
* 3. previewValueAsync
2118+
*/
2119+
callFrameId: callFrames[3].callFrameId,
2120+
expression: 'target',
2121+
}, (err, result) => {
2122+
if (err) {
2123+
return reject(err);
2124+
}
2125+
const { result: evalResult } = result;
2126+
2127+
if (evalResult.type !== 'object') {
2128+
return resolve();
2129+
}
2130+
if (evalResult.subtype === 'error') {
2131+
return reject(ERR_PREVIEW_FAILURE(evalResult.description));
2132+
}
2133+
resolve(getValuePreviewByObjectIdAsync(evalResult.objectId));
2134+
});
2135+
});
2136+
privateInspectionSession.post('Debugger.pause', () => {
2137+
/* pause doesn't return anything. */
2138+
});
2139+
}).finally(() => {
2140+
privatePreviewSessionCount--;
2141+
if (privatePreviewSessionCount === 0) {
2142+
privateInspectionSession.post('Runtime.disable');
2143+
privateInspectionSession.post('Debugger.disable');
2144+
}
2145+
});
2146+
}
2147+
2148+
function getValuePreviewByObjectIdAsync(objectId, options = {}) {
2149+
return new Promise((resolve, reject) => {
2150+
privateInspectionSession.post('Runtime.getProperties', {
2151+
objectId: objectId,
2152+
ownProperties: true,
2153+
...options
2154+
}, (err, result) => {
2155+
if (err) {
2156+
return reject(err);
2157+
}
2158+
resolve(result);
2159+
});
2160+
});
2161+
}
2162+
2163+
async function formatInspectorPropertyPreview(
2164+
ctx, target, preview, isJsValue = true
2165+
) {
2166+
const { result, privateProperties } = preview;
2167+
2168+
const output = [];
2169+
for (var item of [
2170+
// __proto__ is an own property
2171+
...result.filter((it) => it.name !== '__proto__'),
2172+
...(privateProperties ?? [])
2173+
]) {
2174+
const valueDescriptor = item.value;
2175+
const valueType = valueDescriptor.type;
2176+
const subtype = valueDescriptor.subtype;
2177+
const stylize = ctx.stylize;
2178+
let str;
2179+
if (valueType === 'string') {
2180+
str = stylize(strEscape(valueDescriptor.value), valueType);
2181+
} else if (valueType === 'number') {
2182+
str = stylize(
2183+
// -0 is unserializable to JSON representation
2184+
valueDescriptor.value ?? valueDescriptor.unserializableValue,
2185+
valueType);
2186+
} else if (valueType === 'boolean') {
2187+
str = stylize(valueDescriptor.value, valueType);
2188+
} else if (valueType === 'symbol') {
2189+
str = stylize(valueDescriptor.description, valueType);
2190+
} else if (valueType === 'undefined') {
2191+
str = stylize(valueType, valueType);
2192+
} else if (subtype === 'null') {
2193+
str = stylize(subtype, 'null');
2194+
} else if (valueType === 'bigint') {
2195+
str = stylize(valueDescriptor.unserializableValue, valueType);
2196+
} else if (valueType === 'function') {
2197+
str = await previewFunction(ctx, valueDescriptor.objectId);
2198+
} else if (valueType === 'object') {
2199+
// TODO(legendecas): Inspect the whole object, not only the class name.
2200+
str = `#[${valueDescriptor.className}]`;
2201+
} else {
2202+
/* c8 ignore next */
2203+
assert.fail(`Unknown value type '${valueType}'`);
2204+
}
2205+
output.push(`${item.name}: ${str}`);
2206+
}
2207+
2208+
const constructor = isJsValue ?
2209+
getConstructorName(target, ctx, 0) :
2210+
target.className;
2211+
const braces = [`${getPrefix(constructor, '', 'Object')}{`, '}'];
2212+
const base = '';
2213+
const res = reduceToSingleString(
2214+
ctx, output, base, braces, kObjectType, ctx.currentDepth);
2215+
return res;
2216+
}
2217+
2218+
async function previewFunction(ctx, objectId) {
2219+
const { result } = await getValuePreviewByObjectIdAsync(
2220+
objectId, { ownProperties: false });
2221+
const nameDesc = result.find((it) => it.name === 'name');
2222+
if (nameDesc == null) {
2223+
return ctx.stylize('[Function]', 'function');
2224+
}
2225+
return ctx.stylize(`[Function: ${nameDesc.value.value}]`, 'function');
2226+
}
2227+
20302228
module.exports = {
20312229
inspect,
20322230
format,
20332231
formatWithOptions,
20342232
getStringWidth,
20352233
inspectDefaultOptions,
2234+
previewValue,
20362235
stripVTControlCharacters
20372236
};

lib/util.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ const {
4747
const {
4848
format,
4949
formatWithOptions,
50-
inspect
50+
inspect,
51+
previewValue
5152
} = require('internal/util/inspect');
5253
const { debuglog } = require('internal/util/debuglog');
5354
const { validateNumber } = require('internal/validators');
@@ -269,6 +270,7 @@ module.exports = {
269270
isFunction,
270271
isPrimitive,
271272
log,
273+
previewValue,
272274
promisify,
273275
TextDecoder,
274276
TextEncoder,

0 commit comments

Comments
 (0)