diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..14d0bfe --- /dev/null +++ b/.clang-format @@ -0,0 +1,10 @@ +BasedOnStyle: LLVM +Language: C +ColumnLimit: 100 +IndentWidth: 2 +ContinuationIndentWidth: 4 +SortIncludes: false +InsertBraces: true +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +AllowShortBlocksOnASingleLine: Never diff --git a/CHANGELOG.md b/CHANGELOG.md index 670bf2b..24fc5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,39 @@ All notable changes to this project will be documented in this file. ## Unreleased +## [0.3] - 2026-06-17 + +### Added + +- Add top-level `Landlock.capture` and `Landlock.capture!`, direct Landlock/`exec` capture APIs with stdout/stderr capture, stdin, wall-clock timeout with process-group cleanup, output byte limits, `rlimits:`, controlled environments, `chdir:`, TCP/scoped Landlock rules, `allow_all_known:`, and optional `seccomp_deny_network:`. +- Expose `Landlock.seccomp_deny_network!` from the native extension so capture children can install the same deny-network seccomp filter used by the helper binary. + +### Changed + +- Remove the legacy `Landlock::SafeExec` Ruby facade. Use `Landlock.capture`/`capture!` with argv arrays for captured subprocesses and `Landlock.exec`/`spawn` for non-capturing subprocesses. +- `Landlock.exec`, `Landlock.spawn`, and `Landlock.capture` now use the packaged native `landlock-safe-exec` helper when available, passing sandbox policy as helper arguments to avoid forking a large Ruby process for child setup and falling back to the Ruby fork runner if the helper argv exceeds `ARG_MAX`. +- Split subprocess running internals into `Landlock::Runner::Native` and `Landlock::Runner::Fork` backends, with shared validation, process I/O, rlimit, environment, and policy helpers. +- Normalize subprocess `env:` keys and values in Ruby before spawning and keep environment values out of native-helper argv. +- Require non-empty `Landlock.exec`/`spawn` policies instead of launching an unsandboxed command when no Landlock rules are provided. + +### Fixed + +- Treat timeouts as failures for `capture!` even when a command handles termination and exits with an otherwise successful status. +- Bound post-timeout pipe draining so escaped descendants that keep stdout/stderr open cannot hang capture past the requested timeout. +- Harden `landlock-safe-exec` by closing inherited file descriptors, applying rlimits after sandbox setup, matching Ruby `write:` rights, tightening CLI parsing, and making the shared seccomp network-deny filter reject x32 syscall-number bypasses. +- Validate `Landlock.capture` filesystem policy paths before forking so missing paths raise `ArgumentError`. +- Reject empty `Landlock.capture` policies unless another restriction such as seccomp or rlimits is provided. +- Filter directory-only custom path-rule rights for file paths in `Landlock.restrict!`, matching helper behavior. + +### Documentation + +- Document `Landlock.capture`, its result/error types, capture options, and subprocess sandboxing guidance. + ### [0.2.1] - 2026-06-16 - Build `landlock-safe-exec` without Ruby extension `$(LIBS)` to avoid unnecessary runtime library dependencies and improve SafeExec helper startup time. -### [0.2] - 2026-04-30 +## [0.2] - 2026-04-30 - Add `Landlock::SafeExec.capture`, backed by a compiled `landlock-safe-exec` helper, for subprocess capture with Landlock, optional seccomp network denial, resource limits, exact environment handling, stdin, timeout handling, process-group cleanup, result metadata, and output limits. - Share native Landlock syscall/constant definitions between the Ruby extension and helper binary. diff --git a/README.md b/README.md index 7494a01..f393e0d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ See [CHANGELOG.md](CHANGELOG.md) for release notes. ## Safe subprocess execution -Pass commands as an argument array. `Landlock.exec` and `Landlock.spawn` do not invoke a shell implicitly; use an explicit shell in the array if that is really required. Both helpers accept `env:` and `unsetenv_others:` and pass them through to `Kernel.exec` so subprocesses can run with a controlled environment. +Pass commands as an argument array. `Landlock.exec` and `Landlock.spawn` do not invoke a shell implicitly; use an explicit shell in the array if that is really required. Both helpers require a non-empty Landlock policy, accept `env:`/`unsetenv_others:` for controlled environments, and use the packaged native helper when available so the long-lived child is not a forked Ruby process. Allow Ruby to execute and read its runtime, but only allow outbound TCP connections to port 443: @@ -66,23 +66,17 @@ Landlock.exec( ) ``` -## SafeExec helper +## Capturing subprocess output -`Landlock::SafeExec.capture` runs a command through the compiled `landlock-safe-exec` helper. The helper applies Landlock rules, resource limits, and an optional seccomp network-deny filter in the execing process before replacing itself with the target command. This keeps the privileged setup out of Ruby/FFI and avoids running Ruby code in a post-fork child. Use `capture!` when unsuccessful exit statuses should raise. - -For example, inspect an uploaded video with `ffprobe` while only allowing reads from the upload and system runtime paths, denying network access, and bounding CPU/output: +`Landlock.capture` is the stdout/stderr-capturing sibling of `Landlock.exec`: it launches a child process, applies Landlock rules, resource limits, and the optional seccomp network-deny filter before the target command starts, then execs that command directly. When the packaged `landlock-safe-exec` helper is available, `exec`, `spawn`, and `capture` all spawn that small native helper with policy arguments so the parent does not need to fork a bloated Ruby process; they fall back to the Ruby fork path when the helper cannot be used or when an unusually large helper argv would exceed the platform `ARG_MAX`. Environment changes are applied by Ruby when spawning the helper rather than encoded in helper argv. Use `capture!` when unsuccessful exit statuses should raise. ```ruby -result = Landlock::SafeExec.capture( - "ffprobe", - "-v", "error", - "-show_format", - "-show_streams", - "-of", "json", - upload_path, - read: [upload_path, *Landlock::SafeExec.default_read_paths], - execute: Landlock::SafeExec.default_execute_paths, +result = Landlock.capture( + ["ffprobe", "-v", "error", "-show_format", "-of", "json", upload_path], + read: [upload_path, "/usr", "/lib", "/lib64", "/etc"].select { |path| File.exist?(path) }, + execute: ["/usr", "/lib", "/lib64"].select { |path| File.exist?(path) }, env: { "PATH" => ENV.fetch("PATH", "") }, + unsetenv_others: true, rlimits: { cpu_seconds: 5, memory_bytes: 512 * 1024 * 1024, @@ -98,45 +92,46 @@ result = Landlock::SafeExec.capture( metadata = JSON.parse(result.stdout) if result.success? ``` -Pass `stdin:` when a tool should read from standard input instead of a file: +`Landlock.capture` takes the command as a single argv array, like `Landlock.exec`. It returns a `Landlock::CaptureResult` with `stdout`, `stderr`, `status`, `success?`, `timed_out?`, and `output_truncated?`, including for unsuccessful exit statuses. It also supports array destructuring: ```ruby -stdout, stderr, status = Landlock::SafeExec.capture( - "tr", "a-z", "A-Z", - stdin: "hello", - env: { "PATH" => ENV.fetch("PATH", "") } +stdout, stderr, status = Landlock.capture( + ["tool", "arg"], + read: ["/usr", "/lib", "/lib64", "/etc"].select { |path| File.exist?(path) }, + execute: ["/usr", "/lib", "/lib64"].select { |path| File.exist?(path) } ) ``` -`capture` returns a `Landlock::SafeExec::Result` with `stdout`, `stderr`, `status`, `success?`, `timed_out?`, and `output_truncated?`, including for unsuccessful exit statuses. It also supports array destructuring: +`Landlock.capture!` has the same return shape for successful commands, but raises `Landlock::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`. + +`Landlock.capture` requires an actual restriction: provide Landlock rules, `seccomp_deny_network: true`, or `rlimits:`. This avoids accidentally running a command completely unsandboxed when a dynamically built policy is empty. It also requires Linux Landlock support and raises `Landlock::UnsupportedError` when unavailable; it does not fall back to running the command unsandboxed. + +Pass `stdin:` when a tool should read from standard input instead of a file: ```ruby -stdout, stderr, status = Landlock::SafeExec.capture("tool", "arg") +stdout, stderr, status = Landlock.capture( + ["tr", "a-z", "A-Z"], + stdin: "hello", + rlimits: { open_files: 64 } +) ``` -`capture!` has the same return shape for successful commands, but raises `Landlock::SafeExec::CommandError` for unsuccessful statuses. The error also exposes `stdout`, `stderr`, `status`, and `result`. - -SafeExec options: +Capture options: - `read:`, `write:`, `execute:` — filesystem allowlists. Explicit paths must exist; missing paths raise `ArgumentError` instead of being silently ignored. -- `connect_tcp:` — allowed outbound TCP ports. If omitted on Landlock ABI v4+, SafeExec denies outbound TCP by installing a dummy allow rule for port `0`. Pass `connect_tcp: []` to leave outbound TCP unrestricted. -- `bind_tcp:` — allowed TCP bind ports. Binding is unrestricted unless this is provided. +- `paths:` — exact path rules with explicit Landlock rights, e.g. `{ path:, rights: %i[read_file] }`. +- `connect_tcp:` and `bind_tcp:` — allowed TCP ports. TCP access is unrestricted unless a network rule is provided. +- `scope:` — Landlock ABI v6+ scopes such as `:signal` and `:abstract_unix_socket`. - `seccomp_deny_network:` — additionally deny common Linux network syscalls with seccomp. This is Linux-specific and intended as defense in depth. - `rlimits:` — resource limits. Supported keys are `:cpu_seconds`, `:memory_bytes`, `:file_size_bytes`, `:open_files`, and `:processes`. Values must be non-negative integers. -- `timeout:` — wall-clock timeout in seconds. On timeout SafeExec terminates the process group and returns/raises with `result.timed_out?` true. -- `max_output_bytes:` — combined stdout+stderr byte limit. With `truncate_output: false`, exceeding the limit raises. With `truncate_output: true`, output is truncated and `result.output_truncated?` is true. +- `timeout:` — wall-clock timeout in seconds. On timeout capture terminates the process group and returns/raises with `result.timed_out?` true. +- `max_output_bytes:` — combined stdout+stderr byte limit. With `truncate_output: false`, exceeding the limit raises `Landlock::CommandError` with the partial output captured before termination. With `truncate_output: true`, output is truncated and `result.output_truncated?` is true. - `stdin:` — string or IO-like object to write to the child process stdin. - `chdir:` — working directory for the child. -- `env:` — exact child environment by default. -- `inherit_env:` — when true, inherit the parent environment and apply `env:` as overrides. -- `success_status_codes:` — status codes considered successful by `capture!`; defaults to `[0]`. -- `allow_all_known:` — when filesystem rules are present, handle all Landlock filesystem rights known to the running ABI so unlisted filesystem access is denied. Defaults to `true`. - -SafeExec uses an exact environment by default: `env:` is the full environment passed to the child, not additions to the parent environment. Use `inherit_env: true` when a command really needs the parent environment plus the supplied `env:` overrides. - -Use `Landlock::SafeExec.supported?` (or `sandboxing?`) to check whether the Linux helper and Landlock are available. When this is false, SafeExec still runs commands in pass-through mode but does not enforce Landlock/seccomp sandbox options. - -On non-Linux platforms, or when the compiled helper is unavailable, SafeExec runs as a pass-through compatibility wrapper. Process-management features such as capture, timeout, environment handling, `chdir:`, output limits, `stdin:`, and supported `rlimits:` still apply, but Landlock and seccomp options (`read:`, `write:`, `execute:`, `connect_tcp:`, `bind_tcp:`, `seccomp_deny_network:`) are ignored and a warning is emitted. This makes cross-platform integration easier while keeping the security guarantees explicit: sandboxing is Linux-only. +- `env:` — environment entries for the child. +- `unsetenv_others:` — clear the parent environment before applying `env:`. +- `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. ## Restrict current process @@ -204,12 +199,14 @@ Treat small positive or negative deltas as noise and benchmark on the kernel, fi Landlock is not a complete container. It restricts selected kernel-mediated actions for the current thread and its future descendants, but it does not create namespaces, hide process IDs, virtualize the filesystem, or isolate the process from every kernel interface. For serious untrusted execution, combine Landlock with a controlled environment, resource limits, seccomp, and process isolation appropriate to your threat model. -`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. `Landlock::SafeExec` adds practical subprocess hardening around this — exact environment by default, `close_others`, optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination — but it is still not a VM/container boundary. +`Landlock.restrict!` only installs a Landlock ruleset. It does not close already-open file descriptors, impose resource limits, clean the environment, or kill subprocess trees. The subprocess helpers add practical hardening around this: `exec`/`spawn` add controlled environments and `close_others`, while `capture` also adds optional `rlimits:`, optional `seccomp_deny_network:`, output limits, timeout handling, and process-group termination. This is still not a VM/container boundary. By default, subprocess helpers close inherited file descriptors numbered 3 and higher before installing the sandbox; pass `close_others: false` only when the child intentionally needs inherited descriptors. Direct `landlock-safe-exec` use also closes inherited descriptors by default. + +When the native helper is used, sandbox policy details such as allowed paths, TCP ports, scopes, rights, and rlimits are passed as helper argv. They may be visible to same-user processes through tools such as `ps` or `/proc//cmdline` until the helper execs the target command. Environment values passed with `env:` are not encoded in helper argv, but do not put secrets in policy path names or other policy arguments. -If `Landlock.exec` or `Landlock.spawn` child setup fails before `exec`, the child prints a diagnostic and exits 127. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. +If `Landlock.exec`, `Landlock.spawn`, or `Landlock.capture` child setup fails before `exec`, the child prints a diagnostic and exits 127. `landlock-safe-exec` setup/argument failures exit 126. These codes can collide with commands that legitimately exit with the same status, so inspect stderr when debugging failures. -Path rules follow the kernel's normal path resolution when the rule is installed. Because paths are opened without `O_NOFOLLOW`, a symlink rule applies to the symlink target's inode, not to the symlink path itself. SafeExec validates explicit `read:`, `write:`, and `execute:` paths before launching so typos fail closed instead of silently weakening a policy. +Path rules follow the kernel's normal path resolution when the rule is installed. Because paths are opened without `O_NOFOLLOW`, a symlink rule applies to the symlink target's inode, not to the symlink path itself. Capture APIs validate explicit `read:`, `write:`, and `execute:` paths before launching so typos fail closed instead of silently weakening a policy. -Landlock only restricts access rights included in a ruleset's handled set: omitted categories remain allowed. Use `allow_all_known: true` when you want unlisted filesystem actions denied. SafeExec defaults `allow_all_known:` to true when filesystem rules are provided. Landlock TCP rules do not cover UDP or pathname Unix-domain sockets; ABI v6+ scopes can restrict signals and abstract Unix-domain sockets. SafeExec's `seccomp_deny_network:` is Linux-specific defense in depth for common network syscalls, not a general-purpose seccomp policy language. +Landlock only restricts access rights included in a ruleset's handled set: omitted categories remain allowed. Use `allow_all_known: true` when you want unlisted filesystem actions denied. Landlock TCP rules do not cover UDP or pathname Unix-domain sockets; ABI v6+ scopes can restrict signals and abstract Unix-domain sockets. `seccomp_deny_network:` is Linux-specific defense in depth for common network syscalls, not a general-purpose seccomp policy language. -`Landlock.restrict!` applies to the calling thread and its future children; already-running sibling threads are not retroactively sandboxed. Prefer `Landlock::SafeExec`, `Landlock.exec`, or `Landlock.spawn` for subprocess sandboxing from a larger Ruby application. +`Landlock.restrict!` applies to the calling thread and its future children; already-running sibling threads are not retroactively sandboxed. Prefer `Landlock.capture`, `Landlock.exec`, or `Landlock.spawn` for subprocess sandboxing from a larger Ruby application. diff --git a/Rakefile b/Rakefile index ad52bfc..758080a 100644 --- a/Rakefile +++ b/Rakefile @@ -3,6 +3,7 @@ require "bundler" require "rake/testtask" require "rake/extensiontask" +require "shellwords" begin Bundler.setup :default, :development @@ -13,9 +14,7 @@ rescue Bundler::BundlerError => error exit error.status_code end -Rake::ExtensionTask.new("landlock") do |ext| - ext.lib_dir = "lib/landlock" -end +Rake::ExtensionTask.new("landlock") { |ext| ext.lib_dir = "lib/landlock" } Rake::TestTask.new do |t| t.libs << "test" @@ -23,6 +22,25 @@ Rake::TestTask.new do |t| t.pattern = "test/**/*_test.rb" end +formattable_ruby_files = FileList["Gemfile", "Rakefile", "*.gemspec", "{lib,test,benchmark}/**/*.rb"].to_a.freeze +formattable_c_files = FileList["ext/**/*.{c,h}"].to_a.freeze +stree_print_width = 120 +clang_format = ENV.fetch("CLANG_FORMAT", "clang-format") + +namespace :format do + desc "Check Ruby/C formatting" + task :check do + sh "bundle exec stree check --print-width=#{stree_print_width} #{formattable_ruby_files.map(&:shellescape).join(" ")}" + sh "#{clang_format.shellescape} --dry-run --Werror #{formattable_c_files.map(&:shellescape).join(" ")}" + end +end + +desc "Format Ruby/C files" +task :format do + sh "bundle exec stree write --print-width=#{stree_print_width} #{formattable_ruby_files.map(&:shellescape).join(" ")}" + sh "#{clang_format.shellescape} -i #{formattable_c_files.map(&:shellescape).join(" ")}" +end + task test: :compile namespace :bench do @@ -35,4 +53,4 @@ end desc "Run the Landlock overhead benchmark suite" task bench: "bench:overhead" -task default: [:compile, :test] +task default: %i[compile test] diff --git a/benchmark/landlock_overhead.rb b/benchmark/landlock_overhead.rb index f6bdb55..0f8c567 100644 --- a/benchmark/landlock_overhead.rb +++ b/benchmark/landlock_overhead.rb @@ -71,9 +71,7 @@ def child_command(payload) def run_child(payload) stdout, stderr, status = Open3.capture3(*child_command(payload), chdir: ROOT) - unless status.success? - abort "bench child failed (#{status.exitstatus})\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}" - end + abort "bench child failed (#{status.exitstatus})\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}" unless status.success? JSON.parse(stdout) end @@ -91,8 +89,8 @@ def run_parent mode: "workloads", iterations: DEFAULT_ITERATIONS, dir_iterations: DIR_ITERATIONS, - read_paths: read_paths, - workspace: workspace + read_paths:, + workspace: } baseline = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: false)) } @@ -104,9 +102,7 @@ def run_parent end sandbox = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: true)) } - setup = collect_samples(SETUP_SAMPLES) do - run_child(mode: "setup", read_paths: read_paths).fetch("setup_ns") - end + setup = collect_samples(SETUP_SAMPLES) { run_child(mode: "setup", read_paths:).fetch("setup_ns") } print_workload_table(baseline, sandbox) puts @@ -132,14 +128,7 @@ def print_workload_table(baseline, sandbox) locked = median(sandbox.map { |sample| sample.fetch(name) }) delta = locked - base pct = base.positive? ? (delta.to_f / base * 100.0) : 0.0 - puts format( - "%-12s %14s %14s %12s %9.2f%%", - name, - format_ms(base), - format_ms(locked), - format_ms(delta), - pct - ) + puts format("%-12s %14s %14s %12s %9.2f%%", name, format_ms(base), format_ms(locked), format_ms(delta), pct) else puts format("%-12s %14s %14s %12s %10s", name, format_ms(base), "n/a", "n/a", "n/a") end @@ -185,20 +174,10 @@ def run_workloads(payload) GC.disable { - "cpu_loop" => measure do - iterations.times { |index| sink ^= ((index * 31) & 0xffff) } - end, - "file_stat" => measure do - iterations.times { sink ^= File.stat(file).size } - end, - "file_read" => measure do - iterations.times { sink ^= File.binread(file).bytesize } - end, - "dir_scan" => measure do - dir_iterations.times do - Dir.foreach(entries) { |entry| sink ^= entry.bytesize } - end - end + "cpu_loop" => measure { iterations.times { |index| sink ^= ((index * 31) & 0xffff) } }, + "file_stat" => measure { iterations.times { sink ^= File.stat(file).size } }, + "file_read" => measure { iterations.times { sink ^= File.binread(file).bytesize } }, + "dir_scan" => measure { dir_iterations.times { Dir.foreach(entries) { |entry| sink ^= entry.bytesize } } } }.merge("sink" => sink) ensure GC.enable diff --git a/ext/landlock/bin/safe_exec_helper.c b/ext/landlock/bin/safe_exec_helper.c index ffdf543..385bbc8 100644 --- a/ext/landlock/bin/safe_exec_helper.c +++ b/ext/landlock/bin/safe_exec_helper.c @@ -1,28 +1,15 @@ #include "../landlock_native.h" +#include "../seccomp_deny_network.h" #include #include #include #include +#include +#include #include #include -#ifdef __linux__ -#include -#include -#include -#endif - -#ifndef SECCOMP_RET_ALLOW -#define SECCOMP_RET_ALLOW 0x7fff0000U -#endif -#ifndef SECCOMP_RET_ERRNO -#define SECCOMP_RET_ERRNO 0x00050000U -#endif -#ifndef SECCOMP_SET_MODE_FILTER -#define SECCOMP_SET_MODE_FILTER 1 -#endif - typedef struct { char **items; size_t len; @@ -35,21 +22,47 @@ typedef struct { size_t cap; } ull_list; +typedef struct { + char *path; + uint64_t rights; +} path_rule; + +typedef struct { + path_rule *items; + size_t len; + size_t cap; +} path_rule_list; + +#define MAX_CSV_RIGHTS 64U + static void die(const char *message) { perror(message); _exit(126); } +static void die_path(const char *message, const char *path) { + int saved_errno = errno; + fprintf(stderr, "landlock-safe-exec: %s %s: %s\n", message, path, strerror(saved_errno)); + _exit(126); +} + static void die_msg(const char *message) { fprintf(stderr, "landlock-safe-exec: %s\n", message); _exit(126); } +static void die_no_effective_path_rights(const char *path) { + fprintf(stderr, "landlock-safe-exec: path rule has no effective rights: %s\n", path); + _exit(126); +} + static void string_list_push(string_list *list, char *value) { if (list->len == list->cap) { size_t cap = list->cap ? list->cap * 2 : 8; char **items = realloc(list->items, cap * sizeof(char *)); - if (!items) die("realloc"); + if (!items) { + die("realloc"); + } list->items = items; list->cap = cap; } @@ -60,54 +73,81 @@ static void ull_list_push(ull_list *list, unsigned long long value) { if (list->len == list->cap) { size_t cap = list->cap ? list->cap * 2 : 8; unsigned long long *items = realloc(list->items, cap * sizeof(unsigned long long)); - if (!items) die("realloc"); + if (!items) { + die("realloc"); + } list->items = items; list->cap = cap; } list->items[list->len++] = value; } +static void path_rule_list_push(path_rule_list *list, char *path, uint64_t rights) { + if (list->len == list->cap) { + size_t cap = list->cap ? list->cap * 2 : 8; + path_rule *items = realloc(list->items, cap * sizeof(path_rule)); + if (!items) { + die("realloc"); + } + list->items = items; + list->cap = cap; + } + list->items[list->len].path = path; + list->items[list->len].rights = rights; + list->len++; +} + static unsigned long long parse_ull(const char *value, const char *name) { - if (!value || value[0] == '\0' || value[0] == '-') die_msg(name); + if (!value || value[0] == '\0' || value[0] == '-' || value[0] == '+' || + isspace((unsigned char)value[0])) { + die_msg(name); + } errno = 0; char *end = NULL; unsigned long long parsed = strtoull(value, &end, 10); - if (errno == ERANGE || !end || *end != '\0') die_msg(name); + if (errno == ERANGE || !end || *end != '\0') { + die_msg(name); + } return parsed; } static unsigned long long parse_port(const char *value) { unsigned long long port = parse_ull(value, "TCP port must be an integer between 0 and 65535"); - if (port > 65535ULL) die_msg("TCP port must be between 0 and 65535"); + if (port > 65535ULL) { + die_msg("TCP port must be between 0 and 65535"); + } return port; } static int abi_version(void) { long abi = ll_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); - if (abi < 0 && (errno == ENOSYS || errno == EOPNOTSUPP)) return 0; - if (abi < 0) die("landlock_create_ruleset(version)"); + if (abi < 0 && (errno == ENOSYS || errno == EOPNOTSUPP)) { + return 0; + } + if (abi < 0) { + die("landlock_create_ruleset(version)"); + } return (int)abi; } static uint64_t known_fs_rights_for_abi(int abi) { - uint64_t rights = LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_WRITE_FILE | - LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_READ_DIR | - LANDLOCK_ACCESS_FS_REMOVE_DIR | - LANDLOCK_ACCESS_FS_REMOVE_FILE | - LANDLOCK_ACCESS_FS_MAKE_CHAR | - LANDLOCK_ACCESS_FS_MAKE_DIR | - LANDLOCK_ACCESS_FS_MAKE_REG | - LANDLOCK_ACCESS_FS_MAKE_SOCK | - LANDLOCK_ACCESS_FS_MAKE_FIFO | - LANDLOCK_ACCESS_FS_MAKE_BLOCK | - LANDLOCK_ACCESS_FS_MAKE_SYM; - if (abi >= 2) rights |= LANDLOCK_ACCESS_FS_REFER; - if (abi >= 3) rights |= LANDLOCK_ACCESS_FS_TRUNCATE; - if (abi >= 5) rights |= LANDLOCK_ACCESS_FS_IOCTL_DEV; + uint64_t rights = + LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | LANDLOCK_ACCESS_FS_REMOVE_FILE | + LANDLOCK_ACCESS_FS_MAKE_CHAR | LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | LANDLOCK_ACCESS_FS_MAKE_BLOCK | + LANDLOCK_ACCESS_FS_MAKE_SYM; + if (abi >= 2) { + rights |= LANDLOCK_ACCESS_FS_REFER; + } + if (abi >= 3) { + rights |= LANDLOCK_ACCESS_FS_TRUNCATE; + } + if (abi >= 5) { + rights |= LANDLOCK_ACCESS_FS_IOCTL_DEV; + } return rights; } @@ -120,24 +160,54 @@ static uint64_t execute_rights(void) { } static uint64_t write_rights(int abi) { - return known_fs_rights_for_abi(abi) & ~LANDLOCK_ACCESS_FS_EXECUTE; + uint64_t rights = LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | + LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | + LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | + LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | + LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM; + if (abi >= 2) { + rights |= LANDLOCK_ACCESS_FS_REFER; + } + if (abi >= 3) { + rights |= LANDLOCK_ACCESS_FS_TRUNCATE; + } + return rights; } static uint64_t file_path_rights(void) { - return LANDLOCK_ACCESS_FS_EXECUTE | - LANDLOCK_ACCESS_FS_WRITE_FILE | - LANDLOCK_ACCESS_FS_READ_FILE | - LANDLOCK_ACCESS_FS_TRUNCATE | - LANDLOCK_ACCESS_FS_IOCTL_DEV; + return LANDLOCK_ACCESS_FS_EXECUTE | LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_READ_FILE | + LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_IOCTL_DEV; +} + +static uint64_t effective_path_rights(const char *path, uint64_t rights) { + struct stat st; + if (stat(path, &st) != 0) { + die_path("stat(path rule)", path); + } + if (!S_ISDIR(st.st_mode)) { + rights &= file_path_rights(); + } + return rights; } static void add_path_rule(int fd, const char *path, uint64_t rights) { int parent_fd = open(path, O_PATH | O_CLOEXEC); - if (parent_fd < 0) die("open(path rule)"); + if (parent_fd < 0) { + die_path("open(path rule)", path); + } struct stat st; - if (fstat(parent_fd, &st) != 0) die("fstat(path rule)"); - if (!S_ISDIR(st.st_mode)) rights &= file_path_rights(); + if (fstat(parent_fd, &st) != 0) { + die_path("fstat(path rule)", path); + } + if (!S_ISDIR(st.st_mode)) { + rights &= file_path_rights(); + } + if (!rights) { + close(parent_fd); + return; + } struct rb_landlock_path_beneath_attr rule; memset(&rule, 0, sizeof(rule)); @@ -154,216 +224,379 @@ static void add_path_rule(int fd, const char *path, uint64_t rights) { } static void add_net_rule(int fd, unsigned long long port, uint64_t rights) { - if (port > 65535ULL) die_msg("TCP port must be between 0 and 65535"); + if (port > 65535ULL) { + die_msg("TCP port must be between 0 and 65535"); + } struct rb_landlock_net_port_attr rule; memset(&rule, 0, sizeof(rule)); rule.allowed_access = rights; rule.port = port; - if (ll_add_rule(fd, LANDLOCK_RULE_NET_PORT, &rule, 0) < 0) die("landlock_add_rule(net_port)"); + if (ll_add_rule(fd, LANDLOCK_RULE_NET_PORT, &rule, 0) < 0) { + die("landlock_add_rule(net_port)"); + } } -static void apply_landlock(string_list *read_paths, string_list *write_paths, string_list *execute_paths, - ull_list *connect_ports, ull_list *bind_ports, int allow_all_known) { - int need_fs = read_paths->len || write_paths->len || execute_paths->len || allow_all_known; +static void apply_landlock(string_list *read_paths, string_list *write_paths, + string_list *execute_paths, path_rule_list *path_rules, + ull_list *connect_ports, ull_list *bind_ports, uint64_t scoped, + int allow_all_known) { + int need_fs = read_paths->len || write_paths->len || execute_paths->len || path_rules->len || + allow_all_known; int need_net = connect_ports->len || bind_ports->len; - if (!need_fs && !need_net) return; + int need_scope = scoped != 0; + if (!need_fs && !need_net && !need_scope) { + return; + } int abi = abi_version(); - if (abi <= 0) die_msg("Linux Landlock is unavailable"); - if (need_net && abi < 4) die_msg("Landlock network rules require ABI v4+"); + if (abi <= 0) { + die_msg("Linux Landlock is unavailable"); + } + if (need_net && abi < 4) { + die_msg("Landlock network rules require ABI v4+"); + } + if (need_scope && abi < 6) { + die_msg("Landlock scopes require ABI v6+"); + } - uint64_t fs_handled = allow_all_known ? known_fs_rights_for_abi(abi) : 0; + uint64_t known_fs_rights = known_fs_rights_for_abi(abi); + uint64_t fs_handled = allow_all_known ? known_fs_rights : 0; if (!allow_all_known) { - if (read_paths->len) fs_handled |= read_rights(); - if (execute_paths->len) fs_handled |= execute_rights(); - if (write_paths->len) fs_handled |= write_rights(abi); + if (read_paths->len) { + fs_handled |= read_rights(); + } + if (execute_paths->len) { + fs_handled |= execute_rights(); + } + if (write_paths->len) { + fs_handled |= write_rights(abi); + } + for (size_t i = 0; i < path_rules->len; i++) { + uint64_t rights = effective_path_rights(path_rules->items[i].path, + path_rules->items[i].rights & known_fs_rights); + if (!rights) { + die_no_effective_path_rights(path_rules->items[i].path); + } + fs_handled |= rights; + } } uint64_t net_handled = 0; - if (bind_ports->len) net_handled |= LANDLOCK_ACCESS_NET_BIND_TCP; - if (connect_ports->len) net_handled |= LANDLOCK_ACCESS_NET_CONNECT_TCP; + if (bind_ports->len) { + net_handled |= LANDLOCK_ACCESS_NET_BIND_TCP; + } + if (connect_ports->len) { + net_handled |= LANDLOCK_ACCESS_NET_CONNECT_TCP; + } + + if (!fs_handled && !net_handled && !scoped) { + die_msg("empty Landlock policy: provide filesystem paths, TCP ports, or scopes"); + } struct rb_landlock_ruleset_attr attr; memset(&attr, 0, sizeof(attr)); attr.handled_access_fs = fs_handled; attr.handled_access_net = net_handled; + attr.scoped = scoped; - size_t attr_size = net_handled ? offsetof(struct rb_landlock_ruleset_attr, scoped) : offsetof(struct rb_landlock_ruleset_attr, handled_access_net); + size_t attr_size = + scoped ? sizeof(struct rb_landlock_ruleset_attr) + : (net_handled ? offsetof(struct rb_landlock_ruleset_attr, scoped) + : offsetof(struct rb_landlock_ruleset_attr, handled_access_net)); int fd = (int)ll_create_ruleset(&attr, attr_size, 0); - if (fd < 0) die("landlock_create_ruleset"); + if (fd < 0) { + die("landlock_create_ruleset"); + } - for (size_t i = 0; i < read_paths->len; i++) add_path_rule(fd, read_paths->items[i], read_rights()); - for (size_t i = 0; i < execute_paths->len; i++) add_path_rule(fd, execute_paths->items[i], execute_rights()); - for (size_t i = 0; i < write_paths->len; i++) add_path_rule(fd, write_paths->items[i], write_rights(abi)); - for (size_t i = 0; i < connect_ports->len; i++) add_net_rule(fd, connect_ports->items[i], LANDLOCK_ACCESS_NET_CONNECT_TCP); - for (size_t i = 0; i < bind_ports->len; i++) add_net_rule(fd, bind_ports->items[i], LANDLOCK_ACCESS_NET_BIND_TCP); + for (size_t i = 0; i < read_paths->len; i++) { + add_path_rule(fd, read_paths->items[i], read_rights()); + } + for (size_t i = 0; i < execute_paths->len; i++) { + add_path_rule(fd, execute_paths->items[i], execute_rights()); + } + for (size_t i = 0; i < write_paths->len; i++) { + add_path_rule(fd, write_paths->items[i], write_rights(abi)); + } + for (size_t i = 0; i < path_rules->len; i++) { + uint64_t rights = effective_path_rights(path_rules->items[i].path, + path_rules->items[i].rights & known_fs_rights); + if (!rights) { + die_no_effective_path_rights(path_rules->items[i].path); + } + add_path_rule(fd, path_rules->items[i].path, rights); + } + for (size_t i = 0; i < connect_ports->len; i++) { + add_net_rule(fd, connect_ports->items[i], LANDLOCK_ACCESS_NET_CONNECT_TCP); + } + for (size_t i = 0; i < bind_ports->len; i++) { + add_net_rule(fd, bind_ports->items[i], LANDLOCK_ACCESS_NET_BIND_TCP); + } - if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) die("prctl(PR_SET_NO_NEW_PRIVS)"); - if (ll_restrict_self(fd, 0) < 0) die("landlock_restrict_self"); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + die("prctl(PR_SET_NO_NEW_PRIVS)"); + } + if (ll_restrict_self(fd, 0) < 0) { + die("landlock_restrict_self"); + } close(fd); } static void apply_rlimit(const char *spec) { char *copy = strdup(spec); - if (!copy) die("strdup"); + if (!copy) { + die("strdup"); + } char *eq = strchr(copy, '='); - if (!eq) die_msg("rlimit must be name=value"); + if (!eq) { + die_msg("rlimit must be name=value"); + } *eq = '\0'; unsigned long long value = parse_ull(eq + 1, "rlimit value must be a non-negative integer"); int resource = -1; - if (strcmp(copy, "cpu_seconds") == 0) resource = RLIMIT_CPU; + if (strcmp(copy, "cpu_seconds") == 0) { + resource = RLIMIT_CPU; + } #ifdef RLIMIT_AS - else if (strcmp(copy, "memory_bytes") == 0) resource = RLIMIT_AS; + else if (strcmp(copy, "memory_bytes") == 0) { + resource = RLIMIT_AS; + } #endif - else if (strcmp(copy, "file_size_bytes") == 0) resource = RLIMIT_FSIZE; - else if (strcmp(copy, "open_files") == 0) resource = RLIMIT_NOFILE; + else if (strcmp(copy, "file_size_bytes") == 0) { + resource = RLIMIT_FSIZE; + } else if (strcmp(copy, "open_files") == 0) { + resource = RLIMIT_NOFILE; + } #ifdef RLIMIT_NPROC - else if (strcmp(copy, "processes") == 0) resource = RLIMIT_NPROC; + else if (strcmp(copy, "processes") == 0) { + resource = RLIMIT_NPROC; + } #endif - else die_msg("unknown rlimit"); + else { + die_msg("unknown rlimit"); + } struct rlimit limit; - limit.rlim_cur = (rlim_t)value; - limit.rlim_max = (rlim_t)value; - if (setrlimit(resource, &limit) != 0) die("setrlimit"); + rlim_t rlim_value = (rlim_t)value; + if ((unsigned long long)rlim_value != value) { + die_msg("rlimit value is too large for this platform"); + } + limit.rlim_cur = rlim_value; + limit.rlim_max = rlim_value; + if (setrlimit(resource, &limit) != 0) { + die("setrlimit"); + } free(copy); } -static int deny_syscalls[] = { -#ifdef __NR_socket - __NR_socket, -#endif -#ifdef __NR_socketpair - __NR_socketpair, -#endif -#ifdef __NR_connect - __NR_connect, -#endif -#ifdef __NR_bind - __NR_bind, -#endif -#ifdef __NR_listen - __NR_listen, -#endif -#ifdef __NR_accept - __NR_accept, -#endif -#ifdef __NR_accept4 - __NR_accept4, -#endif -#ifdef __NR_sendto - __NR_sendto, -#endif -#ifdef __NR_sendmsg - __NR_sendmsg, -#endif -#ifdef __NR_sendmmsg - __NR_sendmmsg, -#endif -#ifdef __NR_recvfrom - __NR_recvfrom, -#endif -#ifdef __NR_recvmsg - __NR_recvmsg, -#endif -#ifdef __NR_recvmmsg - __NR_recvmmsg, -#endif -#ifdef __NR_socketcall - __NR_socketcall, -#endif -}; - -#if defined(__x86_64__) && defined(AUDIT_ARCH_X86_64) -#define EXPECTED_AUDIT_ARCH AUDIT_ARCH_X86_64 -#elif defined(__aarch64__) && defined(AUDIT_ARCH_AARCH64) -#define EXPECTED_AUDIT_ARCH AUDIT_ARCH_AARCH64 -#elif defined(__i386__) && defined(AUDIT_ARCH_I386) -#define EXPECTED_AUDIT_ARCH AUDIT_ARCH_I386 -#endif +static void apply_seccomp_deny_network(void) { + const char *error_message = "seccomp(SECCOMP_SET_MODE_FILTER)"; + if (rb_landlock_seccomp_deny_network(&error_message) != 0) { + die(error_message); + } +} -#ifndef SECCOMP_RET_KILL_PROCESS -#define SECCOMP_RET_KILL_PROCESS 0x80000000U -#endif +static uint64_t fs_right_name(const char *name) { + if (strcmp(name, "execute") == 0) { + return LANDLOCK_ACCESS_FS_EXECUTE; + } + if (strcmp(name, "write_file") == 0) { + return LANDLOCK_ACCESS_FS_WRITE_FILE; + } + if (strcmp(name, "read_file") == 0) { + return LANDLOCK_ACCESS_FS_READ_FILE; + } + if (strcmp(name, "read_dir") == 0) { + return LANDLOCK_ACCESS_FS_READ_DIR; + } + if (strcmp(name, "remove_dir") == 0) { + return LANDLOCK_ACCESS_FS_REMOVE_DIR; + } + if (strcmp(name, "remove_file") == 0) { + return LANDLOCK_ACCESS_FS_REMOVE_FILE; + } + if (strcmp(name, "make_char") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_CHAR; + } + if (strcmp(name, "make_dir") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_DIR; + } + if (strcmp(name, "make_reg") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_REG; + } + if (strcmp(name, "make_sock") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_SOCK; + } + if (strcmp(name, "make_fifo") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_FIFO; + } + if (strcmp(name, "make_block") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_BLOCK; + } + if (strcmp(name, "make_sym") == 0) { + return LANDLOCK_ACCESS_FS_MAKE_SYM; + } + if (strcmp(name, "refer") == 0) { + return LANDLOCK_ACCESS_FS_REFER; + } + if (strcmp(name, "truncate") == 0) { + return LANDLOCK_ACCESS_FS_TRUNCATE; + } + if (strcmp(name, "ioctl_dev") == 0) { + return LANDLOCK_ACCESS_FS_IOCTL_DEV; + } + die_msg("unknown filesystem right"); + return 0; +} -static void apply_seccomp_deny_network(void) { - size_t count = sizeof(deny_syscalls) / sizeof(deny_syscalls[0]); - if (count == 0) return; +static uint64_t parse_fs_rights(const char *spec) { + char *copy = strdup(spec); + if (!copy) { + die("strdup"); + } - size_t len = 1 + (2 * count) + 1; -#ifdef EXPECTED_AUDIT_ARCH - len += 3; -#endif - struct sock_filter *filter = calloc(len, sizeof(struct sock_filter)); - if (!filter) die("calloc"); - - size_t pc = 0; -#ifdef EXPECTED_AUDIT_ARCH - filter[pc++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); - filter[pc++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, EXPECTED_AUDIT_ARCH, 1, 0); - filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS); -#endif - filter[pc++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - for (size_t i = 0; i < count; i++) { - filter[pc++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, (unsigned int)deny_syscalls[i], 0, 1); - filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM); + uint64_t rights = 0; + size_t count = 0; + char *saveptr = NULL; + for (char *name = strtok_r(copy, ",", &saveptr); name; name = strtok_r(NULL, ",", &saveptr)) { + if (name[0] == '\0') { + free(copy); + die_msg("empty filesystem right"); + } + rights |= fs_right_name(name); + count++; + if (count > MAX_CSV_RIGHTS) { + free(copy); + die_msg("too many filesystem rights"); + } } - filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); - struct sock_fprog prog; - prog.len = (unsigned short)pc; - prog.filter = filter; + free(copy); + if (!count) { + die_msg("path rights must not be empty"); + } + return rights; +} - if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) die("prctl(PR_SET_NO_NEW_PRIVS)"); -#ifdef SYS_seccomp - if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) != 0) -#endif - { - if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) != 0) die("seccomp(SECCOMP_SET_MODE_FILTER)"); +static uint64_t scope_name(const char *name) { + if (strcmp(name, "abstract_unix_socket") == 0) { + return LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET; } - free(filter); + if (strcmp(name, "signal") == 0) { + return LANDLOCK_SCOPE_SIGNAL; + } + die_msg("unknown Landlock scope"); + return 0; } static char *require_arg(int argc, char **argv, int *i) { - if (*i + 1 >= argc) die_msg("missing option argument"); + if (*i + 1 >= argc) { + die_msg("missing option argument"); + } (*i)++; return argv[*i]; } +static void close_inherited_fds(void) { +#ifdef SYS_close_range + if (syscall(SYS_close_range, 3U, ~0U, 0U) == 0) { + return; + } +#endif + + 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) { + close((int)fd); + } + } + closedir(dir); + return; + } + + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0) { + max_fd = 1024; + } + for (long fd = 3; fd < max_fd; fd++) { + close((int)fd); + } +} + int main(int argc, char **argv) { - string_list read_paths = {0}, write_paths = {0}, execute_paths = {0}, env_vars = {0}; + string_list read_paths = {0}, write_paths = {0}, execute_paths = {0}, rlimit_specs = {0}; + path_rule_list path_rules = {0}; ull_list connect_ports = {0}, bind_ports = {0}; - int unsetenv_others = 0, seccomp_deny_network = 0, allow_all_known = 0; + int seccomp_deny_network = 0, allow_all_known = 0, close_others = 1; + uint64_t scoped = 0; char *chdir_path = NULL; + char **command_argv = NULL; int command_index = -1; for (int i = 1; i < argc; i++) { - if (strcmp(argv[i], "--") == 0) { command_index = i + 1; break; } - if (strcmp(argv[i], "--read") == 0) string_list_push(&read_paths, require_arg(argc, argv, &i)); - else if (strcmp(argv[i], "--write") == 0) string_list_push(&write_paths, require_arg(argc, argv, &i)); - else if (strcmp(argv[i], "--execute") == 0) string_list_push(&execute_paths, require_arg(argc, argv, &i)); - else if (strcmp(argv[i], "--connect-tcp") == 0) ull_list_push(&connect_ports, parse_port(require_arg(argc, argv, &i))); - else if (strcmp(argv[i], "--bind-tcp") == 0) ull_list_push(&bind_ports, parse_port(require_arg(argc, argv, &i))); - else if (strcmp(argv[i], "--chdir") == 0) chdir_path = require_arg(argc, argv, &i); - else if (strcmp(argv[i], "--env") == 0) string_list_push(&env_vars, require_arg(argc, argv, &i)); - else if (strcmp(argv[i], "--unsetenv-others") == 0) unsetenv_others = 1; - else if (strcmp(argv[i], "--rlimit") == 0) apply_rlimit(require_arg(argc, argv, &i)); - else if (strcmp(argv[i], "--seccomp-deny-network") == 0) seccomp_deny_network = 1; - else if (strcmp(argv[i], "--allow-all-known") == 0) allow_all_known = 1; - else die_msg("unknown option"); - } - - if (command_index < 0 || command_index >= argc) die_msg("missing command after --"); - - if (chdir_path && chdir(chdir_path) != 0) die("chdir"); - if (unsetenv_others && clearenv() != 0) die("clearenv"); - for (size_t i = 0; i < env_vars.len; i++) { - if (putenv(env_vars.items[i]) != 0) die("putenv"); - } - - apply_landlock(&read_paths, &write_paths, &execute_paths, &connect_ports, &bind_ports, allow_all_known); - if (seccomp_deny_network) apply_seccomp_deny_network(); - - execvp(argv[command_index], &argv[command_index]); - die("execvp"); + if (strcmp(argv[i], "--") == 0) { + command_index = i + 1; + break; + } + if (strcmp(argv[i], "--read") == 0) { + string_list_push(&read_paths, require_arg(argc, argv, &i)); + } else if (strcmp(argv[i], "--write") == 0) { + string_list_push(&write_paths, require_arg(argc, argv, &i)); + } else if (strcmp(argv[i], "--execute") == 0) { + string_list_push(&execute_paths, require_arg(argc, argv, &i)); + } else if (strcmp(argv[i], "--path") == 0) { + char *path = require_arg(argc, argv, &i); + char *rights = require_arg(argc, argv, &i); + path_rule_list_push(&path_rules, path, parse_fs_rights(rights)); + } else if (strcmp(argv[i], "--connect-tcp") == 0) { + ull_list_push(&connect_ports, parse_port(require_arg(argc, argv, &i))); + } else if (strcmp(argv[i], "--bind-tcp") == 0) { + ull_list_push(&bind_ports, parse_port(require_arg(argc, argv, &i))); + } else if (strcmp(argv[i], "--scope") == 0) { + scoped |= scope_name(require_arg(argc, argv, &i)); + } else if (strcmp(argv[i], "--chdir") == 0) { + chdir_path = require_arg(argc, argv, &i); + } else if (strcmp(argv[i], "--rlimit") == 0) { + string_list_push(&rlimit_specs, require_arg(argc, argv, &i)); + } else if (strcmp(argv[i], "--seccomp-deny-network") == 0) { + seccomp_deny_network = 1; + } else if (strcmp(argv[i], "--allow-all-known") == 0) { + allow_all_known = 1; + } else if (strcmp(argv[i], "--keep-fds") == 0) { + close_others = 0; + } else { + die_msg("unknown option"); + } + } + + if (command_index < 0 || command_index >= argc) { + die_msg("missing command after --"); + } + command_argv = &argv[command_index]; + + if (close_others) { + close_inherited_fds(); + } + + if (chdir_path && chdir(chdir_path) != 0) { + die("chdir"); + } + + apply_landlock(&read_paths, &write_paths, &execute_paths, &path_rules, &connect_ports, + &bind_ports, scoped, allow_all_known); + if (seccomp_deny_network) { + apply_seccomp_deny_network(); + } + for (size_t i = 0; i < rlimit_specs.len; i++) { + apply_rlimit(rlimit_specs.items[i]); + } + + execvp(command_argv[0], command_argv); + perror("execvp"); + _exit(127); } diff --git a/ext/landlock/extconf.rb b/ext/landlock/extconf.rb index 90c8df7..69f8e59 100644 --- a/ext/landlock/extconf.rb +++ b/ext/landlock/extconf.rb @@ -2,6 +2,8 @@ require "mkmf" +append_cppflags("-D_GNU_SOURCE") + abort "missing ruby headers" unless have_header("ruby.h") have_header("linux/landlock.h") @@ -17,6 +19,7 @@ if RUBY_PLATFORM.include?("linux") helper = "landlock-safe-exec" helper_src = "$(srcdir)/bin/safe_exec_helper.c" + helper_headers = "$(srcdir)/landlock_native.h $(srcdir)/seccomp_deny_network.h" helper_dest = "$(RUBYARCHDIR)/#{helper}" File.open("Makefile", "a") do |makefile| diff --git a/ext/landlock/landlock.c b/ext/landlock/landlock.c index 3fa60e0..63a2026 100644 --- a/ext/landlock/landlock.c +++ b/ext/landlock/landlock.c @@ -1,5 +1,6 @@ #include "ruby.h" #include "landlock_native.h" +#include "seccomp_deny_network.h" #include @@ -9,8 +10,7 @@ static VALUE eSyscallError; static void raise_syscall_error(const char *syscall_name) { int saved_errno = errno; - VALUE err = rb_funcall(eSyscallError, rb_intern("new"), 3, - rb_str_new_cstr(syscall_name), + VALUE err = rb_funcall(eSyscallError, rb_intern("new"), 3, rb_str_new_cstr(syscall_name), INT2NUM(saved_errno), rb_sprintf("%s failed: %s", syscall_name, strerror(saved_errno))); rb_exc_raise(err); @@ -19,7 +19,9 @@ static void raise_syscall_error(const char *syscall_name) { static VALUE rb_ll_abi_version(VALUE self) { long abi = ll_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); if (abi < 0) { - if (errno == ENOSYS || errno == EOPNOTSUPP) return INT2FIX(0); + if (errno == ENOSYS || errno == EOPNOTSUPP) { + return INT2FIX(0); + } raise_syscall_error("landlock_create_ruleset"); } return LONG2NUM(abi); @@ -45,7 +47,9 @@ static VALUE rb_ll_create_ruleset(int argc, VALUE *argv, VALUE self) { attr.scoped = scoped; long fd = ll_create_ruleset(&attr, attr_size, 0); - if (fd < 0) raise_syscall_error("landlock_create_ruleset"); + if (fd < 0) { + raise_syscall_error("landlock_create_ruleset"); + } return INT2NUM(fd); } @@ -55,7 +59,9 @@ static VALUE rb_ll_add_path_rule(VALUE self, VALUE ruleset_fd, VALUE path, VALUE Check_Type(path, T_STRING); const char *cpath = StringValueCStr(path); int parent_fd = open(cpath, O_PATH | O_CLOEXEC); - if (parent_fd < 0) raise_syscall_error("open"); + if (parent_fd < 0) { + raise_syscall_error("open"); + } struct rb_landlock_path_beneath_attr rule; memset(&rule, 0, sizeof(rule)); @@ -74,7 +80,9 @@ static VALUE rb_ll_add_path_rule(VALUE self, VALUE ruleset_fd, VALUE path, VALUE static VALUE rb_ll_add_net_rule(VALUE self, VALUE ruleset_fd, VALUE port, VALUE access_bits) { unsigned long long p = NUM2ULL(port); - if (p > 65535ULL) rb_raise(rb_eArgError, "TCP port must be between 0 and 65535"); + if (p > 65535ULL) { + rb_raise(rb_eArgError, "TCP port must be between 0 and 65535"); + } struct rb_landlock_net_port_attr rule; memset(&rule, 0, sizeof(rule)); @@ -82,7 +90,9 @@ static VALUE rb_ll_add_net_rule(VALUE self, VALUE ruleset_fd, VALUE port, VALUE rule.port = p; long ret = ll_add_rule(NUM2INT(ruleset_fd), LANDLOCK_RULE_NET_PORT, &rule, 0); - if (ret < 0) raise_syscall_error("landlock_add_rule(net_port)"); + if (ret < 0) { + raise_syscall_error("landlock_add_rule(net_port)"); + } return Qtrue; } @@ -93,7 +103,9 @@ static VALUE rb_ll_restrict_self(VALUE self, VALUE ruleset_fd) { } long ret = ll_restrict_self(NUM2INT(ruleset_fd), 0); - if (ret < 0) raise_syscall_error("landlock_restrict_self"); + if (ret < 0) { + raise_syscall_error("landlock_restrict_self"); + } return Qtrue; #else errno = ENOSYS; @@ -103,10 +115,20 @@ static VALUE rb_ll_restrict_self(VALUE self, VALUE ruleset_fd) { static VALUE rb_ll_close_fd(VALUE self, VALUE fd_value) { int fd = NUM2INT(fd_value); - if (fd >= 0) close(fd); + if (fd >= 0) { + close(fd); + } return Qnil; } +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) { + raise_syscall_error(error_message); + } + return Qtrue; +} + void Init_landlock(void) { mLandlock = rb_define_module("Landlock"); @@ -128,6 +150,7 @@ 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, "seccomp_deny_network!", rb_ll_seccomp_deny_network, 0); rb_define_const(mLandlock, "ACCESS_FS_EXECUTE", ULL2NUM(LANDLOCK_ACCESS_FS_EXECUTE)); rb_define_const(mLandlock, "ACCESS_FS_WRITE_FILE", ULL2NUM(LANDLOCK_ACCESS_FS_WRITE_FILE)); @@ -147,6 +170,7 @@ void Init_landlock(void) { rb_define_const(mLandlock, "ACCESS_FS_IOCTL_DEV", ULL2NUM(LANDLOCK_ACCESS_FS_IOCTL_DEV)); rb_define_const(mLandlock, "ACCESS_NET_BIND_TCP", ULL2NUM(LANDLOCK_ACCESS_NET_BIND_TCP)); rb_define_const(mLandlock, "ACCESS_NET_CONNECT_TCP", ULL2NUM(LANDLOCK_ACCESS_NET_CONNECT_TCP)); - rb_define_const(mLandlock, "SCOPE_ABSTRACT_UNIX_SOCKET", ULL2NUM(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET)); + rb_define_const(mLandlock, "SCOPE_ABSTRACT_UNIX_SOCKET", + ULL2NUM(LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET)); rb_define_const(mLandlock, "SCOPE_SIGNAL", ULL2NUM(LANDLOCK_SCOPE_SIGNAL)); } diff --git a/ext/landlock/landlock_native.h b/ext/landlock/landlock_native.h index 51b6502..d92000d 100644 --- a/ext/landlock/landlock_native.h +++ b/ext/landlock/landlock_native.h @@ -16,22 +16,23 @@ #endif #ifndef SYS_landlock_create_ruleset -# if defined(__linux__) && defined(__NR_landlock_create_ruleset) && defined(__NR_landlock_add_rule) && defined(__NR_landlock_restrict_self) -# define SYS_landlock_create_ruleset __NR_landlock_create_ruleset -# define SYS_landlock_add_rule __NR_landlock_add_rule -# define SYS_landlock_restrict_self __NR_landlock_restrict_self -# elif defined(__linux__) && defined(__x86_64__) && defined(__ILP32__) -# ifndef __X32_SYSCALL_BIT -# define __X32_SYSCALL_BIT 0x40000000 -# endif -# define SYS_landlock_create_ruleset (__X32_SYSCALL_BIT + 444) -# define SYS_landlock_add_rule (__X32_SYSCALL_BIT + 445) -# define SYS_landlock_restrict_self (__X32_SYSCALL_BIT + 446) -# elif defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__) || defined(__i386__)) -# define SYS_landlock_create_ruleset 444 -# define SYS_landlock_add_rule 445 -# define SYS_landlock_restrict_self 446 -# endif +#if defined(__linux__) && defined(__NR_landlock_create_ruleset) && \ + defined(__NR_landlock_add_rule) && defined(__NR_landlock_restrict_self) +#define SYS_landlock_create_ruleset __NR_landlock_create_ruleset +#define SYS_landlock_add_rule __NR_landlock_add_rule +#define SYS_landlock_restrict_self __NR_landlock_restrict_self +#elif defined(__linux__) && defined(__x86_64__) && defined(__ILP32__) +#ifndef __X32_SYSCALL_BIT +#define __X32_SYSCALL_BIT 0x40000000 +#endif +#define SYS_landlock_create_ruleset (__X32_SYSCALL_BIT + 444) +#define SYS_landlock_add_rule (__X32_SYSCALL_BIT + 445) +#define SYS_landlock_restrict_self (__X32_SYSCALL_BIT + 446) +#elif defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__) || defined(__i386__)) +#define SYS_landlock_create_ruleset 444 +#define SYS_landlock_add_rule 445 +#define SYS_landlock_restrict_self 446 +#endif #endif #ifndef LANDLOCK_CREATE_RULESET_VERSION @@ -47,16 +48,16 @@ #endif #ifndef LANDLOCK_ACCESS_FS_EXECUTE -#define LANDLOCK_ACCESS_FS_EXECUTE (1ULL << 0) +#define LANDLOCK_ACCESS_FS_EXECUTE (1ULL << 0) #endif #ifndef LANDLOCK_ACCESS_FS_WRITE_FILE #define LANDLOCK_ACCESS_FS_WRITE_FILE (1ULL << 1) #endif #ifndef LANDLOCK_ACCESS_FS_READ_FILE -#define LANDLOCK_ACCESS_FS_READ_FILE (1ULL << 2) +#define LANDLOCK_ACCESS_FS_READ_FILE (1ULL << 2) #endif #ifndef LANDLOCK_ACCESS_FS_READ_DIR -#define LANDLOCK_ACCESS_FS_READ_DIR (1ULL << 3) +#define LANDLOCK_ACCESS_FS_READ_DIR (1ULL << 3) #endif #ifndef LANDLOCK_ACCESS_FS_REMOVE_DIR #define LANDLOCK_ACCESS_FS_REMOVE_DIR (1ULL << 4) @@ -65,38 +66,38 @@ #define LANDLOCK_ACCESS_FS_REMOVE_FILE (1ULL << 5) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_CHAR -#define LANDLOCK_ACCESS_FS_MAKE_CHAR (1ULL << 6) +#define LANDLOCK_ACCESS_FS_MAKE_CHAR (1ULL << 6) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_DIR -#define LANDLOCK_ACCESS_FS_MAKE_DIR (1ULL << 7) +#define LANDLOCK_ACCESS_FS_MAKE_DIR (1ULL << 7) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_REG -#define LANDLOCK_ACCESS_FS_MAKE_REG (1ULL << 8) +#define LANDLOCK_ACCESS_FS_MAKE_REG (1ULL << 8) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_SOCK -#define LANDLOCK_ACCESS_FS_MAKE_SOCK (1ULL << 9) +#define LANDLOCK_ACCESS_FS_MAKE_SOCK (1ULL << 9) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_FIFO -#define LANDLOCK_ACCESS_FS_MAKE_FIFO (1ULL << 10) +#define LANDLOCK_ACCESS_FS_MAKE_FIFO (1ULL << 10) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_BLOCK #define LANDLOCK_ACCESS_FS_MAKE_BLOCK (1ULL << 11) #endif #ifndef LANDLOCK_ACCESS_FS_MAKE_SYM -#define LANDLOCK_ACCESS_FS_MAKE_SYM (1ULL << 12) +#define LANDLOCK_ACCESS_FS_MAKE_SYM (1ULL << 12) #endif #ifndef LANDLOCK_ACCESS_FS_REFER -#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13) +#define LANDLOCK_ACCESS_FS_REFER (1ULL << 13) #endif #ifndef LANDLOCK_ACCESS_FS_TRUNCATE -#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14) +#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14) #endif #ifndef LANDLOCK_ACCESS_FS_IOCTL_DEV -#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15) +#define LANDLOCK_ACCESS_FS_IOCTL_DEV (1ULL << 15) #endif #ifndef LANDLOCK_ACCESS_NET_BIND_TCP -#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0) +#define LANDLOCK_ACCESS_NET_BIND_TCP (1ULL << 0) #endif #ifndef LANDLOCK_ACCESS_NET_CONNECT_TCP #define LANDLOCK_ACCESS_NET_CONNECT_TCP (1ULL << 1) diff --git a/ext/landlock/seccomp_deny_network.h b/ext/landlock/seccomp_deny_network.h new file mode 100644 index 0000000..c4a85ac --- /dev/null +++ b/ext/landlock/seccomp_deny_network.h @@ -0,0 +1,176 @@ +#ifndef RB_LANDLOCK_SECCOMP_DENY_NETWORK_H +#define RB_LANDLOCK_SECCOMP_DENY_NETWORK_H + +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#include +#include +#include +#endif + +#ifndef SECCOMP_RET_ALLOW +#define SECCOMP_RET_ALLOW 0x7fff0000U +#endif +#ifndef SECCOMP_RET_ERRNO +#define SECCOMP_RET_ERRNO 0x00050000U +#endif +#ifndef SECCOMP_RET_KILL_PROCESS +#define SECCOMP_RET_KILL_PROCESS 0x80000000U +#endif +#ifndef SECCOMP_SET_MODE_FILTER +#define SECCOMP_SET_MODE_FILTER 1 +#endif + +#ifdef __linux__ +static int rb_landlock_deny_network_syscalls[] = { +#ifdef __NR_socket + __NR_socket, +#endif +#ifdef __NR_socketpair + __NR_socketpair, +#endif +#ifdef __NR_connect + __NR_connect, +#endif +#ifdef __NR_bind + __NR_bind, +#endif +#ifdef __NR_listen + __NR_listen, +#endif +#ifdef __NR_accept + __NR_accept, +#endif +#ifdef __NR_accept4 + __NR_accept4, +#endif +#ifdef __NR_sendto + __NR_sendto, +#endif +#ifdef __NR_sendmsg + __NR_sendmsg, +#endif +#ifdef __NR_sendmmsg + __NR_sendmmsg, +#endif +#ifdef __NR_recvfrom + __NR_recvfrom, +#endif +#ifdef __NR_recvmsg + __NR_recvmsg, +#endif +#ifdef __NR_recvmmsg + __NR_recvmmsg, +#endif +#ifdef __NR_socketcall + __NR_socketcall, +#endif +}; + +#if defined(__x86_64__) && defined(AUDIT_ARCH_X86_64) +#define RB_LANDLOCK_EXPECTED_AUDIT_ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) && defined(AUDIT_ARCH_AARCH64) +#define RB_LANDLOCK_EXPECTED_AUDIT_ARCH AUDIT_ARCH_AARCH64 +#elif defined(__i386__) && defined(AUDIT_ARCH_I386) +#define RB_LANDLOCK_EXPECTED_AUDIT_ARCH AUDIT_ARCH_I386 +#endif + +#if defined(__x86_64__) && !defined(__ILP32__) +#ifndef __X32_SYSCALL_BIT +#define __X32_SYSCALL_BIT 0x40000000 +#endif +#define RB_LANDLOCK_DENY_X32_SYSCALLS 1 +#endif +#endif + +static int rb_landlock_seccomp_deny_network(const char **error_message) { +#ifdef __linux__ +#ifndef RB_LANDLOCK_EXPECTED_AUDIT_ARCH + errno = ENOSYS; + if (error_message) { + *error_message = "seccomp unsupported architecture"; + } + return -1; +#else + size_t count = + sizeof(rb_landlock_deny_network_syscalls) / sizeof(rb_landlock_deny_network_syscalls[0]); + if (count == 0) { + return 0; + } + + size_t len = 1 + (2 * count) + 1; + len += 3; +#ifdef RB_LANDLOCK_DENY_X32_SYSCALLS + len += 2; +#endif + struct sock_filter *filter = calloc(len, sizeof(struct sock_filter)); + if (!filter) { + if (error_message) { + *error_message = "calloc"; + } + return -1; + } + + size_t pc = 0; + filter[pc++] = + (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); + filter[pc++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, + RB_LANDLOCK_EXPECTED_AUDIT_ARCH, 1, 0); + filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS); + filter[pc++] = + (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); +#ifdef RB_LANDLOCK_DENY_X32_SYSCALLS + filter[pc++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, __X32_SYSCALL_BIT, 0, 1); + filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM); +#endif + for (size_t i = 0; i < count; i++) { + filter[pc++] = (struct sock_filter)BPF_JUMP( + BPF_JMP | BPF_JEQ | BPF_K, (unsigned int)rb_landlock_deny_network_syscalls[i], 0, 1); + filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM); + } + filter[pc++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); + + struct sock_fprog prog; + prog.len = (unsigned short)pc; + prog.filter = filter; + + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + if (error_message) { + *error_message = "prctl(PR_SET_NO_NEW_PRIVS)"; + } + free(filter); + return -1; + } +#ifdef SYS_seccomp + if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) == 0) { + free(filter); + return 0; + } +#endif + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) != 0) { + if (error_message) { + *error_message = "seccomp(SECCOMP_SET_MODE_FILTER)"; + } + free(filter); + return -1; + } + + free(filter); + return 0; +#endif +#else + errno = ENOSYS; + if (error_message) { + *error_message = "seccomp(SECCOMP_SET_MODE_FILTER)"; + } + return -1; +#endif +} + +#endif diff --git a/landlock.gemspec b/landlock.gemspec index 61824fb..79c7fef 100644 --- a/landlock.gemspec +++ b/landlock.gemspec @@ -19,22 +19,14 @@ Gem::Specification.new do |spec| spec.metadata["source_code_uri"] = spec.homepage spec.metadata["rubygems_mfa_required"] = "true" - spec.files = - Dir[ - "lib/**/*.rb", - "ext/**/*.{c,h,rb}", - "benchmark/**/*.rb", - "README.md", - "CHANGELOG.md", - "LICENSE.txt" - ] + spec.files = Dir["lib/**/*.rb", "ext/**/*.{c,h,rb}", "benchmark/**/*.rb", "README.md", "CHANGELOG.md", "LICENSE.txt"] spec.require_paths = ["lib"] spec.extensions = ["ext/landlock/extconf.rb"] spec.add_development_dependency "minitest", "~> 5.27" spec.add_development_dependency "rake", "~> 13.4" spec.add_development_dependency "rake-compiler", "~> 1.3" - # rubocop-discourse 3.17.0 references the pre-2.23 Capybara/CurrentPathExpectation cop name. - spec.add_development_dependency "rubocop-capybara", "~> 2.22", "< 2.23" - spec.add_development_dependency "rubocop-discourse", "~> 3.17" + spec.add_development_dependency "rubocop-capybara" + spec.add_development_dependency "rubocop-discourse", "~> 3.18" + spec.add_development_dependency "syntax_tree", "~> 6.3" end diff --git a/lib/landlock.rb b/lib/landlock.rb index 3fb32de..7be6240 100644 --- a/lib/landlock.rb +++ b/lib/landlock.rb @@ -1,264 +1,43 @@ # frozen_string_literal: true require_relative "landlock/version" -require_relative "landlock/landlock" -require_relative "landlock/safe_exec" +require_relative "landlock/errors" +require_relative "landlock/native" +require_relative "landlock/result" +require_relative "landlock/rights" +require_relative "landlock/validation" +require_relative "landlock/env" +require_relative "landlock/rlimits" +require_relative "landlock/process_io" +require_relative "landlock/policy" +require_relative "landlock/execution" module Landlock - class Error < StandardError; end - class UnsupportedError < Error; end - - class SyscallError < Error - attr_reader :errno, :syscall - - def initialize(syscall, errno, message = nil) - @syscall = syscall - @errno = errno - super(message || "#{syscall} failed: #{errno}") + class << self + def supported? + abi_version.positive? + rescue Error + false end - end - - FS_RIGHTS = { - execute: ACCESS_FS_EXECUTE, - write_file: ACCESS_FS_WRITE_FILE, - read_file: ACCESS_FS_READ_FILE, - read_dir: ACCESS_FS_READ_DIR, - remove_dir: ACCESS_FS_REMOVE_DIR, - remove_file: ACCESS_FS_REMOVE_FILE, - make_char: ACCESS_FS_MAKE_CHAR, - make_dir: ACCESS_FS_MAKE_DIR, - make_reg: ACCESS_FS_MAKE_REG, - make_sock: ACCESS_FS_MAKE_SOCK, - make_fifo: ACCESS_FS_MAKE_FIFO, - make_block: ACCESS_FS_MAKE_BLOCK, - make_sym: ACCESS_FS_MAKE_SYM, - refer: ACCESS_FS_REFER, - truncate: ACCESS_FS_TRUNCATE, - ioctl_dev: ACCESS_FS_IOCTL_DEV - }.freeze - - NET_RIGHTS = { - bind_tcp: ACCESS_NET_BIND_TCP, - connect_tcp: ACCESS_NET_CONNECT_TCP - }.freeze - - SCOPE_FLAGS = { - abstract_unix_socket: SCOPE_ABSTRACT_UNIX_SOCKET, - signal: SCOPE_SIGNAL - }.freeze - - READ_RIGHTS = %i[read_file read_dir].freeze - EXEC_RIGHTS = %i[execute read_file read_dir].freeze - WRITE_RIGHTS = %i[ - read_file read_dir write_file truncate remove_dir remove_file make_char - make_dir make_reg make_sock make_fifo make_block make_sym refer - ].freeze - FILE_PATH_RIGHTS = %i[execute write_file read_file truncate ioctl_dev].freeze - - module_function - def supported? - abi_version.positive? - rescue Error - false - end - - def restrict!(read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], allow_all_known: false) - abi = abi_version - raise UnsupportedError, "Linux Landlock is unavailable" unless abi.positive? - - fs_handled = allow_all_known ? _fs_rights_for_abi(abi) : _handled_fs_for(read:, write:, execute:, paths:, abi:) - net_handled = _handled_net_for(connect_tcp:, bind_tcp:, abi:) - scoped = _scope_for(scope:, abi:) - - if fs_handled.zero? && net_handled.zero? && scoped.zero? - raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes" + def restrict!(...) + Policy.restrict!(...) end - fd = _create_ruleset(fs_handled, net_handled, scoped) - begin - add_path_rules(fd, read, READ_RIGHTS, abi) - add_path_rules(fd, execute, EXEC_RIGHTS, abi) - add_path_rules(fd, write, WRITE_RIGHTS, abi) - - paths.each do |rule| - path, rights = normalize_path_rule(rule) - access_mask = mask(rights, FS_RIGHTS, abi) - next if access_mask.zero? - - _add_path_rule(fd, File.expand_path(path), access_mask) - end - - add_net_rules(fd, connect_tcp, [:connect_tcp], abi) - add_net_rules(fd, bind_tcp, [:bind_tcp], abi) - - _restrict_self(fd) - ensure - _close_fd(fd) if fd && fd >= 0 + def exec(...) + Execution.exec(...) end - true - end - - def exec(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true, allow_all_known: false) - argv = normalize_argv(argv) - ensure_landlock_supported! - - pid = fork do - begin - # Safe after fork: this runs only in the child process before exec. - Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir - restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) - - Kernel.exec(*kernel_exec_args(argv, env, unsetenv_others:, close_others:)) - rescue Exception => error - exit_child!(error) - end + def spawn(...) + Execution.spawn(...) end - _, status = Process.wait2(pid) - status - end - - def spawn(argv, read: [], write: [], execute: [], connect_tcp: [], bind_tcp: [], paths: [], scope: [], chdir: nil, env: nil, unsetenv_others: false, close_others: true, allow_all_known: false) - argv = normalize_argv(argv) - ensure_landlock_supported! - - fork do - begin - # Safe after fork: this runs only in the child process before exec. - Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir - restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) - Kernel.exec(*kernel_exec_args(argv, env, unsetenv_others:, close_others:)) - rescue Exception => error - exit_child!(error) - end + def capture(...) + Execution.capture(...) end - end - - def normalize_argv(argv) - raise ArgumentError, "argv must be an Array of command arguments" unless argv.is_a?(Array) - raise ArgumentError, "argv must not be empty" if argv.empty? - - argv - end - private_class_method :normalize_argv - - def argv_for_exec(argv) - command = argv.fetch(0) - [[command, command], *argv.drop(1)] - end - private_class_method :argv_for_exec - - def kernel_exec_args(argv, env, unsetenv_others:, close_others:) - exec_options = { close_others: close_others } - exec_options[:unsetenv_others] = true if unsetenv_others - if env - [env, *argv_for_exec(argv), exec_options] - else - [*argv_for_exec(argv), exec_options] + def capture!(...) + Execution.capture!(...) end end - private_class_method :kernel_exec_args - - def ensure_landlock_supported! - raise UnsupportedError, "Linux Landlock is unavailable" unless abi_version.positive? - end - private_class_method :ensure_landlock_supported! - - def exit_child!(error) - warn "Landlock child failed before exec: #{error.class}: #{error.message}" - ensure - exit! 127 - end - private_class_method :exit_child! - - def path_rights(path, rights) - File.directory?(path) ? rights : Array(rights) & FILE_PATH_RIGHTS - end - private_class_method :path_rights - - def add_path_rules(fd, paths, rights, abi) - Array(paths).each do |path| - expanded_path = File.expand_path(path) - access_mask = mask(path_rights(expanded_path, rights), FS_RIGHTS, abi) - next if access_mask.zero? - - _add_path_rule(fd, expanded_path, access_mask) - end - end - private_class_method :add_path_rules - - def add_net_rules(fd, ports, rights, abi) - ports = Array(ports) - return if ports.empty? - raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4 - - access_mask = mask(rights, NET_RIGHTS, abi) - return if access_mask.zero? - - ports.each { |port| _add_net_rule(fd, Integer(port), access_mask) } - end - private_class_method :add_net_rules - - def normalize_path_rule(rule) - case rule - when Hash - [rule.fetch(:path), Array(rule.fetch(:rights))] - when Array - [rule.fetch(0), Array(rule.fetch(1))] - else - raise ArgumentError, "path rule must be {path:, rights:} or [path, rights]" - end - end - private_class_method :normalize_path_rule - - def mask(names, table, abi) - Array(names).reduce(0) do |bits, name| - bit = table.fetch(name.to_sym) { raise ArgumentError, "unknown Landlock right: #{name.inspect}" } - next bits if bit == ACCESS_FS_REFER && abi < 2 - next bits if bit == ACCESS_FS_TRUNCATE && abi < 3 - next bits if bit == ACCESS_FS_IOCTL_DEV && abi < 5 - bits | bit - end - end - private_class_method :mask - - def _fs_rights_for_abi(abi) - rights = FS_RIGHTS.values.reduce(0, :|) - rights &= ~ACCESS_FS_REFER if abi < 2 - rights &= ~ACCESS_FS_TRUNCATE if abi < 3 - rights &= ~ACCESS_FS_IOCTL_DEV if abi < 5 - rights - end - - def _handled_fs_for(read:, write:, execute:, paths:, abi:) - bits = 0 - bits |= mask(READ_RIGHTS, FS_RIGHTS, abi) unless Array(read).empty? - bits |= mask(EXEC_RIGHTS, FS_RIGHTS, abi) unless Array(execute).empty? - bits |= mask(WRITE_RIGHTS, FS_RIGHTS, abi) unless Array(write).empty? - Array(paths).each { |rule| bits |= mask(normalize_path_rule(rule).last, FS_RIGHTS, abi) } - bits - end - private_class_method :_handled_fs_for - - def _handled_net_for(connect_tcp:, bind_tcp:, abi:) - bits = 0 - bits |= ACCESS_NET_CONNECT_TCP unless Array(connect_tcp).empty? - bits |= ACCESS_NET_BIND_TCP unless Array(bind_tcp).empty? - return 0 if bits.zero? - raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4 - bits - end - private_class_method :_handled_net_for - - def _scope_for(scope:, abi:) - bits = mask(scope, SCOPE_FLAGS, abi) - return 0 if bits.zero? - raise UnsupportedError, "Landlock scopes require ABI v6+; running ABI v#{abi}" if abi < 6 - - bits - end - private_class_method :_scope_for end diff --git a/lib/landlock/env.rb b/lib/landlock/env.rb new file mode 100644 index 0000000..08810b9 --- /dev/null +++ b/lib/landlock/env.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Landlock + module Env + module_function + + def normalize(env) + return nil if env.nil? + + raise ArgumentError, "env must be a Hash-compatible object" unless env.respond_to?(:each_pair) + + normalized = {} + env.each_pair do |key, value| + key = key.to_s + raise ArgumentError, "env key must not be empty" if key.empty? + raise ArgumentError, "env key must not contain '='" if key.include?("=") + raise ArgumentError, "env key must not contain NUL" if key.include?("\0") + + if value.nil? + normalized[key] = nil + else + value = value.to_s + raise ArgumentError, "env value must not contain NUL" if value.include?("\0") + + normalized[key] = value + end + end + normalized + end + end +end diff --git a/lib/landlock/errors.rb b/lib/landlock/errors.rb new file mode 100644 index 0000000..7469da3 --- /dev/null +++ b/lib/landlock/errors.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Landlock + Error = Class.new(StandardError) + UnsupportedError = Class.new(Error) + + class SyscallError < Error + attr_reader :errno, :syscall + + def initialize(syscall, errno, message = nil) + @syscall = syscall + @errno = errno + super(message || "#{syscall} failed: #{errno}") + end + end + + class CommandError < Error + attr_reader :stdout, :stderr, :status, :result + + def initialize(message, stdout: "", stderr: "", status: nil, result: nil) + @stdout = stdout + @stderr = stderr + @status = status + @result = result + super(message) + end + end + + class OutputTooLargeError < Error + attr_accessor :result + end +end diff --git a/lib/landlock/execution.rb b/lib/landlock/execution.rb new file mode 100644 index 0000000..190f48f --- /dev/null +++ b/lib/landlock/execution.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require_relative "env" +require_relative "native" +require_relative "policy" +require_relative "result" +require_relative "rlimits" +require_relative "runner" +require_relative "validation" + +module Landlock + module Execution + module_function + + def exec( + argv, + read: [], + write: [], + execute: [], + connect_tcp: [], + bind_tcp: [], + paths: [], + scope: [], + chdir: nil, + env: nil, + unsetenv_others: false, + close_others: true, + allow_all_known: false + ) + pid = + spawn( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known: + ) + _, status = ::Process.wait2(pid) + status + end + + def spawn( + argv, + read: [], + write: [], + execute: [], + connect_tcp: [], + bind_tcp: [], + paths: [], + scope: [], + chdir: nil, + env: nil, + unsetenv_others: false, + close_others: true, + allow_all_known: false + ) + argv = Validation.normalize_argv(argv).map(&:to_s) + ensure_landlock_supported! + env = Env.normalize(env) + policy = + prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:) + validate_landlock_restriction!(**policy) + + spawn_with_runner(argv, **policy, chdir:, env:, unsetenv_others:, close_others:) + end + + def capture(argv, **options) + capture_with(argv, raise_on_failure: false, **options) + end + + def capture!(argv, **options) + capture_with(argv, raise_on_failure: true, **options) + end + + def capture_with( + argv, + read: [], + write: [], + execute: [], + connect_tcp: [], + bind_tcp: [], + paths: [], + scope: [], + 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, + success_status_codes: [0], + failure_message: "", + 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, + chdir:, + env:, + unsetenv_others:, + close_others:, + timeout:, + stdin:, + rlimits: normalized_rlimits, + seccomp_deny_network:, + max_output_bytes:, + truncate_output: + ) + + 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") + raise CommandError.new(message, stdout: result.stdout, stderr: result.stderr, status: result.status, result:) + end + + result + rescue OutputTooLargeError => e + message = [argv&.join(" "), failure_message, e.message].filter { |part| part.to_s != "" }.join("\n") + result = e.result + raise CommandError.new( + message, + stdout: result&.stdout.to_s, + stderr: result&.stderr.to_s, + status: result&.status, + result: + ) + end + + def spawn_with_runner(argv, **options) + if Runner::Native.available? + begin + return Runner::Native.spawn(argv, **options) + rescue Errno::E2BIG + return Runner::Fork.spawn(argv, **options) + end + end + + Runner::Fork.spawn(argv, **options) + end + + def call_with_runner(argv, **options) + if Runner::Native.available? + begin + return Runner::Native.call(argv, **options) + rescue Errno::E2BIG + return Runner::Fork.call(argv, **options) + end + end + + Runner::Fork.call(argv, **options) + end + + def ensure_landlock_supported! + raise UnsupportedError, "Linux Landlock is unavailable" unless Native.abi_version.positive? + end + + def prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:) + connect_tcp = Validation.normalize_ports(connect_tcp, :connect_tcp) + bind_tcp = Validation.normalize_ports(bind_tcp, :bind_tcp) + read, write, execute, paths = validate_policy_paths!(read:, write:, execute:, paths:, chdir:) + { read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known: } + end + + def validate_landlock_restriction!( + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + allow_all_known: + ) + return if Policy.requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) + + raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes" + end + + def validate_capture_restriction!( + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + allow_all_known:, + seccomp_deny_network:, + rlimits: + ) + return if Policy.requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) + return if seccomp_deny_network + return if Array(rlimits).any? + + raise ArgumentError, "empty capture policy: provide Landlock rules, seccomp_deny_network, or rlimits" + end + + def validate_policy_paths!(read:, write:, execute:, paths:, chdir:) + base = chdir ? File.expand_path(chdir) : Dir.pwd + abi = Native.abi_version + read = Validation.validate_existing_paths(read, :read, chdir:) + write = Validation.validate_existing_paths(write, :write, chdir:) + execute = Validation.validate_existing_paths(execute, :execute, chdir:) + paths = + Array(paths).map do |rule| + path, rights = Policy.normalize_path_rule(rule) + Validation.validate_existing_path!(path, :path, base) + Policy.path_rule_access_mask(File.expand_path(path, base), rights, abi) + { path: path.to_s, rights: } + end + + [read, write, execute, paths] + end + end +end diff --git a/lib/landlock/native.rb b/lib/landlock/native.rb new file mode 100644 index 0000000..6035f17 --- /dev/null +++ b/lib/landlock/native.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative "errors" +require_relative "landlock" + +module Landlock + module Native + module_function + + def abi_version + Landlock.abi_version + end + + def create_ruleset(fs_handled, net_handled, scoped) + Landlock.__send__(:_create_ruleset, fs_handled, net_handled, scoped) + end + + def add_path_rule(fd, path, access_mask) + Landlock.__send__(:_add_path_rule, fd, path, access_mask) + end + + def add_net_rule(fd, port, access_mask) + Landlock.__send__(:_add_net_rule, fd, port, access_mask) + end + + def restrict_self(fd) + Landlock.__send__(:_restrict_self, fd) + end + + def close_fd(fd) + Landlock.__send__(:_close_fd, fd) + end + + def seccomp_deny_network! + Landlock.seccomp_deny_network! + end + end +end diff --git a/lib/landlock/policy.rb b/lib/landlock/policy.rb new file mode 100644 index 0000000..45f87b3 --- /dev/null +++ b/lib/landlock/policy.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require_relative "native" +require_relative "rights" + +module Landlock + module Policy + module_function + + def restrict!( + read: [], + write: [], + execute: [], + connect_tcp: [], + bind_tcp: [], + paths: [], + scope: [], + allow_all_known: false + ) + abi = Native.abi_version + raise UnsupportedError, "Linux Landlock is unavailable" unless abi.positive? + + fs_handled = + ( + if allow_all_known + fs_rights_for_abi(abi) + else + handled_fs_for(read:, write:, execute:, paths:, abi:) + end + ) + net_handled = handled_net_for(connect_tcp:, bind_tcp:, abi:) + scoped = scope_for(scope:, abi:) + + if fs_handled.zero? && net_handled.zero? && scoped.zero? + raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes" + end + + fd = Native.create_ruleset(fs_handled, net_handled, scoped) + begin + add_path_rules(fd, read, READ_RIGHTS, abi) + add_path_rules(fd, execute, EXEC_RIGHTS, abi) + add_path_rules(fd, write, WRITE_RIGHTS, abi) + + Array(paths).each do |rule| + path, rights = normalize_path_rule(rule) + expanded_path = File.expand_path(path) + access_mask = path_rule_access_mask(expanded_path, rights, abi) + + Native.add_path_rule(fd, expanded_path, access_mask) + end + + add_net_rules(fd, connect_tcp, [:connect_tcp], abi) + add_net_rules(fd, bind_tcp, [:bind_tcp], abi) + + Native.restrict_self(fd) + ensure + Native.close_fd(fd) if fd && fd >= 0 + end + + true + end + + def requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) + allow_all_known || Array(read).any? || Array(write).any? || Array(execute).any? || Array(connect_tcp).any? || + Array(bind_tcp).any? || Array(paths).any? || Array(scope).any? + end + + def path_rights(path, rights) + File.directory?(path) ? rights : Array(rights) & FILE_PATH_RIGHTS + end + + def path_rule_access_mask(path, rights, abi) + mask(path_rights(path, rights), FS_RIGHTS, abi).tap do |access_mask| + raise ArgumentError, "path rule has no effective rights: #{path}" if access_mask.zero? + end + end + + def add_path_rules(fd, paths, rights, abi) + Array(paths).each do |path| + expanded_path = File.expand_path(path) + access_mask = mask(path_rights(expanded_path, rights), FS_RIGHTS, abi) + next if access_mask.zero? + + Native.add_path_rule(fd, expanded_path, access_mask) + end + end + + def add_net_rules(fd, ports, rights, abi) + ports = Array(ports) + return if ports.empty? + raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4 + + access_mask = mask(rights, NET_RIGHTS, abi) + return if access_mask.zero? + + ports.each { |port| Native.add_net_rule(fd, Integer(port), access_mask) } + end + + def normalize_path_rule(rule) + case rule + when Hash + [rule.fetch(:path), Array(rule.fetch(:rights))] + when Array + [rule.fetch(0), Array(rule.fetch(1))] + else + raise ArgumentError, "path rule must be {path:, rights:} or [path, rights]" + end + end + + def mask(names, table, abi) + Array(names).reduce(0) do |bits, name| + bit = table.fetch(name.to_sym) { raise ArgumentError, "unknown Landlock right: #{name.inspect}" } + next bits if bit == ACCESS_FS_REFER && abi < 2 + next bits if bit == ACCESS_FS_TRUNCATE && abi < 3 + next bits if bit == ACCESS_FS_IOCTL_DEV && abi < 5 + + bits | bit + end + end + + def fs_rights_for_abi(abi) + rights = FS_RIGHTS.values.reduce(0, :|) + rights &= ~ACCESS_FS_REFER if abi < 2 + rights &= ~ACCESS_FS_TRUNCATE if abi < 3 + rights &= ~ACCESS_FS_IOCTL_DEV if abi < 5 + rights + end + + def handled_fs_for(read:, write:, execute:, paths:, abi:) + bits = 0 + bits |= mask(READ_RIGHTS, FS_RIGHTS, abi) unless Array(read).empty? + bits |= mask(EXEC_RIGHTS, FS_RIGHTS, abi) unless Array(execute).empty? + bits |= mask(WRITE_RIGHTS, FS_RIGHTS, abi) unless Array(write).empty? + Array(paths).each do |rule| + path, rights = normalize_path_rule(rule) + bits |= path_rule_access_mask(File.expand_path(path), rights, abi) + end + bits + end + + def handled_net_for(connect_tcp:, bind_tcp:, abi:) + bits = 0 + bits |= ACCESS_NET_CONNECT_TCP unless Array(connect_tcp).empty? + bits |= ACCESS_NET_BIND_TCP unless Array(bind_tcp).empty? + return 0 if bits.zero? + + raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4 + + bits + end + + def scope_for(scope:, abi:) + bits = mask(scope, SCOPE_FLAGS, abi) + return 0 if bits.zero? + + raise UnsupportedError, "Landlock scopes require ABI v6+; running ABI v#{abi}" if abi < 6 + + bits + end + end +end diff --git a/lib/landlock/process_io.rb b/lib/landlock/process_io.rb new file mode 100644 index 0000000..904dd94 --- /dev/null +++ b/lib/landlock/process_io.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require_relative "errors" +require_relative "result" + +module Landlock + READ_CHUNK_BYTES = 16 * 1024 + PROCESS_POLL_SECONDS = 0.1 + STDIN_THREAD_JOIN_SECONDS = 0.1 + POST_TIMEOUT_DRAIN_SECONDS = 0.05 + + module ProcessIO + module_function + + def complete_pipe_capture( + pid, + stdout_reader, + stderr_reader, + stdin_writer, + stdin, + timeout, + max_output_bytes, + truncate_output + ) + stdin_thread = write_input(stdin_writer, stdin) + + stdout = +"".b + stderr = +"".b + state = { bytes: 0, truncated: false } + begin + status, timed_out = + read_and_wait( + pid, + { stdout_reader => stdout, stderr_reader => stderr }, + timeout, + max_output_bytes, + truncate_output, + state + ) + rescue OutputTooLargeError => error + status ||= wait_for_pid(pid) + error.result = capture_result(stdout, stderr, status, output_truncated: true, timed_out:) + raise + ensure + finish_input_thread(stdin_thread, stdin_writer) + end + + capture_result(stdout, stderr, status, output_truncated: state[:truncated], timed_out:) + end + + def capture_result(stdout, stderr, status, output_truncated:, timed_out:) + stdout.force_encoding(Encoding.default_external) + stderr.force_encoding(Encoding.default_external) + CaptureResult.new(stdout:, stderr:, status:, output_truncated:, timed_out:) + end + + def write_input(io, input) + return io.close if input.nil? + + Thread.new do + Thread.current.report_on_exception = false + begin + if input.respond_to?(:read) + while (chunk = input.read(READ_CHUNK_BYTES)) + io.write(chunk) + end + else + io.write(input.to_s) + end + rescue Errno::EPIPE, IOError + ensure + io.close unless io.closed? + end + end + end + + def finish_input_thread(thread, io) + close_stream(io) + return unless thread + + if thread.join(STDIN_THREAD_JOIN_SECONDS) + thread.value + else + thread.kill + thread.join(STDIN_THREAD_JOIN_SECONDS) + end + end + + def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, state) + deadline = timeout ? ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout : nil + timed_out = false + status = nil + + until streams.empty? && status + if deadline + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + if remaining <= 0 + timed_out = true + terminate_process(pid) + status = wait_for_pid(pid) + drain_streams_until( + streams, + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + POST_TIMEOUT_DRAIN_SECONDS, + max_output_bytes, + truncate_output, + state, + pid + ) + close_streams(streams) + break + end + end + + status ||= poll_pid(pid) + + break if streams.empty? && status + + wait = + ( + if deadline + [deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), PROCESS_POLL_SECONDS].min + else + PROCESS_POLL_SECONDS + end + ) + wait = 0 if wait.negative? + if streams.empty? + sleep wait + next + end + + readable, = IO.select(streams.keys, nil, nil, wait) + next unless readable + + readable.each do |io| + begin + chunk = io.read_nonblock(READ_CHUNK_BYTES) + append_output_chunk(streams.fetch(io), chunk, state, max_output_bytes, truncate_output, pid) + rescue IO::WaitReadable + next + rescue EOFError + streams.delete(io) + io.close + end + end + end + + status ||= wait_for_pid(pid) + [status, timed_out] + end + + def poll_pid(pid) + result = ::Process.wait2(pid, ::Process::WNOHANG) + result&.last + rescue Errno::ECHILD + nil + end + + def wait_for_pid(pid) + ::Process.wait2(pid).last + rescue Errno::ECHILD + nil + end + + def close_stream(io) + io.close unless io.closed? + rescue IOError + end + + def read_available_streams(streams, max_output_bytes, truncate_output, state, pid) + readable, = IO.select(streams.keys, nil, nil, 0) + return false unless readable + + readable.each do |io| + begin + chunk = io.read_nonblock(READ_CHUNK_BYTES) + append_output_chunk(streams.fetch(io), chunk, state, max_output_bytes, truncate_output, pid) + rescue IO::WaitReadable + next + rescue EOFError + streams.delete(io) + io.close + end + end + + true + end + + def drain_streams_until(streams, drain_deadline, max_output_bytes, truncate_output, state, pid) + while streams.any? && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < drain_deadline + break unless read_available_streams(streams, max_output_bytes, truncate_output, state, pid) + end + end + + def close_streams(streams) + streams.keys.each do |io| + streams.delete(io) + io.close unless io.closed? + rescue IOError + end + end + + def append_output_chunk( + buffer, + chunk, + state, + max_output_bytes, + truncate_output, + pid, + output_too_large_error: Landlock::OutputTooLargeError + ) + return buffer << chunk if max_output_bytes.nil? + + chunk_to_append = chunk + over_limit = false + remaining_bytes = max_output_bytes - state[:bytes] + if remaining_bytes <= 0 + chunk_to_append = "" + over_limit = true + elsif chunk.bytesize > remaining_bytes + chunk_to_append = chunk.byteslice(0, remaining_bytes) + over_limit = true + end + + state[:bytes] += chunk.bytesize + state[:truncated] = true if over_limit + buffer << chunk_to_append + return unless over_limit + + terminate_process(pid) + raise output_too_large_error, "Process output exceeded #{max_output_bytes} bytes" unless truncate_output + end + + def terminate_process(pid) + signal_process("TERM", pid) + sleep 0.5 + signal_process("KILL", pid) + end + + def signal_process(signal, pid) + ::Process.kill(signal, -pid) + rescue Errno::ESRCH, Errno::EPERM + begin + ::Process.kill(signal, pid) + rescue Errno::ESRCH, Errno::EPERM + end + end + end +end diff --git a/lib/landlock/result.rb b/lib/landlock/result.rb new file mode 100644 index 0000000..3966fad --- /dev/null +++ b/lib/landlock/result.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Landlock + module ResultBehavior + attr_reader :stdout, :stderr, :status + + def success? + !timed_out? && status&.success? + end + + def output_truncated? + @output_truncated + end + + def timed_out? + @timed_out + end + + def to_ary + [stdout, stderr, status] + end + + def to_s + stdout.to_s + end + + def inspect + "#<#{self.class} status=#{status.inspect} timed_out=#{timed_out?} output_truncated=#{output_truncated?} stdout=#{stdout.inspect} stderr=#{stderr.inspect}>" + end + end + + class CaptureResult + include ResultBehavior + + def initialize(stdout:, stderr:, status:, output_truncated: false, timed_out: false) + @stdout = stdout + @stderr = stderr + @status = status + @output_truncated = output_truncated + @timed_out = timed_out + end + end +end diff --git a/lib/landlock/rights.rb b/lib/landlock/rights.rb new file mode 100644 index 0000000..9761a06 --- /dev/null +++ b/lib/landlock/rights.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "native" + +module Landlock + FS_RIGHTS = { + execute: ACCESS_FS_EXECUTE, + write_file: ACCESS_FS_WRITE_FILE, + read_file: ACCESS_FS_READ_FILE, + read_dir: ACCESS_FS_READ_DIR, + remove_dir: ACCESS_FS_REMOVE_DIR, + remove_file: ACCESS_FS_REMOVE_FILE, + make_char: ACCESS_FS_MAKE_CHAR, + make_dir: ACCESS_FS_MAKE_DIR, + make_reg: ACCESS_FS_MAKE_REG, + make_sock: ACCESS_FS_MAKE_SOCK, + make_fifo: ACCESS_FS_MAKE_FIFO, + make_block: ACCESS_FS_MAKE_BLOCK, + make_sym: ACCESS_FS_MAKE_SYM, + refer: ACCESS_FS_REFER, + truncate: ACCESS_FS_TRUNCATE, + ioctl_dev: ACCESS_FS_IOCTL_DEV + }.freeze + + NET_RIGHTS = { bind_tcp: ACCESS_NET_BIND_TCP, connect_tcp: ACCESS_NET_CONNECT_TCP }.freeze + + SCOPE_FLAGS = { abstract_unix_socket: SCOPE_ABSTRACT_UNIX_SOCKET, signal: SCOPE_SIGNAL }.freeze + + READ_RIGHTS = %i[read_file read_dir].freeze + EXEC_RIGHTS = %i[execute read_file read_dir].freeze + WRITE_RIGHTS = %i[ + read_file + read_dir + write_file + truncate + remove_dir + remove_file + make_char + make_dir + make_reg + make_sock + make_fifo + make_block + make_sym + refer + ].freeze + FILE_PATH_RIGHTS = %i[execute write_file read_file truncate ioctl_dev].freeze +end diff --git a/lib/landlock/rlimits.rb b/lib/landlock/rlimits.rb new file mode 100644 index 0000000..5460f19 --- /dev/null +++ b/lib/landlock/rlimits.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Landlock + module Rlimits + VALID_NAMES = %i[cpu_seconds memory_bytes file_size_bytes open_files processes].freeze + + module_function + + def normalize(rlimits) + Array(rlimits).filter_map do |name, value| + next if value.nil? + + key = name.to_sym + raise ArgumentError, "Unknown rlimit: #{name}" if !VALID_NAMES.include?(key) + + value = Integer(value) + raise ArgumentError, "rlimit #{name} must be non-negative" if value.negative? + + [key, value] + end + end + + def apply!(rlimits) + rlimits.each do |key, value| + case key + when :cpu_seconds + ::Process.setrlimit(:CPU, value, value) + when :memory_bytes + ::Process.setrlimit(:AS, value, value) + when :file_size_bytes + ::Process.setrlimit(:FSIZE, value, value) + when :open_files + ::Process.setrlimit(:NOFILE, value, value) + when :processes + ::Process.setrlimit(:NPROC, value, value) + end + end + end + end +end diff --git a/lib/landlock/runner.rb b/lib/landlock/runner.rb new file mode 100644 index 0000000..73c012a --- /dev/null +++ b/lib/landlock/runner.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Landlock + module Runner + module_function + + def argv_for_exec(argv) + command = argv.fetch(0) + [[command, command], *argv.drop(1)] + end + + def kernel_exec_args(argv, env, unsetenv_others:, close_others:) + exec_options = { close_others: } + exec_options[:unsetenv_others] = true if unsetenv_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}" + ensure + exit! 127 + end + end +end + +require_relative "runner/fork" +require_relative "runner/native" diff --git a/lib/landlock/runner/fork.rb b/lib/landlock/runner/fork.rb new file mode 100644 index 0000000..3200b2f --- /dev/null +++ b/lib/landlock/runner/fork.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require_relative "../errors" +require_relative "../native" +require_relative "../policy" +require_relative "../process_io" +require_relative "../rlimits" + +module Landlock + module Runner + module Fork + module_function + + def spawn( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known: + ) + fork do + begin + 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: false + ) + rescue Exception => error + Runner.exit_child!(error) + end + end + end + + def call( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + timeout:, + stdin:, + rlimits:, + seccomp_deny_network:, + max_output_bytes:, + truncate_output: + ) + stdout_reader, stdout_writer = IO.pipe + stderr_reader, stderr_writer = IO.pipe + stdin_reader, stdin_writer = IO.pipe + + pid = + fork do + begin + stdout_reader.close + stderr_reader.close + stdin_writer.close + ::Process.setpgrp + STDIN.reopen(stdin_reader) + STDOUT.reopen(stdout_writer) + STDERR.reopen(stderr_writer) + 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: + ) + rescue Exception => error + Runner.exit_child!(error) + end + end + + stdin_reader.close + stdout_writer.close + stderr_writer.close + + ProcessIO.complete_pipe_capture( + pid, + stdout_reader, + stderr_reader, + stdin_writer, + stdin, + timeout, + max_output_bytes, + truncate_output + ) + rescue OutputTooLargeError + raise + rescue Exception + if pid + ProcessIO.terminate_process(pid) + ProcessIO.wait_for_pid(pid) + end + raise + ensure + [stdin_reader, stdin_writer, stdout_reader, stdout_writer, stderr_reader, stderr_writer].each do |io| + io&.close unless io.closed? + rescue IOError + end + end + + def 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: + ) + Dir.chdir(chdir) if chdir # rubocop:disable Discourse/NoChdir + if Policy.requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) + Landlock.restrict!(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:) + end + Landlock::Native.seccomp_deny_network! if seccomp_deny_network + Rlimits.apply!(rlimits) + Kernel.exec(*Runner.kernel_exec_args(argv, env, unsetenv_others:, close_others:)) + end + end + end +end diff --git a/lib/landlock/runner/native.rb b/lib/landlock/runner/native.rb new file mode 100644 index 0000000..cfbf43a --- /dev/null +++ b/lib/landlock/runner/native.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "rbconfig" +require_relative "../errors" +require_relative "../policy" +require_relative "../process_io" + +module Landlock + module Runner + module Native + # Starts the native helper with the sandbox policy encoded as argv flags. + # The helper applies the policy, then execs the target command so the + # long-lived child is not a forked Ruby process. + module_function + + def available? + File.executable?(helper_path) + end + + def helper_path + candidates = [ + File.expand_path("../landlock-safe-exec", __dir__), + File.expand_path( + "../../../tmp/#{RbConfig::CONFIG.fetch("arch")}/landlock/#{RUBY_VERSION}/landlock-safe-exec", + __dir__ + ), + File.expand_path("../../../ext/landlock/landlock-safe-exec", __dir__) + ] + candidates.find { |path| File.executable?(path) } || candidates.first + end + + def spawn( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known: + ) + spawn_helper( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + rlimits: [], + seccomp_deny_network: false + ) + end + + def call( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + timeout:, + stdin:, + rlimits:, + seccomp_deny_network:, + max_output_bytes:, + truncate_output: + ) + stdout_reader, stdout_writer = IO.pipe + stderr_reader, stderr_writer = IO.pipe + stdin_reader, stdin_writer = IO.pipe + + pid = + spawn_helper( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + rlimits:, + seccomp_deny_network:, + stdin_reader:, + stdout_writer:, + stderr_writer:, + pgroup: true + ) + + stdin_reader.close + stdout_writer.close + stderr_writer.close + + ProcessIO.complete_pipe_capture( + pid, + stdout_reader, + stderr_reader, + stdin_writer, + stdin, + timeout, + max_output_bytes, + truncate_output + ) + rescue OutputTooLargeError + raise + rescue Exception + if pid + ProcessIO.terminate_process(pid) + ProcessIO.wait_for_pid(pid) + end + raise + ensure + [stdin_reader, stdin_writer, stdout_reader, stdout_writer, stderr_reader, stderr_writer].each do |io| + io&.close unless io.closed? + rescue IOError + end + end + + def spawn_helper( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + chdir:, + env:, + unsetenv_others:, + close_others:, + allow_all_known:, + rlimits:, + seccomp_deny_network:, + stdin_reader: nil, + stdout_writer: nil, + stderr_writer: nil, + pgroup: false + ) + spawn_options = { close_others: } + spawn_options[:unsetenv_others] = true if unsetenv_others + spawn_options[:chdir] = chdir if chdir + spawn_options[:in] = stdin_reader if stdin_reader + spawn_options[:out] = stdout_writer if stdout_writer + spawn_options[:err] = stderr_writer if stderr_writer + spawn_options[:pgroup] = true if pgroup + + spawn_args = + helper_argv( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + allow_all_known:, + rlimits:, + seccomp_deny_network:, + close_others: + ) + env ? ::Process.spawn(env, *spawn_args, spawn_options) : ::Process.spawn(*spawn_args, spawn_options) + end + + def helper_argv( + argv, + read:, + write:, + execute:, + connect_tcp:, + bind_tcp:, + paths:, + scope:, + allow_all_known:, + rlimits:, + seccomp_deny_network:, + close_others: + ) + args = [helper_path] + Array(read).each { |path| args << "--read" << path.to_s } + Array(write).each { |path| args << "--write" << path.to_s } + Array(execute).each { |path| args << "--execute" << path.to_s } + Array(paths).each do |rule| + path, rights = Policy.normalize_path_rule(rule) + args << "--path" << path.to_s << Array(rights).map(&:to_s).join(",") + end + Array(connect_tcp).each { |port| args << "--connect-tcp" << port.to_s } + Array(bind_tcp).each { |port| args << "--bind-tcp" << port.to_s } + Array(scope).each { |name| args << "--scope" << name.to_s } + Array(rlimits).each { |key, value| args << "--rlimit" << "#{key}=#{value}" } + args << "--allow-all-known" if allow_all_known + args << "--seccomp-deny-network" if seccomp_deny_network + args << "--keep-fds" unless close_others + args << "--" + args.concat(argv.map(&:to_s)) + args + end + end + end +end diff --git a/lib/landlock/safe_exec.rb b/lib/landlock/safe_exec.rb deleted file mode 100644 index a1bba8e..0000000 --- a/lib/landlock/safe_exec.rb +++ /dev/null @@ -1,522 +0,0 @@ -# frozen_string_literal: true - -require "open3" -require "rbconfig" -require "timeout" -require_relative "landlock" - -module Landlock - class SafeExec - Error = Class.new(StandardError) - OutputTooLargeError = Class.new(Error) - - class CommandError < Error - attr_reader :stdout, :stderr, :status, :result - - def initialize(message, stdout: "", stderr: "", status: nil, result: nil) - @stdout = stdout - @stderr = stderr - @status = status - @result = result - super(message) - end - end - - class Result - attr_reader :stdout, :stderr, :status - - def initialize(stdout:, stderr:, status:, output_truncated: false, timed_out: false) - @stdout = stdout - @stderr = stderr - @status = status - @output_truncated = output_truncated - @timed_out = timed_out - end - - def success? - !timed_out? && status&.success? - end - - def output_truncated? - @output_truncated - end - - def timed_out? - @timed_out - end - - def to_ary - [stdout, stderr, status] - end - - def to_s - stdout.to_s - end - - def inspect - "#<#{self.class} status=#{status.inspect} timed_out=#{timed_out?} output_truncated=#{output_truncated?} stdout=#{stdout.inspect} stderr=#{stderr.inspect}>" - end - end - - DEFAULT_READ_PATHS = %w[/bin /etc /lib /lib64 /usr].freeze - DEFAULT_EXECUTE_PATHS = %w[/bin /lib /lib64 /usr].freeze - READ_CHUNK_BYTES = 16 * 1024 - - class << self - def capture(*command, **options) - perform_capture(*command, raise_on_failure: false, **options) - end - - def capture!(*command, **options) - perform_capture(*command, raise_on_failure: true, **options) - end - - def perform_capture( - *command, - read: [], - write: [], - execute: [], - timeout: nil, - failure_message: "", - success_status_codes: [0], - env: {}, - inherit_env: false, - chdir: nil, - stdin: nil, - connect_tcp: nil, - bind_tcp: [], - rlimits: {}, - seccomp_deny_network: false, - max_output_bytes: nil, - truncate_output: false, - allow_all_known: true, - raise_on_failure: - ) - validate_sandbox_option_values!(connect_tcp: connect_tcp, bind_tcp: bind_tcp) - - unsupported_options = unsupported_sandbox_options( - read: read, - write: write, - execute: execute, - connect_tcp: connect_tcp, - bind_tcp: bind_tcp, - seccomp_deny_network: seccomp_deny_network - ) - use_helper = helper_available? - warn_unsupported_platform_once(unsupported_options) if !use_helper && unsupported_options.any? - - stdout, stderr, status, output_truncated, timed_out = if use_helper - max_output_bytes = validate_output_limit!(max_output_bytes) - capture_process( - command, - read: read, - write: write, - execute: execute, - timeout: timeout, - env: env, - inherit_env: inherit_env, - chdir: chdir, - stdin: stdin, - connect_tcp: connect_tcp, - bind_tcp: bind_tcp, - rlimits: rlimits, - seccomp_deny_network: seccomp_deny_network, - max_output_bytes: max_output_bytes, - truncate_output: truncate_output, - allow_all_known: allow_all_known - ) - else - max_output_bytes = validate_output_limit!(max_output_bytes) - capture_process_without_helper( - command, - timeout: timeout, - env: env, - inherit_env: inherit_env, - chdir: chdir, - stdin: stdin, - rlimits: rlimits, - max_output_bytes: max_output_bytes, - truncate_output: truncate_output - ) - end - - result = Result.new(stdout: stdout, stderr: stderr, status: status, output_truncated: output_truncated, timed_out: timed_out) - - if raise_on_failure && (!status.exited? || !success_status_codes.include?(status.exitstatus)) - message = [command.join(" "), failure_message, stderr].filter { |part| part.to_s != "" }.join("\n") - raise CommandError.new(message, stdout: stdout, stderr: stderr, status: status, result: result) - end - - result - rescue OutputTooLargeError => e - message = [command.join(" "), failure_message, e.message].filter { |part| part.to_s != "" }.join("\n") - raise CommandError.new(message) - end - private :perform_capture - - def supported? - helper_available? && Landlock.supported? - end - - def sandboxing? - supported? - end - - def helper_path - candidates = [ - File.expand_path("landlock-safe-exec", __dir__), - File.expand_path("../../tmp/#{RbConfig::CONFIG.fetch("arch")}/landlock/#{RUBY_VERSION}/landlock-safe-exec", __dir__), - File.expand_path("../../ext/landlock/landlock-safe-exec", __dir__) - ] - candidates.find { |path| File.executable?(path) } || candidates.first - end - - def default_read_paths - existing_paths(DEFAULT_READ_PATHS) - end - - def default_execute_paths - existing_paths(DEFAULT_EXECUTE_PATHS) - end - - def existing_paths(paths) - Array(paths).filter { |path| path.to_s != "" && File.exist?(path) }.uniq - end - - private - - def helper_available? - RUBY_PLATFORM.include?("linux") && File.executable?(helper_path) - end - - def validate_sandbox_option_values!(connect_tcp:, bind_tcp:) - normalized_ports(connect_tcp, :connect_tcp) if !connect_tcp.nil? - normalized_ports(bind_tcp, :bind_tcp) - end - - def unsupported_sandbox_options(read:, write:, execute:, connect_tcp:, bind_tcp:, seccomp_deny_network:) - options = [] - options << :read if Array(read).any? - options << :write if Array(write).any? - options << :execute if Array(execute).any? - options << :connect_tcp if !connect_tcp.nil? - options << :bind_tcp if Array(bind_tcp).any? - options << :seccomp_deny_network if seccomp_deny_network - options - end - - def warn_unsupported_platform_once(options) - return if @warned_unsupported_sandbox - - @warned_unsupported_sandbox = true - warn( - "Landlock::SafeExec sandbox options #{options.join(", ")} are unavailable without the Linux " \ - "landlock-safe-exec helper; running command as a pass-through with those restrictions ignored" - ) - end - - def validate_output_limit!(max_output_bytes) - return if max_output_bytes.nil? - - Integer(max_output_bytes).tap do |value| - raise ArgumentError, "max_output_bytes must be non-negative" if value.negative? - end - end - - def capture_process_without_helper( - command, - timeout:, - env:, - inherit_env:, - chdir:, - stdin:, - rlimits:, - max_output_bytes:, - truncate_output: - ) - argv = normalize_command(command) - spawn_options = fallback_spawn_options( - inherit_env: inherit_env, - chdir: chdir, - rlimits: rlimits - ) - popen_args = [env || {}, *argv, spawn_options] - - output_state = { bytes: 0, truncated: false } - output_mutex = Mutex.new - stdout = stderr = status = nil - timed_out = false - - Open3.popen3(*popen_args) do |stdin_io, stdout_io, stderr_io, wait_thread| - stdin_thread = write_process_input(stdin_io, stdin) - stdout_thread = Thread.new do - Thread.current.report_on_exception = false - read_process_output(stdout_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid) - end - stderr_thread = Thread.new do - Thread.current.report_on_exception = false - read_process_output(stderr_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid) - end - - status, timed_out = wait_for_process(wait_thread, timeout) - stdin_thread&.value - stdout = stdout_thread.value - stderr = stderr_thread.value - end - - [stdout, stderr, status, output_state[:truncated], timed_out] - end - - def fallback_spawn_options(inherit_env:, chdir:, rlimits:) - options = { close_others: true, pgroup: true } - options[:unsetenv_others] = true if !inherit_env - options[:chdir] = chdir if chdir - options.merge!(rlimit_spawn_options(rlimits)) - options - end - - def rlimit_spawn_options(rlimits) - normalized_rlimits(rlimits).to_h do |key, value| - [rlimit_spawn_key(key), [value, value]] - end - end - - def rlimit_spawn_key(name) - case name - when :cpu_seconds - :rlimit_cpu - when :memory_bytes - :rlimit_as - when :file_size_bytes - :rlimit_fsize - when :open_files - :rlimit_nofile - when :processes - :rlimit_nproc - end - end - - def normalize_command(command) - raise ArgumentError, "command must not be empty" if command.empty? - - command.map(&:to_s) - end - - def capture_process( - command, - read:, - write:, - execute:, - timeout:, - env:, - inherit_env:, - chdir:, - stdin:, - connect_tcp:, - bind_tcp:, - rlimits:, - seccomp_deny_network:, - max_output_bytes:, - truncate_output:, - allow_all_known: - ) - argv = helper_argv( - command, - read: read, - write: write, - execute: execute, - env: env, - inherit_env: inherit_env, - chdir: chdir, - connect_tcp: connect_tcp, - bind_tcp: bind_tcp, - rlimits: rlimits, - seccomp_deny_network: seccomp_deny_network, - allow_all_known: allow_all_known - ) - - output_state = { bytes: 0, truncated: false } - output_mutex = Mutex.new - stdout = stderr = status = nil - timed_out = false - - Open3.popen3(*argv, pgroup: true) do |stdin_io, stdout_io, stderr_io, wait_thread| - stdin_thread = write_process_input(stdin_io, stdin) - stdout_thread = Thread.new do - Thread.current.report_on_exception = false - read_process_output(stdout_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid) - end - stderr_thread = Thread.new do - Thread.current.report_on_exception = false - read_process_output(stderr_io, max_output_bytes, truncate_output, output_state, output_mutex, wait_thread.pid) - end - - status, timed_out = wait_for_process(wait_thread, timeout) - stdin_thread&.value - stdout = stdout_thread.value - stderr = stderr_thread.value - end - - [stdout, stderr, status, output_state[:truncated], timed_out] - end - - def helper_argv( - command, - read:, - write:, - execute:, - env:, - inherit_env:, - chdir:, - connect_tcp:, - bind_tcp:, - rlimits:, - seccomp_deny_network:, - allow_all_known: - ) - normalize_command(command) - read_paths = validate_existing_paths(read, :read) - write_paths = validate_existing_paths(write, :write) - execute_paths = validate_existing_paths(execute, :execute) - filesystem_policy_requested = read_paths.any? || write_paths.any? || execute_paths.any? - - argv = [helper_path] - read_paths.each { |path| argv << "--read" << path } - write_paths.each { |path| argv << "--write" << path } - execute_paths.each { |path| argv << "--execute" << path } - sandbox_connect_tcp_ports(connect_tcp).each { |port| argv << "--connect-tcp" << port.to_s } - normalized_ports(bind_tcp, :bind_tcp).each { |port| argv << "--bind-tcp" << port.to_s } - argv << "--chdir" << chdir if chdir - Array(env).each { |key, value| argv << "--env" << "#{key}=#{value}" } - argv << "--unsetenv-others" if !inherit_env - normalized_rlimits(rlimits).each { |key, value| argv << "--rlimit" << "#{key}=#{value}" } - argv << "--seccomp-deny-network" if seccomp_deny_network - argv << "--allow-all-known" if allow_all_known && filesystem_policy_requested - argv << "--" - argv.concat(command.map(&:to_s)) - argv - end - - def sandbox_connect_tcp_ports(connect_tcp) - return normalized_ports(connect_tcp, :connect_tcp) if !connect_tcp.nil? - return [] if !Landlock.supported? || Landlock.abi_version < 4 - - [0] - end - - def normalized_ports(ports, name) - Array(ports).map do |port| - integer = Integer(port) - raise ArgumentError, "#{name} port must be between 0 and 65535" if integer.negative? || integer > 65_535 - - integer - end - end - - def validate_existing_paths(paths, name) - Array(paths).map do |path| - string = path.to_s - raise ArgumentError, "#{name} path must not be empty" if string.empty? - raise ArgumentError, "#{name} path does not exist: #{string}" if !File.exist?(string) - - string - end.uniq - end - - def normalized_rlimits(rlimits) - Array(rlimits).filter_map do |name, value| - next if value.nil? - - key = name.to_sym - if !%i[cpu_seconds memory_bytes file_size_bytes open_files processes].include?(key) - raise ArgumentError, "Unknown rlimit: #{name}" - end - - value = Integer(value) - raise ArgumentError, "rlimit #{name} must be non-negative" if value.negative? - - [key, value] - end - end - - def wait_for_process(wait_thread, timeout) - if timeout - [Timeout.timeout(timeout) { wait_thread.value }, false] - else - [wait_thread.value, false] - end - rescue Timeout::Error - terminate_process(wait_thread.pid) - [wait_thread.value, true] - end - - def write_process_input(io, input) - return io.close if input.nil? - - Thread.new do - Thread.current.report_on_exception = false - begin - if input.respond_to?(:read) - while (chunk = input.read(READ_CHUNK_BYTES)) - io.write(chunk) - end - else - io.write(input.to_s) - end - rescue Errno::EPIPE, IOError - ensure - io.close unless io.closed? - end - end - end - - def read_process_output(io, max_output_bytes, truncate_output, output_state, output_mutex, pid) - return io.read if max_output_bytes.nil? - - output = +"" - while (chunk = io.read(READ_CHUNK_BYTES)) - chunk_to_append = chunk - over_limit = false - - output_mutex.synchronize do - remaining_bytes = max_output_bytes - output_state[:bytes] - if remaining_bytes <= 0 - chunk_to_append = "" - over_limit = true - elsif chunk.bytesize > remaining_bytes - chunk_to_append = chunk.byteslice(0, remaining_bytes) - over_limit = true - end - - output_state[:bytes] += chunk.bytesize - output_state[:truncated] = true if over_limit - end - - output << chunk_to_append - if over_limit - terminate_process(pid) - raise OutputTooLargeError, "Process output exceeded #{max_output_bytes} bytes" if !truncate_output - - break - end - end - output - end - - def terminate_process(pid) - signal_process("TERM", pid) - sleep 0.5 - signal_process("KILL", pid) - end - - def signal_process(signal, pid) - Process.kill(signal, -pid) - rescue Errno::ESRCH, Errno::EPERM - begin - Process.kill(signal, pid) - rescue Errno::ESRCH, Errno::EPERM - end - end - end - end -end diff --git a/lib/landlock/validation.rb b/lib/landlock/validation.rb new file mode 100644 index 0000000..a9d99b4 --- /dev/null +++ b/lib/landlock/validation.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Landlock + module Validation + module_function + + def normalize_argv(argv) + raise ArgumentError, "argv must be an Array of command arguments" unless argv.is_a?(Array) + raise ArgumentError, "argv must not be empty" if argv.empty? + + argv + end + + def normalize_ports(ports, name) + Array(ports).map do |port| + integer = Integer(port) + raise ArgumentError, "#{name} port must be between 0 and 65535" if integer.negative? || integer > 65_535 + + integer + end + end + + def validate_existing_paths(paths, name, chdir: nil) + base = chdir ? File.expand_path(chdir) : Dir.pwd + Array(paths) + .map do |path| + validate_existing_path!(path, name, base) + path.to_s + end + .uniq + end + + def validate_existing_path!(path, name, base) + string = path.to_s + raise ArgumentError, "#{name} path must not be empty" if string.empty? + + expanded = File.expand_path(string, base) + raise ArgumentError, "#{name} path does not exist: #{string}" if !File.exist?(expanded) + end + + def validate_output_limit!(max_output_bytes) + return if max_output_bytes.nil? + + Integer(max_output_bytes).tap do |value| + raise ArgumentError, "max_output_bytes must be non-negative" if value.negative? + end + end + + def validate_timeout!(timeout) + return if timeout.nil? + raise ArgumentError, "timeout must be numeric" unless timeout.is_a?(Numeric) + + Float(timeout).tap do |value| + raise ArgumentError, "timeout must be finite" unless value.finite? + raise ArgumentError, "timeout must be non-negative" if value.negative? + end + end + end +end diff --git a/lib/landlock/version.rb b/lib/landlock/version.rb index f64abe8..efa3d02 100644 --- a/lib/landlock/version.rb +++ b/lib/landlock/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Landlock - VERSION = "0.2.1" + VERSION = "0.3" end diff --git a/test/landlock_capture_test.rb b/test/landlock_capture_test.rb new file mode 100644 index 0000000..b2d796b --- /dev/null +++ b/test/landlock_capture_test.rb @@ -0,0 +1,586 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LandlockCaptureTest < LandlockTestCase + def test_capture_returns_stdout_stderr_and_status + skip "Landlock unsupported" unless Landlock.supported? + + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "$stdout.print 'ok'; $stderr.print 'warn'"], + read: runtime_paths, + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) + + assert_equal "ok", result.stdout + assert_equal "warn", result.stderr + assert result.status.success? + end + + def test_capture_bang_raises_and_exposes_output_and_status + skip "Landlock unsupported" unless Landlock.supported? + + error = + assert_raises(Landlock::CommandError) do + Landlock.capture!( + [RbConfig.ruby, "--disable=gems", "-e", "$stdout.print 'out'; $stderr.print 'err'; exit 7"], + read: runtime_paths, + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) + end + + assert_equal "out", error.stdout + assert_equal "err", error.stderr + assert_equal 7, error.status.exitstatus + refute error.result.success? + end + + def test_capture_enforces_output_limit + skip "Landlock unsupported" unless Landlock.supported? + + error = + assert_raises(Landlock::CommandError) do + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "print 'x' * 1024"], + read: runtime_paths, + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + max_output_bytes: 10 + ) + end + + assert_match(/exceeded 10 bytes/, error.message) + assert_equal "x" * 10, error.stdout + assert error.result.output_truncated? + end + + def test_capture_rejects_invalid_timeout_before_launch + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + marker = File.join(dir, "ran") + + assert_raises(ArgumentError) do + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "File.write(ARGV.fetch(0), 'ran')", marker], + rlimits: { + open_files: 64 + }, + timeout: "1" + ) + end + + refute_path_exists marker + end + end + + def test_capture_does_not_false_timeout_after_streams_close + skip "Landlock unsupported" unless Landlock.supported? + + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "STDOUT.close; STDERR.close; sleep 0.1; exit 0"], + rlimits: { + open_files: 64 + }, + timeout: 2 + ) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + + assert_operator elapsed, :<, 1 + assert_equal 0, result.status.exitstatus + refute result.timed_out? + end + + def test_capture_does_not_wait_forever_for_blocked_stdin_reader + skip "Landlock unsupported" unless Landlock.supported? + + reader, writer = IO.pipe + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "exit 0"], + stdin: reader, + rlimits: { + open_files: 64 + }, + timeout: 2 + ) + elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + + assert_operator elapsed, :<, 1 + assert_equal 0, result.status.exitstatus + ensure + reader&.close unless reader&.closed? + writer&.close unless writer&.closed? + end + + def test_capture_rejects_zero_effective_custom_path_rules + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + file = File.join(dir, "file.txt") + File.write(file, "content") + + error = + assert_raises(ArgumentError) do + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "exit 0"], + paths: [{ path: file, rights: [:read_dir] }] + ) + end + + assert_match(/no effective rights/, error.message) + end + end + + def test_capture_rejects_empty_policy + skip "Landlock unsupported" unless Landlock.supported? + + error = + assert_raises(ArgumentError) { Landlock.capture([RbConfig.ruby, "--disable=gems", "-e", "print 'unsandboxed'"]) } + + assert_match(/empty capture policy/, error.message) + end + + def test_capture_allows_rlimits_only_policy + skip "Landlock unsupported" unless Landlock.supported? + + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "print Process.getrlimit(Process::RLIMIT_NOFILE).first"], + rlimits: { + open_files: 32 + }, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) + + assert result.status.success?, result.stderr + assert_equal "32", result.stdout + end + + def test_capture_rejects_invalid_env_before_backend_selection + skip "Landlock unsupported" unless Landlock.supported? + + assert_raises(ArgumentError) do + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "exit 0"], + bind_tcp: [free_port], + env: { + "BAD=KEY" => "value" + }, + close_others: false + ) + end + end + + def test_capture_normalizes_environment_entries_before_spawning + skip "Landlock unsupported" unless Landlock.supported? + + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "print ENV.fetch('LANDLOCK_TEST_CHILD')"], + rlimits: { + open_files: 64 + }, + env: { + LANDLOCK_TEST_CHILD: :child + }, + unsetenv_others: true + ) + + assert result.status.success?, result.stderr + assert_equal "child", result.stdout + end + + def test_capture_falls_back_to_fork_when_native_policy_argv_is_too_large + expected = Object.new + forked = nil + + Landlock::Native.stub(:abi_version, 1) do + Landlock::Runner::Native.stub(:available?, true) do + Landlock::Runner::Native.stub(:call, ->(*, **) { raise Errno::E2BIG }) do + Landlock::Runner::Fork.stub( + :call, + ->(argv, **options) do + forked = [argv, options] + expected + end + ) { assert_same expected, Landlock.capture(["true"], rlimits: { open_files: 64 }) } + end + end + end + + assert_equal ["true"], forked.fetch(0) + assert_equal [[:open_files, 64]], forked.fetch(1).fetch(:rlimits) + end + + def test_capture_seccomp_denies_network + skip "Landlock unsupported" unless Landlock.supported? + + result = + Landlock.capture( + [ + RbConfig.ruby, + "--disable=gems", + "-rsocket", + "-e", + "begin; Socket.new(:INET, :STREAM); rescue Errno::EPERM; print 'denied'; end" + ], + read: runtime_paths, + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + seccomp_deny_network: true + ) + + assert result.status.success?, result.stderr + assert_equal "denied", result.stdout + end + + def test_capture_custom_path_rule_allows_read + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + allowed = File.join(dir, "allowed.txt") + denied = File.join(dir, "denied.txt") + File.write(allowed, "ok") + File.write(denied, "no") + + result = + Landlock.capture( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "print File.read(ARGV.fetch(0)); begin; File.read(ARGV.fetch(1)); rescue Errno::EACCES; print ':denied'; end", + allowed, + denied + ], + read: runtime_paths, + execute: runtime_paths, + paths: [{ path: allowed, rights: %i[read_file read_dir] }], + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + allow_all_known: true + ) + + assert result.status.success?, result.stderr + assert_equal "ok:denied", result.stdout + end + end + + def test_capture_signal_scope_denies_signalling_parent + skip "Landlock unsupported" unless Landlock.supported? + skip "Landlock scopes unsupported" if Landlock.abi_version < 6 + + result = + Landlock.capture( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "begin; Process.kill(0, Process.ppid); rescue Errno::EPERM; print 'denied'; end" + ], + read: runtime_paths, + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + scope: [:signal] + ) + + assert_equal "denied", result.stdout + end + + def test_capture_timeout_kills_process_group + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + pidfile = File.join(dir, "grandchild.pid") + assert_raises(Landlock::CommandError) do + Landlock.capture!( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "pid = Process.fork { sleep 30 }; File.write(ARGV.fetch(0), pid); sleep 30", + pidfile + ], + read: runtime_paths, + write: [dir], + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + timeout: 0.2 + ) + end + + assert_path_exists pidfile + refute_process_alive Integer(File.read(pidfile)) + end + end + + def test_capture_bang_raises_when_timeout_handler_exits_successfully + skip "Landlock unsupported" unless Landlock.supported? + + error = + assert_raises(Landlock::CommandError) do + Landlock.capture!( + [RbConfig.ruby, "--disable=gems", "-e", "trap('TERM') { exit 0 }; sleep 30"], + read: runtime_paths, + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + timeout: 0.1 + ) + end + + assert error.result.timed_out? + assert_equal 0, error.status.exitstatus + refute error.result.success? + end + + def test_capture_timeout_returns_when_escaped_descendant_keeps_stdout_open + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + pidfile = File.join(dir, "escaped.pid") + error = nil + elapsed = + Timeout.timeout(3) do + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + error = + assert_raises(Landlock::CommandError) do + Landlock.capture!( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "pid = Process.fork { Process.setsid; $stdout.sync = true; print 'held'; sleep 30 }; File.write(ARGV.fetch(0), pid); sleep 30", + pidfile + ], + read: runtime_paths, + write: [dir], + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + timeout: 0.2 + ) + end + Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + end + + assert error.result.timed_out? + assert_operator elapsed, :<, 2 + ensure + kill_process_from_file(pidfile) + end + end + + def test_capture_validates_relative_sandbox_paths_against_chdir + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + allowed = File.join(dir, "relative-allowed") + Dir.mkdir(allowed) + File.write(File.join(allowed, "input.txt"), "ok") + + result = + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "print File.read('relative-allowed/input.txt')"], + chdir: dir, + read: ["relative-allowed"], + execute: runtime_paths, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) + + assert_equal "ok", result.stdout + end + end + + def test_capture_rejects_missing_sandbox_paths_before_fork + skip "Landlock unsupported" unless Landlock.supported? + + missing = "/definitely/missing/landlock-test-#{$$}" + + assert_raises(ArgumentError) do + Landlock.capture([RbConfig.ruby, "--disable=gems", "-e", "exit 0"], read: [missing]) + end + + assert_raises(ArgumentError) do + Landlock.capture( + [RbConfig.ruby, "--disable=gems", "-e", "exit 0"], + paths: [{ path: missing, rights: [:read_file] }] + ) + end + end + + def test_native_and_fork_runners_are_empirically_equivalent + skip "Landlock unsupported" unless Landlock.supported? + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + Dir.mktmpdir do |dir| + allowed = File.join(dir, "allowed.txt") + denied = File.join(dir, "denied.txt") + File.write(allowed, "allowed") + File.write(denied, "denied") + + old_env_remove = ENV["LANDLOCK_TEST_REMOVE"] + connect_server = nil + accept_thread = nil + ENV["LANDLOCK_TEST_REMOVE"] = "parent" + + cases = { + "stdout/stderr/status" => { + argv: [RbConfig.ruby, "--disable=gems", "-e", "$stdout.print 'out'; $stderr.print 'err'; exit 7"], + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + }, + "stdin/chdir/env" => { + argv: [ + RbConfig.ruby, + "--disable=gems", + "-e", + "print [STDIN.read.upcase, ENV['LANDLOCK_TEST_CHILD'], ENV.key?('LANDLOCK_TEST_REMOVE'), File.basename(Dir.pwd)].join(':')" + ], + stdin: "hello", + chdir: dir, + env: { + "PATH" => ENV.fetch("PATH", ""), + "LANDLOCK_TEST_CHILD" => "child", + "LANDLOCK_TEST_REMOVE" => nil + }, + unsetenv_others: true + }, + "env nil without unsetenv_others" => { + argv: [RbConfig.ruby, "--disable=gems", "-e", "print ENV.key?('LANDLOCK_TEST_REMOVE')"], + env: { + "LANDLOCK_TEST_REMOVE" => nil + } + }, + "truncated output" => { + argv: [RbConfig.ruby, "--disable=gems", "-e", "print 'x' * 1024"], + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + max_output_bytes: 10, + truncate_output: true + }, + "custom path rules" => { + argv: [ + RbConfig.ruby, + "--disable=gems", + "-e", + "print File.read(ARGV.fetch(0)); begin; File.read(ARGV.fetch(1)); rescue Errno::EACCES; print ':denied'; end", + allowed, + denied + ], + read: runtime_paths, + execute: runtime_paths, + paths: [{ path: allowed, rights: %i[read_file read_dir] }], + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + allow_all_known: true + }, + "seccomp network denial" => { + argv: [ + RbConfig.ruby, + "--disable=gems", + "-rsocket", + "-e", + "begin; Socket.new(:INET, :STREAM); rescue Errno::EPERM; print 'denied'; end" + ], + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + seccomp_deny_network: true + } + } + + if Landlock.abi_version >= 4 + connect_server = TCPServer.new("127.0.0.1", 0) + connect_port = connect_server.addr.fetch(1) + accept_thread = + Thread.new do + 2.times do + socket = connect_server.accept + socket.close + rescue IOError + break + end + end + bind_port = free_port + cases["connect tcp"] = { + argv: [ + RbConfig.ruby, + "--disable=gems", + "-rsocket", + "-e", + "TCPSocket.new('127.0.0.1', Integer(ARGV.fetch(0))).close; print 'connected'", + connect_port.to_s + ], + connect_tcp: [connect_port] + } + cases["bind tcp"] = { + argv: [ + RbConfig.ruby, + "--disable=gems", + "-rsocket", + "-e", + "server = TCPServer.new('127.0.0.1', Integer(ARGV.fetch(0))); print 'bound'; server.close", + bind_port.to_s + ], + bind_tcp: [bind_port] + } + end + + cases.each { |name, options| assert_capture_backends_equivalent(name, **options) } + ensure + if old_env_remove.nil? + ENV.delete("LANDLOCK_TEST_REMOVE") + else + ENV["LANDLOCK_TEST_REMOVE"] = old_env_remove + end + connect_server&.close + accept_thread&.join(1) + accept_thread&.kill if accept_thread&.alive? + end + end +end diff --git a/test/landlock_test.rb b/test/landlock_test.rb index b52576e..d0cd914 100644 --- a/test/landlock_test.rb +++ b/test/landlock_test.rb @@ -1,15 +1,8 @@ # frozen_string_literal: true -require "minitest/autorun" -require "tmpdir" -require "rbconfig" -require "socket" -require "English" -require "open3" -require "stringio" -require "landlock" - -class LandlockTest < Minitest::Test +require_relative "test_helper" + +class LandlockCoreTest < LandlockTestCase def test_supported_predicate_returns_boolean assert_includes [true, false], Landlock.supported? end @@ -32,33 +25,19 @@ def test_string_argv_rejected_to_avoid_implicit_shell assert_raises(ArgumentError) { Landlock.spawn("echo unsafe") } end - def test_child_setup_failure_does_not_run_inherited_at_exit_handlers + def test_exec_rejects_empty_policy skip "Landlock unsupported" unless Landlock.supported? - Dir.mktmpdir do |dir| - marker = File.join(dir, "at_exit_ran") - script = <<~RUBY - require "landlock" - parent_pid = Process.pid - marker = #{marker.inspect} - at_exit { File.write(marker, "ran") if Process.pid != parent_pid } - status = Landlock.exec([#{RbConfig.ruby.inspect}, "-e", "exit 0"]) - exit 10 if File.exist?(marker) - exit(status.exitstatus == 127 ? 0 : 11) - RUBY - - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) - assert $CHILD_STATUS.success?, out - refute_path_exists marker - end + assert_raises(ArgumentError) { Landlock.exec([RbConfig.ruby, "-e", "exit 0"]) } + assert_raises(ArgumentError) { Landlock.spawn([RbConfig.ruby, "-e", "exit 0"]) } end def test_rule_validation_helpers - assert_equal ["/tmp", [:read_file]], Landlock.send(:normalize_path_rule, path: "/tmp", rights: :read_file) - assert_equal ["/tmp", [:read_file]], Landlock.send(:normalize_path_rule, ["/tmp", :read_file]) + assert_equal ["/tmp", [:read_file]], Landlock::Policy.normalize_path_rule(path: "/tmp", rights: :read_file) + assert_equal ["/tmp", [:read_file]], Landlock::Policy.normalize_path_rule(["/tmp", :read_file]) - assert_raises(ArgumentError) { Landlock.send(:normalize_path_rule, "/tmp") } - assert_raises(ArgumentError) { Landlock.send(:mask, [:bogus], Landlock::FS_RIGHTS, Landlock.abi_version) } + assert_raises(ArgumentError) { Landlock::Policy.normalize_path_rule("/tmp") } + assert_raises(ArgumentError) { Landlock::Policy.mask([:bogus], Landlock::FS_RIGHTS, Landlock.abi_version) } end def test_abi_detection @@ -88,7 +67,7 @@ def test_filesystem_read_denied_outside_allowlist end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out assert_equal "ok", out end @@ -115,7 +94,7 @@ def test_filesystem_read_allows_single_file_path end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out assert_equal "ok", out end @@ -142,7 +121,7 @@ def test_filesystem_write_denied_outside_allowlist end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out assert_equal "ok", File.read(File.join(allowed, "ok.txt")) refute_path_exists File.join(denied, "no.txt") @@ -170,7 +149,7 @@ def test_filesystem_write_allows_single_existing_file_path end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out assert_equal "ok", File.read(allowed) assert_equal "old", File.read(denied) @@ -198,7 +177,26 @@ def test_custom_path_rule_allows_read end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) + assert $CHILD_STATUS.success?, out + assert_equal "ok", out + end + end + + def test_custom_file_path_rule_ignores_directory_only_rights + skip "Landlock unsupported" unless Landlock.supported? + + Dir.mktmpdir do |dir| + allowed = File.join(dir, "allowed.txt") + File.write(allowed, "ok") + + script = <<~RUBY + require "landlock" + Landlock.restrict!(paths: [{ path: #{allowed.inspect}, rights: [:read_file, :read_dir] }]) + print File.read(#{allowed.inspect}) + RUBY + + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out assert_equal "ok", out end @@ -228,7 +226,7 @@ def test_signal_scope_denies_signalling_parent end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out end @@ -237,12 +235,15 @@ def test_exec_unsetenv_others_clears_parent_environment skip "Landlock network unsupported" if Landlock.abi_version < 4 ENV["LANDLOCK_TEST_SECRET"] = "secret" - status = Landlock.exec( - [RbConfig.ruby, "--disable=gems", "-e", "exit(ENV.key?('LANDLOCK_TEST_SECRET') ? 10 : 0)"], - bind_tcp: [free_port], - env: { "PATH" => ENV.fetch("PATH", "") }, - unsetenv_others: true - ) + status = + Landlock.exec( + [RbConfig.ruby, "--disable=gems", "-e", "exit(ENV.key?('LANDLOCK_TEST_SECRET') ? 10 : 0)"], + bind_tcp: [free_port], + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) assert status.success? ensure @@ -254,11 +255,19 @@ def test_exec_env_without_unsetenv_others_adds_to_parent_environment skip "Landlock network unsupported" if Landlock.abi_version < 4 ENV["LANDLOCK_TEST_PARENT"] = "parent" - status = Landlock.exec( - [RbConfig.ruby, "--disable=gems", "-e", "exit(ENV['LANDLOCK_TEST_PARENT'] == 'parent' && ENV['LANDLOCK_TEST_CHILD'] == 'child' ? 0 : 10)"], - bind_tcp: [free_port], - env: { "LANDLOCK_TEST_CHILD" => "child" } - ) + status = + Landlock.exec( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "exit(ENV['LANDLOCK_TEST_PARENT'] == 'parent' && ENV['LANDLOCK_TEST_CHILD'] == 'child' ? 0 : 10)" + ], + bind_tcp: [free_port], + env: { + "LANDLOCK_TEST_CHILD" => "child" + } + ) assert status.success? ensure @@ -270,16 +279,72 @@ def test_exec_chdir skip "Landlock network unsupported" if Landlock.abi_version < 4 Dir.mktmpdir do |dir| - status = Landlock.exec( - [RbConfig.ruby, "--disable=gems", "-e", "exit(Dir.pwd == ARGV.fetch(0) ? 0 : 10)", dir], - bind_tcp: [free_port], - chdir: dir - ) + status = + Landlock.exec( + [RbConfig.ruby, "--disable=gems", "-e", "exit(Dir.pwd == ARGV.fetch(0) ? 0 : 10)", dir], + bind_tcp: [free_port], + chdir: dir + ) assert status.success? end end + def test_exec_closes_inherited_file_descriptors_by_default + skip "Landlock unsupported" unless Landlock.supported? + + reader, writer = non_stdio_pipe + status = + Landlock.exec( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "begin; IO.for_fd(ENV.fetch('LANDLOCK_TEST_FD').to_i).write('leaked'); exit 10; rescue Exception; exit 0; end" + ], + read: runtime_paths, + execute: runtime_paths, + env: { + "LANDLOCK_TEST_FD" => writer.fileno.to_s, + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) + writer.close + + assert status.success? + assert IO.select([reader], nil, nil, 1), "expected EOF after child did not inherit fd" + assert_equal "", reader.read + ensure + reader&.close unless reader&.closed? + writer&.close unless writer&.closed? + end + + def test_exec_can_preserve_inherited_file_descriptors + skip "Landlock unsupported" unless Landlock.supported? + + reader, writer = non_stdio_pipe + status = + Landlock.exec( + [RbConfig.ruby, "--disable=gems", "-e", "IO.for_fd(ENV.fetch('LANDLOCK_TEST_FD').to_i).write('leaked')"], + read: runtime_paths, + execute: runtime_paths, + env: { + "LANDLOCK_TEST_FD" => writer.fileno.to_s, + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + close_others: false + ) + writer.close + + assert status.success? + assert_equal "leaked", reader.read + ensure + reader&.close unless reader&.closed? + writer&.close unless writer&.closed? + end + def test_exec_unsupported_kernel_raises_before_fork Landlock.stub(:abi_version, 0) do assert_raises(Landlock::UnsupportedError) do @@ -301,12 +366,20 @@ def test_spawn_env_and_unsetenv_others skip "Landlock network unsupported" if Landlock.abi_version < 4 ENV["LANDLOCK_TEST_SECRET"] = "secret" - pid = Landlock.spawn( - [RbConfig.ruby, "--disable=gems", "-e", "exit(ENV['LANDLOCK_TEST_CHILD'] == 'child' && !ENV.key?('LANDLOCK_TEST_SECRET') ? 0 : 10)"], - bind_tcp: [free_port], - env: { "LANDLOCK_TEST_CHILD" => "child" }, - unsetenv_others: true - ) + pid = + Landlock.spawn( + [ + RbConfig.ruby, + "--disable=gems", + "-e", + "exit(ENV['LANDLOCK_TEST_CHILD'] == 'child' && !ENV.key?('LANDLOCK_TEST_SECRET') ? 0 : 10)" + ], + bind_tcp: [free_port], + env: { + "LANDLOCK_TEST_CHILD" => "child" + }, + unsetenv_others: true + ) _, status = Process.wait2(pid) assert status.success? @@ -328,20 +401,44 @@ def test_exec_allow_all_known_denies_unlisted_writes end RUBY - status = Landlock.exec( - [RbConfig.ruby, "--disable=gems", "-e", script], - read: runtime_paths, - execute: runtime_paths, - allow_all_known: true, - env: { "PATH" => ENV.fetch("PATH", "") }, - unsetenv_others: true - ) + status = + Landlock.exec( + [RbConfig.ruby, "--disable=gems", "-e", script], + read: runtime_paths, + execute: runtime_paths, + allow_all_known: true, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true + ) assert status.success? refute_path_exists denied end end + def test_spawn_falls_back_to_fork_when_native_policy_argv_is_too_large + forked = nil + + Landlock::Native.stub(:abi_version, 1) do + Landlock::Runner::Native.stub(:available?, true) do + Landlock::Runner::Native.stub(:spawn, ->(*, **) { raise Errno::E2BIG }) do + Landlock::Runner::Fork.stub( + :spawn, + ->(argv, **options) do + forked = [argv, options] + 123_456 + end + ) { assert_equal 123_456, Landlock.spawn(["true"], bind_tcp: [1]) } + end + end + end + + assert_equal ["true"], forked.fetch(0) + assert_equal [1], forked.fetch(1).fetch(:bind_tcp) + end + def test_spawn_returns_pid skip "Landlock unsupported" unless Landlock.supported? skip "Landlock network unsupported" if Landlock.abi_version < 4 @@ -365,11 +462,12 @@ def test_connect_tcp_denied_except_allowed_port other = TCPServer.new("127.0.0.1", 0) denied_port = other.addr[1] - accept_thread = Thread.new do - socket = server.accept - socket.close - rescue IOError, Errno::EBADF - end + accept_thread = + Thread.new do + socket = server.accept + socket.close + rescue IOError, Errno::EBADF + end script = <<~RUBY require "socket" @@ -384,7 +482,7 @@ def test_connect_tcp_denied_except_allowed_port end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out ensure server&.close @@ -412,800 +510,7 @@ def test_bind_tcp_denied_except_allowed_port end RUBY - out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: [:child, :out], &:read) + out = IO.popen([RbConfig.ruby, "-Ilib", "-Ilib/landlock", "-e", script], chdir: root, err: %i[child out], &:read) assert $CHILD_STATUS.success?, out end - - def test_safe_exec_capture_returns_stdout_stderr_and_status - stdout, stderr, status = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "$stdout.print 'ok'; $stderr.print 'warn'", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "ok", stdout - assert_equal "warn", stderr - assert status.success? - end - - def test_safe_exec_enforces_output_limit - error = assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print 'x' * 1024", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - max_output_bytes: 10 - ) - end - - assert_match(/exceeded 10 bytes/, error.message) - end - - def test_safe_exec_truncates_output - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print 'x' * 1024", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - max_output_bytes: 10, - truncate_output: true - ) - - assert_equal "x" * 10, output.stdout - assert output.output_truncated? - refute output.timed_out? - end - - def test_safe_exec_stdin_support - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print STDIN.read.upcase", - stdin: "hello", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "HELLO", output.stdout - end - - def test_safe_exec_io_stdin_support - input = StringIO.new("streamed") - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print STDIN.read.reverse", - stdin: input, - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "demaerts", output.stdout - end - - def test_safe_exec_output_limit_counts_stdout_and_stderr_together - capture_method = Landlock::SafeExec.send(:helper_available?) ? :capture_process : :capture_process_without_helper - capture_options = if capture_method == :capture_process - { - read: runtime_paths, - write: [], - execute: runtime_paths, - connect_tcp: nil, - bind_tcp: [], - seccomp_deny_network: false, - allow_all_known: true - } - else - {} - end - - stdout, stderr, _status, truncated = Landlock::SafeExec.send( - capture_method, - [RbConfig.ruby, "--disable=gems", "-e", "$stdout.print('o' * 8); $stderr.print('e' * 8)"], - **capture_options, - timeout: nil, - env: { "PATH" => ENV.fetch("PATH", "") }, - inherit_env: false, - stdin: nil, - chdir: nil, - rlimits: {}, - max_output_bytes: 10, - truncate_output: true - ) - - assert truncated - assert_equal 10, stdout.bytesize + stderr.bytesize - assert_operator stdout.bytesize, :<=, 8 - assert_operator stderr.bytesize, :<=, 8 - end - - def test_safe_exec_seccomp_denies_network - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "begin; Socket.new(:INET, :STREAM); rescue Errno::EPERM; print 'denied'; end", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - seccomp_deny_network: true - ) - - assert_equal "denied", output.stdout - end - - def test_safe_exec_seccomp_denies_socketpair - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "begin; Socket.pair(:UNIX, :STREAM, 0); rescue Errno::EPERM; print 'denied'; end", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - seccomp_deny_network: true - ) - - assert_equal "denied", output.stdout - end - - def test_safe_exec_seccomp_denies_tcp_server_creation - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "begin; TCPServer.new('127.0.0.1', 0); rescue Errno::EPERM; print 'denied'; end", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - seccomp_deny_network: true - ) - - assert_equal "denied", output.stdout - end - - def test_safe_exec_env_is_exact_by_default - ENV["LANDLOCK_TEST_SECRET"] = "secret" - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print ENV['LANDLOCK_TEST_CHILD']; exit(ENV.key?('LANDLOCK_TEST_SECRET') ? 10 : 0)", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", ""), "LANDLOCK_TEST_CHILD" => "child" }, - ) - - assert_equal "child", output.stdout - ensure - ENV.delete("LANDLOCK_TEST_SECRET") - end - - def test_safe_exec_inherit_env_keeps_parent_environment - ENV["LANDLOCK_TEST_PARENT"] = "parent" - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print ENV['LANDLOCK_TEST_PARENT']", - connect_tcp: [], - env: { "PATH" => ENV.fetch("PATH", "") }, - inherit_env: true - ) - - assert_equal "parent", output.stdout - ensure - ENV.delete("LANDLOCK_TEST_PARENT") - end - - def test_safe_exec_chdir - Dir.mktmpdir do |dir| - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print Dir.pwd", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - chdir: dir - ) - - assert_equal File.realpath(dir), output.stdout - end - end - - def test_safe_exec_capture_bang_raises_and_exposes_output_and_status - error = assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture!( - RbConfig.ruby, - "--disable=gems", - "-e", - "$stdout.print 'out'; $stderr.print 'err'; exit 7", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - end - - assert_equal "out", error.stdout - assert_equal "err", error.stderr - assert_equal 7, error.status.exitstatus - assert_equal error.status, error.result.status - refute error.result.success? - end - - def test_safe_exec_capture_returns_non_success_status_without_raising - stdout, stderr, status = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "$stdout.print 'out'; $stderr.print 'err'; exit 7", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - ) - - assert_equal "out", stdout - assert_equal "err", stderr - assert_equal 7, status.exitstatus - end - - def test_safe_exec_timeout - error = assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture!( - RbConfig.ruby, - "--disable=gems", - "-e", - "sleep 10", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - timeout: 0.1 - ) - end - - refute error.status.success? - assert error.result.timed_out? - end - - def test_safe_exec_applies_open_files_rlimit - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print Process.getrlimit(Process::RLIMIT_NOFILE).first", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - rlimits: { open_files: 32 } - ) - - assert_equal "32", output.stdout - end - - def test_safe_exec_applies_memory_rlimit - skip "RLIMIT_AS is not portable on macOS" if RUBY_PLATFORM.include?("darwin") - - memory_limit = 8 * 1024 * 1024 * 1024 - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print Process.getrlimit(Process::RLIMIT_AS).first", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - rlimits: { memory_bytes: memory_limit } - ) - - assert_equal memory_limit.to_s, output.stdout - end - - def test_safe_exec_accepts_processes_rlimit - argv = Landlock::SafeExec.send( - :helper_argv, - [RbConfig.ruby, "--disable=gems", "-e", "exit 0"], - read: [], - write: [], - execute: [], - env: {}, - inherit_env: false, - chdir: nil, - connect_tcp: [], - bind_tcp: [], - rlimits: { processes: 64 }, - seccomp_deny_network: false, - allow_all_known: true - ) - - assert_includes argv, "--rlimit" - assert_includes argv, "processes=64" - end - - def test_safe_exec_applies_file_size_rlimit - Dir.mktmpdir do |dir| - path = File.join(dir, "too-large.txt") - error = assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture!( - RbConfig.ruby, - "--disable=gems", - "-e", - "File.binwrite(ARGV.fetch(0), 'x' * 4096)", - path, - read: runtime_paths, - write: [dir], - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - rlimits: { file_size_bytes: 1024 } - ) - end - - refute error.status.success? - assert_operator File.size(path), :<=, 1024 if File.exist?(path) - end - end - - def test_safe_exec_applies_cpu_rlimit - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - error = assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture!( - RbConfig.ruby, - "--disable=gems", - "-e", - "loop { 1 + 1 }", - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - rlimits: { cpu_seconds: 1 }, - timeout: 5 - ) - end - - refute error.status.success? - end - - def test_safe_exec_rejects_unknown_rlimit - assert_raises(ArgumentError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "exit 0", - rlimits: { bogus: 1 } - ) - end - end - - def test_safe_exec_rejects_negative_rlimit - assert_raises(ArgumentError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "exit 0", - rlimits: { open_files: -1 } - ) - end - end - - def test_safe_exec_rejects_negative_output_limit - assert_raises(ArgumentError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "exit 0", - max_output_bytes: -1 - ) - end - end - - def test_safe_exec_rejects_missing_sandbox_paths - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - assert_raises(ArgumentError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "exit 0", - read: ["/definitely/missing/landlock-test"] - ) - end - end - - def test_safe_exec_rejects_invalid_tcp_ports - assert_raises(ArgumentError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "exit 0", - connect_tcp: ["123x"] - ) - end - - assert_raises(ArgumentError) do - Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "exit 0", - bind_tcp: [-1] - ) - end - end - - def test_safe_exec_landlock_denies_unlisted_write - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - - Dir.mktmpdir do |dir| - denied = File.join(dir, "denied.txt") - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "begin; File.write(ARGV.fetch(0), 'no'); rescue Errno::EACCES; print 'denied'; end", - denied, - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - ) - - assert_equal "denied", output.stdout - refute_path_exists denied - end - end - - def test_safe_exec_landlock_allows_listed_write - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - - Dir.mktmpdir do |dir| - allowed = File.join(dir, "allowed.txt") - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "File.write(ARGV.fetch(0), 'ok'); print File.read(ARGV.fetch(0))", - allowed, - read: runtime_paths, - write: [dir], - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "ok", output.stdout - assert_equal "ok", File.read(allowed) - end - end - - def test_safe_exec_landlock_allows_listed_read_and_denies_unlisted_read - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - - Dir.mktmpdir do |dir| - allowed = File.join(dir, "allowed.txt") - denied = File.join(dir, "denied.txt") - File.write(allowed, "allowed") - File.write(denied, "denied") - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print File.read(ARGV.fetch(0)); begin; File.read(ARGV.fetch(1)); rescue Errno::EACCES; print ':denied'; end", - allowed, - denied, - read: [*runtime_paths, allowed], - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "allowed:denied", output.stdout - end - end - - def test_safe_exec_landlock_denies_unlisted_execute - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - - Dir.mktmpdir do |dir| - executable = File.join(dir, "program") - File.write(executable, "#!/bin/sh\necho nope\n") - File.chmod(0o755, executable) - - error = assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture!( - executable, - read: [*runtime_paths, executable], - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - end - - assert_equal 126, error.status.exitstatus - assert_match(/Permission denied/, error.stderr) - end - end - - def test_safe_exec_allow_all_known_false_does_not_install_strict_filesystem_policy - argv = Landlock::SafeExec.send( - :helper_argv, - [RbConfig.ruby, "--disable=gems", "-e", "exit 0"], - read: runtime_paths, - write: [], - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - inherit_env: false, - chdir: nil, - connect_tcp: [], - bind_tcp: [], - rlimits: {}, - seccomp_deny_network: false, - allow_all_known: false - ) - - refute_includes argv, "--allow-all-known" - end - - def test_safe_exec_timeout_kills_process_group - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - Dir.mktmpdir do |dir| - marker = File.join(dir, "child-survived") - assert_raises(Landlock::SafeExec::CommandError) do - Landlock::SafeExec.capture!( - RbConfig.ruby, - "--disable=gems", - "-e", - "Process.fork { sleep 1; File.write(ARGV.fetch(0), 'alive') }; sleep 10", - marker, - read: runtime_paths, - write: [dir], - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") }, - timeout: 0.1 - ) - end - - sleep 1.2 - refute_path_exists marker - end - end - - def test_safe_exec_connect_tcp_allows_only_listed_port - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - skip "Landlock network unsupported" if Landlock.abi_version < 4 - - server = TCPServer.new("127.0.0.1", 0) - allowed_port = server.addr[1] - other = TCPServer.new("127.0.0.1", 0) - denied_port = other.addr[1] - - accept_thread = Thread.new do - socket = server.accept - socket.close - rescue IOError, Errno::EBADF - end - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "TCPSocket.new('127.0.0.1', ARGV.fetch(0).to_i).close; begin; TCPSocket.new('127.0.0.1', ARGV.fetch(1).to_i).close; rescue Errno::EACCES; print 'denied'; end", - allowed_port.to_s, - denied_port.to_s, - read: runtime_paths, - execute: runtime_paths, - connect_tcp: [allowed_port], - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "denied", output.stdout - ensure - server&.close - other&.close - accept_thread&.join(1) - end - - def test_safe_exec_omitted_connect_tcp_denies_connects - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - skip "Landlock network unsupported" if Landlock.abi_version < 4 - - server = TCPServer.new("127.0.0.1", 0) - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "begin; TCPSocket.new('127.0.0.1', ARGV.fetch(0).to_i).close; rescue Errno::EACCES; print 'denied'; end", - server.addr[1].to_s, - read: runtime_paths, - execute: runtime_paths, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "denied", output.stdout - ensure - server&.close - end - - def test_safe_exec_empty_connect_tcp_leaves_connects_unrestricted - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - server = TCPServer.new("127.0.0.1", 0) - accept_thread = Thread.new do - socket = server.accept - socket.close - rescue IOError, Errno::EBADF - end - - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "TCPSocket.new('127.0.0.1', ARGV.fetch(0).to_i).close; print 'connected'", - server.addr[1].to_s, - connect_tcp: [], - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "connected", output.stdout - ensure - server&.close - accept_thread&.join(1) - end - - def test_safe_exec_bind_tcp_allows_only_listed_port - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - skip "Landlock unsupported" unless Landlock.supported? - skip "Landlock network unsupported" if Landlock.abi_version < 4 - - allowed = free_port - denied = free_port - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-rsocket", - "-e", - "TCPServer.new('127.0.0.1', ARGV.fetch(0).to_i).close; begin; TCPServer.new('127.0.0.1', ARGV.fetch(1).to_i).close; rescue Errno::EACCES; print 'denied'; end", - allowed.to_s, - denied.to_s, - read: runtime_paths, - execute: runtime_paths, - bind_tcp: [allowed], - env: { "PATH" => ENV.fetch("PATH", "") } - ) - - assert_equal "denied", output.stdout - end - - def test_safe_exec_helper_reports_cli_parse_errors - skip "SafeExec helper unavailable" unless File.executable?(Landlock::SafeExec.helper_path) - - cases = [ - [["--bogus", "--", "true"], /unknown option/], - [["--read"], /missing option argument/], - [["--"], /missing command/], - [["--bind-tcp", "70000", "--", "true"], /TCP port must be between 0 and 65535/], - [["--rlimit", "nope", "--", "true"], /rlimit must be name=value/], - [["--rlimit", "bogus=1", "--", "true"], /unknown rlimit/], - [["--rlimit", "open_files=12x", "--", "true"], /rlimit value/], - [["--connect-tcp", "abc", "--", "true"], /TCP port must be an integer/], - [["--connect-tcp", "-1", "--", "true"], /TCP port must be an integer/] - ] - - cases.each do |argv, error_pattern| - _stdout, stderr, status = Open3.capture3(Landlock::SafeExec.helper_path, *argv) - - assert_equal 126, status.exitstatus, argv.inspect - assert_match error_pattern, stderr - end - end - - def test_safe_exec_without_helper_is_pass_through_and_warns_for_sandbox_options - Landlock::SafeExec.instance_variable_set(:@warned_unsupported_sandbox, false) - output = nil - _stdout, stderr = capture_io do - Landlock::SafeExec.stub(:helper_available?, false) do - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print 'ok'", - read: ["/definitely/sandbox/only"], - seccomp_deny_network: true, - env: { "PATH" => ENV.fetch("PATH", "") } - ) - end - end - - assert_equal "ok", output.stdout - assert_match(/running command as a pass-through/, stderr) - ensure - Landlock::SafeExec.instance_variable_set(:@warned_unsupported_sandbox, false) - end - - def test_safe_exec_without_helper_preserves_process_management_features - ENV["LANDLOCK_TEST_SECRET"] = "secret" - output = nil - - Landlock::SafeExec.stub(:helper_available?, false) do - Dir.mktmpdir do |dir| - output = Landlock::SafeExec.capture( - RbConfig.ruby, - "--disable=gems", - "-e", - "print [Dir.pwd, ENV['LANDLOCK_TEST_CHILD'], ENV.key?('LANDLOCK_TEST_SECRET'), Process.getrlimit(Process::RLIMIT_NOFILE).first].join(':')", - chdir: dir, - env: { "PATH" => ENV.fetch("PATH", ""), "LANDLOCK_TEST_CHILD" => "child" }, - rlimits: { open_files: 32 }, - max_output_bytes: 1_024 - ) - - assert_equal "#{File.realpath(dir)}:child:false:32", output.stdout - end - end - ensure - ENV.delete("LANDLOCK_TEST_SECRET") - end - - private - - def root - File.expand_path("..", __dir__) - end - - def free_port - s = TCPServer.new("127.0.0.1", 0) - s.addr[1] - ensure - s&.close - end - - def runtime_paths - [ - File.dirname(RbConfig.ruby), - RbConfig::CONFIG["libdir"], - RbConfig::CONFIG["archlibdir"], - "/usr", - "/lib", - "/lib64", - "/etc" - ].compact.uniq.select { |path| File.exist?(path) } - end end diff --git a/test/native_runner_test.rb b/test/native_runner_test.rb new file mode 100644 index 0000000..05186dd --- /dev/null +++ b/test/native_runner_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class LandlockRunnerNativeTest < LandlockTestCase + def test_native_helper_reports_cli_parse_errors + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + cases = [ + [%w[--bogus -- true], /unknown option/], + [["--read"], /missing option argument/], + [["--"], /missing command/], + [%w[--bind-tcp 70000 -- true], /TCP port must be between 0 and 65535/], + [%w[--rlimit nope -- true], /rlimit must be name=value/], + [%w[--rlimit bogus=1 -- true], /unknown rlimit/], + [%w[--rlimit open_files=12x -- true], /rlimit value/], + [%w[--path /tmp], /missing option argument/], + [%w[--path /tmp bogus -- true], /unknown filesystem right/], + [%w[--scope bogus -- true], /unknown Landlock scope/], + [%w[--env LANDLOCK_TEST=value -- true], /unknown option/], + [%w[--unsetenv-others -- true], /unknown option/], + [%w[--connect-tcp abc -- true], /TCP port must be an integer/], + [%w[--connect-tcp +1 -- true], /TCP port must be an integer/], + [%w[--connect-tcp -1 -- true], /TCP port must be an integer/] + ] + + cases.each do |argv, error_pattern| + _stdout, stderr, status = Open3.capture3(Landlock::Runner::Native.helper_path, *argv) + + assert_equal 126, status.exitstatus, argv.inspect + assert_match error_pattern, stderr + end + end + + def test_spawn_backends_are_equivalent_for_basic_process + skip "Landlock unsupported" unless Landlock.supported? + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + options = { + read: runtime_paths, + write: [], + execute: runtime_paths, + connect_tcp: [], + bind_tcp: [], + paths: [], + scope: [], + chdir: nil, + env: { + "PATH" => ENV.fetch("PATH", "") + }, + unsetenv_others: true, + close_others: true, + allow_all_known: false + } + + Dir.mktmpdir do |dir| + [["native", Landlock::Runner::Native], ["fork", Landlock::Runner::Fork]].each do |name, runner| + marker = File.join(dir, "#{name}.txt") + pid = + runner.spawn([RbConfig.ruby, "--disable=gems", "-e", "File.write(ARGV.fetch(0), 'ok')", marker], **options) + _finished_pid, status = Process.wait2(pid) + + assert status.success?, name + assert_equal "ok", File.read(marker), name + end + end + end + + def test_native_helper_closes_inherited_file_descriptors + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + reader, writer = non_stdio_pipe + pid = + Process.spawn( + { "LANDLOCK_TEST_FD" => writer.fileno.to_s }, + Landlock::Runner::Native.helper_path, + "--", + RbConfig.ruby, + "--disable=gems", + "-e", + "begin; IO.for_fd(ENV.fetch('LANDLOCK_TEST_FD').to_i).write('leaked'); rescue Exception; end", + close_others: false, + out: File::NULL, + err: File::NULL + ) + writer.close + _finished_pid, status = Process.wait2(pid) + + assert status.success? + assert IO.select([reader], nil, nil, 1), "expected pipe EOF after helper closed inherited fd" + assert_equal "", reader.read + ensure + reader&.close unless reader&.closed? + writer&.close unless writer&.closed? + end + + def test_native_helper_keep_fds_preserves_inherited_file_descriptors + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + reader, writer = non_stdio_pipe + pid = + Process.spawn( + { "LANDLOCK_TEST_FD" => writer.fileno.to_s }, + Landlock::Runner::Native.helper_path, + "--keep-fds", + "--", + RbConfig.ruby, + "--disable=gems", + "-e", + "IO.for_fd(ENV.fetch('LANDLOCK_TEST_FD').to_i).write('kept')", + close_others: false, + out: File::NULL, + err: File::NULL + ) + writer.close + _finished_pid, status = Process.wait2(pid) + + assert status.success? + assert_equal "kept", reader.read + ensure + reader&.close unless reader&.closed? + writer&.close unless writer&.closed? + end + + def test_native_helper_chdir_changes_target_working_directory + skip "native runner helper unavailable" unless File.executable?(Landlock::Runner::Native.helper_path) + + Dir.mktmpdir do |dir| + stdout, stderr, status = + Open3.capture3( + Landlock::Runner::Native.helper_path, + "--chdir", + dir, + "--", + RbConfig.ruby, + "--disable=gems", + "-e", + "print Dir.pwd" + ) + + assert status.success?, stderr + assert_equal dir, stdout + end + end + + def test_internal_files_can_be_required_directly + stdout, stderr, status = + Open3.capture3( + RbConfig.ruby, + "--disable=gems", + "-I#{File.join(root, "lib")}", + "-e", + "require 'landlock/process_io'; require 'landlock/runner/native'; require 'landlock/runner/fork'; print 'ok'" + ) + + assert status.success?, stderr + assert_equal "ok", stdout + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..1ef2b37 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" +require "rbconfig" +require "socket" +require "English" +require "open3" +require "stringio" +require "timeout" +require "landlock" + +class LandlockTestCase < Minitest::Test + private + + def assert_capture_backends_equivalent(name, argv:, **options) + native = capture_backend_result(Landlock::Runner::Native, argv, **options) + forked = capture_backend_result(Landlock::Runner::Fork, argv, **options) + + assert_equal forked.stdout, native.stdout, "#{name}: stdout" + assert_equal forked.stderr, native.stderr, "#{name}: stderr" + assert_equal forked.status.exitstatus, native.status.exitstatus, "#{name}: exitstatus" + if forked.status.termsig.nil? + assert_nil native.status.termsig, "#{name}: termsig" + else + assert_equal forked.status.termsig, native.status.termsig, "#{name}: termsig" + end + assert_equal forked.success?, native.success?, "#{name}: success" + assert_equal forked.timed_out?, native.timed_out?, "#{name}: timed_out" + assert_equal forked.output_truncated?, native.output_truncated?, "#{name}: output_truncated" + end + + def capture_backend_result(runner, argv, **options) + defaults = { + read: [], + write: [], + execute: [], + connect_tcp: [], + bind_tcp: [], + paths: [], + scope: [], + chdir: nil, + env: nil, + unsetenv_others: false, + close_others: true, + allow_all_known: false, + timeout: nil, + stdin: nil, + rlimits: nil, + seccomp_deny_network: false, + max_output_bytes: nil, + truncate_output: false + }.merge(options) + defaults[:rlimits] = Landlock::Rlimits.normalize(defaults[:rlimits]) + defaults[:connect_tcp] = Landlock::Validation.normalize_ports(defaults[:connect_tcp], :connect_tcp) + defaults[:bind_tcp] = Landlock::Validation.normalize_ports(defaults[:bind_tcp], :bind_tcp) + + runner.call(argv.map(&:to_s), **defaults) + end + + def root + File.expand_path("..", __dir__) + end + + def free_port + s = TCPServer.new("127.0.0.1", 0) + s.addr[1] + ensure + s&.close + end + + def kill_process_from_file(path) + return unless path && File.exist?(path) + + kill_process_if_alive(Integer(File.read(path))) + rescue ArgumentError + nil + end + + def kill_process_if_alive(pid) + Process.kill("KILL", pid) + Process.wait(pid, Process::WNOHANG) + rescue Errno::ESRCH, Errno::ECHILD + nil + end + + def refute_process_alive(pid) + if File.exist?("/proc/#{pid}/stat") + state = File.read("/proc/#{pid}/stat").split.fetch(2) + return if state == "Z" + end + + Process.kill(0, pid) + Process.kill("KILL", pid) + flunk "process #{pid} survived timeout process-group kill" + rescue Errno::ESRCH + nil + end + + def runtime_paths + [ + File.dirname(RbConfig.ruby), + RbConfig::CONFIG["libdir"], + RbConfig::CONFIG["archlibdir"], + "/usr", + "/lib", + "/lib64", + "/etc" + ].compact.uniq.select { |path| File.exist?(path) } + end + + def non_stdio_pipe + reader, writer = IO.pipe + while writer.fileno <= 3 + next_writer = writer.dup + writer.close + writer = next_writer + end + writer.close_on_exec = false + [reader, writer] + end +end