From 0e2fc3c640d5beeea0bf073d74c119cb28b8a9d7 Mon Sep 17 00:00:00 2001 From: Ihar Ivaniuk Date: Sun, 8 Nov 2026 00:07:45 +0100 Subject: [PATCH] fs: keep openAsBlob paths stable across chdir openAsBlob() and openAsBlobSync() stored the caller path and reopened it on every read. A relative path was resolved against the cwd again, so process.chdir() could read a different file of the same size and mtime, or fail the read. Pin the directory the path was opened against when the blob is created, without lexically collapsing ".." so symlink traversal stays the same. Signed-off-by: Ihar Ivaniuk --- src/node_blob.cc | 171 +++++++++++++++++++++- test/parallel/test-fs-openAsBlob-chdir.js | 160 ++++++++++++++++++++ 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-fs-openAsBlob-chdir.js diff --git a/src/node_blob.cc b/src/node_blob.cc index ab4267fb81f0..9bc74117f30c 100644 --- a/src/node_blob.cc +++ b/src/node_blob.cc @@ -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 +#include +#include + +#ifdef _WIN32 +#include +#endif namespace node { @@ -104,15 +112,176 @@ void Concat(const FunctionCallbackInfo& 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 +std::string WidePathToWtf8(const wchar_t* wide, size_t chars) { + const uint16_t* src = reinterpret_cast(wide); + const ssize_t src_len = static_cast(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(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& 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 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. diff --git a/test/parallel/test-fs-openAsBlob-chdir.js b/test/parallel/test-fs-openAsBlob-chdir.js new file mode 100644 index 000000000000..37aeee5ca527 --- /dev/null +++ b/test/parallel/test-fs-openAsBlob-chdir.js @@ -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'], { + encoding: 'utf8', + }); + const output = `${child.stdout ?? ''}${child.stderr ?? ''}`; + assert.strictEqual(child.status, 0, output); +} else { + (async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openasblob-chdir-')); + 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()); +}