Skip to content

Commit e60f5d2

Browse files
authored
fix(ext/node): fs.mkdtemp and fs.mkdtempSync compatibility (#30602)
`fs.mkdtemp` and `fs.mkdtempSync` now accept `Buffer` and `Uint8Array` path. The implementation has been moved to Rust, including directory suffix generation and directory creation.
1 parent 71a74cb commit e60f5d2

File tree

6 files changed

+267
-100
lines changed

6 files changed

+267
-100
lines changed

ext/node/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ deno_core::extension!(deno_node,
357357
ops::fs::op_node_lchown<P>,
358358
ops::fs::op_node_lutimes_sync<P>,
359359
ops::fs::op_node_lutimes<P>,
360+
ops::fs::op_node_mkdtemp_sync<P>,
361+
ops::fs::op_node_mkdtemp<P>,
360362
ops::fs::op_node_open_sync<P>,
361363
ops::fs::op_node_open<P>,
362364
ops::fs::op_node_statfs<P>,

ext/node/ops/fs.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,3 +554,87 @@ where
554554
fs.lchmod_async(path.into_owned(), mode).await?;
555555
Ok(())
556556
}
557+
558+
#[op2(stack_trace)]
559+
#[string]
560+
pub fn op_node_mkdtemp_sync<P>(
561+
state: &mut OpState,
562+
#[string] path: &str,
563+
) -> Result<String, FsError>
564+
where
565+
P: NodePermissions + 'static,
566+
{
567+
// https://github.com/nodejs/node/blob/2ea31e53c61463727c002c2d862615081940f355/deps/uv/src/unix/os390-syscalls.c#L409
568+
for _ in 0..libc::TMP_MAX {
569+
let path = temp_path_append_suffix(path);
570+
let checked_path = state.borrow_mut::<P>().check_open(
571+
Cow::Borrowed(Path::new(&path)),
572+
OpenAccessKind::WriteNoFollow,
573+
Some("node:fs.mkdtempSync()"),
574+
)?;
575+
let fs = state.borrow::<FileSystemRc>();
576+
577+
match fs.mkdir_sync(&checked_path, false, Some(0o700)) {
578+
Ok(()) => return Ok(path),
579+
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
580+
continue;
581+
}
582+
Err(err) => return Err(FsError::Fs(err)),
583+
}
584+
}
585+
586+
Err(FsError::Io(std::io::Error::new(
587+
std::io::ErrorKind::AlreadyExists,
588+
"too many temp dirs exist",
589+
)))
590+
}
591+
592+
#[op2(async, stack_trace)]
593+
#[string]
594+
pub async fn op_node_mkdtemp<P>(
595+
state: Rc<RefCell<OpState>>,
596+
#[string] path: String,
597+
) -> Result<String, FsError>
598+
where
599+
P: NodePermissions + 'static,
600+
{
601+
// https://github.com/nodejs/node/blob/2ea31e53c61463727c002c2d862615081940f355/deps/uv/src/unix/os390-syscalls.c#L409
602+
for _ in 0..libc::TMP_MAX {
603+
let path = temp_path_append_suffix(&path);
604+
let (fs, checked_path) = {
605+
let mut state = state.borrow_mut();
606+
let checked_path = state.borrow_mut::<P>().check_open(
607+
Cow::Owned(PathBuf::from(path.clone())),
608+
OpenAccessKind::WriteNoFollow,
609+
Some("node:fs.mkdtemp()"),
610+
)?;
611+
(state.borrow::<FileSystemRc>().clone(), checked_path)
612+
};
613+
614+
match fs
615+
.mkdir_async(checked_path.into_owned(), false, Some(0o700))
616+
.await
617+
{
618+
Ok(()) => return Ok(path),
619+
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
620+
continue;
621+
}
622+
Err(err) => return Err(FsError::Fs(err)),
623+
}
624+
}
625+
626+
Err(FsError::Io(std::io::Error::new(
627+
std::io::ErrorKind::AlreadyExists,
628+
"too many temp dirs exist",
629+
)))
630+
}
631+
632+
fn temp_path_append_suffix(prefix: &str) -> String {
633+
use rand::Rng;
634+
use rand::distributions::Alphanumeric;
635+
use rand::rngs::OsRng;
636+
637+
let suffix: String =
638+
(0..6).map(|_| OsRng.sample(Alphanumeric) as char).collect();
639+
format!("{}{}", prefix, suffix)
640+
}
Lines changed: 110 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,134 +1,150 @@
11
// Copyright 2018-2025 the Deno authors. MIT license.
22
// Copyright Node.js contributors. All rights reserved. MIT License.
33

