Skip to content

Commit 46fd2e1

Browse files
committed
feat(host): bind capability profiles and IO limits
1 parent cc43369 commit 46fd2e1

12 files changed

Lines changed: 1095 additions & 105 deletions

File tree

src/builtins/runtime/io.rs

Lines changed: 150 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::fs::OpenOptions;
22
use std::future::Future;
33
use std::io::{Read, Write};
4+
use std::path::{Path, PathBuf};
45
use std::pin::Pin;
56
use std::process::{Child, Command, Stdio};
67
use std::sync::atomic::{AtomicU32, Ordering};
@@ -200,7 +201,16 @@ pub(super) fn poll_builtin_io_op(
200201
/// Opens a file handle for runtime I/O.
201202
#[pd_host_function(name = "io::open")]
202203
pub(super) fn builtin_io_open(vm: &mut Vm, path: &str, mode: &str) -> VmResult<HostCallResult<i64>> {
203-
let path = path.to_string();
204+
let writes = match mode {
205+
"r" => false,
206+
"w" | "a" | "r+" | "w+" | "a+" => true,
207+
other => {
208+
return Err(VmError::HostError(format!(
209+
"unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+"
210+
)));
211+
}
212+
};
213+
let path = authorize_io_path(vm, path, writes)?;
204214
let mode = mode.to_string();
205215
let op_id = schedule_io_task(vm, None, move || {
206216
let mut options = OpenOptions::new();
@@ -256,6 +266,16 @@ pub(super) fn builtin_io_popen(
256266
"unsupported io_popen mode '{mode}', expected r or w"
257267
)));
258268
}
269+
if vm
270+
.host
271+
.io_policy
272+
.as_ref()
273+
.is_some_and(|policy| !policy.allow_process)
274+
{
275+
return Err(VmError::HostError(
276+
"io_popen requires the process capability".to_string(),
277+
));
278+
}
259279
let command = command.to_string();
260280
let mode = mode.to_string();
261281
let op_id = schedule_io_task(vm, None, move || {
@@ -294,23 +314,31 @@ pub(super) fn builtin_io_popen(
294314
/// Reads all remaining text from an I/O handle.
295315
#[pd_host_function(name = "io::read_all")]
296316
pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult<HostCallResult<String>> {
317+
let max_read_bytes = vm
318+
.host
319+
.io_policy
320+
.as_ref()
321+
.map(|policy| policy.max_read_bytes);
297322
let handle = resource_handle(handle_id)?;
298323
let resource = io_resource_for_handle(vm, handle)?;
299324
let op_id = schedule_io_task(vm, Some(handle), move || {
300325
let result = resource.with_handle_mut(|handle| {
301326
let mut out = String::new();
302327
match handle {
303-
IoHandle::File(file) => file
304-
.read_to_string(&mut out)
305-
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?,
306-
IoHandle::PopenRead { child } => child
307-
.stdout
308-
.as_mut()
309-
.ok_or_else(|| {
310-
VmError::HostError("io_read_all popen handle missing stdout".to_string())
311-
})?
312-
.read_to_string(&mut out)
313-
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?,
328+
IoHandle::File(file) => {
329+
read_to_string_with_limit(file, max_read_bytes, &mut out)?;
330+
}
331+
IoHandle::PopenRead { child } => {
332+
read_to_string_with_limit(
333+
child.stdout.as_mut().ok_or_else(|| {
334+
VmError::HostError(
335+
"io_read_all popen handle missing stdout".to_string(),
336+
)
337+
})?,
338+
max_read_bytes,
339+
&mut out,
340+
)?;
341+
}
314342
IoHandle::PopenWrite { .. } => {
315343
return Err(VmError::HostError(
316344
"io_read_all requires a readable handle".to_string(),
@@ -327,17 +355,23 @@ pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult<HostC
327355
/// Reads a single line of text from an I/O handle.
328356
#[pd_host_function(name = "io::read_line")]
329357
pub(super) fn builtin_io_read_line(vm: &mut Vm, handle_id: i64) -> VmResult<HostCallResult<String>> {
358+
let max_read_bytes = vm
359+
.host
360+
.io_policy
361+
.as_ref()
362+
.map(|policy| policy.max_read_bytes);
330363
let handle = resource_handle(handle_id)?;
331364
let resource = io_resource_for_handle(vm, handle)?;
332365
let op_id = schedule_io_task(vm, Some(handle), move || {
333366
let result = resource.with_handle_mut(|handle| {
334367
let line = match handle {
335-
IoHandle::File(file) => read_line_from_reader(file)?,
336-
IoHandle::PopenRead { child } => {
337-
read_line_from_reader(child.stdout.as_mut().ok_or_else(|| {
368+
IoHandle::File(file) => read_line_from_reader(file, max_read_bytes)?,
369+
IoHandle::PopenRead { child } => read_line_from_reader(
370+
child.stdout.as_mut().ok_or_else(|| {
338371
VmError::HostError("io_read_line popen handle missing stdout".to_string())
339-
})?)?
340-
}
372+
})?,
373+
max_read_bytes,
374+
)?,
341375
IoHandle::PopenWrite { .. } => {
342376
return Err(VmError::HostError(
343377
"io_read_line requires a readable handle".to_string(),
@@ -358,6 +392,14 @@ pub(super) fn builtin_io_write(
358392
handle_id: i64,
359393
text: &str,
360394
) -> VmResult<HostCallResult<i64>> {
395+
if let Some(policy) = vm.host.io_policy.as_ref()
396+
&& text.len() > policy.max_write_bytes
397+
{
398+
return Err(VmError::HostError(format!(
399+
"io_write exceeds the configured write limit of {} bytes",
400+
policy.max_write_bytes
401+
)));
402+
}
361403
let bytes = text.as_bytes().to_vec();
362404
let handle = resource_handle(handle_id)?;
363405
let resource = io_resource_for_handle(vm, handle)?;
@@ -438,15 +480,65 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult<HostCall
438480
/// Returns whether a file system path exists.
439481
#[pd_host_function(name = "io::exists")]
440482
pub(super) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult<HostCallResult<bool>> {
441-
let path = path.to_string();
483+
let path = authorize_io_path(vm, path, false)?;
442484
let op_id = schedule_io_task(vm, None, move || {
443-
IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool(
444-
std::path::Path::new(path.as_str()).exists(),
445-
))))
485+
IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool(path.exists()))))
446486
})?;
447487
Ok(HostCallResult::Pending(op_id))
448488
}
449489

490+
fn authorize_io_path(vm: &Vm, path: &str, writes: bool) -> VmResult<PathBuf> {
491+
let requested = PathBuf::from(path);
492+
let Some(policy) = vm.host.io_policy.as_ref() else {
493+
return Ok(requested);
494+
};
495+
if writes && !policy.allow_write {
496+
return Err(VmError::HostError(
497+
"io path write requires the write capability".to_string(),
498+
));
499+
}
500+
let absolute = if requested.is_absolute() {
501+
requested
502+
} else {
503+
std::env::current_dir()
504+
.map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))?
505+
.join(requested)
506+
};
507+
let canonical = canonicalize_io_target(&absolute)?;
508+
for root in &policy.allowed_roots {
509+
let root = Path::new(root).canonicalize().map_err(|error| {
510+
VmError::HostError(format!(
511+
"io allowed root '{root}' cannot be resolved: {error}"
512+
))
513+
})?;
514+
if canonical.starts_with(root) {
515+
return Ok(canonical);
516+
}
517+
}
518+
Err(VmError::HostError(format!(
519+
"io path '{}' is outside the allowed roots",
520+
canonical.display()
521+
)))
522+
}
523+
524+
fn canonicalize_io_target(path: &Path) -> VmResult<PathBuf> {
525+
if path.exists() {
526+
return path
527+
.canonicalize()
528+
.map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")));
529+
}
530+
let parent = path
531+
.parent()
532+
.ok_or_else(|| VmError::HostError(format!("io path '{}' has no parent", path.display())))?;
533+
let file_name = path.file_name().ok_or_else(|| {
534+
VmError::HostError(format!("io path '{}' has no file name", path.display()))
535+
})?;
536+
parent
537+
.canonicalize()
538+
.map(|parent| parent.join(file_name))
539+
.map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))
540+
}
541+
450542
fn spawn_shell_command(command: &str, mode: &str) -> VmResult<Child> {
451543
let mut process = if cfg!(windows) {
452544
let mut cmd = Command::new("cmd");
@@ -875,7 +967,37 @@ fn terminate_process_tree(process_id: u32) -> VmResult<()> {
875967
)))
876968
}
877969

878-
fn read_line_from_reader(reader: &mut impl Read) -> VmResult<String> {
970+
fn read_to_string_with_limit(
971+
reader: &mut impl Read,
972+
max_read_bytes: Option<usize>,
973+
out: &mut String,
974+
) -> VmResult<()> {
975+
match max_read_bytes {
976+
None => {
977+
reader
978+
.read_to_string(out)
979+
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?;
980+
}
981+
Some(limit) => {
982+
let take_limit = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
983+
reader
984+
.take(take_limit)
985+
.read_to_string(out)
986+
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?;
987+
if out.len() > limit {
988+
return Err(VmError::HostError(format!(
989+
"io_read_all exceeds the configured read limit of {limit} bytes"
990+
)));
991+
}
992+
}
993+
}
994+
Ok(())
995+
}
996+
997+
fn read_line_from_reader(
998+
reader: &mut impl Read,
999+
max_read_bytes: Option<usize>,
1000+
) -> VmResult<String> {
8791001
let mut bytes = Vec::new();
8801002
let mut one = [0u8; 1];
8811003
loop {
@@ -886,6 +1008,12 @@ fn read_line_from_reader(reader: &mut impl Read) -> VmResult<String> {
8861008
break;
8871009
}
8881010
bytes.push(one[0]);
1011+
if max_read_bytes.is_some_and(|limit| bytes.len() > limit) {
1012+
return Err(VmError::HostError(format!(
1013+
"io_read_line exceeds the configured read limit of {} bytes",
1014+
max_read_bytes.expect("read limit should be present")
1015+
)));
1016+
}
8891017
if one[0] == b'\n' {
8901018
break;
8911019
}

src/lib.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,13 @@ pub use jit::{
8888
pub use vm::diagnostics::render_vm_error;
8989
#[cfg(feature = "runtime")]
9090
pub use vm::{
91-
AotArtifactError, CallOutcome, CallReturn, CancellationReason, DEFAULT_MAX_SCRIPT_CALL_DEPTH,
92-
EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge,
93-
HostBindingPlan, HostFunction, HostFunctionRegistry, HostOpId, HostStackFunction,
94-
IntoScriptValue, QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult,
95-
StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError,
96-
VmResult, VmStatus, VmYieldReason,
91+
AotArtifactError, CallOutcome, CallReturn, CancellationReason, CapabilityProfile,
92+
CapabilityProfileBuilder, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle,
93+
FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction,
94+
HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, IoPolicy,
95+
QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction,
96+
StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus,
97+
VmYieldReason,
9798
};
9899
#[cfg(feature = "sqlite")]
99100
pub use vm::{SqliteLimits, SqlitePolicy};

0 commit comments

Comments
 (0)