diff --git a/src/sdk/dotnet/core/Api/Sandbox.cs b/src/sdk/dotnet/core/Api/Sandbox.cs index c0a4427..1a1b98b 100644 --- a/src/sdk/dotnet/core/Api/Sandbox.cs +++ b/src/sdk/dotnet/core/Api/Sandbox.cs @@ -49,6 +49,7 @@ internal Sandbox( string? inputDir, string? outputDir, bool tempOutput, + FilesystemLimitsConfiguration? filesystemLimits, SandboxBackend backend = SandboxBackend.Wasm) { // Pin the module path string for the FFI call duration (null for JS backend). @@ -78,6 +79,17 @@ internal Sandbox( } } + if (filesystemLimits is { } limits) + { + var r = SafeNativeMethods.hyperlight_sandbox_set_filesystem_limits( + _handle, + (uint)limits.Mode, + limits.MaxFileSize, + limits.MaxTotalSize, + limits.MaxFileCount); + r.ThrowIfError(); + } + // Apply optional configuration. // Note: GC.KeepAlive(this) is not needed in the constructor — the // object cannot be finalized while its constructor is still running. diff --git a/src/sdk/dotnet/core/Api/SandboxBuilder.cs b/src/sdk/dotnet/core/Api/SandboxBuilder.cs index f4d05c7..2e74c17 100644 --- a/src/sdk/dotnet/core/Api/SandboxBuilder.cs +++ b/src/sdk/dotnet/core/Api/SandboxBuilder.cs @@ -27,6 +27,7 @@ public sealed class SandboxBuilder private string? _inputDir; private string? _outputDir; private bool _tempOutput; + private FilesystemLimitsConfiguration? _filesystemLimits; private SandboxBackend _backend = SandboxBackend.Wasm; /// @@ -145,6 +146,74 @@ public SandboxBuilder WithTempOutput(bool enabled = true) return this; } + /// + /// Sets finite logical resource limits for the writable filesystem. + /// + /// + /// Maximum logical size of one file, such as "64Mi", or raw bytes + /// as a string. + /// + /// + /// Maximum combined logical size of all files, such as "256Mi", + /// or raw bytes as a string. + /// + /// Maximum number of files. + /// This builder for chaining. + /// + /// A value of zero is a real finite limit. Calling this method replaces + /// any filesystem policy previously configured on this builder. + /// + public SandboxBuilder WithFilesystemLimits( + string maxFileSize, + string maxTotalSize, + ulong maxFileCount) + { + return WithFilesystemLimits( + SizeParser.Parse(maxFileSize), + SizeParser.Parse(maxTotalSize), + maxFileCount); + } + + /// + /// Sets finite logical resource limits for the writable filesystem. + /// + /// Maximum logical size of one file, in bytes. + /// + /// Maximum combined logical size of all files, in bytes. + /// + /// Maximum number of files. + /// This builder for chaining. + /// + /// A value of zero is a real finite limit. Calling this method replaces + /// any filesystem policy previously configured on this builder. + /// + public SandboxBuilder WithFilesystemLimits( + ulong maxFileSize, + ulong maxTotalSize, + ulong maxFileCount) + { + _filesystemLimits = FilesystemLimitsConfiguration.Finite( + maxFileSize, + maxTotalSize, + maxFileCount); + return this; + } + + /// + /// Removes all logical size and file-count limits from the writable + /// filesystem. + /// + /// This builder for chaining. + /// + /// Calling this method replaces any filesystem policy previously + /// configured on this builder. + /// + public SandboxBuilder WithUnlimitedFilesystemLimits() + { + _filesystemLimits = FilesystemLimitsConfiguration.Unlimited; + return this; + } + /// /// Creates a new with the configured settings. /// @@ -176,6 +245,29 @@ public Sandbox Build() _inputDir, _outputDir, _tempOutput, + _filesystemLimits, _backend); } } + +internal readonly record struct FilesystemLimitsConfiguration( + FilesystemLimitsMode Mode, + ulong MaxFileSize, + ulong MaxTotalSize, + ulong MaxFileCount) +{ + internal static FilesystemLimitsConfiguration Finite( + ulong maxFileSize, + ulong maxTotalSize, + ulong maxFileCount) + => new(FilesystemLimitsMode.Finite, maxFileSize, maxTotalSize, maxFileCount); + + internal static FilesystemLimitsConfiguration Unlimited => + new(FilesystemLimitsMode.Unlimited, 0, 0, 0); +} + +internal enum FilesystemLimitsMode : uint +{ + Finite = 0, + Unlimited = 1, +} diff --git a/src/sdk/dotnet/core/PInvoke/SafeNativeMethods.cs b/src/sdk/dotnet/core/PInvoke/SafeNativeMethods.cs index 6eac545..7e005cf 100644 --- a/src/sdk/dotnet/core/PInvoke/SafeNativeMethods.cs +++ b/src/sdk/dotnet/core/PInvoke/SafeNativeMethods.cs @@ -169,6 +169,19 @@ internal static partial FFIResult hyperlight_sandbox_set_temp_output( SandboxSafeHandle handle, [MarshalAs(UnmanagedType.I1)] bool enabled); + /// + /// Sets the writable filesystem quota policy. + /// Mode 0 is finite and mode 1 is unlimited. + /// + [LibraryImport(LibName)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial FFIResult hyperlight_sandbox_set_filesystem_limits( + SandboxSafeHandle handle, + uint mode, + ulong maxFileSize, + ulong maxTotalSize, + ulong maxFileCount); + /// Adds a domain to the network allowlist. [LibraryImport(LibName)] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] diff --git a/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/PInvokeLayerTests.cs b/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/PInvokeLayerTests.cs index d10e753..bcd95b2 100644 --- a/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/PInvokeLayerTests.cs +++ b/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/PInvokeLayerTests.cs @@ -224,4 +224,45 @@ public void FreeSandbox_Null_DoesNotCrash() { SafeNativeMethods.hyperlight_sandbox_free(IntPtr.Zero); } + + [Fact] + public void SetFilesystemLimits_ZeroFiniteValues_Succeeds() + { + using var handle = CreateSandboxHandle(); + var result = SafeNativeMethods.hyperlight_sandbox_set_filesystem_limits( + handle, 0, 0, 0, 0); + + result.ThrowIfError(); + } + + [Fact] + public void SetFilesystemLimits_InvalidMode_ThrowsArgumentException() + { + using var handle = CreateSandboxHandle(); + var result = SafeNativeMethods.hyperlight_sandbox_set_filesystem_limits( + handle, 2, 0, 0, 0); + + Assert.Throws(() => result.ThrowIfError()); + } + + private static SandboxSafeHandle CreateSandboxHandle() + { + var modulePath = Marshal.StringToCoTaskMemUTF8("/tmp/test.wasm"); + try + { + var result = SafeNativeMethods.hyperlight_sandbox_create(new FFISandboxOptions + { + module_path = modulePath, + heap_size = 0, + stack_size = 0, + backend = 0, + }); + result.ThrowIfError(); + return new SandboxSafeHandle(result.value); + } + finally + { + Marshal.FreeCoTaskMem(modulePath); + } + } } diff --git a/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/SandboxBuilderTests.cs b/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/SandboxBuilderTests.cs index 268c469..78fbdd3 100644 --- a/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/SandboxBuilderTests.cs +++ b/src/sdk/dotnet/core/Tests/HyperlightSandbox.Tests/SandboxBuilderTests.cs @@ -120,6 +120,52 @@ public void WithTempOutput_Works() Assert.NotNull(sandbox); } + [Fact] + public void WithFilesystemLimits_StringValues_Works() + { + using var sandbox = new SandboxBuilder() + .WithModulePath("/tmp/test.wasm") + .WithFilesystemLimits("64Mi", "256Mi", 1024) + .Build(); + + Assert.NotNull(sandbox); + } + + [Fact] + public void WithFilesystemLimits_ZeroValues_AreFiniteLimits() + { + using var sandbox = new SandboxBuilder() + .WithModulePath("/tmp/test.wasm") + .WithFilesystemLimits(0, 0, 0) + .Build(); + + Assert.NotNull(sandbox); + } + + [Fact] + public void WithUnlimitedFilesystemLimits_Works() + { + using var sandbox = new SandboxBuilder() + .WithModulePath("/tmp/test.wasm") + .WithUnlimitedFilesystemLimits() + .Build(); + + Assert.NotNull(sandbox); + } + + [Fact] + public void FilesystemPolicy_LastCallReplacesPriorPolicy() + { + using var sandbox = new SandboxBuilder() + .WithModulePath("/tmp/test.wasm") + .WithFilesystemLimits(1, 2, 3) + .WithUnlimitedFilesystemLimits() + .WithFilesystemLimits(4, 5, 6) + .Build(); + + Assert.NotNull(sandbox); + } + [Fact] public void ChainedConfiguration_AllOptions_Works() { diff --git a/src/sdk/dotnet/ffi/src/lib.rs b/src/sdk/dotnet/ffi/src/lib.rs index 62990e9..79f498c 100644 --- a/src/sdk/dotnet/ffi/src/lib.rs +++ b/src/sdk/dotnet/ffi/src/lib.rs @@ -26,8 +26,8 @@ use std::ptr::NonNull; use anyhow::Result; use hyperlight_javascript_sandbox::HyperlightJs; use hyperlight_sandbox::{ - DEFAULT_HEAP_SIZE, DEFAULT_STACK_SIZE, DirPerms, FilePerms, GuestSandbox, HttpMethod, Sandbox, - SandboxBuilder, SandboxConfig, ToolRegistry, ToolSchema, + DEFAULT_HEAP_SIZE, DEFAULT_STACK_SIZE, DirPerms, FilePerms, FilesystemLimits, GuestSandbox, + HttpMethod, Sandbox, SandboxBuilder, SandboxConfig, ToolRegistry, ToolSchema, }; use hyperlight_wasm_sandbox::Wasm; use log::{debug, error}; @@ -136,6 +136,16 @@ pub enum FFIBackend { JavaScript = 1, } +/// Filesystem limit policy discriminant. +#[repr(u32)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FFIFilesystemLimitsMode { + /// Apply the supplied finite values. Zero is a valid finite value. + Finite = 0, + /// Disable all filesystem quotas. Value arguments must be zero. + Unlimited = 1, +} + // --------------------------------------------------------------------------- // Tool callback type // --------------------------------------------------------------------------- @@ -217,6 +227,8 @@ struct SandboxState { output_dir: Option, /// Whether to use a temporary output directory. temp_output: bool, + /// Logical resource limits for the writable filesystem. + filesystem_limits: FilesystemLimits, } // --------------------------------------------------------------------------- @@ -632,6 +644,7 @@ pub unsafe extern "C" fn hyperlight_sandbox_create(options: FFISandboxOptions) - input_dir: None, output_dir: None, temp_output: false, + filesystem_limits: FilesystemLimits::default(), }; let handle = Box::into_raw(Box::new(state)); @@ -752,6 +765,80 @@ pub unsafe extern "C" fn hyperlight_sandbox_set_temp_output( FFIResult::success_null() } +/// Sets logical resource limits for the writable filesystem. +/// +/// Must be called before the first `run()`. A call replaces the entire +/// previously configured policy. +/// +/// * `mode = 0` applies the three finite values. Zero is a real finite limit. +/// * `mode = 1` disables all limits and requires all value arguments to be zero. +/// +/// # Safety +/// +/// `handle` must be a valid sandbox handle. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn hyperlight_sandbox_set_filesystem_limits( + handle: *mut SandboxState, + mode: u32, + max_file_size: u64, + max_total_size: u64, + max_file_count: u64, +) -> FFIResult { + let state = match unsafe { deref_handle_mut(handle, "sandbox") } { + Ok(s) => s, + Err(e) => return e, + }; + if state.inner.is_some() { + return FFIResult::error( + FFIErrorCode::InvalidArgument, + safe_cstring("Cannot set filesystem limits after sandbox has been initialized"), + ); + } + + let limits = match mode { + mode if mode == FFIFilesystemLimitsMode::Finite as u32 => { + let max_file_count = match usize::try_from(max_file_count) { + Ok(value) => value, + Err(_) => { + return FFIResult::error( + FFIErrorCode::InvalidArgument, + safe_cstring("max_file_count exceeds the platform usize range"), + ); + } + }; + match FilesystemLimits::new(max_file_size, max_total_size, max_file_count) { + Ok(limits) => limits, + Err(error) => { + return FFIResult::error( + FFIErrorCode::InvalidArgument, + safe_cstring(error.to_string()), + ); + } + } + } + mode if mode == FFIFilesystemLimitsMode::Unlimited as u32 => { + if max_file_size != 0 || max_total_size != 0 || max_file_count != 0 { + return FFIResult::error( + FFIErrorCode::InvalidArgument, + safe_cstring("Unlimited filesystem mode requires all limit values to be zero"), + ); + } + FilesystemLimits::unlimited() + } + other => { + return FFIResult::error( + FFIErrorCode::InvalidArgument, + safe_cstring(format!( + "Invalid filesystem limits mode: {other}. Use 0 (finite) or 1 (unlimited)." + )), + ); + } + }; + + state.filesystem_limits = limits; + FFIResult::success_null() +} + /// Adds a domain to the network allowlist. /// /// Can be called before or after initialization. @@ -918,6 +1005,7 @@ fn ensure_initialized(state: &mut SandboxState) -> Result<()> { .module_path(&state.config.module_path) .heap_size(state.config.heap_size) .stack_size(state.config.stack_size) + .filesystem_limits(state.filesystem_limits) .with_tools(registry) .guest(Wasm); @@ -945,6 +1033,7 @@ fn ensure_initialized(state: &mut SandboxState) -> Result<()> { let mut builder = SandboxBuilder::new() .heap_size(state.config.heap_size) .stack_size(state.config.stack_size) + .filesystem_limits(state.filesystem_limits) .with_tools(registry) .guest(HyperlightJs); @@ -1748,6 +1837,98 @@ mod tests { unsafe { hyperlight_sandbox_free_string(result.value) }; } + // ----------------------------------------------------------------------- + // Configuration: set_filesystem_limits + // ----------------------------------------------------------------------- + + #[test] + fn filesystem_limits_default_to_shared_defaults() { + let handle = create_test_handle(); + let state = unsafe { &*handle }; + assert_eq!(state.filesystem_limits, FilesystemLimits::default()); + unsafe { hyperlight_sandbox_free(handle) }; + } + + #[test] + fn set_filesystem_limits_finite_accepts_zero_values() { + let handle = create_test_handle(); + let result = unsafe { hyperlight_sandbox_set_filesystem_limits(handle, 0, 0, 0, 0) }; + assert!(result.is_success); + + let state = unsafe { &*handle }; + assert_eq!(state.filesystem_limits.max_file_size(), Some(0)); + assert_eq!(state.filesystem_limits.max_total_size(), Some(0)); + assert_eq!(state.filesystem_limits.max_file_count(), Some(0)); + unsafe { hyperlight_sandbox_free(handle) }; + } + + #[test] + fn set_filesystem_limits_unlimited_succeeds() { + let handle = create_test_handle(); + let result = unsafe { hyperlight_sandbox_set_filesystem_limits(handle, 1, 0, 0, 0) }; + assert!(result.is_success); + + let state = unsafe { &*handle }; + assert_eq!(state.filesystem_limits, FilesystemLimits::unlimited()); + unsafe { hyperlight_sandbox_free(handle) }; + } + + #[test] + fn set_filesystem_limits_replaces_previous_policy() { + let handle = create_test_handle(); + let first = unsafe { hyperlight_sandbox_set_filesystem_limits(handle, 0, 1, 2, 3) }; + assert!(first.is_success); + let second = unsafe { hyperlight_sandbox_set_filesystem_limits(handle, 0, 4, 5, 6) }; + assert!(second.is_success); + + let state = unsafe { &*handle }; + assert_eq!(state.filesystem_limits.max_file_size(), Some(4)); + assert_eq!(state.filesystem_limits.max_total_size(), Some(5)); + assert_eq!(state.filesystem_limits.max_file_count(), Some(6)); + unsafe { hyperlight_sandbox_free(handle) }; + } + + #[test] + fn set_filesystem_limits_rejects_invalid_mode() { + let handle = create_test_handle(); + let result = unsafe { hyperlight_sandbox_set_filesystem_limits(handle, 2, 0, 0, 0) }; + assert!(!result.is_success); + assert_eq!(result.error_code, FFIErrorCode::InvalidArgument as u32); + unsafe { hyperlight_sandbox_free_string(result.value) }; + unsafe { hyperlight_sandbox_free(handle) }; + } + + #[test] + fn set_filesystem_limits_rejects_null_handle() { + let result = + unsafe { hyperlight_sandbox_set_filesystem_limits(ptr::null_mut(), 0, 1, 2, 3) }; + assert!(!result.is_success); + assert_eq!(result.error_code, FFIErrorCode::InvalidArgument as u32); + unsafe { hyperlight_sandbox_free_string(result.value) }; + } + + #[test] + fn set_filesystem_limits_rejects_unlimited_values() { + let handle = create_test_handle(); + let result = unsafe { hyperlight_sandbox_set_filesystem_limits(handle, 1, 1, 0, 0) }; + assert!(!result.is_success); + assert_eq!(result.error_code, FFIErrorCode::InvalidArgument as u32); + unsafe { hyperlight_sandbox_free_string(result.value) }; + unsafe { hyperlight_sandbox_free(handle) }; + } + + #[test] + fn set_filesystem_limits_rejects_oversized_file_limit() { + let handle = create_test_handle(); + let result = unsafe { + hyperlight_sandbox_set_filesystem_limits(handle, 0, i64::MAX as u64 + 1, 0, 0) + }; + assert!(!result.is_success); + assert_eq!(result.error_code, FFIErrorCode::InvalidArgument as u32); + unsafe { hyperlight_sandbox_free_string(result.value) }; + unsafe { hyperlight_sandbox_free(handle) }; + } + // ----------------------------------------------------------------------- // Configuration: allow_domain // -----------------------------------------------------------------------