Skip to content

Commit b38e74e

Browse files
committed
fix(ext/node): return real OS file descriptors from node:fs APIs
1 parent 8cf29d5 commit b38e74e

File tree

19 files changed

+907
-166
lines changed

19 files changed

+907
-166
lines changed

ext/node/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ deno_core::extension!(deno_node,
212212
ops::fs::op_node_statfs_sync,
213213
ops::fs::op_node_statfs,
214214
ops::fs::op_node_file_from_fd,
215+
ops::fs::op_node_dup_fd,
215216
ops::fs::op_node_cp_check_paths_recursive,
216217
ops::fs::op_node_cp_on_file,
217218
ops::fs::op_node_cp_on_link,
@@ -470,6 +471,7 @@ deno_core::extension!(deno_node,
470471
"internal/fs/streams.mjs",
471472
"internal/fs/utils.mjs",
472473
"internal/fs/handle.ts",
474+
"internal/fs/fd_map.ts",
473475
"internal/hide_stack_frames.ts",
474476
"internal/http.ts",
475477
"internal/http2/util.ts",

ext/node/ops/fs.rs

Lines changed: 263 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,14 +211,50 @@ fn open_options_to_access_kind(open_options: &OpenOptions) -> OpenAccessKind {
211211
}
212212
}
213213

214-
#[op2(fast, stack_trace)]
215-
#[smi]
214+
#[derive(Debug, Serialize)]
215+
pub struct OpenResult {
216+
pub rid: ResourceId,
217+
pub fd: i32,
218+
}
219+
220+
/// Extract the real OS file descriptor from a Deno resource ID.
221+
#[cfg(unix)]
222+
fn get_fd_from_rid(
223+
state: &mut OpState,
224+
rid: ResourceId,
225+
) -> Result<i32, FsError> {
226+
let handle = state.resource_table.get_handle(rid).map_err(|_| {
227+
FsError::Io(std::io::Error::new(
228+
std::io::ErrorKind::NotFound,
229+
"Bad resource ID",
230+
))
231+
})?;
232+
match handle {
233+
deno_core::ResourceHandle::Fd(fd) => Ok(fd),
234+
_ => Err(FsError::Io(std::io::Error::other(
235+
"Resource is not a file descriptor",
236+
))),
237+
}
238+
}
239+
240+
/// On Windows, RIDs are used directly as FDs (no real fd extraction).
241+
/// This preserves the existing behavior until Windows fd support is added.
242+
#[cfg(not(unix))]
243+
fn get_fd_from_rid(
244+
_state: &mut OpState,
245+
rid: ResourceId,
246+
) -> Result<i32, FsError> {
247+
Ok(rid as i32)
248+
}
249+
250+
#[op2(stack_trace)]
251+
#[serde]
216252
pub fn op_node_open_sync(
217253
state: &mut OpState,
218254
#[string] path: &str,
219255
#[smi] flags: i32,
220256
#[smi] mode: u32,
221-
) -> Result<ResourceId, FsError> {
257+
) -> Result<OpenResult, FsError> {
222258
let path = Path::new(path);
223259
let options = get_open_options(flags, Some(mode));
224260

@@ -232,17 +268,18 @@ pub fn op_node_open_sync(
232268
let rid = state
233269
.resource_table
234270
.add(FileResource::new(file, "fsFile".to_string()));
235-
Ok(rid)
271+
let fd = get_fd_from_rid(state, rid)?;
272+
Ok(OpenResult { rid, fd })
236273
}
237274

238275
#[op2(stack_trace)]
239-
#[smi]
276+
#[serde]
240277
pub async fn op_node_open(
241278
state: Rc<RefCell<OpState>>,
242279
#[string] path: String,
243280
#[smi] flags: i32,
244281
#[smi] mode: u32,
245-
) -> Result<ResourceId, FsError> {
282+
) -> Result<OpenResult, FsError> {
246283
let path = PathBuf::from(path);
247284
let options = get_open_options(flags, Some(mode));
248285

@@ -259,11 +296,12 @@ pub async fn op_node_open(
259296
};
260297
let file = fs.open_async(path.as_owned(), options).await?;
261298

299+
let mut state = state.borrow_mut();
262300
let rid = state
263-
.borrow_mut()
264301
.resource_table
265302
.add(FileResource::new(file, "fsFile".to_string()));
266-
Ok(rid)
303+
let fd = get_fd_from_rid(&mut state, rid)?;
304+
Ok(OpenResult { rid, fd })
267305
}
268306
#[derive(Debug, Serialize)]
269307
pub struct StatFs {
@@ -722,6 +760,99 @@ pub async fn op_node_rmdir(
722760
Ok(())
723761
}
724762

763+
/// Create a file resource from a raw file descriptor by dup'ing it first.
764+
/// This is safe for cross-worker use because the dup'd fd is independently
765+
/// owned and can be closed without affecting the original.
766+
///
767+
/// Safety: allows dup of fds that are either unmanaged (external: child_process
768+
/// pipes, native addons) or owned by an "fsFile" resource (opened via node:fs).
769+
/// Denies dup of fds managed by non-fsFile internal Deno resources (e.g.
770+
/// SQLite, internal pipes) to prevent unauthorized access.
771+
#[cfg(unix)]
772+
#[op2(fast)]
773+
#[smi]
774+
pub fn op_node_dup_fd(
775+
state: &mut OpState,
776+
#[smi] fd: i32,
777+
) -> Result<ResourceId, FsError> {
778+
dup_fd_impl(state, fd)
779+
}
780+
781+
#[cfg(unix)]
782+
fn dup_fd_impl(state: &mut OpState, fd: i32) -> Result<ResourceId, FsError> {
783+
use std::fs::File as StdFile;
784+
use std::os::unix::io::FromRawFd;
785+
786+
if fd < 0 {
787+
return Err(FsError::Io(std::io::Error::new(
788+
std::io::ErrorKind::InvalidInput,
789+
"Invalid file descriptor",
790+
)));
791+
}
792+
793+
// Check if this fd is managed by a non-fsFile internal Deno resource.
794+
// fsFile resources (opened via node:fs) are safe to dup.
795+
// Other managed resources (SQLite, internal pipes) are denied.
796+
// Unmanaged fds (child_process, native addons) are allowed through.
797+
for (rid, name) in state.resource_table.names() {
798+
if name == "fsFile" || name == "signal" {
799+
// node:fs and node:signal resources are allowed to be dup'd for use in
800+
// native addons like node-pty
801+
continue;
802+
}
803+
if matches!(
804+
state.resource_table.get_handle(rid),
805+
Ok(deno_core::ResourceHandle::Fd(existing_fd)) if existing_fd == fd
806+
) {
807+
return Err(FsError::Io(std::io::Error::new(
808+
std::io::ErrorKind::PermissionDenied,
809+
"File descriptor is managed by an internal Deno resource",
810+
)));
811+
}
812+
}
813+
814+
// SAFETY: dup() creates a new fd pointing to the same open file description.
815+
let new_fd = unsafe { libc::dup(fd) };
816+
if new_fd < 0 {
817+
return Err(FsError::Io(std::io::Error::last_os_error()));
818+
}
819+
820+
// Clear O_NONBLOCK flag only for TTY fds (e.g. PTYs from node-pty).
821+
// StdFileResourceInner uses spawn_blocking for reads which expects
822+
// blocking I/O. Non-TTY fds keep their original flags.
823+
// SAFETY: new_fd is valid, isatty/fcntl are safe on valid fds
824+
unsafe {
825+
if libc::isatty(new_fd) == 1 {
826+
let flags = libc::fcntl(new_fd, libc::F_GETFL);
827+
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
828+
libc::fcntl(new_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
829+
}
830+
}
831+
}
832+
833+
// SAFETY: new_fd is a valid fd we just created via dup().
834+
let std_file = unsafe { StdFile::from_raw_fd(new_fd) };
835+
let file: Rc<dyn deno_io::fs::File> =
836+
Rc::new(deno_io::StdFileResourceInner::file(std_file, None));
837+
let rid = state
838+
.resource_table
839+
.add(FileResource::new(file, "fsFile".to_string()));
840+
Ok(rid)
841+
}
842+
843+
#[cfg(not(unix))]
844+
#[op2(fast)]
845+
#[smi]
846+
pub fn op_node_dup_fd(
847+
_state: &mut OpState,
848+
#[smi] _fd: i32,
849+
) -> Result<ResourceId, FsError> {
850+
Err(FsError::Io(std::io::Error::new(
851+
std::io::ErrorKind::Unsupported,
852+
"op_node_dup_fd is not supported on this platform",
853+
)))
854+
}
855+
725856
#[derive(Debug, Serialize)]
726857
#[serde(rename_all = "camelCase")]
727858
pub struct CpStatInfo {
@@ -1585,4 +1716,128 @@ mod tests {
15851716
assert!(!is_src_subdir(&src, &sibling));
15861717
assert!(!is_src_subdir(&child, &src));
15871718
}
1719+
1720+
#[cfg(unix)]
1721+
#[allow(clippy::disallowed_methods)]
1722+
mod dup_fd_tests {
1723+
use std::borrow::Cow;
1724+
use std::rc::Rc;
1725+
1726+
use deno_core::OpState;
1727+
use deno_core::Resource;
1728+
use deno_core::ResourceHandle;
1729+
use deno_core::ResourceHandleFd;
1730+
use deno_io::fs::FileResource;
1731+
1732+
/// A mock non-fsFile resource that reports a specific fd via
1733+
/// `backing_handle`. Used to test that `op_node_dup_fd` denies
1734+
/// dup of fds managed by non-fsFile internal resources.
1735+
struct MockInternalResource {
1736+
fd: ResourceHandleFd,
1737+
}
1738+
1739+
impl Resource for MockInternalResource {
1740+
fn name(&self) -> Cow<'_, str> {
1741+
// Intentionally NOT "fsFile" — simulates an internal resource
1742+
// like a SQLite handle or internal pipe.
1743+
"mockInternal".into()
1744+
}
1745+
1746+
fn backing_handle(self: Rc<Self>) -> Option<ResourceHandle> {
1747+
Some(ResourceHandle::Fd(self.fd))
1748+
}
1749+
}
1750+
1751+
#[test]
1752+
fn dup_fd_denies_non_fsfile_resource() {
1753+
// Create a real fd via a temp file so dup() would succeed
1754+
// if the security check weren't there.
1755+
let tmp = std::env::temp_dir().join("deno_dup_fd_test");
1756+
let file = std::fs::File::create(&tmp).unwrap();
1757+
use std::os::unix::io::AsRawFd;
1758+
let fd = file.as_raw_fd();
1759+
1760+
let mut state = OpState::new(None);
1761+
1762+
// Register the fd under a non-fsFile resource name
1763+
state.resource_table.add(MockInternalResource { fd });
1764+
1765+
// op_node_dup_fd should deny this fd
1766+
let result = super::super::dup_fd_impl(&mut state, fd);
1767+
assert!(result.is_err());
1768+
match result.unwrap_err() {
1769+
super::super::FsError::Io(e) => {
1770+
assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied);
1771+
}
1772+
other => {
1773+
panic!("expected FsError::Io(PermissionDenied), got: {other:?}")
1774+
}
1775+
}
1776+
1777+
drop(file);
1778+
let _ = std::fs::remove_file(&tmp);
1779+
}
1780+
1781+
#[test]
1782+
fn dup_fd_allows_fsfile_resource() {
1783+
// Create a real fd via a temp file
1784+
let tmp = std::env::temp_dir().join("deno_dup_fd_allow_test");
1785+
let std_file = std::fs::File::create(&tmp).unwrap();
1786+
use std::os::unix::io::AsRawFd;
1787+
let fd = std_file.as_raw_fd();
1788+
1789+
let mut state = OpState::new(None);
1790+
1791+
// Register it as an "fsFile" resource (the allowed kind)
1792+
let file: Rc<dyn deno_io::fs::File> =
1793+
Rc::new(deno_io::StdFileResourceInner::file(std_file, None));
1794+
state
1795+
.resource_table
1796+
.add(FileResource::new(file, "fsFile".to_string()));
1797+
1798+
// op_node_dup_fd should allow this fd
1799+
let result = super::super::dup_fd_impl(&mut state, fd);
1800+
assert!(result.is_ok());
1801+
1802+
// Clean up the dup'd resource
1803+
let dup_rid = result.unwrap();
1804+
let _ = state.resource_table.take_any(dup_rid);
1805+
1806+
let _ = std::fs::remove_file(&tmp);
1807+
}
1808+
1809+
#[test]
1810+
fn dup_fd_allows_unmanaged_fd() {
1811+
// Create a real fd that is NOT in the resource table at all
1812+
let tmp = std::env::temp_dir().join("deno_dup_fd_unmanaged_test");
1813+
let file = std::fs::File::create(&tmp).unwrap();
1814+
use std::os::unix::io::AsRawFd;
1815+
let fd = file.as_raw_fd();
1816+
1817+
let mut state = OpState::new(None);
1818+
1819+
// fd is valid but not in the resource table — should be allowed
1820+
let result = super::super::dup_fd_impl(&mut state, fd);
1821+
assert!(result.is_ok());
1822+
1823+
let dup_rid = result.unwrap();
1824+
let _ = state.resource_table.take_any(dup_rid);
1825+
1826+
drop(file);
1827+
let _ = std::fs::remove_file(&tmp);
1828+
}
1829+
1830+
#[test]
1831+
fn dup_fd_rejects_invalid_fd() {
1832+
let mut state = OpState::new(None);
1833+
1834+
// Negative fd should be rejected
1835+
let result = super::super::dup_fd_impl(&mut state, -1);
1836+
assert!(result.is_err());
1837+
1838+
// Very high fd (likely invalid) should fail with EBADF
1839+
let result = super::super::dup_fd_impl(&mut state, 99999);
1840+
assert!(result.is_err());
1841+
}
1842+
}
15881843
}

ext/node/polyfills/_fs/_fs_fstat.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { BigIntStats, Stats } from "ext:deno_node/internal/fs/utils.mjs";
1313
import { FsFile } from "ext:deno_fs/30_fs.js";
1414
import { denoErrorToNodeError } from "ext:deno_node/internal/errors.ts";
15+
import { getRid } from "ext:deno_node/internal/fs/fd_map.ts";
1516

1617
export function fstat(fd: number, callback: statCallback): void;
1718
export function fstat(
@@ -41,7 +42,7 @@ export function fstat(
4142

4243
if (!callback) throw new Error("No callback function supplied");
4344

44-
new FsFile(fd, Symbol.for("Deno.internal.FsFile")).stat().then(
45+
new FsFile(getRid(fd), Symbol.for("Deno.internal.FsFile")).stat().then(
4546
(stat) => callback(null, CFISBIS(stat, options.bigint)),
4647
(err) => callback(denoErrorToNodeError(err, { syscall: "fstat" })),
4748
);
@@ -61,7 +62,7 @@ export function fstatSync(
6162
options?: statOptions,
6263
): Stats | BigIntStats {
6364
try {
64-
const origin = new FsFile(fd, Symbol.for("Deno.internal.FsFile"))
65+
const origin = new FsFile(getRid(fd), Symbol.for("Deno.internal.FsFile"))
6566
.statSync();
6667
return CFISBIS(origin, options?.bigint || false);
6768
} catch (err) {

0 commit comments

Comments
 (0)