diff --git a/src/node_blob.cc b/src/node_blob.cc index ab4267fb81f..9bc74117f30 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 00000000000..37aeee5ca52 --- /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()); +}