Skip to content

Commit 2ed098c

Browse files
committed
feat(host): bind capability profiles and IO limits
1 parent b73fa51 commit 2ed098c

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};
@@ -204,7 +205,16 @@ pub(super) fn builtin_io_open(
204205
path: &str,
205206
mode: &str,
206207
) -> VmResult<HostCallResult<i64>> {
207-
let path = path.to_string();
208+
let writes = match mode {
209+
"r" => false,
210+
"w" | "a" | "r+" | "w+" | "a+" => true,
211+
other => {
212+
return Err(VmError::HostError(format!(
213+
"unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+"
214+
)));
215+
}
216+
};
217+
let path = authorize_io_path(vm, path, writes)?;
208218
let mode = mode.to_string();
209219
let op_id = schedule_io_task(vm, None, move || {
210220
let mut options = OpenOptions::new();
@@ -260,6 +270,16 @@ pub(super) fn builtin_io_popen(
260270
"unsupported io_popen mode '{mode}', expected r or w"
261271
)));
262272
}
273+
if vm
274+
.host
275+
.io_policy
276+
.as_ref()
277+
.is_some_and(|policy| !policy.allow_process)
278+
{
279+
return Err(VmError::HostError(
280+
"io_popen requires the process capability".to_string(),
281+
));
282+
}
263283
let command = command.to_string();
264284
let mode = mode.to_string();
265285
let op_id = schedule_io_task(vm, None, move || {
@@ -298,23 +318,31 @@ pub(super) fn builtin_io_popen(
298318
/// Reads all remaining text from an I/O handle.
299319
#[pd_host_function(name = "io::read_all")]
300320
pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult<HostCallResult<String>> {
321+
let max_read_bytes = vm
322+
.host
323+
.io_policy
324+
.as_ref()
325+
.map(|policy| policy.max_read_bytes);
301326
let handle = resource_handle(handle_id)?;
302327
let resource = io_resource_for_handle(vm, handle)?;
303328
let op_id = schedule_io_task(vm, Some(handle), move || {
304329
let result = resource.with_handle_mut(|handle| {
305330
let mut out = String::new();
306331
match handle {
307-
IoHandle::File(file) => file
308-
.read_to_string(&mut out)
309-
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?,
310-
IoHandle::PopenRead { child } => child
311-
.stdout
312-
.as_mut()
313-
.ok_or_else(|| {
314-
VmError::HostError("io_read_all popen handle missing stdout".to_string())
315-
})?
316-
.read_to_string(&mut out)
317-
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?,
332+
IoHandle::File(file) => {
333+
read_to_string_with_limit(file, max_read_bytes, &mut out)?;
334+
}
335+
IoHandle::PopenRead { child } => {
336+
read_to_string_with_limit(
337+
child.stdout.as_mut().ok_or_else(|| {
338+
VmError::HostError(
339+
"io_read_all popen handle missing stdout".to_string(),
340+
)
341+
})?,
342+
max_read_bytes,
343+
&mut out,
344+
)?;
345+
}
318346
IoHandle::PopenWrite { .. } => {
319347
return Err(VmError::HostError(
320348
"io_read_all requires a readable handle".to_string(),
@@ -334,17 +362,23 @@ pub(super) fn builtin_io_read_line(
334362
vm: &mut Vm,
335363
handle_id: i64,
336364
) -> VmResult<HostCallResult<String>> {
365+
let max_read_bytes = vm
366+
.host
367+
.io_policy
368+
.as_ref()
369+
.map(|policy| policy.max_read_bytes);
337370
let handle = resource_handle(handle_id)?;
338371
let resource = io_resource_for_handle(vm, handle)?;
339372
let op_id = schedule_io_task(vm, Some(handle), move || {
340373
let result = resource.with_handle_mut(|handle| {
341374
let line = match handle {
342-
IoHandle::File(file) => read_line_from_reader(file)?,
343-
IoHandle::PopenRead { child } => {
344-
read_line_from_reader(child.stdout.as_mut().ok_or_else(|| {
375+
IoHandle::File(file) => read_line_from_reader(file, max_read_bytes)?,
376+
IoHandle::PopenRead { child } => read_line_from_reader(
377+
child.stdout.as_mut().ok_or_else(|| {
345378
VmError::HostError("io_read_line popen handle missing stdout".to_string())
346-
})?)?
347-
}
379+
})?,
380+
max_read_bytes,
381+
)?,
348382
IoHandle::PopenWrite { .. } => {
349383
return Err(VmError::HostError(
350384
"io_read_line requires a readable handle".to_string(),
@@ -365,6 +399,14 @@ pub(super) fn builtin_io_write(
365399
handle_id: i64,
366400
text: &str,
367401
) -> VmResult<HostCallResult<i64>> {
402+
if let Some(policy) = vm.host.io_policy.as_ref()
403+
&& text.len() > policy.max_write_bytes
404+
{
405+
return Err(VmError::HostError(format!(
406+
"io_write exceeds the configured write limit of {} bytes",
407+
policy.max_write_bytes
408+
)));
409+
}
368410
let bytes = text.as_bytes().to_vec();
369411
let handle = resource_handle(handle_id)?;
370412
let resource = io_resource_for_handle(vm, handle)?;
@@ -445,15 +487,65 @@ pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult<HostCall
445487
/// Returns whether a file system path exists.
446488
#[pd_host_function(name = "io::exists")]
447489
pub(super) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult<HostCallResult<bool>> {
448-
let path = path.to_string();
490+
let path = authorize_io_path(vm, path, false)?;
449491
let op_id = schedule_io_task(vm, None, move || {
450-
IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool(
451-
std::path::Path::new(path.as_str()).exists(),
452-
))))
492+
IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool(path.exists()))))
453493
})?;
454494
Ok(HostCallResult::Pending(op_id))
455495
}
456496

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

885-
fn read_line_from_reader(reader: &mut impl Read) -> VmResult<String> {
977+
fn read_to_string_with_limit(
978+
reader: &mut impl Read,
979+
max_read_bytes: Option<usize>,
980+
out: &mut String,
981+
) -> VmResult<()> {
982+
match max_read_bytes {
983+
None => {
984+
reader
985+
.read_to_string(out)
986+
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?;
987+
}
988+
Some(limit) => {
989+
let take_limit = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
990+
reader
991+
.take(take_limit)
992+
.read_to_string(out)
993+
.map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?;
994+
if out.len() > limit {
995+
return Err(VmError::HostError(format!(
996+
"io_read_all exceeds the configured read limit of {limit} bytes"
997+
)));
998+
}
999+
}
1000+
}
1001+
Ok(())
1002+
}
1003+
1004+
fn read_line_from_reader(
1005+
reader: &mut impl Read,
1006+
max_read_bytes: Option<usize>,
1007+
) -> VmResult<String> {
8861008
let mut bytes = Vec::new();
8871009
let mut one = [0u8; 1];
8881010
loop {
@@ -893,6 +1015,12 @@ fn read_line_from_reader(reader: &mut impl Read) -> VmResult<String> {
8931015
break;
8941016
}
8951017
bytes.push(one[0]);
1018+
if max_read_bytes.is_some_and(|limit| bytes.len() > limit) {
1019+
return Err(VmError::HostError(format!(
1020+
"io_read_line exceeds the configured read limit of {} bytes",
1021+
max_read_bytes.expect("read limit should be present")
1022+
)));
1023+
}
8961024
if one[0] == b'\n' {
8971025
break;
8981026
}

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)