diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f5b38b..04e84e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Added + +- Add `Landlock.fork` for running a sandboxed Ruby block in a supervised forked child. + ## [0.4.1] - 2026-08-20 ### Fixed diff --git a/README.md b/README.md index 0c05a7b..0293104 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,42 @@ Capture options: - `success_status_codes:` and `failure_message:` — `capture!` failure handling options. - `allow_all_known:` — when filesystem rules are present, handle all Landlock filesystem rights known to the running ABI so unlisted filesystem access is denied. +## Forking a Ruby block + +`Landlock.fork` is a supervised, synchronous fork. It forks the current Ruby process, applies the requested restrictions in the child, runs the block there, waits for it, and returns a `Landlock::CaptureResult`. It is intended for applications that need to reuse initialized Ruby state without executing a new command: + +```ruby +result = Landlock.fork( + read: [input_path], + timeout: 5, + rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024 }, + seccomp_deny_network: true +) do |stdout, _stderr| + stdout.write(calculate_dominant_color(input_path)) +end + +color = result.stdout if result.success? +``` + +The block receives its child-side stdout and stderr streams. Write response data to stdout and diagnostics to stderr, then inspect them through the capture result in the parent. The block's return value is discarded. An exception makes the child exit with status 1 and writes a diagnostic to stderr. `fork` accepts the capture options listed above except `success_status_codes:` and `failure_message:`, which only apply to `capture!`. + +By default, `Landlock.fork` requires Linux Landlock support and raises `Landlock::UnsupportedError` before forking when the Landlock ABI is unavailable. A Linux caller that explicitly accepts running without Landlock filesystem, TCP, and scope enforcement can opt in to the fallback: + +```ruby +result = Landlock.fork( + on_unsupported: :run_without_landlock, + timeout: 5, + rlimits: { memory_bytes: 512 * 1024 * 1024 }, + seccomp_deny_network: true +) { |stdout, _stderr| stdout.write(run_plugin) } +``` + +This fallback is used only when the Linux kernel has no Landlock ABI. It skips only Landlock policy enforcement; fork supervision, timeout handling, environment changes, descriptor closing, rlimits, output capture, and seccomp remain active. It is never selected implicitly, and non-Linux systems still raise `Landlock::UnsupportedError`. When fallback is active, the call must include `seccomp_deny_network: true` or at least one `rlimits:` entry because Landlock rules are not effective restrictions in that mode. Timeout, environment handling, descriptor closing, and output limits do not satisfy this requirement. `Landlock.fork` requires an actual restriction. By default the child closes inherited Ruby `IO` objects other than stdin, stdout, and stderr, but native extensions may hold descriptors Ruby does not expose as `IO` objects. Pass `close_others: false` only when the child intentionally needs an inherited descriptor. Child setup failures exit 127. + +The worker is a process-group leader and reserves Linux real-time signal `SIGRTMIN+2` for parent-death handling while the block runs. If the Ruby thread supervising the synchronous `Landlock.fork` call terminates, a native signal handler sends `SIGKILL` to the worker's process group. This terminates the worker and ordinary descendants that remain in that group. It does not cover descendants that create another process group or session, and the group-wide guarantee can be disabled by code that replaces or blocks the reserved signal, clears the parent-death signal, changes credentials in a way that clears it, or replaces the worker with `exec`. After `exec`, the reserved signal still terminates the worker by default, but the reset handler no longer kills its process group. This is process-lifecycle hardening, not a cgroup, PID namespace, or hostile-process containment boundary. + +Fork only from a process whose loaded libraries and runtime state are safe to use after `fork`. `Landlock.fork` cannot make an unsafe parent fork-safe, and the block must not depend on threads that exist only in the parent. + ## Restrict current process This is irreversible for the current thread and its future children. Use `Landlock.exec` or `Landlock.spawn` unless you really mean it. diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 55b8f43..9fad9a7 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -2,8 +2,14 @@ #include "landlock_native.h" #include "seccomp_deny_network.h" +#include #include +#ifdef __linux__ +#include +#include +#endif + static VALUE mLandlock; static VALUE eLandlockError; static VALUE eSyscallError; @@ -121,6 +127,40 @@ static VALUE rb_ll_close_fd(VALUE self, VALUE fd_value) { return Qnil; } +static VALUE rb_ll_close_inherited_fds(VALUE self) { + /* The forked child keeps running Ruby, so interpreter-reserved descriptors + * must survive. This rules out close_range across the entire descriptor table. */ +#ifdef __linux__ + DIR *dir = opendir("/proc/self/fd"); + if (dir) { + int dir_fd = dirfd(dir); + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + char *end = NULL; + errno = 0; + long fd = strtol(entry->d_name, &end, 10); + if (errno == 0 && end && *end == '\0' && fd >= 3 && fd != dir_fd && + !rb_reserved_fd_p((int)fd)) { + close((int)fd); + } + } + closedir(dir); + return Qtrue; + } +#endif + + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0) { + max_fd = 1024; + } + for (long fd = 3; fd < max_fd; fd++) { + if (!rb_reserved_fd_p((int)fd)) { + close((int)fd); + } + } + return Qtrue; +} + static VALUE rb_ll_pidfd_open(VALUE self, VALUE pid_value) { #ifdef SYS_pidfd_open int fd = syscall(SYS_pidfd_open, NUM2PIDT(pid_value), 0); @@ -135,6 +175,62 @@ static VALUE rb_ll_pidfd_open(VALUE self, VALUE pid_value) { #endif } +/* Runs after the worker has become its own process-group leader. */ +static void terminate_own_process_group(int signal_number) { + (void)signal_number; + kill(0, SIGKILL); + _exit(0); +} + +static VALUE rb_ll_arm_parent_death_process_group(VALUE self, VALUE parent_pid_value) { +#ifdef __linux__ + pid_t parent_pid = NUM2PIDT(parent_pid_value); + /* Leave the first two application-visible realtime signals available to callers. */ + int parent_death_signal = SIGRTMIN + 2; + + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = terminate_own_process_group; + sigemptyset(&action.sa_mask); + if (sigaction(parent_death_signal, &action, NULL) != 0) { + raise_syscall_error("sigaction(parent death process group)"); + } + + sigset_t signals; + sigemptyset(&signals); + sigaddset(&signals, parent_death_signal); + if (sigprocmask(SIG_UNBLOCK, &signals, NULL) != 0) { + raise_syscall_error("sigprocmask(parent death process group)"); + } + + if (prctl(PR_SET_PDEATHSIG, parent_death_signal) != 0) { + raise_syscall_error("prctl(PR_SET_PDEATHSIG)"); + } + + if (getppid() != parent_pid) { + terminate_own_process_group(parent_death_signal); + } + + return Qtrue; +#else + errno = ENOSYS; + raise_syscall_error("parent death process group"); + return Qnil; +#endif +} + +static VALUE rb_ll_set_parent_death_signal(VALUE self) { +#ifdef __linux__ + if (prctl(PR_SET_PDEATHSIG, SIGKILL) != 0) { + raise_syscall_error("prctl(PR_SET_PDEATHSIG)"); + } + return Qtrue; +#else + errno = ENOSYS; + raise_syscall_error("prctl(PR_SET_PDEATHSIG)"); +#endif +} + static VALUE rb_ll_seccomp_deny_network(VALUE self) { const char *error_message = "seccomp(SECCOMP_SET_MODE_FILTER)"; if (rb_landlock_seccomp_deny_network(&error_message) != 0) { @@ -164,7 +260,12 @@ void Init_landlock(void) { rb_define_singleton_method(mLandlock, "_add_net_rule", rb_ll_add_net_rule, 3); rb_define_singleton_method(mLandlock, "_restrict_self", rb_ll_restrict_self, 1); rb_define_singleton_method(mLandlock, "_close_fd", rb_ll_close_fd, 1); + rb_define_singleton_method(mLandlock, "_close_inherited_fds", rb_ll_close_inherited_fds, 0); rb_define_singleton_method(mLandlock, "_pidfd_open", rb_ll_pidfd_open, 1); + rb_define_singleton_method(mLandlock, "_arm_parent_death_process_group", + rb_ll_arm_parent_death_process_group, 1); + rb_define_singleton_method(mLandlock, "_set_parent_death_signal", rb_ll_set_parent_death_signal, + 0); rb_define_singleton_method(mLandlock, "seccomp_deny_network!", rb_ll_seccomp_deny_network, 0); rb_define_const(mLandlock, "ACCESS_FS_EXECUTE", ULL2NUM(LANDLOCK_ACCESS_FS_EXECUTE)); diff --git a/lib/landlock.rb b/lib/landlock.rb index 7be6240..3e1ae00 100644 --- a/lib/landlock.rb +++ b/lib/landlock.rb @@ -39,5 +39,9 @@ def capture(...) def capture!(...) Execution.capture!(...) end + + def fork(...) + Execution.fork(...) + end end end diff --git a/lib/landlock/execution.rb b/lib/landlock/execution.rb index 2109bf2..0391c1e 100644 --- a/lib/landlock/execution.rb +++ b/lib/landlock/execution.rb @@ -80,6 +80,36 @@ def capture!(argv, **options) capture_with(argv, raise_on_failure: true, **options) end + def fork(on_unsupported: :raise, **options, &block) + raise ArgumentError, "fork requires a block" if !block + if !%i[raise run_without_landlock].include?(on_unsupported) + raise ArgumentError, "on_unsupported must be :raise or :run_without_landlock" + end + + enforce_landlock = Native.abi_version.positive? + if !enforce_landlock && (on_unsupported == :raise || !RUBY_PLATFORM.include?("linux")) + raise UnsupportedError, "Linux Landlock is unavailable" + end + + capture_options = prepare_capture_options(**options, require_landlock: enforce_landlock) + validate_fallback_restriction!(**capture_options) if !enforce_landlock + + Runner::Fork.call_block( + **capture_options, + enforce_landlock:, + &block + ) + rescue OutputTooLargeError => error + result = error.result + raise CommandError.new( + error.message, + stdout: result&.stdout.to_s, + stderr: result&.stderr.to_s, + status: result&.status, + result: + ) + end + def capture_with( argv, read: nil, @@ -105,31 +135,30 @@ def capture_with( raise_on_failure: ) argv = Validation.normalize_argv(argv).map(&:to_s) - ensure_landlock_supported! - max_output_bytes = Validation.validate_output_limit!(max_output_bytes) - timeout = Validation.validate_timeout!(timeout) - normalized_rlimits = Rlimits.normalize(rlimits) - env = Env.normalize(env) - policy = - prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:) - validate_capture_restriction!(**policy, seccomp_deny_network:, rlimits: normalized_rlimits) - - result = - call_with_runner( - argv, - **policy, + options = + prepare_capture_options( + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, chdir:, env:, unsetenv_others:, close_others:, + allow_all_known:, timeout:, stdin:, - rlimits: normalized_rlimits, + rlimits:, seccomp_deny_network:, max_output_bytes:, truncate_output: ) + result = call_with_runner(argv, **options) + if raise_on_failure && (result.timed_out? || !result.status.exited? || !success_status_codes.include?(result.status.exitstatus)) message = [argv.join(" "), failure_message, result.stderr].filter { |part| part.to_s != "" }.join("\n") @@ -149,6 +178,51 @@ def capture_with( ) end + def prepare_capture_options( + read: nil, + write: nil, + execute: nil, + connect_tcp: nil, + bind_tcp: nil, + paths: nil, + scope: nil, + chdir: nil, + env: nil, + unsetenv_others: false, + close_others: true, + allow_all_known: false, + timeout: nil, + stdin: nil, + rlimits: {}, + seccomp_deny_network: false, + max_output_bytes: nil, + truncate_output: false, + require_landlock: true + ) + ensure_landlock_supported! if require_landlock + max_output_bytes = Validation.validate_output_limit!(max_output_bytes) + timeout = Validation.validate_timeout!(timeout) + rlimits = Rlimits.normalize(rlimits) + env = Env.normalize(env) + policy = + prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:) + validate_capture_restriction!(**policy, seccomp_deny_network:, rlimits:) + + { + **policy, + chdir:, + env:, + unsetenv_others:, + close_others:, + timeout:, + stdin:, + rlimits:, + seccomp_deny_network:, + max_output_bytes:, + truncate_output: + } + end + def spawn_with_runner(argv, **options) if Runner::Native.available? begin @@ -199,6 +273,12 @@ def validate_landlock_restriction!( raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes" end + def validate_fallback_restriction!(seccomp_deny_network:, rlimits:, **) + return if seccomp_deny_network || rlimits.any? + + raise ArgumentError, "Landlock fallback requires seccomp_deny_network or rlimits" + end + def validate_capture_restriction!( read:, write:, diff --git a/lib/landlock/native.rb b/lib/landlock/native.rb index 546c9c1..65588fb 100644 --- a/lib/landlock/native.rb +++ b/lib/landlock/native.rb @@ -31,10 +31,22 @@ def close_fd(fd) Landlock.__send__(:_close_fd, fd) end + def close_inherited_fds! + Landlock.__send__(:_close_inherited_fds) + end + def pidfd_open(pid) Landlock.__send__(:_pidfd_open, pid) end + def arm_parent_death_process_group!(parent_pid) + Landlock.__send__(:_arm_parent_death_process_group, parent_pid) + end + + def set_parent_death_signal! + Landlock.__send__(:_set_parent_death_signal) + end + def seccomp_deny_network! Landlock.seccomp_deny_network! end diff --git a/lib/landlock/runner.rb b/lib/landlock/runner.rb index 73c012a..29a1db7 100644 --- a/lib/landlock/runner.rb +++ b/lib/landlock/runner.rb @@ -16,11 +16,19 @@ def kernel_exec_args(argv, env, unsetenv_others:, close_others:) env ? [env, *argv_for_exec(argv), exec_options] : [*argv_for_exec(argv), exec_options] end - def exit_child!(error) - warn "Landlock child failed before exec: #{error.class}: #{error.message}" + def exit_child!(error, stderr: STDERR) + stderr.puts "Landlock child setup failed: #{error.class}: #{error.message}" + stderr.flush ensure exit! 127 end + + def exit_forked_block!(error, stderr: STDERR) + stderr.puts "Landlock forked block failed: #{error.class}: #{error.message}" + stderr.flush + ensure + exit! 1 + end end end diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb index 3200b2f..2261091 100644 --- a/lib/landlock/runner/fork.rb +++ b/lib/landlock/runner/fork.rb @@ -71,44 +71,97 @@ def call( seccomp_deny_network:, max_output_bytes:, truncate_output: + ) + capture_pipes(timeout:, stdin:, max_output_bytes:, truncate_output:) do + setup_child!( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + rlimits:, + seccomp_deny_network: + ) + rescue Exception => error + Runner.exit_child!(error) + end + end + + def call_block(timeout:, stdin:, max_output_bytes:, truncate_output:, enforce_landlock:, **options, &block) + capture_pipes( + timeout:, + stdin:, + max_output_bytes:, + truncate_output:, + kill_process_group_on_parent_death: true + ) do + begin + prepare_forked_block!(**options, enforce_landlock:) + rescue SystemExit, SignalException + raise + rescue Exception => error + Runner.exit_child!(error) + end + + block.call(STDOUT, STDERR) + exit! 0 + rescue SystemExit => error + exit! error.status + rescue SignalException + raise + rescue Exception => error + Runner.exit_forked_block!(error) + end + end + + def capture_pipes( + timeout:, + stdin:, + max_output_bytes:, + truncate_output:, + kill_process_group_on_parent_death: false ) stdout_reader, stdout_writer = IO.pipe stderr_reader, stderr_writer = IO.pipe stdin_reader, stdin_writer = IO.pipe + parent_pid = ::Process.pid pid = fork do begin + # Arm group cleanup only after leaving the supervisor's process group. + ::Process.setpgrp + if kill_process_group_on_parent_death + Landlock::Native.arm_parent_death_process_group!(parent_pid) + else + Landlock::Native.set_parent_death_signal! + exit! 1 if ::Process.ppid != parent_pid + end stdout_reader.close stderr_reader.close stdin_writer.close - ::Process.setpgrp STDIN.reopen(stdin_reader) STDOUT.reopen(stdout_writer) STDERR.reopen(stderr_writer) + STDOUT.sync = true + STDERR.sync = true stdin_reader.close stdout_writer.close stderr_writer.close - setup_child!( - argv, - read:, - write:, - execute:, - connect_tcp:, - bind_tcp:, - paths:, - scope:, - chdir:, - env:, - unsetenv_others:, - close_others:, - allow_all_known:, - rlimits:, - seccomp_deny_network: - ) + yield + rescue SystemExit, SignalException + raise rescue Exception => error - Runner.exit_child!(error) + Runner.exit_child!(error, stderr: capture_error_stream(stderr_writer)) end end @@ -141,6 +194,12 @@ def call( end end + def capture_error_stream(stderr_writer) + stderr_writer && !stderr_writer.closed? ? stderr_writer : STDERR + rescue IOError + STDERR + end + def setup_child!( argv, read:, @@ -166,6 +225,39 @@ def setup_child!( Rlimits.apply!(rlimits) Kernel.exec(*Runner.kernel_exec_args(argv, env, unsetenv_others:, close_others:)) end + + def prepare_forked_block!( + chdir:, + env:, + unsetenv_others:, + close_others:, + rlimits:, + seccomp_deny_network:, + enforce_landlock:, + **policy + ) + close_inherited_ios if close_others + Dir.public_send(:chdir, chdir) if chdir + ENV.clear if unsetenv_others + env&.each { |key, value| value.nil? ? ENV.delete(key) : ENV[key] = value } + Landlock.restrict!(**policy) if enforce_landlock && Policy.requested?(**policy) + Landlock::Native.seccomp_deny_network! if seccomp_deny_network + Rlimits.apply!(rlimits) + end + + def close_inherited_ios + ObjectSpace + .each_object(IO) + .to_a + .each do |io| + next if io.closed? || io.fileno <= 2 + + io.close + rescue IOError + end + + Landlock::Native.close_inherited_fds! + end end end end diff --git a/test/landlock_fork_test.rb b/test/landlock_fork_test.rb new file mode 100644 index 0000000..242035c --- /dev/null +++ b/test/landlock_fork_test.rb @@ -0,0 +1,386 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LandlockForkTest < LandlockTestCase + def test_fork_raises_when_landlock_is_unsupported_by_default + Landlock.stub(:abi_version, 0) do + assert_raises(Landlock::UnsupportedError) { Landlock.fork(rlimits: { open_files: 64 }) { print "unreachable" } } + end + end + + def test_fork_runs_without_landlock_when_explicitly_requested + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Dir.mktmpdir do |directory| + path = File.join(directory, "secret") + File.write(path, "secret") + + Landlock.stub(:abi_version, 0) do + result = + Landlock.fork( + on_unsupported: :run_without_landlock, + read: [], + write: [], + timeout: 1, + env: { + "LANDLOCK_FORK_FALLBACK" => "enabled" + }, + rlimits: { + open_files: 32 + } + ) { print [File.read(path), ENV.fetch("LANDLOCK_FORK_FALLBACK"), Process.getrlimit(:NOFILE).first].join(":") } + + assert_equal "secret:enabled:32", result.stdout + assert_predicate result, :success? + end + end + end + + def test_fork_fallback_rejects_landlock_only_policy + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Landlock.stub(:abi_version, 0) do + error = + assert_raises(ArgumentError) do + Landlock.fork(on_unsupported: :run_without_landlock, read: []) { print "unreachable" } + end + + assert_equal "Landlock fallback requires seccomp_deny_network or rlimits", error.message + end + end + + def test_fork_fallback_enforces_timeout + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Landlock.stub(:abi_version, 0) do + result = + Landlock.fork(on_unsupported: :run_without_landlock, timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } + + assert_predicate result, :timed_out? + refute_predicate result, :success? + end + end + + def test_fork_fallback_applies_seccomp + skip "Landlock fallback is Linux-only" if RUBY_PLATFORM !~ /linux/ + + Landlock.stub(:abi_version, 0) do + result = + Landlock.fork(on_unsupported: :run_without_landlock, seccomp_deny_network: true) do + Socket.new(:INET, :STREAM) + rescue Errno::EPERM + print "denied" + end + + assert_equal "denied", result.stdout + assert_predicate result, :success? + end + end + + def test_fork_rejects_an_invalid_on_unsupported_value + error = + assert_raises(ArgumentError) do + Landlock.fork(on_unsupported: :ignore, rlimits: { open_files: 64 }) { print "unreachable" } + end + + assert_equal "on_unsupported must be :raise or :run_without_landlock", error.message + end + + def test_fork_does_not_fallback_on_non_linux + skip "Non-Linux behavior" if RUBY_PLATFORM.include?("linux") + + Landlock.stub(:abi_version, 0) do + assert_raises(Landlock::UnsupportedError) do + Landlock.fork(on_unsupported: :run_without_landlock, rlimits: { open_files: 64 }) { print "unreachable" } + end + end + end + + def test_fork_captures_an_inherited_ruby_block + skip "Landlock unsupported" unless Landlock.supported? + + inherited = "ready" + result = + Landlock.fork(rlimits: { open_files: 64 }) do |stdout, stderr| + stdout.print inherited + stderr.puts "warning" + end + + assert_equal "ready", result.stdout + assert_equal "warning\n", result.stderr + assert_predicate result, :success? + end + + def test_fork_returns_block_errors + skip "Landlock unsupported" unless Landlock.supported? + + result = Landlock.fork(rlimits: { open_files: 64 }) { raise "failed" } + + assert_equal 1, result.status.exitstatus + assert_match(/RuntimeError: failed/, result.stderr) + refute_predicate result, :success? + end + + def test_fork_preserves_system_exit_status + skip "Landlock unsupported" unless Landlock.supported? + + [0, 7].each do |exit_status| + result = Landlock.fork(rlimits: { open_files: 64 }) { exit exit_status } + + assert_predicate result.status, :exited? + assert_equal exit_status, result.status.exitstatus + assert_equal exit_status.zero?, result.success? + assert_empty result.stderr + end + end + + def test_fork_preserves_signal_status + skip "Landlock unsupported" unless Landlock.supported? + + result = + Landlock.fork(rlimits: { open_files: 64 }) do + Process.kill("TERM", Process.pid) + sleep 1 + end + + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_empty result.stderr + refute_predicate result, :success? + end + + def test_fork_captures_block_errors_when_global_stderr_is_reassigned + skip "Landlock unsupported" unless Landlock.supported? + + original_stderr = $stderr + replacement_stderr = StringIO.new + result = + begin + $stderr = replacement_stderr + Landlock.fork(rlimits: { open_files: 64 }) { raise "failed" } + ensure + $stderr = original_stderr + end + + assert_equal 1, result.status.exitstatus + assert_equal "Landlock forked block failed: RuntimeError: failed\n", result.stderr + assert_empty replacement_stderr.string + end + + def test_fork_captures_child_bootstrap_errors + skip "Landlock unsupported" unless Landlock.supported? + + result = nil + Landlock::Native.stub(:arm_parent_death_process_group!, ->(*) { raise "bootstrap failed" }) do + result = Landlock.fork(rlimits: { open_files: 64 }) { print "unreachable" } + end + + assert_equal 127, result.status.exitstatus + assert_equal "Landlock child setup failed: RuntimeError: bootstrap failed\n", result.stderr + refute_predicate result, :success? + end + + def test_fork_timeout_during_child_bootstrap_preserves_signal_status + skip "Landlock unsupported" unless Landlock.supported? + + slow_setup = ->(**) { sleep 30 } + result = nil + Landlock::Runner::Fork.stub(:prepare_forked_block!, slow_setup) do + result = Landlock.fork(timeout: 0.01, rlimits: { open_files: 64 }) { raise "unreachable" } + end + + assert_predicate result, :timed_out? + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_empty result.stderr + refute_predicate result, :success? + end + + def test_fork_discards_the_block_return_value + skip "Landlock unsupported" unless Landlock.supported? + + result = Landlock.fork(rlimits: { open_files: 64 }) { Object.new } + + assert_empty result.stdout + assert_predicate result, :success? + end + + def test_fork_enforces_timeout + skip "Landlock unsupported" unless Landlock.supported? + + result = Landlock.fork(timeout: 0.01, rlimits: { open_files: 64 }) { sleep 30 } + + assert_predicate result, :timed_out? + assert_predicate result.status, :signaled? + assert_equal Signal.list.fetch("TERM"), result.status.termsig + assert_empty result.stderr + refute_predicate result, :success? + end + + def test_fork_child_exits_with_its_parent + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |directory| + pid_path = File.join(directory, "child.pid") + supervisor_pid = + fork do + Landlock.fork(write: [directory]) do + File.write(pid_path, Process.pid) + sleep 30 + end + end + + sleep 0.01 until File.exist?(pid_path) + child_pid = Integer(File.read(pid_path)) + Process.kill("KILL", supervisor_pid) + Process.waitpid(supervisor_pid) + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1 + sleep 0.01 while process_alive?(child_pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + + refute process_alive?(child_pid) + end + end + + def test_fork_descendants_exit_with_their_supervisor + skip "Landlock unsupported" unless Landlock.supported? + + supervisor_pid = nil + descendant_pid = nil + + Dir.mktmpdir do |directory| + pid_path = File.join(directory, "descendant.pid") + fork_options = { write: [directory], close_others: false } + fork_options[:scope] = [:signal] if Landlock.abi_version >= 6 + supervisor_pid = + fork do + Landlock.fork(**fork_options) do + # A nested Ruby fork needs the runtime descriptors inherited by the worker. + fork do + contents = [Process.pid, Process.ppid, Process.getpgrp].join(":") + File.write("#{pid_path}.tmp", contents) + File.rename("#{pid_path}.tmp", pid_path) + sleep 30 + end + sleep 30 + end + end + + descendant_pid, worker_pid, process_group = + Timeout.timeout(2) do + loop do + break File.read(pid_path).split(":").map { |value| Integer(value) } if File.size?(pid_path) + + sleep 0.01 + end + end + + assert_equal worker_pid, process_group, "descendant did not inherit the worker process group" + + Process.kill("KILL", supervisor_pid) + Process.waitpid(supervisor_pid) + supervisor_pid = nil + + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 1 + sleep 0.01 while process_alive?(descendant_pid) && Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline + + refute process_alive?(descendant_pid), "forked descendant survived its supervisor" + end + ensure + kill_process_if_alive(supervisor_pid) if supervisor_pid + kill_process_if_alive(descendant_pid) if descendant_pid + end + + def test_fork_applies_the_filesystem_policy + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |directory| + path = File.join(directory, "secret") + File.write(path, "secret") + + result = Landlock.fork(read: [], write: []) { File.read(path) } + + assert_equal 1, result.status.exitstatus + assert_match(/Errno::EACCES/, result.stderr) + end + end + + def test_fork_applies_process_options + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |directory| + result = + Landlock.fork( + chdir: directory, + env: { + LANDLOCK_FORK: "child" + }, + unsetenv_others: true, + stdin: "input", + rlimits: { + open_files: 32 + } + ) { print [Dir.pwd, ENV.fetch("LANDLOCK_FORK"), STDIN.read, Process.getrlimit(:NOFILE).first].join(":") } + + assert_equal "#{directory}:child:input:32", result.stdout + assert_predicate result, :success? + end + end + + def test_fork_closes_inherited_io + skip "Landlock unsupported" unless Landlock.supported? + + reader, writer = IO.pipe + result = Landlock.fork(rlimits: { open_files: 64 }) { print writer.closed? } + + assert_equal "true", result.stdout + ensure + reader&.close + writer&.close + end + + def test_fork_closes_inherited_raw_file_descriptors + skip "Landlock unsupported" unless Landlock.supported? + + fd = IO.sysopen(File::NULL) + result = + Landlock.fork(rlimits: { open_files: 64 }) do + IO.for_fd(fd, autoclose: false).stat + print "open" + rescue Errno::EBADF + print "closed" + end + + assert_equal "closed", result.stdout + ensure + Landlock::Native.close_fd(fd) if fd + end + + def test_fork_enforces_output_limit + skip "Landlock unsupported" unless Landlock.supported? + + error = + assert_raises(Landlock::CommandError) do + Landlock.fork(rlimits: { open_files: 64 }, max_output_bytes: 4) { print "output" } + end + + assert_equal "outp", error.stdout + assert_predicate error.result, :output_truncated? + end + + def test_fork_requires_a_block + error = assert_raises(ArgumentError) { Landlock.fork(rlimits: { open_files: 64 }) } + + assert_equal "fork requires a block", error.message + end + + private + + def process_alive?(pid) + Process.kill(0, pid) + !File.read("/proc/#{pid}/stat").split.fetch(2).eql?("Z") + rescue Errno::ESRCH, Errno::ENOENT + false + end +end