Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 170 additions & 1 deletion src/node_blob.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@
#include "env-inl.h"
#include "memory_tracker-inl.h"
#include "node_bob-inl.h"
#include "node_buffer.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_file.h"
#include "path.h"
#include "permission/permission.h"
#include "util.h"
#include "uv.h"
#include "v8.h"

#include <algorithm>
#include <cstring>
#include <string>

#ifdef _WIN32
#include <windows.h>
#endif

namespace node {

Expand Down Expand Up @@ -104,15 +112,176 @@ void Concat(const FunctionCallbackInfo<Value>& args) {
args.GetReturnValue().Set(ArrayBuffer::New(isolate, std::move(store)));
}

// uv_cwd() fails closed: a relative path is left unchanged rather than pinned
// to exec_path, which would select a different file.
std::string GetProcessCwd() {
size_t size = PATH_MAX_BYTES;
std::string buf(size, '\0');
int err = uv_cwd(buf.data(), &size);
if (err == UV_ENOBUFS) {
buf.assign(size, '\0');
err = uv_cwd(buf.data(), &size);
}
if (err != 0) return {};
buf.resize(strlen(buf.data()));
return buf;
}

#ifdef _WIN32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On Windows, could we just store the path already resolved by ToNamespacedPath() instead of adding all this?

std::string WidePathToWtf8(const wchar_t* wide, size_t chars) {
const uint16_t* src = reinterpret_cast<const uint16_t*>(wide);
const ssize_t src_len = static_cast<ssize_t>(chars);
const size_t bytes = uv_utf16_length_as_wtf8(src, src_len);
// uv_utf16_to_wtf8 writes a trailing NUL past the byte length.
std::string out(bytes + 1, '\0');
char* ptr = out.data();
size_t len = bytes;
if (uv_utf16_to_wtf8(src, src_len, &ptr, &len) != 0) return {};
out.resize(len);
return out;
}

std::string DriveRoot(char letter) {
std::string root;
root.push_back(letter);
root.append(":\\");
return root;
}

// Current directory of one drive, without the caller's relative suffix.
// GetFullPathNameW("C:") is the directory CreateFileW uses for "C:foo".
std::string DirectoryOnDrive(char letter) {
const wchar_t spec[3] = {static_cast<wchar_t>(letter), L':', L'\0'};
constexpr DWORD kPathChars = 32768;
std::wstring wide(kPathChars, L'\0');
const DWORD written =
GetFullPathNameW(spec, kPathChars, wide.data(), nullptr);
if (written == 0 || written >= kPathChars) return DriveRoot(letter);
std::string converted = WidePathToWtf8(wide.data(), written);
if (converted.empty()) return DriveRoot(letter);
return converted;
}

// "C:" or "\\server\share" (or the \\?\ equivalent) from an absolute path.
std::string VolumePrefix(std::string_view absolute) {
if (absolute.size() >= 2 && IsWindowsDeviceRoot(absolute[0]) &&
absolute[1] == ':') {
return std::string(absolute.substr(0, 2));
}
if (absolute.size() < 2 || !IsPathSeparator(absolute[0]) ||
!IsPathSeparator(absolute[1])) {
return {};
}

size_t component_end = 2;
while (component_end < absolute.size() &&
!IsPathSeparator(absolute[component_end])) {
component_end++;
}
if (component_end >= absolute.size()) return std::string(absolute);
const std::string_view first = absolute.substr(2, component_end - 2);
size_t prefix_end = component_end;
if (first == "?" || first == ".") {
const size_t after = component_end + 1;
const bool unc =
after + 3 <= absolute.size() &&
(absolute.substr(after, 3) == "UNC" ||
absolute.substr(after, 3) == "unc") &&
(after + 3 == absolute.size() || IsPathSeparator(absolute[after + 3]));
size_t cursor = unc ? after + 4 : after;
const int parts = unc ? 2 : 1;
for (int part = 0; part < parts; part++) {
while (cursor < absolute.size() && !IsPathSeparator(absolute[cursor])) {
cursor++;
}
prefix_end = cursor;
if (part + 1 < parts) cursor++;
}
} else {
size_t cursor = component_end + 1;
while (cursor < absolute.size() && !IsPathSeparator(absolute[cursor])) {
cursor++;
}
prefix_end = cursor;
}
return std::string(absolute.substr(0, prefix_end));
}

std::string JoinAbsolute(std::string_view prefix, std::string_view rest) {
std::string out(prefix);
if (rest.empty()) return out;
const bool prefix_sep = !prefix.empty() && IsPathSeparator(prefix.back());
const bool rest_sep = IsPathSeparator(rest.front());
if (!prefix_sep && !rest_sep) out.push_back('\\');
if (prefix_sep && rest_sep) rest.remove_prefix(1);
out.append(rest);
return out;
}

bool HasAbsoluteBlobRoot(std::string_view path) {
if (path.size() >= 2 && IsPathSeparator(path[0]) &&
IsPathSeparator(path[1])) {
return true;
}
return path.size() >= 3 && IsWindowsDeviceRoot(path[0]) && path[1] == ':' &&
IsPathSeparator(path[2]);
}
#endif // _WIN32

// Make a path independent of later process.chdir() calls without removing
// "." or "..". Lexical normalization would resolve ".." before symlinks and
// could open a different file than the path the caller passed.
std::string PinBlobBackingPath(std::string_view path) {
if (path.empty()) return std::string(path);
#ifdef _WIN32
if (HasAbsoluteBlobRoot(path)) return std::string(path);
if (path.size() >= 2 && IsWindowsDeviceRoot(path[0]) && path[1] == ':') {
return JoinAbsolute(DirectoryOnDrive(path[0]), path.substr(2));
}
if (IsPathSeparator(path[0])) {
const std::string volume = VolumePrefix(GetProcessCwd());
if (volume.empty()) return std::string(path);
return JoinAbsolute(volume, path);
}
const char separator = '\\';
#else
if (path[0] == '/') return std::string(path);
const char separator = '/';
#endif
const std::string cwd = GetProcessCwd();
if (cwd.empty()) return std::string(path);
std::string pinned;
pinned.reserve(cwd.size() + path.size() + 1);
pinned.append(cwd);
if (!IsPathSeparator(cwd.back())) pinned.push_back(separator);
pinned.append(path);
return pinned;
}

void BlobFromFilePath(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
BufferValue path(env->isolate(), args[0]);
CHECK_NOT_NULL(*path);
// Snapshot the caller bytes before ToNamespacedPath() rewrites `path` on
// Windows. Permission checks and stat errors keep using that rewritten
// buffer, so their diagnostics stay the same.
const std::string original(path.out(), path.length());
ToNamespacedPath(env, &path);
THROW_IF_INSUFFICIENT_PERMISSIONS(
env, permission::PermissionScope::kFileSystemRead, path.ToStringView());

const std::string pinned = PinBlobBackingPath(original);
Local<Value> stored_path = args[0];
if (pinned != original) {
// A V8 string would UTF-8-decode a POSIX Buffer path. Copy the raw bytes.
if (!Buffer::Copy(env->isolate(), pinned.data(), pinned.size())
.ToLocal(&stored_path)) {
return;
}
}

int status = 0;
auto entry = DataQueue::CreateFdEntry(env, args[0], &status);
auto entry = DataQueue::CreateFdEntry(env, stored_path, &status);
if (entry == nullptr) {
// The file could not be stat'd. Report the libuv error so callers can tell
// ENOENT apart from any other reason the path could not be used.
Expand Down
160 changes: 160 additions & 0 deletions test/parallel/test-fs-openAsBlob-chdir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
'use strict';

// A file-backed Blob must keep reading the file it was opened against after
// process.chdir(). a/x and b/x share a size and a full mtime, so the stat
// guard cannot tell them apart: a cwd-relative reopen reads the wrong file.
// Paths are not lexically normalized, so ".." through a symlink must keep
// selecting the same file fs.readFileSync() selected at creation time.
// Refs: https://github.com/nodejs/node/issues/67282

const common = require('../common');
const assert = require('node:assert');
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { pathToFileURL } = require('node:url');

if (process.argv[2] !== 'child') {
const child = spawnSync(process.execPath, [__filename, 'child'], {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this to avoid the worker thread from the test harness? You can skip instead. It's not necessary to test this under worker mode.

if (!common.isMainThread)
  common.skip('process.chdir is not available in Workers');

encoding: 'utf8',
});
const output = `${child.stdout ?? ''}${child.stderr ?? ''}`;
assert.strictEqual(child.status, 0, output);

Check failure on line 23 in test/parallel/test-fs-openAsBlob-chdir.js

View workflow job for this annotation

GitHub Actions / test-macOS

--- stderr --- node:internal/assert/utils:146 throw error; ^ AssertionError [ERR_ASSERTION]: node:fs:3028 return binding.writeFileUtf8( ^ Error: EILSEQ: illegal byte sequence, open '/var/folders/2s/vty41l455yv698g4xkgfv7z00000gn/T/openasblob-chdir-4hOPJT/a/f��' at Object.writeFileSync (node:fs:3028:20) at /Users/runner/work/node/node/node/test/parallel/test-fs-openAsBlob-chdir.js:137:12 { errno: -92, code: 'EILSEQ', syscall: 'open', path: '/var/folders/2s/vty41l455yv698g4xkgfv7z00000gn/T/openasblob-chdir-4hOPJT/a/f��' } Node.js v28.0.0-pre 1 !== 0 at Object.<anonymous> (/Users/runner/work/node/node/node/test/parallel/test-fs-openAsBlob-chdir.js:23:10) at Module._compile (node:internal/modules/cjs/loader:1968:14) at Object..js (node:internal/modules/cjs/loader:2108:10) at Module.load (node:internal/modules/cjs/loader:1690:32) at Module._load (node:internal/modules/cjs/loader:1480:12) at wrapModuleLoad (node:internal/modules/cjs/loader:261:19) at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:173:5) at node:internal/main/run_main_module:33:47 { generatedMessage: false, code: 'ERR_ASSERTION', actual: 1, expected: 0, operator: 'strictEqual', diff: 'simple' } Node.js v28.0.0-pre Command: out/Release/node /Users/runner/work/node/node/node/test/parallel/test-fs-openAsBlob-chdir.js

Check failure on line 23 in test/parallel/test-fs-openAsBlob-chdir.js

View workflow job for this annotation

GitHub Actions / x86_64-darwin: with shared libraries / build

--- stderr --- node:internal/assert/utils:146 throw error; ^ AssertionError [ERR_ASSERTION]: node:fs:3028 return binding.writeFileUtf8( ^ Error: EILSEQ: illegal byte sequence, open '/private/tmp/nix-shell-2365-4173697116/openasblob-chdir-dFmSZu/a/f��' at Object.writeFileSync (node:fs:3028:20) at /Users/runner/work/_temp/node-v28.0.0-nightly2026-11-09cb7e0783b2-slim/test/parallel/test-fs-openAsBlob-chdir.js:137:12 { errno: -92, code: 'EILSEQ', syscall: 'open', path: '/private/tmp/nix-shell-2365-4173697116/openasblob-chdir-dFmSZu/a/f��' } Node.js v28.0.0-pre 1 !== 0 at Object.<anonymous> (/Users/runner/work/_temp/node-v28.0.0-nightly2026-11-09cb7e0783b2-slim/test/parallel/test-fs-openAsBlob-chdir.js:23:10) at Module._compile (node:internal/modules/cjs/loader:1968:14) at Object..js (node:internal/modules/cjs/loader:2108:10) at Module.load (node:internal/modules/cjs/loader:1690:32) at Module._load (node:internal/modules/cjs/loader:1480:12) at wrapModuleLoad (node:internal/modules/cjs/loader:261:19) at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:173:5) at node:internal/main/run_main_module:33:47 { generatedMessage: false, code: 'ERR_ASSERTION', actual: 1, expected: 0, operator: 'strictEqual', diff: 'simple' } Node.js v28.0.0-pre Command: out/Release/node /Users/runner/work/_temp/node-v28.0.0-nightly2026-11-09cb7e0783b2-slim/test/parallel/test-fs-openAsBlob-chdir.js

Check failure on line 23 in test/parallel/test-fs-openAsBlob-chdir.js

View workflow job for this annotation

GitHub Actions / aarch64-darwin: with shared libraries / build

--- stderr --- node:internal/assert/utils:146 throw error; ^ AssertionError [ERR_ASSERTION]: node:fs:3028 return binding.writeFileUtf8( ^ Error: EILSEQ: illegal byte sequence, open '/private/tmp/nix-shell-1992-2846774696/openasblob-chdir-gU1aCa/a/f��' at Object.writeFileSync (node:fs:3028:20) at /Users/runner/work/_temp/node-v28.0.0-nightly2026-11-09cb7e0783b2-slim/test/parallel/test-fs-openAsBlob-chdir.js:137:12 { errno: -92, code: 'EILSEQ', syscall: 'open', path: '/private/tmp/nix-shell-1992-2846774696/openasblob-chdir-gU1aCa/a/f��' } Node.js v28.0.0-pre 1 !== 0 at Object.<anonymous> (/Users/runner/work/_temp/node-v28.0.0-nightly2026-11-09cb7e0783b2-slim/test/parallel/test-fs-openAsBlob-chdir.js:23:10) at Module._compile (node:internal/modules/cjs/loader:1968:14) at Object..js (node:internal/modules/cjs/loader:2108:10) at Module.load (node:internal/modules/cjs/loader:1690:32) at Module._load (node:internal/modules/cjs/loader:1480:12) at wrapModuleLoad (node:internal/modules/cjs/loader:261:19) at Module.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:173:5) at node:internal/main/run_main_module:33:47 { generatedMessage: false, code: 'ERR_ASSERTION', actual: 1, expected: 0, operator: 'strictEqual', diff: 'simple' } Node.js v28.0.0-pre Command: out/Release/node /Users/runner/work/_temp/node-v28.0.0-nightly2026-11-09cb7e0783b2-slim/test/parallel/test-fs-openAsBlob-chdir.js
} else {
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openasblob-chdir-'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The convention is to use common/tmpdir

const originalCwd = process.cwd();
try {
const dirA = path.join(root, 'a');
const dirB = path.join(root, 'b');
const fileA = path.join(dirA, 'x');
const fileB = path.join(dirB, 'x');
fs.mkdirSync(dirA);
fs.mkdirSync(dirB);
fs.writeFileSync(fileA, 'from-a');
fs.writeFileSync(fileB, 'from-b');
const stamp = 1_700_000_000;
fs.utimesSync(fileA, stamp, stamp);
fs.utimesSync(fileB, stamp, stamp);
const statA = fs.statSync(fileA, { bigint: true });
const statB = fs.statSync(fileB, { bigint: true });
assert.strictEqual(statA.size, statB.size);
assert.strictEqual(statA.mtimeNs, statB.mtimeNs);

const symDir = path.join(root, 'sym');
let symlinkCase = null;
if (common.canCreateSymLink()) {
fs.mkdirSync(path.join(symDir, 'target', 'sub'), { recursive: true });
fs.writeFileSync(path.join(symDir, 'target', 'file'), 'via-symlink');
fs.writeFileSync(path.join(symDir, 'file'), 'via-lexical');
fs.symlinkSync(path.join('target', 'sub'), path.join(symDir, 'link'), 'dir');
symlinkCase = true;
}

const blobs = [];
const track = async (open, expected) => {
blobs.push({ blob: await open(), expected });
};
async function flush() {
const pending = blobs.splice(0, blobs.length);
for (const item of pending) {
assert.strictEqual(await item.blob.text(), item.expected);
assert.strictEqual(await item.blob.text(), item.expected);
const head = item.blob.slice(0, 4);
assert.strictEqual(await head.text(), item.expected.slice(0, 4));
assert.strictEqual(
await item.blob.slice(4).text(),
item.expected.slice(4),
);
assert.strictEqual(await head.slice(1, 3).text(), item.expected.slice(1, 3));
}
}

process.chdir(dirA);
// POSIX reports the path the caller passed. Windows reports the
// namespaced path with the long-path prefix removed, which is the
// resolved absolute path.
const missing = 'no-such-blob-file';
const missingPath = common.isWindows ? path.resolve(missing) : missing;
await assert.rejects(async () => fs.openAsBlob(missing), {
code: 'ENOENT',
syscall: 'stat',
path: missingPath,
});
assert.throws(() => fs.openAsBlobSync(missing), {
code: 'ENOENT',
syscall: 'stat',
path: missingPath,
});

await track(() => fs.openAsBlob('x'), 'from-a');
await track(() => fs.openAsBlob(Buffer.from('x')), 'from-a');
await track(() => fs.openAsBlobSync('x'), 'from-a');
await track(() => fs.openAsBlobSync(Buffer.from('x')), 'from-a');
await track(() => fs.openAsBlob(fileA), 'from-a');
await track(() => fs.openAsBlobSync(Buffer.from(fileA)), 'from-a');
await track(() => fs.openAsBlob(pathToFileURL(fileA)), 'from-a');

if (common.isWindows) {
const letter = path.parse(fileA).root[0];
const driveRelative = `${letter}:x`;
await track(() => fs.openAsBlob(driveRelative), 'from-a');
await track(() => fs.openAsBlobSync(driveRelative), 'from-a');
const namespaced = path.toNamespacedPath(fileA);
await track(() => fs.openAsBlob(namespaced), 'from-a');
await track(() => fs.openAsBlobSync(Buffer.from(namespaced)), 'from-a');
}

// Read while cwd is b. b/x matches a/x in size and full mtime.
process.chdir(dirB);
await flush();

await track(() => fs.openAsBlob(path.join('..', 'a', 'x')), 'from-a');
await track(() => fs.openAsBlobSync(path.join('..', 'a', 'x')), 'from-a');
process.chdir(originalCwd);
await flush();

if (symlinkCase) {
process.chdir(symDir);
// path.join() would collapse ".." before the symlink is traversed.
const relativeLink = ['link', '..', 'file'].join(path.sep);
const atCreation = fs.readFileSync(relativeLink, 'utf8');
const lexical = fs.readFileSync(path.resolve(relativeLink), 'utf8');
if (!common.isWindows) {
assert.notStrictEqual(atCreation, lexical);
}
await track(() => fs.openAsBlob(relativeLink), atCreation);
await track(() => fs.openAsBlobSync(relativeLink), atCreation);
process.chdir(dirB);
await flush();
}

if (!common.isWindows) {
const rawName = Buffer.from([0x66, 0xff, 0xfe]);
const rawA = Buffer.concat([Buffer.from(`${dirA}/`), rawName]);
const rawB = Buffer.concat([Buffer.from(`${dirB}/`), rawName]);
fs.writeFileSync(rawA, 'raw-a');
fs.writeFileSync(rawB, 'raw-b');
fs.utimesSync(rawA, stamp, stamp);
fs.utimesSync(rawB, stamp, stamp);
const rawStatA = fs.statSync(rawA, { bigint: true });
const rawStatB = fs.statSync(rawB, { bigint: true });
assert.strictEqual(rawStatA.size, rawStatB.size);
assert.strictEqual(rawStatA.mtimeNs, rawStatB.mtimeNs);
process.chdir(dirA);
await track(() => fs.openAsBlob(rawName), 'raw-a');
await track(() => fs.openAsBlobSync(rawName), 'raw-a');
process.chdir(dirB);
await flush();
}
} finally {
try {
process.chdir(originalCwd);
} catch {
// Removal still runs when the original directory cannot be restored.
}
fs.rmSync(root, { recursive: true, force: true });
}
})().then(common.mustCall());
}
Loading