Commit aead708af41 for nodejs

commit aead708af41d3e83ef0eefd4096860609618f477
Author: Filip Skokan <panva.ip@gmail.com>
Date:   Thu Oct 15 19:20:43 2026 +0200

    buffer: align Blob and File with Web IDL

    Convert constructor arguments in Web IDL order before processing blob
    parts, including inherited dictionary members and File.lastModified's
    long long conversion.

    Read buffer slots and copy bytes without invoking user getters, slice
    methods, or species constructors. Treat detached parts as empty and
    track Blob brands without invoking user getters or accepting proxies.

    Keep rejecting bare SharedArrayBuffers and shared-memory views for
    browser compatibility pending resolution of the union conversion rules.

    Refs: https://github.com/whatwg/webidl/issues/1491
    Signed-off-by: Filip Skokan <panva.ip@gmail.com>
    Assisted-by: Codex
    PR-URL: https://github.com/nodejs/node/pull/66776
    Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
    Reviewed-By: Aviv Keller <me@aviv.sh>
    Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day>
    Reviewed-By: James M Snell <jasnell@gmail.com>

diff --git a/lib/internal/blob.js b/lib/internal/blob.js
index 6d359c5adf7..aea19d6e747 100644
--- a/lib/internal/blob.js
+++ b/lib/internal/blob.js
@@ -1,7 +1,12 @@
 'use strict';

 const {
+  ArrayBufferPrototypeGetByteLength,
+  ArrayBufferPrototypeGetDetached,
   ArrayPrototypePush,
+  DataViewPrototypeGetBuffer,
+  DataViewPrototypeGetByteLength,
+  DataViewPrototypeGetByteOffset,
   MathMax,
   MathMin,
   ObjectDefineProperties,
@@ -12,10 +17,14 @@ const {
   PromiseWithResolvers,
   RegExpPrototypeExec,
   RegExpPrototypeSymbolReplace,
+  SafeWeakSet,
   StringPrototypeSplit,
   StringPrototypeToLowerCase,
   Symbol,
   SymbolToStringTag,
+  TypedArrayPrototypeGetBuffer,
+  TypedArrayPrototypeGetByteLength,
+  TypedArrayPrototypeGetByteOffset,
   Uint8Array,
 } = primordials;

@@ -43,7 +52,9 @@ const {

 const {
   isAnyArrayBuffer,
+  isArrayBuffer,
   isArrayBufferView,
+  isDataView,
 } = require('internal/util/types');

 const {
@@ -55,22 +66,19 @@ const {
 const { inspect } = require('internal/util/inspect');
 const {
   converters,
+  createDictionaryConverter,
+  createEnumConverter,
   createSequenceConverter,
 } = require('internal/webidl');

 const {
   codes: {
     ERR_BUFFER_TOO_LARGE,
-    ERR_INVALID_ARG_VALUE,
     ERR_INVALID_STATE,
     ERR_INVALID_THIS,
   },
 } = require('internal/errors');

-const {
-  validateDictionary,
-} = require('internal/validators');
-
 const {
   setImmediate,
 } = require('timers');
@@ -81,6 +89,8 @@ const kHandle = Symbol('kHandle');
 const kType = Symbol('kType');
 const kLength = Symbol('kLength');
 const kNotCloneable = Symbol('kNotCloneable');
+const kAlreadyConverted = Symbol('kAlreadyConverted');
+const blobBrand = new SafeWeakSet();

 const disallowedTypeCharacters = /[^\u{0020}-\u{007E}]/u;

@@ -110,16 +120,14 @@ function lazyTextDecoderStream() {
 const { EOL } = require('internal/constants');

 function isBlob(object) {
-  return object?.[kHandle] !== undefined;
+  return blobBrand.has(object);
 }

 function getSource(source, endings) {
   if (isBlob(source))
-    return [source.size, source[kHandle]];
+    return [source[kLength], source[kHandle]];

-  if (isAnyArrayBuffer(source)) {
-    source = new Uint8Array(source);
-  } else if (!isArrayBufferView(source)) {
+  if (typeof source === 'string') {
     if (endings === 'native')
       source = RegExpPrototypeSymbolReplace(/\r\n|\r|\n/g, source, EOL);
     source = enc.encode(source);
@@ -128,21 +136,49 @@ function getSource(source, endings) {
   // We copy into a new Uint8Array because the underlying
   // BackingStores are going to be detached and owned by
   // the Blob.
-  const { buffer, byteOffset, byteLength } = source;
-  const slice = buffer.slice(byteOffset, byteOffset + byteLength);
-  return [byteLength, new Uint8Array(slice)];
+  const isBuffer = isArrayBuffer(source);
+  const isDataViewSource = !isBuffer && isDataView(source);
+  const buffer = isBuffer ? source :
+    isDataViewSource ? DataViewPrototypeGetBuffer(source) : TypedArrayPrototypeGetBuffer(source);
+  // Check before reading byteOffset and byteLength, whose DataView getters
+  // throw when the backing buffer is detached.
+  if (ArrayBufferPrototypeGetDetached(buffer))
+    return [0, new Uint8Array()];
+  const byteOffset = isBuffer ? 0 :
+    isDataViewSource ? DataViewPrototypeGetByteOffset(source) : TypedArrayPrototypeGetByteOffset(source);
+  const byteLength = isBuffer ? ArrayBufferPrototypeGetByteLength(source) :
+    isDataViewSource ? DataViewPrototypeGetByteLength(source) : TypedArrayPrototypeGetByteLength(source);
+  const copy = new Uint8Array(new Uint8Array(buffer, byteOffset, byteLength));
+  return [byteLength, copy];
 }

-const sourcesConverter = createSequenceConverter((source, opts = kEmptyObject) => {
+const blobPartsConverter = createSequenceConverter((source, opts = kEmptyObject) => {
   if (isBlob(source)) {
     return source;
   }
+  // Match browsers for bare SharedArrayBuffer pending a resolution of
+  // https://github.com/whatwg/webidl/issues/1491.
   if (isAnyArrayBuffer(source) || isArrayBufferView(source)) {
     return converters.BufferSource(source, opts);
   }
-  return converters.DOMString(source, opts);
+  return converters.USVString(source, opts);
 });

+const blobPropertyBag = [
+  {
+    key: 'endings',
+    converter: createEnumConverter('EndingType', ['transparent', 'native']),
+    defaultValue: () => 'transparent',
+  },
+  {
+    key: 'type',
+    converter: converters.DOMString,
+    defaultValue: () => '',
+  },
+];
+
+const blobPropertyBagConverter = createDictionaryConverter('BlobPropertyBag', blobPropertyBag);
+
 class Blob {
   /**
    * @typedef {string|ArrayBuffer|ArrayBufferView|Blob} SourcePart
@@ -156,40 +192,38 @@ class Blob {
    * }} [options]
    * @constructs Blob
    */
-  constructor(sources = [], options) {
+  constructor(sources = [], options, alreadyConverted) {
     markTransferMode(this, true, false);

-    const sources_ = sourcesConverter(sources, {
-      __proto__: null,
-      context: 'sources',
-    });
-
-    validateDictionary(options, 'options');
-    let {
-      endings = 'transparent',
-      type = '',
-    } = options ?? kEmptyObject;
-
-    endings = `${endings}`;
-    if (endings !== 'transparent' && endings !== 'native')
-      throw new ERR_INVALID_ARG_VALUE('options.endings', endings);
+    // File converts all of its arguments before entering the Blob constructor.
+    if (alreadyConverted !== kAlreadyConverted) {
+      sources = blobPartsConverter(sources, {
+        __proto__: null,
+        context: 'sources',
+      });
+      options = blobPropertyBagConverter(options, {
+        __proto__: null,
+        context: 'options',
+      });
+    }
+    const { endings, type } = options;

     let length = 0;
-    for (let i = 0; i < sources_.length; ++i) {
-      const { 0: len, 1: src } = getSource(sources_[i], endings);
+    for (let i = 0; i < sources.length; ++i) {
+      const { 0: len, 1: src } = getSource(sources[i], endings);
       length += len;
-      sources_[i] = src;
+      sources[i] = src;
     }

     if (length > kMaxLength)
       throw new ERR_BUFFER_TOO_LARGE(kMaxLength);

-    this[kHandle] = _createBlob(sources_, length);
+    this[kHandle] = _createBlob(sources, length);
     this[kLength] = length;

-    type = `${type}`;
     this[kType] = RegExpPrototypeExec(disallowedTypeCharacters, type) !== null ?
       '' : StringPrototypeToLowerCase(type);
+    blobBrand.add(this);
   }

   [kInspect](depth, options) {
@@ -226,6 +260,7 @@ class Blob {
     this[kHandle] = handle;
     this[kType] = type;
     this[kLength] = length;
+    blobBrand.add(this);
   }

   /**
@@ -357,6 +392,7 @@ function TransferableBlob(handle, length, type = '') {
   this[kHandle] = handle;
   this[kType] = type;
   this[kLength] = length;
+  blobBrand.add(this);
 }

 ObjectSetPrototypeOf(TransferableBlob.prototype, Blob.prototype);
@@ -701,12 +737,15 @@ async function* createBlobReaderIterable(reader, options = kEmptyObject) {

 module.exports = {
   Blob,
+  blobPartsConverter,
+  blobPropertyBag,
   createBlob,
   createBlobFromFilePath,
   createBlobReaderIterable,
   createBlobReaderStream,
   getBlobDataSync,
   isBlob,
+  kAlreadyConverted,
   kHandle,
   kType,
   resolveObjectURL,
diff --git a/lib/internal/file.js b/lib/internal/file.js
index aa05a602975..befbb4e11a3 100644
--- a/lib/internal/file.js
+++ b/lib/internal/file.js
@@ -3,19 +3,25 @@
 const {
   DateNow,
   FunctionPrototypeCall,
-  NumberIsNaN,
   ObjectDefineProperties,
   ObjectSetPrototypeOf,
-  StringPrototypeToWellFormed,
   Symbol,
   SymbolToStringTag,
 } = primordials;

 const {
   Blob,
+  blobPartsConverter,
+  blobPropertyBag,
+  kAlreadyConverted,
   TransferableBlob,
 } = require('internal/blob');

+const {
+  converters,
+  createDictionaryConverter,
+} = require('internal/webidl');
+
 const {
   customInspectSymbol: kInspect,
   kEnumerableProperty,
@@ -40,6 +46,11 @@ const {

 const kState = Symbol('state');

+const filePropertyBagConverter = createDictionaryConverter('FilePropertyBag', [
+  blobPropertyBag,
+  [{ key: 'lastModified', converter: converters['long long'] }],
+]);
+
 function isFile(object) {
   return object?.[kState] !== undefined;
 }
@@ -64,22 +75,13 @@ class File extends Blob {
       throw new ERR_MISSING_ARGS('fileBits', 'fileName');
     }

-    super(fileBits, options);
+    fileBits = blobPartsConverter(fileBits, { __proto__: null, context: 'fileBits' });
+    fileName = converters.USVString(fileName, { __proto__: null, context: 'fileName' });
+    options = filePropertyBagConverter(options, { __proto__: null, context: 'options' });

-    let { lastModified } = options ?? kEmptyObject;
-
-    if (lastModified !== undefined) {
-      // Using Number(...) will not throw an error for bigints.
-      lastModified = +lastModified;
-
-      if (NumberIsNaN(lastModified)) {
-        lastModified = 0;
-      }
-    } else {
-      lastModified = DateNow();
-    }
+    super(fileBits, options, kAlreadyConverted);

-    this[kState] = new FileState(StringPrototypeToWellFormed(`${fileName}`), lastModified);
+    this[kState] = new FileState(fileName, options.lastModified ?? DateNow());
   }

   get name() {
diff --git a/test/parallel/test-blob-detached-buffer.js b/test/parallel/test-blob-detached-buffer.js
new file mode 100644
index 00000000000..ed3ae2209d3
--- /dev/null
+++ b/test/parallel/test-blob-detached-buffer.js
@@ -0,0 +1,46 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert');
+const { Blob, File } = require('node:buffer');
+
+(async () => {
+  for (const create of [
+    (parts, options) => new Blob(parts, options),
+    (parts, options) => new File(parts, 'test', options),
+  ]) {
+    const buffer = new ArrayBuffer(16);
+    const parts = [
+      buffer,
+      new Uint8Array(buffer),
+      new Uint8Array(buffer, 4, 8),
+      new Uint16Array(buffer, 4, 4),
+      new DataView(buffer),
+      new DataView(buffer, 4, 8),
+      Buffer.from(buffer, 4, 8),
+    ];
+    buffer.transfer();
+
+    for (const part of parts) {
+      const empty = create([part]);
+      assert.strictEqual(empty.size, 0);
+      assert.deepStrictEqual(await empty.arrayBuffer(), new ArrayBuffer(0));
+
+      const mixed = create(['hello', part, 'world']);
+      assert.strictEqual(mixed.size, 10);
+      assert.strictEqual(await mixed.text(), 'helloworld');
+    }
+
+    // Options conversion can detach buffers after blob parts conversion.
+    const anotherBuffer = new ArrayBuffer(16);
+    const view = new DataView(anotherBuffer, 4, 8);
+    const blob = create([anotherBuffer, view, 'hello'], {
+      get endings() {
+        anotherBuffer.transfer();
+        return 'transparent';
+      },
+    });
+    assert.strictEqual(blob.size, 5);
+    assert.strictEqual(await blob.text(), 'hello');
+  }
+})().then(common.mustCall());
diff --git a/test/parallel/test-blob-file-webidl.js b/test/parallel/test-blob-file-webidl.js
new file mode 100644
index 00000000000..86b05bf7aa6
--- /dev/null
+++ b/test/parallel/test-blob-file-webidl.js
@@ -0,0 +1,221 @@
+'use strict';
+
+const common = require('../common');
+const assert = require('node:assert');
+const { Blob, File } = require('node:buffer');
+
+(async () => {
+  for (const create of [
+    (parts, options) => new Blob(parts, options),
+    (parts, options) => new File(parts, 'test', options),
+  ]) {
+    // Each dictionary member is converted before the next member is read.
+    const calls = [];
+    const blob = create([], {
+      get endings() {
+        calls.push('get endings');
+        return {
+          toString() {
+            calls.push('convert endings');
+            return 'transparent';
+          },
+        };
+      },
+      get type() {
+        calls.push('get type');
+        return {
+          toString() {
+            calls.push('convert type');
+            return 'TEXT/PLAIN';
+          },
+        };
+      },
+    });
+    assert.deepStrictEqual(calls, [
+      'get endings', 'convert endings', 'get type', 'convert type',
+    ]);
+    assert.strictEqual(blob.type, 'text/plain');
+
+    assert.throws(() => create([], {
+      endings: 'invalid',
+      get type() { return common.mustNotCall('type must not be read')(); },
+    }), { name: 'TypeError', code: 'ERR_INVALID_ARG_VALUE' });
+
+    // Dictionary conversion finishes before the input bytes are copied.
+    const bytes = new Uint8Array([65]);
+    const changed = create([bytes], {
+      type: {
+        toString() {
+          bytes[0] = 66;
+          return '';
+        },
+      },
+    });
+    assert.strictEqual(await changed.text(), 'B');
+
+    const detached = new Uint8Array([65]);
+    const empty = create([detached], {
+      type: {
+        toString() {
+          detached.buffer.transfer();
+          return '';
+        },
+      },
+    });
+    assert.strictEqual(empty.size, 0);
+
+    // BufferSource copying uses internal slots, not ordinary properties or
+    // ArrayBuffer.prototype.slice(), which consults Symbol.species.
+    for (const makePart of [
+      (buffer) => buffer,
+      (buffer) => new Uint8Array(buffer, 1, 2),
+      (buffer) => new DataView(buffer, 1, 2),
+      (buffer) => Buffer.from(buffer, 1, 2),
+    ]) {
+      const buffer = new Uint8Array([65, 66, 67, 68]).buffer;
+      const part = makePart(buffer);
+      if (part !== buffer) {
+        for (const property of ['buffer', 'byteOffset', 'byteLength']) {
+          Object.defineProperty(part, property, {
+            get: common.mustNotCall(`${property} must not be read`),
+          });
+        }
+      }
+      buffer.slice = common.mustNotCall('slice must not be called');
+      buffer.constructor = {
+        get [Symbol.species]() { return common.mustNotCall('species must not be read')(); },
+      };
+      const copied = create([part]);
+      assert.strictEqual(await copied.text(), part === buffer ? 'ABCD' : 'BC');
+    }
+
+    // Reject both bare shared buffers and views for browser compatibility.
+    for (const part of [
+      new SharedArrayBuffer(4),
+      new Uint8Array(new SharedArrayBuffer(4)),
+      new DataView(new SharedArrayBuffer(4)),
+    ]) {
+      assert.throws(() => create([part]), {
+        name: 'TypeError',
+        code: 'ERR_INVALID_ARG_TYPE',
+      });
+    }
+    for (const part of [
+      new ArrayBuffer(4, { maxByteLength: 8 }),
+      new Uint8Array(new ArrayBuffer(4, { maxByteLength: 8 })),
+      new DataView(new ArrayBuffer(4, { maxByteLength: 8 })),
+    ]) {
+      assert.throws(() => create([part]), TypeError);
+    }
+
+    const source = new Blob(['abc']);
+    Object.defineProperty(source, 'size', {
+      get: common.mustNotCall('size must not be read'),
+    });
+    assert.strictEqual(await create([source]).text(), 'abc');
+
+    const gets = [];
+    const proxy = new Proxy(source, {
+      get(target, key, receiver) {
+        gets.push(key);
+        return Reflect.get(target, key, receiver);
+      },
+    });
+    assert.strictEqual(await create([proxy]).text(), '[object Blob]');
+    assert.deepStrictEqual(gets, [Symbol.toPrimitive, 'toString', Symbol.toStringTag]);
+    assert.strictEqual(await create([{ __proto__: source }]).text(), '[object Blob]');
+  }
+
+  // File arguments are converted from left to right, with the inherited
+  // BlobPropertyBag members converted before FilePropertyBag.lastModified.
+  const calls = [];
+  const file = new File([{
+    toString() {
+      calls.push('part');
+      return 'x';
+    },
+  }], {
+    toString() {
+      calls.push('name');
+      return 'file\ud800';
+    },
+  }, {
+    get endings() {
+      calls.push('get endings');
+      return {
+        toString() {
+          calls.push('convert endings');
+          return 'transparent';
+        },
+      };
+    },
+    get type() {
+      calls.push('get type');
+      return {
+        toString() {
+          calls.push('convert type');
+          return 'TEXT/PLAIN';
+        },
+      };
+    },
+    get lastModified() {
+      calls.push('get lastModified');
+      return {
+        valueOf() {
+          calls.push('convert lastModified');
+          return 1.9;
+        },
+      };
+    },
+  });
+  assert.deepStrictEqual(calls, [
+    'part', 'name', 'get endings', 'convert endings', 'get type', 'convert type',
+    'get lastModified', 'convert lastModified',
+  ]);
+  assert.strictEqual(file.name, 'file\ufffd');
+  assert.strictEqual(file.type, 'text/plain');
+  assert.strictEqual(file.lastModified, 1);
+
+  assert.throws(() => new File(undefined, {
+    toString: common.mustNotCall('filename must not be converted'),
+  }), TypeError);
+  const nameError = new Error('filename conversion');
+  assert.throws(() => new File([], {
+    toString() { throw nameError; },
+  }, {
+    get endings() { return common.mustNotCall('options must not be read')(); },
+  }), nameError);
+
+  const bytes = new Uint8Array([65]);
+  const named = new File([bytes], {
+    toString() {
+      bytes[0] = 66;
+      return 'file';
+    },
+  });
+  assert.strictEqual(await named.text(), 'B');
+
+  const modified = new File([bytes], 'file', {
+    lastModified: {
+      valueOf() {
+        bytes[0] = 67;
+        return 1;
+      },
+    },
+  });
+  assert.strictEqual(await modified.text(), 'C');
+
+  for (const [lastModified, expected] of [
+    [1.9, 1],
+    [-1.9, -1],
+    [Infinity, 0],
+    [-Infinity, 0],
+    [NaN, 0],
+    [-0, 0],
+    [2 ** 63, -(2 ** 63)],
+    [2 ** 64, 0],
+    [null, 0],
+  ]) {
+    assert.strictEqual(new File([], '', { lastModified }).lastModified, expected);
+  }
+})().then(common.mustCall());