Repository: nodejs/node
Pull request: util: fix nested proxy inspection

Fixes: https://github.com/nodejs/node/issues/61061

Changed files:
filename: lib/internal/util/inspect.js
patch: @@ -1118,17 +1118,29 @@ function formatValue(ctx, value, recurseTimes, typedArray) {
 
   // Memorize the context for custom inspection on proxies.
   const context = value;
+  let proxies = 0;
   // Always check for proxies to prevent side effects and to prevent triggering
   // any proxy handlers.
-  const proxy = getProxyDetails(value, !!ctx.showProxy);
+  let proxy = getProxyDetails(value, !!ctx.showProxy);
   if (proxy !== undefined) {
-    if (proxy === null || proxy[0] === null) {
-      return ctx.stylize('<Revoked Proxy>', 'special');
-    }
     if (ctx.showProxy) {
+      if (proxy[0] === null) {
+        return ctx.stylize('<Revoked Proxy>', 'special');
+      }
       return formatProxy(ctx, proxy, recurseTimes);
     }
-    value = proxy;
+    do {
+      if (proxy === null) {
+        let formatted = ctx.stylize('<Revoked Proxy>', 'special');
+        for (let i = 0; i < proxies; i++) {
+          formatted = `${ctx.stylize('Proxy(', 'special')}${formatted}${ctx.stylize(')', 'special')}`;
+        }
+        return formatted;
+      }
+      value = proxy;
+      proxy = getProxyDetails(value, false);
+      proxies += 1;
+    } while (proxy !== undefined);
   }
 
   // Provide a hook for user-specified inspect functions.
@@ -1144,7 +1156,7 @@ function formatValue(ctx, value, recurseTimes, typedArray) {
       // a counter internally.
       const depth = ctx.depth === null ? null : ctx.depth - recurseTimes;
       const isCrossContext =
-        proxy !== undefined || !FunctionPrototypeSymbolHasInstance(Object, context);
+        proxies !== 0 || !FunctionPrototypeSymbolHasInstance(Object, context);
       const ret = FunctionPrototypeCall(
         maybeCustom,
         context,
@@ -1180,10 +1192,12 @@ function formatValue(ctx, value, recurseTimes, typedArray) {
     return ctx.stylize(`[Circular *${index}]`, 'special');
   }
 
-  const formatted = formatRaw(ctx, value, recurseTimes, typedArray);
+  let formatted = formatRaw(ctx, value, recurseTimes, typedArray);
 
-  if (proxy !== undefined) {
-    return `${ctx.stylize('Proxy(', 'special')}${formatted}${ctx.stylize(')', 'special')}`;
+  if (proxies !== 0) {
+    for (let i = 0; i < proxies; i++) {
+      formatted = `${ctx.stylize('Proxy(', 'special')}${formatted}${ctx.stylize(')', 'special')}`;
+    }
   }
 
   return formatted;
@@ -2691,7 +2705,7 @@ function hasBuiltInToString(value) {
     if (proxyTarget === null) {
       return true;
     }
-    value = proxyTarget;
+    return hasBuiltInToString(proxyTarget);
   }
 
   let hasOwnToString = ObjectPrototypeHasOwnProperty;

filename: test/parallel/test-util-inspect-proxy.js
patch: @@ -42,7 +42,11 @@ proxyObj = new Proxy(target, handler);
 util.inspect(proxyObj, opts);
 
 // Make sure inspecting object does not trigger any proxy traps.
-util.format('%s', proxyObj);
+// %i%f%d use Symbol.toPrimitive to convert the value to a string.
+// %j uses JSON.stringify, accessing the value's toJSON and toString method.
+util.format('%s%o%O%c', proxyObj, proxyObj, proxyObj, proxyObj);
+const nestedProxy = new Proxy(new Proxy({}, handler), {});
+util.format('%s%o%O%c', nestedProxy, nestedProxy, nestedProxy, nestedProxy);
 
 // getProxyDetails is an internal method, not intended for public use.
 // This is here to test that the internals are working correctly.
@@ -135,6 +139,10 @@ const expected6 = 'Proxy [\n' +
                   '    Proxy [ Proxy [Array], Proxy [Array] ]\n' +
                   '  ]\n' +
                   ']';
+const expected2NoShowProxy = 'Proxy(Proxy({}))';
+const expected3NoShowProxy = 'Proxy(Proxy(Proxy({})))';
+const expected4NoShowProxy = 'Proxy(Proxy(Proxy(Proxy({}))))';
+const expected5NoShowProxy = 'Proxy(Proxy(Proxy(Proxy(Proxy({})))))';
 assert.strictEqual(
   util.inspect(proxy1, { showProxy: 1, depth: null }),
   expected1);
@@ -144,11 +152,11 @@ assert.strictEqual(util.inspect(proxy4, opts), expected4);
 assert.strictEqual(util.inspect(proxy5, opts), expected5);
 assert.strictEqual(util.inspect(proxy6, opts), expected6);
 assert.strictEqual(util.inspect(proxy1), expected0);
-assert.strictEqual(util.inspect(proxy2), expected0);
-assert.strictEqual(util.inspect(proxy3), expected0);
-assert.strictEqual(util.inspect(proxy4), expected0);
-assert.strictEqual(util.inspect(proxy5), expected0);
-assert.strictEqual(util.inspect(proxy6), expected0);
+assert.strictEqual(util.inspect(proxy2), expected2NoShowProxy);
+assert.strictEqual(util.inspect(proxy3), expected3NoShowProxy);
+assert.strictEqual(util.inspect(proxy4), expected2NoShowProxy);
+assert.strictEqual(util.inspect(proxy5), expected4NoShowProxy);
+assert.strictEqual(util.inspect(proxy6), expected5NoShowProxy);
 
 // Just for fun, let's create a Proxy using Arrays.
 const proxy7 = new Proxy([], []);
@@ -188,3 +196,24 @@ assert.strictEqual(
     ')\x1B[39m'
 );
 assert.strictEqual(util.format('%s', proxy12), 'Proxy([ 1, 2, 3 ])');
+
+{
+  // Nested proxies should not trigger any proxy handlers.
+  const nestedProxy = new Proxy(new Proxy(new Proxy({}, handler), {}), {});
+
+  assert.strictEqual(
+    util.inspect(nestedProxy, { showProxy: true }),
+    'Proxy [ Proxy [ Proxy [ {}, [Object] ], {} ], {} ]'
+  );
+  assert.strictEqual(util.inspect(nestedProxy, { showProxy: false }), expected3NoShowProxy);
+}
+
+{
+  // Nested revoked proxies should work as expected as well as custom inspection functions.
+  const revocable = Proxy.revocable({}, handler);
+  revocable.revoke();
+  const nestedProxy = new Proxy(revocable.proxy, {});
+
+  assert.strictEqual(util.inspect(nestedProxy, { showProxy: true }), 'Proxy [ <Revoked Proxy>, {} ]');
+  assert.strictEqual(util.inspect(nestedProxy, { showProxy: false }), 'Proxy(<Revoked Proxy>)');
+}