Skip to content
Merged
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
2 changes: 2 additions & 0 deletions ext/node/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,8 @@ deno_core::extension!(deno_node,
ops::fs::op_node_lchown<P>,
ops::fs::op_node_lutimes_sync<P>,
ops::fs::op_node_lutimes<P>,
ops::fs::op_node_mkdtemp_sync<P>,
ops::fs::op_node_mkdtemp<P>,
ops::fs::op_node_open_sync<P>,
ops::fs::op_node_open<P>,
ops::fs::op_node_statfs<P>,
Expand Down
84 changes: 84 additions & 0 deletions ext/node/ops/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,87 @@ where
fs.lchmod_async(path.into_owned(), mode).await?;
Ok(())
}

#[op2(stack_trace)]
#[string]
pub fn op_node_mkdtemp_sync<P>(
state: &mut OpState,
#[string] path: &str,
) -> Result<String, FsError>
where
P: NodePermissions + 'static,
{
// https://github.com/nodejs/node/blob/2ea31e53c61463727c002c2d862615081940f355/deps/uv/src/unix/os390-syscalls.c#L409
for _ in 0..libc::TMP_MAX {
let path = temp_path_append_suffix(path);
let checked_path = state.borrow_mut::<P>().check_open(
Cow::Borrowed(Path::new(&path)),
OpenAccessKind::WriteNoFollow,
Some("node:fs.mkdtempSync()"),
)?;
let fs = state.borrow::<FileSystemRc>();

match fs.mkdir_sync(&checked_path, false, Some(0o700)) {
Ok(()) => return Ok(path),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
continue;
}
Err(err) => return Err(FsError::Fs(err)),
}
}

Err(FsError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"too many temp dirs exist",
)))
}

#[op2(async, stack_trace)]
#[string]
pub async fn op_node_mkdtemp<P>(
state: Rc<RefCell<OpState>>,
#[string] path: String,
) -> Result<String, FsError>
where
P: NodePermissions + 'static,
{
// https://github.com/nodejs/node/blob/2ea31e53c61463727c002c2d862615081940f355/deps/uv/src/unix/os390-syscalls.c#L409
for _ in 0..libc::TMP_MAX {
let path = temp_path_append_suffix(&path);
let (fs, checked_path) = {
let mut state = state.borrow_mut();
let checked_path = state.borrow_mut::<P>().check_open(
Cow::Owned(PathBuf::from(path.clone())),
OpenAccessKind::WriteNoFollow,
Some("node:fs.mkdtemp()"),
)?;
(state.borrow::<FileSystemRc>().clone(), checked_path)
};

match fs
.mkdir_async(checked_path.into_owned(), false, Some(0o700))
.await
{
Ok(()) => return Ok(path),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
continue;
}
Err(err) => return Err(FsError::Fs(err)),
}
}

Err(FsError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"too many temp dirs exist",
)))
}

fn temp_path_append_suffix(prefix: &str) -> String {
use rand::Rng;
use rand::distributions::Alphanumeric;
use rand::rngs::OsRng;

let suffix: String =
(0..6).map(|_| OsRng.sample(Alphanumeric) as char).collect();
format!("{}{}", prefix, suffix)
}
Comment on lines +632 to +640
Copy link
Contributor Author

@Tango992 Tango992 Sep 5, 2025

Choose a reason for hiding this comment

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

Chose this approach instead of using the tempfile crate because it relies on fastrand, which is not cryptographically secure. Not that it matters that much, but this method aligns more closely with how libuv handles temp file suffixes https://github.com/nodejs/node/blob/591ba692bfe30408e6a67397e7d18bfa1b9c3561/deps/uv/src/win/fs.c#L1267-L1270

Copy link
Member

Choose a reason for hiding this comment

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

Nice!

204 changes: 110 additions & 94 deletions ext/node/polyfills/_fs/_fs_mkdtemp.ts
Original file line number Diff line number Diff line change
@@ -1,134 +1,150 @@
// Copyright 2018-2025 the Deno authors. MIT license.
// Copyright Node.js contributors. All rights reserved. MIT License.

import { TextDecoder, TextEncoder } from "ext:deno_web/08_text_encoding.js";
import { existsSync } from "ext:deno_node/_fs/_fs_exists.ts";
import { mkdir, mkdirSync } from "ext:deno_node/_fs/_fs_mkdir.ts";
import { ERR_INVALID_OPT_VALUE_ENCODING } from "ext:deno_node/internal/errors.ts";
import { promisify } from "ext:deno_node/internal/util.mjs";
import { normalizeEncoding, promisify } from "ext:deno_node/internal/util.mjs";
import { primordials } from "ext:core/mod.js";
import { makeCallback } from "ext:deno_node/_fs/_fs_common.ts";