4-
import { TextDecoder, TextEncoder } from "ext:deno_web/08_text_encoding.js";
5-
import { existsSync } from "ext:deno_node/_fs/_fs_exists.ts";
6-
import { mkdir, mkdirSync } from "ext:deno_node/_fs/_fs_mkdir.ts";
7-
import { ERR_INVALID_OPT_VALUE_ENCODING } from "ext:deno_node/internal/errors.ts";
8-
import { promisify } from "ext:deno_node/internal/util.mjs";
4+
import { normalizeEncoding, promisify } from "ext:deno_node/internal/util.mjs";
95
import { primordials } from "ext:core/mod.js";
106
import { makeCallback } from "ext:deno_node/_fs/_fs_common.ts";
11-
12-
const {
13-
ObjectPrototypeIsPrototypeOf,
14-
Array,
15-
SafeArrayIterator,
16-
MathRandom,
17-
MathFloor,
18-
ArrayPrototypeJoin,
19-
ArrayPrototypeMap,
20-
ObjectPrototype,
21-
} = primordials;
22-
23-
export type mkdtempCallback = (
7+
import { Buffer } from "node:buffer";
8+
import {
9+
getValidatedPathToString,
10+
warnOnNonPortableTemplate,
11+
} from "ext:deno_node/internal/fs/utils.mjs";
12+
import {
13+
denoErrorToNodeError,
14+
ERR_INVALID_ARG_TYPE,
15+
} from "ext:deno_node/internal/errors.ts";
16+
import { op_node_mkdtemp, op_node_mkdtemp_sync } from "ext:core/ops";
17+
import type { Encoding } from "node:crypto";
18+
19+
const { PromisePrototypeThen } = primordials;
20+
21+
export type MkdtempCallback = (
2422
err: Error | null,
2523
directory?: string,
2624
) => void;
25+
export type MkdtempBufferCallback = (
26+
err: Error | null,
27+
directory?: Buffer<ArrayBufferLike>,
28+
) => void;
29+
type MkdTempPromise = (
30+
prefix: string | Buffer | Uint8Array | URL,
31+
options?: { encoding: string } | string,
32+
) => Promise<string>;
33+
type MkdTempPromiseBuffer = (
34+
prefix: string | Buffer | Uint8Array | URL,
35+
options: { encoding: "buffer" } | "buffer",
36+
) => Promise<Buffer<ArrayBufferLike>>;
2737

2838
// https://nodejs.org/dist/latest-v15.x/docs/api/fs.html#fs_fs_mkdtemp_prefix_options_callback
29-
export function mkdtemp(prefix: string, callback: mkdtempCallback): void;
3039
export function mkdtemp(
31-
prefix: string,
40+
prefix: string | Buffer | Uint8Array | URL,
41+
callback: MkdtempCallback,
42+
): void;
43+
export function mkdtemp(
44+
prefix: string | Buffer | Uint8Array | URL,
45+
options: { encoding: "buffer" } | "buffer",
46+
callback: MkdtempBufferCallback,
47+
): void;
48+
export function mkdtemp(
49+
prefix: string | Buffer | Uint8Array | URL,
3250
options: { encoding: string } | string,
33-
callback: mkdtempCallback,
51+
callback: MkdtempCallback,
3452
): void;
3553
export function mkdtemp(
36-
prefix: string,
37-
options: { encoding: string } | string | mkdtempCallback | undefined,
38-
callback?: mkdtempCallback,
54+
prefix: string | Buffer | Uint8Array | URL,
55+
options: { encoding: string } | string | MkdtempCallback | undefined,
56+
callback?: MkdtempCallback | MkdtempBufferCallback,
3957
) {
4058
if (typeof options === "function") {
4159
callback = options;
4260
options = undefined;
4361
}
4462
callback = makeCallback(callback);
45-
46-
const encoding: string | undefined = parseEncoding(options);
47-
const path = tempDirPath(prefix);
48-
49-
mkdir(
50-
path,
51-
{ recursive: false, mode: 0o700 },
52-
(err: Error | null | undefined) => {
53-
if (err) callback(err);
54-
else callback(null, decode(path, encoding));
55-
},
63+
const encoding = parseEncoding(options);
64+
prefix = getValidatedPathToString(prefix, "prefix");
65+
66+
warnOnNonPortableTemplate(prefix);
67+
68+
PromisePrototypeThen(
69+
op_node_mkdtemp(prefix),
70+
(path: string) => callback(null, decode(path, encoding)),
71+
(err: Error) =>
72+
callback(denoErrorToNodeError(err, {
73+
syscall: "mkdtemp",
74+
path: `${prefix}XXXXXX`,
75+
})),
5676
);
5777
}
5878

59-
export const mkdtempPromise = promisify(mkdtemp) as (
60-
prefix: string,
61-
options?: { encoding: string } | string,
62-
) => Promise<string>;
79+
export const mkdtempPromise = promisify(mkdtemp) as
80+
| MkdTempPromise
81+
| MkdTempPromiseBuffer;
6382

6483
// https://nodejs.org/dist/latest-v15.x/docs/api/fs.html#fs_fs_mkdtempsync_prefix_options
6584
export function mkdtempSync(
66-
prefix: string,
85+
prefix: string | Buffer | Uint8Array | URL,
86+
options?: { encoding: "buffer" } | "buffer",
87+
): Buffer<ArrayBufferLike>;
88+
export function mkdtempSync(
89+
prefix: string | Buffer | Uint8Array | URL,
6790
options?: { encoding: string } | string,
68-
): string {
69-
const encoding: string | undefined = parseEncoding(options);
70-
const path = tempDirPath(prefix);
91+
): string;
92+
export function mkdtempSync(
93+
prefix: string | Buffer | Uint8Array | URL,
94+
options?: { encoding: string } | string,
95+
): string | Buffer<ArrayBufferLike> {
96+
const encoding = parseEncoding(options);
97+
prefix = getValidatedPathToString(prefix, "prefix");
98+
99+
warnOnNonPortableTemplate(prefix);
100+
101+
try {
102+
const path = op_node_mkdtemp_sync(prefix) as string;
103+
return decode(path, encoding);
104+
} catch (err) {
105+
throw denoErrorToNodeError(err as Error, {
106+
syscall: "mkdtemp",
107+
path: `${prefix}XXXXXX`,
108+
});
109+
}
110+
}
71111

72-
mkdirSync(path, { recursive: false, mode: 0o700 });
73-
return decode(path, encoding);
112+
function decode(str: string, encoding: Encoding): string;
113+
function decode(str: string, encoding: "buffer"): Buffer<ArrayBufferLike>;
114+
function decode(
115+
str: string,
116+
encoding: Encoding | "buffer",
117+
): string | Buffer<ArrayBufferLike> {
118+
if (encoding === "utf8") return str;
119+
const buffer = Buffer.from(str);
120+
if (encoding === "buffer") return buffer;
121+
// deno-lint-ignore prefer-primordials
122+
return buffer.toString(encoding);
74123
}
75124

76125
function parseEncoding(
77-
optionsOrCallback?: { encoding: string } | string | mkdtempCallback,
78-
): string | undefined {
126+
options: string | { encoding?: string } | undefined,
127+
): Encoding | "buffer" {
79128
let encoding: string | undefined;
80-
if (typeof optionsOrCallback === "function") {
81-
encoding = undefined;
82-
} else if (isOptionsObject(optionsOrCallback)) {
83-
encoding = optionsOrCallback.encoding;
129+
130+
if (typeof options === "undefined" || options === null) {
131+
encoding = "utf8";
132+
} else if (typeof options === "string") {
133+
encoding = options;
134+
} else if (typeof options === "object") {
135+
encoding = options.encoding ?? "utf8";
84136
} else {
85-
encoding = optionsOrCallback;
137+
throw new ERR_INVALID_ARG_TYPE("options", ["string", "Object"], options);
86138
}
87139

88-
if (encoding) {
89-
try {
90-
new TextDecoder(encoding);
91-
} catch {
92-
throw new ERR_INVALID_OPT_VALUE_ENCODING(encoding);
93-
}
140+
if (encoding === "buffer") {
141+
return encoding;
94142
}
95143

96-
return encoding;
97-
}
98-
99-
function decode(str: string, encoding?: string): string {
100-
if (!encoding) return str;
101-
else {
102-
const decoder = new TextDecoder(encoding);
103-
const encoder = new TextEncoder();
104-
return decoder.decode(encoder.encode(str));
144+
const parsedEncoding = normalizeEncoding(encoding);
145+
if (!parsedEncoding) {
146+
throw new ERR_INVALID_ARG_TYPE("encoding", encoding, "is invalid encoding");
105147
}
106-
}
107148

108-
const CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
109-
function randomName(): string {
110-
return ArrayPrototypeJoin(
111-
ArrayPrototypeMap(
112-
[...new SafeArrayIterator(Array(6))],
113-
() => CHARS[MathFloor(MathRandom() * CHARS.length)],
114-
),
115-
"",
116-
);
117-
}
118-
119-
function tempDirPath(prefix: string): string {
120-
let path: string;
121-
do {
122-
path = prefix + randomName();
123-
} while (existsSync(path));
124-
125-
return path;
126-
}
127-
128-
function isOptionsObject(value: unknown): value is { encoding: string } {
129-
return (
130-
value !== null &&
131-
typeof value === "object" &&
132-
ObjectPrototypeIsPrototypeOf(ObjectPrototype, value)
133-
);
149+
return parsedEncoding;
134150
}

ext/node/polyfills/internal/fs/utils.mjs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -814,17 +814,20 @@ export const getValidatedPath = hideStackFrames(
814814
);
815815

816816
/**
817-
* @param {string | Buffer | URL} fileURLOrPath
817+
* @param {string | Buffer | Uint8Array | URL} fileURLOrPath
818818
* @param {string} [propName]
819819
* @returns string
820820
*/
821821
export const getValidatedPathToString = (fileURLOrPath, propName) => {
822822
const path = getValidatedPath(fileURLOrPath, propName);
823-
if (!Buffer.isBuffer(path)) {
824-
return path;
823+
if (isUint8Array(path)) {
824+
return new TextDecoder().decode(path);
825+
}
826+
if (Buffer.isBuffer(path)) {
827+
// deno-lint-ignore prefer-primordials
828+
return path.toString();
825829
}
826-
// deno-lint-ignore prefer-primordials
827-
return path.toString();
830+
return path;
828831
};
829832

830833
export const getValidatedFd = hideStackFrames((fd, propName = "fd") => {

tests/node_compat/config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,8 @@
441441
"parallel/test-fs-long-path.js" = {}
442442
"parallel/test-fs-make-callback.js" = {}
443443
"parallel/test-fs-makeStatsCallback.js" = {}
444+
"parallel/test-fs-mkdtemp-prefix-check.js" = {}
445+
"parallel/test-fs-mkdtemp.js" = {}
444446
"parallel/test-fs-open-flags.js" = {}
445447
"parallel/test-fs-open-no-close.js" = {}
446448
"parallel/test-fs-open-numeric-flags.js" = {}

0 commit comments

Comments
 (0)