Skip to content

Commit f9ca414

Browse files
committed
feat(io): add retained confined process cwd
1 parent 31e4003 commit f9ca414

5 files changed

Lines changed: 412 additions & 29 deletions

File tree

src/builtins/runtime/io/bounded_process.rs

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock};
1717
use std::thread::{self, JoinHandle};
1818
use std::time::{Duration, Instant};
1919

20+
use crate::confined_fs::ConfinedDirectory;
21+
2022
#[cfg(windows)]
2123
use super::windows_process_tree::ProcessJob;
2224
#[cfg(unix)]
@@ -97,6 +99,10 @@ pub struct BoundedProcessRequest {
9799
pub argv: Vec<String>,
98100
/// Optional explicit working directory. Must be absolute when present.
99101
pub cwd: Option<PathBuf>,
102+
/// Optional retained confined directory used as the child cwd.
103+
///
104+
/// Mutually exclusive with [`Self::cwd`] and [`Self::workspace_root`].
105+
pub confined_cwd: Option<ConfinedDirectory>,
100106
/// Workspace root used as the child cwd when `cwd` is omitted.
101107
pub workspace_root: Option<PathBuf>,
102108
/// Explicit environment entries. They are allowlisted; inheritance is
@@ -129,6 +135,7 @@ impl fmt::Debug for BoundedProcessRequest {
129135
.debug_struct("BoundedProcessRequest")
130136
.field("argv_count", &self.argv.len())
131137
.field("cwd_present", &self.cwd.is_some())
138+
.field("confined_cwd_present", &self.confined_cwd.is_some())
132139
.field("workspace_root_present", &self.workspace_root.is_some())
133140
.field("env_count", &self.env.len())
134141
.field("inherit_env", &self.inherit_env)
@@ -183,6 +190,7 @@ impl BoundedProcessRequest {
183190
Self {
184191
argv,
185192
cwd: None,
193+
confined_cwd: None,
186194
workspace_root: None,
187195
env: BTreeMap::new(),
188196
inherit_env: false,
@@ -201,6 +209,11 @@ impl BoundedProcessRequest {
201209
self
202210
}
203211

212+
pub fn with_confined_cwd(mut self, cwd: ConfinedDirectory) -> Self {
213+
self.confined_cwd = Some(cwd);
214+
self
215+
}
216+
204217
pub fn with_workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
205218
self.workspace_root = Some(root.into());
206219
self
@@ -262,22 +275,32 @@ impl BoundedProcessRequest {
262275
pub fn validate(&self) -> Result<(), ValidationError> {
263276
validate_argv(&self.argv)?;
264277

265-
if let Some(cwd) = self.resolved_cwd() {
266-
let cwd_len = os_string_len(cwd.as_os_str());
267-
if cwd_len == 0 {
268-
return Err(ValidationError::EmptyCwd);
269-
}
270-
if cwd_len > MAX_ARG_TOTAL_BYTES {
271-
return Err(ValidationError::CwdTooLong);
272-
}
273-
if os_string_has_nul(cwd.as_os_str()) {
274-
return Err(ValidationError::CwdContainsNul);
275-
}
276-
if !cwd.is_absolute() {
277-
return Err(ValidationError::CwdNotAbsolute);
278+
let has_path_cwd = self.cwd.is_some() || self.workspace_root.is_some();
279+
if self.confined_cwd.is_some() && has_path_cwd {
280+
return Err(ValidationError::ConflictingCwd);
281+
}
282+
if self.confined_cwd.is_none() {
283+
if let Some(cwd) = self.resolved_cwd() {
284+
let cwd_len = os_string_len(cwd.as_os_str());
285+
if cwd_len == 0 {
286+
return Err(ValidationError::EmptyCwd);
287+
}
288+
if cwd_len > MAX_ARG_TOTAL_BYTES {
289+
return Err(ValidationError::CwdTooLong);
290+
}
291+
if os_string_has_nul(cwd.as_os_str()) {
292+
return Err(ValidationError::CwdContainsNul);
293+
}
294+
if !cwd.is_absolute() {
295+
return Err(ValidationError::CwdNotAbsolute);
296+
}
297+
} else {
298+
return Err(ValidationError::CwdRequired);
278299
}
279-
} else {
280-
return Err(ValidationError::CwdRequired);
300+
}
301+
#[cfg(not(unix))]
302+
if self.confined_cwd.is_some() {
303+
return Err(ValidationError::ConfinedCwdUnsupported);
281304
}
282305

283306
if self.inherit_env {
@@ -427,6 +450,8 @@ pub enum ValidationError {
427450
CwdNotAbsolute,
428451
CwdTooLong,
429452
CwdContainsNul,
453+
ConflictingCwd,
454+
ConfinedCwdUnsupported,
430455
EnvCountExceeded,
431456
InvalidEnvKey,
432457
EnvKeyTooLong,
@@ -460,6 +485,8 @@ impl fmt::Display for ValidationError {
460485
Self::CwdNotAbsolute => "cwd must be an absolute path",
461486
Self::CwdTooLong => "cwd exceeds the configured bound",
462487
Self::CwdContainsNul => "cwd contains a NUL byte",
488+
Self::ConflictingCwd => "cwd path and confined cwd cannot both be specified",
489+
Self::ConfinedCwdUnsupported => "confined cwd is unavailable on this target",
463490
Self::EnvCountExceeded => "environment entry count exceeds the configured bound",
464491
Self::InvalidEnvKey => "environment key has invalid grammar",
465492
Self::EnvKeyTooLong => "environment key exceeds the configured bound",
@@ -1981,15 +2008,44 @@ impl BoundedProcess {
19812008
return Err(BoundedProcessError::Cancelled);
19822009
}
19832010

1984-
let cwd = request
1985-
.resolved_cwd()
1986-
.cloned()
1987-
.ok_or(BoundedProcessError::InvalidRequest(
1988-
ValidationError::CwdRequired,
1989-
))?;
2011+
let confined_cwd = request.confined_cwd.clone();
19902012
let mut command = std::process::Command::new(&request.argv[0]);
19912013
command.args(&request.argv[1..]);
1992-
command.current_dir(cwd);
2014+
if let Some(directory) = &confined_cwd {
2015+
#[cfg(unix)]
2016+
{
2017+
let fd = directory.as_raw_fd();
2018+
// SAFETY: `fchdir` is async-signal-safe. The retained directory
2019+
// descriptor stays alive in `confined_cwd` until `spawn` returns,
2020+
// and close-on-exec remains set so the child does not inherit it
2021+
// across `exec`.
2022+
unsafe {
2023+
command.pre_exec(move || {
2024+
if libc::fchdir(fd) != 0 {
2025+
Err(std::io::Error::last_os_error())
2026+
} else {
2027+
Ok(())
2028+
}
2029+
});
2030+
}
2031+
}
2032+
#[cfg(not(unix))]
2033+
{
2034+
let _ = directory;
2035+
return Err(BoundedProcessError::InvalidRequest(
2036+
ValidationError::ConfinedCwdUnsupported,
2037+
));
2038+
}
2039+
} else {
2040+
let cwd =
2041+
request
2042+
.resolved_cwd()
2043+
.cloned()
2044+
.ok_or(BoundedProcessError::InvalidRequest(
2045+
ValidationError::CwdRequired,
2046+
))?;
2047+
command.current_dir(cwd);
2048+
}
19932049
command.env_clear();
19942050
command.envs(&request.env);
19952051
command
@@ -2004,6 +2060,7 @@ impl BoundedProcess {
20042060
let mut child = command
20052061
.spawn()
20062062
.map_err(|error| BoundedProcessError::Spawn(spawn_error(&error)))?;
2063+
drop(confined_cwd);
20072064
let pid = child.id();
20082065
#[cfg(target_os = "linux")]
20092066
let pidfd = match linux_pidfd::PidFd::open(pid) {

src/builtins/runtime/io/confined_fs.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd, RawFd};
4141
#[cfg(unix)]
4242
use std::os::unix::ffi::{OsStrExt, OsStringExt};
4343
#[cfg(unix)]
44+
use std::sync::Arc;
45+
#[cfg(unix)]
4446
use std::sync::atomic::{AtomicU64, Ordering};
4547
#[cfg(unix)]
4648
use std::time::{SystemTime, UNIX_EPOCH};
@@ -673,6 +675,45 @@ impl ConfinedFile {
673675
}
674676
}
675677

678+
/// An opaque retained directory handle opened through a [`ConfinedFsRoot`].
679+
///
680+
/// The capability owns the directory descriptor and exposes no public path or
681+
/// raw-fd accessor. Cloning retains the same directory through an `Arc`.
682+
#[derive(Clone)]
683+
pub struct ConfinedDirectory {
684+
#[cfg(unix)]
685+
inner: Arc<ConfinedDirectoryInner>,
686+
#[cfg(not(unix))]
687+
_private: (),
688+
}
689+
690+
#[cfg(unix)]
691+
struct ConfinedDirectoryInner {
692+
fd: OwnedFd,
693+
}
694+
695+
impl fmt::Debug for ConfinedDirectory {
696+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
697+
formatter
698+
.debug_struct("ConfinedDirectory")
699+
.finish_non_exhaustive()
700+
}
701+
}
702+
703+
impl ConfinedDirectory {
704+
#[cfg(unix)]
705+
fn from_fd(fd: OwnedFd) -> Self {
706+
Self {
707+
inner: Arc::new(ConfinedDirectoryInner { fd }),
708+
}
709+
}
710+
711+
#[cfg(unix)]
712+
pub(crate) fn as_raw_fd(&self) -> RawFd {
713+
self.inner.fd.as_raw_fd()
714+
}
715+
}
716+
676717
/// A securely created temporary file and its retained parent directory.
677718
#[derive(Debug)]
678719
pub struct ConfinedTempFile {
@@ -1086,6 +1127,29 @@ impl ConfinedFsRoot {
10861127
}
10871128
}
10881129

1130+
/// Opens a directory relative to the retained root and keeps the handle.
1131+
///
1132+
/// Passing an empty path selects the retained root itself. Traversal uses
1133+
/// the same no-follow component walk and root-binding verification as other
1134+
/// confined operations. The returned capability owns the directory handle
1135+
/// and does not expose a path or raw descriptor.
1136+
pub fn open_directory(&self, path: &str) -> Result<ConfinedDirectory, ConfinedFsError> {
1137+
let path = validate_directory_path(path, "fs::open_directory")?;
1138+
#[cfg(unix)]
1139+
{
1140+
self.ensure_bound("fs::open_directory")?;
1141+
let fd = unix::open_directory(self.fd.as_raw_fd(), &path.components)
1142+
.map_err(|error| ConfinedFsError::os("fs::open_directory", &error))?;
1143+
self.ensure_bound("fs::open_directory")?;
1144+
Ok(ConfinedDirectory::from_fd(fd))
1145+
}
1146+
#[cfg(not(unix))]
1147+
{
1148+
let _ = path;
1149+
Err(unsupported_error("fs::open_directory"))
1150+
}
1151+
}
1152+
10891153
/// Enumerates a directory relative to the root with the default budget.
10901154
///
10911155
/// Passing an empty directory path selects the retained root itself. Empty

src/lib.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,11 @@ pub use bytecode::{
7272
};
7373
#[cfg(feature = "runtime")]
7474
pub use confined_fs::{
75-
ConfinedDirEntry, ConfinedFile, ConfinedFileType, ConfinedFsError, ConfinedFsErrorKind,
76-
ConfinedFsLimits, ConfinedFsRoot, ConfinedMetadata, ConfinedObservedIdentity,
77-
ConfinedPublication, ConfinedPublicationState, ConfinedTempFile, EnumerationBudget,
78-
MAX_COMPONENT_BYTES, MAX_ENUM_ENTRIES, MAX_PATH_BYTES, MAX_READ_BYTES, MAX_TEMP_ATTEMPTS,
79-
MAX_TEMP_PREFIX_BYTES, MAX_WRITE_BYTES, publication_supported,
75+
ConfinedDirEntry, ConfinedDirectory, ConfinedFile, ConfinedFileType, ConfinedFsError,
76+
ConfinedFsErrorKind, ConfinedFsLimits, ConfinedFsRoot, ConfinedMetadata,
77+
ConfinedObservedIdentity, ConfinedPublication, ConfinedPublicationState, ConfinedTempFile,
78+
EnumerationBudget, MAX_COMPONENT_BYTES, MAX_ENUM_ENTRIES, MAX_PATH_BYTES, MAX_READ_BYTES,
79+
MAX_TEMP_ATTEMPTS, MAX_TEMP_PREFIX_BYTES, MAX_WRITE_BYTES, publication_supported,
8080
};
8181
pub fn builtin_call_index(name: &str) -> Option<u16> {
8282
use builtins::BuiltinFunction;

0 commit comments

Comments
 (0)