const {
ObjectPrototypeIsPrototypeOf,
Array,
SafeArrayIterator,
MathRandom,
MathFloor,
ArrayPrototypeJoin,
ArrayPrototypeMap,
ObjectPrototype,
} = primordials;

export type mkdtempCallback = (
import { Buffer } from "node:buffer";
import {
getValidatedPathToString,
warnOnNonPortableTemplate,
} from "ext:deno_node/internal/fs/utils.mjs";
import {
denoErrorToNodeError,
ERR_INVALID_ARG_TYPE,
} from "ext:deno_node/internal/errors.ts";
import { op_node_mkdtemp, op_node_mkdtemp_sync } from "ext:core/ops";
import type { Encoding } from "node:crypto";

const { PromisePrototypeThen } = primordials;

export type MkdtempCallback = (
err: Error | null,
directory?: string,
) => void;
export type MkdtempBufferCallback = (
err: Error | null,
directory?: Buffer<ArrayBufferLike>,
) => void;
type MkdTempPromise = (
prefix: string | Buffer | Uint8Array | URL,
options?: { encoding: string } | string,
) => Promise<string>;
type MkdTempPromiseBuffer = (
prefix: string | Buffer | Uint8Array | URL,
options: { encoding: "buffer" } | "buffer",
) => Promise<Buffer<ArrayBufferLike>>;

// https://nodejs.org/dist/latest-v15.x/docs/api/fs.html#fs_fs_mkdtemp_prefix_options_callback
export function mkdtemp(prefix: string, callback: mkdtempCallback): void;
export function mkdtemp(
prefix: string,
prefix: string | Buffer | Uint8Array | URL,
callback: MkdtempCallback,
): void;
export function mkdtemp(
prefix: string | Buffer | Uint8Array | URL,
options: { encoding: "buffer" } | "buffer",
callback: MkdtempBufferCallback,
): void;
export function mkdtemp(
prefix: string | Buffer | Uint8Array | URL,
options: { encoding: string } | string,
callback: mkdtempCallback,
callback: MkdtempCallback,
): void;
export function mkdtemp(
prefix: string,
options: { encoding: string } | string | mkdtempCallback | undefined,
callback?: mkdtempCallback,
prefix: string | Buffer | Uint8Array | URL,
options: { encoding: string } | string | MkdtempCallback | undefined,
callback?: MkdtempCallback | MkdtempBufferCallback,
) {
if (typeof options === "function") {
callback = options;
options = undefined;
}
callback = makeCallback(callback);

const encoding: string | undefined = parseEncoding(options);
const path = tempDirPath(prefix);

mkdir(
path,
{ recursive: false, mode: 0o700 },
(err: Error | null | undefined) => {
if (err) callback(err);
else callback(null, decode(path, encoding));
},
const encoding = parseEncoding(options);
prefix = getValidatedPathToString(prefix, "prefix");

warnOnNonPortableTemplate(prefix);

PromisePrototypeThen(
op_node_mkdtemp(prefix),
(path: string) => callback(null, decode(path, encoding)),
(err: Error) =>
callback(denoErrorToNodeError(err, {
syscall: "mkdtemp",
path: `${prefix}XXXXXX`,
})),
);
}

export const mkdtempPromise = promisify(mkdtemp) as (
prefix: string,
options?: { encoding: string } | string,
) => Promise<string>;
export const mkdtempPromise = promisify(mkdtemp) as
| MkdTempPromise
| MkdTempPromiseBuffer;

// https://nodejs.org/dist/latest-v15.x/docs/api/fs.html#fs_fs_mkdtempsync_prefix_options
export function mkdtempSync(
prefix: string,
prefix: string | Buffer | Uint8Array | URL,
options?: { encoding: "buffer" } | "buffer",
): Buffer<ArrayBufferLike>;
export function mkdtempSync(
prefix: string | Buffer | Uint8Array | URL,
options?: { encoding: string } | string,
): string {
const encoding: string | undefined = parseEncoding(options);
const path = tempDirPath(prefix);
): string;
export function mkdtempSync(
prefix: string | Buffer | Uint8Array | URL,
options?: { encoding: string } | string,
): string | Buffer<ArrayBufferLike> {
const encoding = parseEncoding(options);
prefix = getValidatedPathToString(prefix, "prefix");

warnOnNonPortableTemplate(prefix);

try {
const path = op_node_mkdtemp_sync(prefix) as string;
return decode(path, encoding);
} catch (err) {
throw denoErrorToNodeError(err as Error, {
syscall: "mkdtemp",
path: `${prefix}XXXXXX`,
});
}
}

mkdirSync(path, { recursive: false, mode: 0o700 });
return decode(path, encoding);
function decode(str: string, encoding: Encoding): string;
function decode(str: string, encoding: "buffer"): Buffer<ArrayBufferLike>;
function decode(
str: string,
encoding: Encoding | "buffer",
): string | Buffer<ArrayBufferLike> {
if (encoding === "utf8") return str;
const buffer = Buffer.from(str);
if (encoding === "buffer") return buffer;
// deno-lint-ignore prefer-primordials
return buffer.toString(encoding);
}

function parseEncoding(
optionsOrCallback?: { encoding: string } | string | mkdtempCallback,
): string | undefined {
options: string | { encoding?: string } | undefined,
): Encoding | "buffer" {
let encoding: string | undefined;
if (typeof optionsOrCallback === "function") {
encoding = undefined;
} else if (isOptionsObject(optionsOrCallback)) {
encoding = optionsOrCallback.encoding;

if (typeof options === "undefined" || options === null) {
encoding = "utf8";
} else if (typeof options === "string") {
encoding = options;
} else if (typeof options === "object") {
encoding = options.encoding ?? "utf8";
} else {
encoding = optionsOrCallback;
throw new ERR_INVALID_ARG_TYPE("options", ["string", "Object"], options);
}

if (encoding) {
try {
new TextDecoder(encoding);
} catch {
throw new ERR_INVALID_OPT_VALUE_ENCODING(encoding);
}
if (encoding === "buffer") {
return encoding;
}

return encoding;
}

function decode(str: string, encoding?: string): string {
if (!encoding) return str;
else {
const decoder = new TextDecoder(encoding);
const encoder = new TextEncoder();
return decoder.decode(encoder.encode(str));
const parsedEncoding = normalizeEncoding(encoding);
if (!parsedEncoding) {
throw new ERR_INVALID_ARG_TYPE("encoding", encoding, "is invalid encoding");
}
}

const CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
function randomName(): string {
return ArrayPrototypeJoin(
ArrayPrototypeMap(
[...new SafeArrayIterator(Array(6))],
() => CHARS[MathFloor(MathRandom() * CHARS.length)],
),
"",
);
}

function tempDirPath(prefix: string): string {
let path: string;
do {
path = prefix + randomName();
} while (existsSync(path));

return path;
}

function isOptionsObject(value: unknown): value is { encoding: string } {
return (
value !== null &&
typeof value === "object" &&
ObjectPrototypeIsPrototypeOf(ObjectPrototype, value)
);
return parsedEncoding;
}
13 changes: 8 additions & 5 deletions ext/node/polyfills/internal/fs/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -814,17 +814,20 @@ export const getValidatedPath = hideStackFrames(
);

/**
* @param {string | Buffer | URL} fileURLOrPath
* @param {string | Buffer | Uint8Array | URL} fileURLOrPath
* @param {string} [propName]
* @returns string
*/
export const getValidatedPathToString = (fileURLOrPath, propName) => {
const path = getValidatedPath(fileURLOrPath, propName);
if (!Buffer.isBuffer(path)) {
return path;
if (isUint8Array(path)) {
return new TextDecoder().decode(path);
}
if (Buffer.isBuffer(path)) {
// deno-lint-ignore prefer-primordials
return path.toString();
}
// deno-lint-ignore prefer-primordials
return path.toString();
return path;
};

export const getValidatedFd = hideStackFrames((fd, propName = "fd") => {
Expand Down
2 changes: 2 additions & 0 deletions tests/node_compat/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@
"parallel/test-fs-long-path.js" = {}
"parallel/test-fs-make-callback.js" = {}
"parallel/test-fs-makeStatsCallback.js" = {}
"parallel/test-fs-mkdtemp-prefix-check.js" = {}
"parallel/test-fs-mkdtemp.js" = {}
"parallel/test-fs-open-flags.js" = {}
"parallel/test-fs-open-no-close.js" = {}
"parallel/test-fs-open-numeric-flags.js" = {}
Expand Down
Loading
Loading