landlock 0.1.0 → 0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +141 -9
- data/benchmark/landlock_overhead.rb +212 -0
- data/ext/landlock/bin/safe_exec_helper.c +369 -0
- data/ext/landlock/extconf.rb +30 -0
- data/ext/landlock/landlock.c +25 -153
- data/ext/landlock/landlock_native.h +167 -0
- data/lib/landlock/safe_exec.rb +522 -0
- data/lib/landlock/version.rb +1 -1
- data/lib/landlock.rb +103 -34
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baea0cbd5b22406f288880dd5bcec85569c3134b256657d876a502f37e622364
|
|
4
|
+
data.tar.gz: a383e9cb807f51e2fd6e5d15a5d3d22c61d20a9ee2f9dffb046a1a97e24c2a6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c385390bd6b239d5a7b495d74388ba9bb357787900ef73bfbd953d8353c49ce893117df8cc80fe95b0c9b2e8a6c836988a8540b2fbfdbce244ff76a6879d7067
|
|
7
|
+
data.tar.gz: b1d754bbfd7b60ec81cc4d036bc13ab627253fdf2fdb55a752477a43f917381d819f8fa20870abcad264e2a86d0c5635cb327799ccc614800e3adb1088b9dfec
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### [0.2] - 2026-04-30
|
|
8
|
+
|
|
9
|
+
- 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.
|
|
10
|
+
- Share native Landlock syscall/constant definitions between the Ruby extension and helper binary.
|
|
11
|
+
- Add non-Linux/pass-through SafeExec behavior so integration code can run on platforms without the Linux sandbox backend while warning that sandbox options are ignored.
|
|
12
|
+
|
|
13
|
+
## [0.1.1] - 2026-04-30
|
|
14
|
+
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
- Require `Landlock.exec` and `Landlock.spawn` commands to be passed as argument arrays. This avoids Ruby's implicit shell execution path for string commands.
|
|
18
|
+
- Execute subprocesses with an explicit `argv[0]` tuple (`[command, command]`) so array commands keep their no-shell behavior.
|
|
19
|
+
- Use `exit! 127` for child setup failures before `exec`, preventing inherited `at_exit` handlers from running in the forked child.
|
|
20
|
+
- Honor `unsetenv_others: true` by passing Ruby's `unsetenv_others` exec option instead of only constructing a reduced environment hash.
|
|
21
|
+
- Add ABI v6 Landlock scoping support via `scope: [:signal, :abstract_unix_socket]` to restrict signalling and abstract Unix-domain socket access outside the sandbox domain.
|
|
22
|
+
- Expose `allow_all_known:` on `Landlock.exec` and `Landlock.spawn` so subprocess sandboxes can deny unlisted filesystem actions without needing dummy allow rules.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Allow high-level `read`, `write`, and `execute` helpers to target individual files by filtering directory-only rights before adding file path rules.
|
|
27
|
+
- Fix fallback Landlock syscall numbers on i386, handle x32, and prefer platform `__NR_*` constants when available.
|
|
28
|
+
- Convert path rule arguments before opening path file descriptors in the native extension to avoid leaking descriptors on argument conversion errors.
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
- Document important sandbox caveats: only handled rights are restricted, TCP rules do not cover UDP/pathname Unix sockets, already-open descriptors remain usable, and `restrict!` applies to the calling thread and future children.
|
|
33
|
+
|
|
34
|
+
### Tests
|
|
35
|
+
|
|
36
|
+
- Add coverage for no-shell argv validation, child setup failure behavior, `unsetenv_others`, strict filesystem subprocess policies, file-specific path rules, and ABI v6 signal scoping.
|
|
37
|
+
|
|
38
|
+
## [0.1.0] - 2026-04-30
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- Initial Ruby bindings for Linux Landlock rulesets, filesystem path rules, TCP port rules, and safe subprocess helpers.
|
data/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# landlock
|
|
2
2
|
|
|
3
|
-
Ruby bindings for Linux [Landlock](https://docs.kernel.org/userspace-api/landlock.html): unprivileged, kernel-enforced sandboxing for the
|
|
3
|
+
Ruby bindings for Linux [Landlock](https://docs.kernel.org/userspace-api/landlock.html): unprivileged, kernel-enforced sandboxing for the calling thread and its future descendants.
|
|
4
4
|
|
|
5
5
|
This gem includes a small native extension around the three Landlock syscalls and a Ruby API for safe subprocess execution.
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
Experimental. Filesystem support requires Landlock ABI v1+. TCP network rules require ABI v4+.
|
|
9
|
+
Experimental. Filesystem support requires Landlock ABI v1+. TCP network rules require ABI v4+. Signal and abstract Unix-domain socket scopes require ABI v6+.
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
12
|
require "landlock"
|
|
@@ -15,8 +15,12 @@ puts Landlock.abi_version
|
|
|
15
15
|
puts Landlock.supported?
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
19
|
+
|
|
18
20
|
## Safe subprocess execution
|
|
19
21
|
|
|
22
|
+
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.
|
|
23
|
+
|
|
20
24
|
Allow Ruby to execute and read its runtime, but only allow outbound TCP connections to port 443:
|
|
21
25
|
|
|
22
26
|
```ruby
|
|
@@ -24,7 +28,8 @@ status = Landlock.exec(
|
|
|
24
28
|
[RbConfig.ruby, "script.rb"],
|
|
25
29
|
read: ["/usr", "/lib", "/lib64", "/etc/ssl"],
|
|
26
30
|
execute: ["/usr", "/lib", "/lib64"],
|
|
27
|
-
connect_tcp: [443]
|
|
31
|
+
connect_tcp: [443],
|
|
32
|
+
allow_all_known: true
|
|
28
33
|
)
|
|
29
34
|
|
|
30
35
|
abort "failed" unless status.success?
|
|
@@ -35,12 +40,20 @@ Deny all outbound TCP except the listed ports:
|
|
|
35
40
|
```ruby
|
|
36
41
|
Landlock.exec(
|
|
37
42
|
["curl", "https://example.com"],
|
|
38
|
-
read: [
|
|
43
|
+
read: [
|
|
44
|
+
"/usr", "/lib", "/lib64",
|
|
45
|
+
"/etc/ssl", "/etc/resolv.conf", "/etc/hosts",
|
|
46
|
+
"/etc/nsswitch.conf", "/etc/gai.conf", "/etc/host.conf",
|
|
47
|
+
"/run/systemd/resolve", "/var/lib/sss"
|
|
48
|
+
].select { |path| File.exist?(path) },
|
|
39
49
|
execute: ["/usr", "/lib", "/lib64"],
|
|
40
|
-
connect_tcp: [443]
|
|
50
|
+
connect_tcp: [443],
|
|
51
|
+
allow_all_known: true
|
|
41
52
|
)
|
|
42
53
|
```
|
|
43
54
|
|
|
55
|
+
TLS and name-resolution dependencies vary by distribution and NSS configuration; add any local CA, DNS, NSS, or resolver paths your system needs.
|
|
56
|
+
|
|
44
57
|
Allow binding a local TCP port:
|
|
45
58
|
|
|
46
59
|
```ruby
|
|
@@ -48,22 +61,99 @@ Landlock.exec(
|
|
|
48
61
|
[RbConfig.ruby, "server.rb"],
|
|
49
62
|
read: ["/usr", "/lib", "/lib64", Dir.pwd],
|
|
50
63
|
execute: ["/usr", "/lib", "/lib64"],
|
|
51
|
-
bind_tcp: [9292]
|
|
64
|
+
bind_tcp: [9292],
|
|
65
|
+
allow_all_known: true
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## SafeExec helper
|
|
70
|
+
|
|
71
|
+
`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.
|
|
72
|
+
|
|
73
|
+
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:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
result = Landlock::SafeExec.capture(
|
|
77
|
+
"ffprobe",
|
|
78
|
+
"-v", "error",
|
|
79
|
+
"-show_format",
|
|
80
|
+
"-show_streams",
|
|
81
|
+
"-of", "json",
|
|
82
|
+
upload_path,
|
|
83
|
+
read: [upload_path, *Landlock::SafeExec.default_read_paths],
|
|
84
|
+
execute: Landlock::SafeExec.default_execute_paths,
|
|
85
|
+
env: { "PATH" => ENV.fetch("PATH", "") },
|
|
86
|
+
rlimits: {
|
|
87
|
+
cpu_seconds: 5,
|
|
88
|
+
memory_bytes: 512 * 1024 * 1024,
|
|
89
|
+
file_size_bytes: 0,
|
|
90
|
+
open_files: 64,
|
|
91
|
+
processes: 0
|
|
92
|
+
},
|
|
93
|
+
seccomp_deny_network: true,
|
|
94
|
+
max_output_bytes: 256 * 1024,
|
|
95
|
+
truncate_output: false
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
metadata = JSON.parse(result.stdout) if result.success?
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Pass `stdin:` when a tool should read from standard input instead of a file:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
stdout, stderr, status = Landlock::SafeExec.capture(
|
|
105
|
+
"tr", "a-z", "A-Z",
|
|
106
|
+
stdin: "hello",
|
|
107
|
+
env: { "PATH" => ENV.fetch("PATH", "") }
|
|
52
108
|
)
|
|
53
109
|
```
|
|
54
110
|
|
|
111
|
+
`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:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
stdout, stderr, status = Landlock::SafeExec.capture("tool", "arg")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`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`.
|
|
118
|
+
|
|
119
|
+
SafeExec options:
|
|
120
|
+
|
|
121
|
+
- `read:`, `write:`, `execute:` — filesystem allowlists. Explicit paths must exist; missing paths raise `ArgumentError` instead of being silently ignored.
|
|
122
|
+
- `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.
|
|
123
|
+
- `bind_tcp:` — allowed TCP bind ports. Binding is unrestricted unless this is provided.
|
|
124
|
+
- `seccomp_deny_network:` — additionally deny common Linux network syscalls with seccomp. This is Linux-specific and intended as defense in depth.
|
|
125
|
+
- `rlimits:` — resource limits. Supported keys are `:cpu_seconds`, `:memory_bytes`, `:file_size_bytes`, `:open_files`, and `:processes`. Values must be non-negative integers.
|
|
126
|
+
- `timeout:` — wall-clock timeout in seconds. On timeout SafeExec terminates the process group and returns/raises with `result.timed_out?` true.
|
|
127
|
+
- `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.
|
|
128
|
+
- `stdin:` — string or IO-like object to write to the child process stdin.
|
|
129
|
+
- `chdir:` — working directory for the child.
|
|
130
|
+
- `env:` — exact child environment by default.
|
|
131
|
+
- `inherit_env:` — when true, inherit the parent environment and apply `env:` as overrides.
|
|
132
|
+
- `success_status_codes:` — status codes considered successful by `capture!`; defaults to `[0]`.
|
|
133
|
+
- `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`.
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
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.
|
|
138
|
+
|
|
139
|
+
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.
|
|
140
|
+
|
|
55
141
|
## Restrict current process
|
|
56
142
|
|
|
57
|
-
This is irreversible for the current thread
|
|
143
|
+
This is irreversible for the current thread and its future children. Use `Landlock.exec` or `Landlock.spawn` unless you really mean it.
|
|
58
144
|
|
|
59
145
|
```ruby
|
|
60
146
|
Landlock.restrict!(
|
|
61
147
|
read: ["/usr", "/app"],
|
|
62
148
|
write: ["/tmp/my-output"],
|
|
63
|
-
connect_tcp: [443]
|
|
149
|
+
connect_tcp: [443],
|
|
150
|
+
scope: [:signal, :abstract_unix_socket],
|
|
151
|
+
allow_all_known: true
|
|
64
152
|
)
|
|
65
153
|
```
|
|
66
154
|
|
|
155
|
+
`write:` grants the filesystem rights needed for practical writes under the listed paths, including directory traversal and reads (`read_file`/`read_dir`). If you need exact rights, use `paths:` with an explicit `rights:` list.
|
|
156
|
+
|
|
67
157
|
## Lower-level path rules
|
|
68
158
|
|
|
69
159
|
```ruby
|
|
@@ -76,6 +166,48 @@ Landlock.restrict!(
|
|
|
76
166
|
)
|
|
77
167
|
```
|
|
78
168
|
|
|
169
|
+
## Performance
|
|
170
|
+
|
|
171
|
+
Landlock enforcement is done by the kernel after a ruleset is installed. In normal use the practical cost should be dominated by the one-time sandbox setup and by the work your process already performs, not by Ruby-side wrappers.
|
|
172
|
+
|
|
173
|
+
This repository includes a small benchmark suite that compares common workloads before and after applying a read-only Landlock policy:
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
bundle exec rake bench
|
|
177
|
+
# or
|
|
178
|
+
bundle exec ruby benchmark/landlock_overhead.rb
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
The suite reports median timings for CPU-only work, file metadata reads, small file reads, directory scans, and the one-time ruleset setup cost. You can tune the run length with environment variables:
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
SAMPLES=15 ITERATIONS=100000 DIR_ITERATIONS=5000 bundle exec rake bench
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Sample output looks like:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
workload baseline landlocked delta delta %
|
|
191
|
+
--------------------------------------------------------------------
|
|
192
|
+
cpu_loop 0.650 ms 0.648 ms -0.002 ms -0.31%
|
|
193
|
+
file_stat 42.100 ms 42.300 ms 0.200 ms 0.48%
|
|
194
|
+
file_read 120.500 ms 120.900 ms 0.400 ms 0.33%
|
|
195
|
+
dir_scan 88.000 ms 88.200 ms 0.200 ms 0.23%
|
|
196
|
+
|
|
197
|
+
Setup cost (create ruleset, add read rules, restrict current process):
|
|
198
|
+
median 0.080 ms (25 samples)
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Treat small positive or negative deltas as noise and benchmark on the kernel, filesystem, and hardware you deploy on. The expected result is no practical steady-state overhead for typical application work, with a small one-time cost when installing the sandbox.
|
|
202
|
+
|
|
79
203
|
## Caveats
|
|
80
204
|
|
|
81
|
-
Landlock is not a complete container. It does not impose CPU/memory limits, hide already-open file descriptors, or replace seccomp/namespaces/cgroups. For serious untrusted execution, combine it with controlled environment, `close_others`, resource limits, and preferably process isolation.
|
|
205
|
+
Landlock is not a complete container. It does not impose CPU/memory limits, hide already-open file descriptors, or replace seccomp/namespaces/cgroups. For serious untrusted execution, combine it with a controlled environment, `close_others`, resource limits, and preferably process isolation.
|
|
206
|
+
|
|
207
|
+
If a child fails during sandbox setup or `exec`, the helpers print a diagnostic and the child exits 127. That code can collide with a command that legitimately exits 127; unsupported kernels are checked before forking so they raise `Landlock::UnsupportedError` synchronously instead.
|
|
208
|
+
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
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. The high-level helpers handle the categories you pass (`read`, `write`, `execute`, `connect_tcp`, `bind_tcp`, `scope`). Landlock's TCP rules do not cover UDP or pathname Unix-domain sockets; ABI v6+ scopes can restrict signals and abstract Unix-domain sockets.
|
|
212
|
+
|
|
213
|
+
`Landlock.restrict!` applies to the calling thread and its future children; already-running sibling threads are not retroactively sandboxed. Prefer `Landlock.exec` or `Landlock.spawn` for subprocess sandboxing from a larger Ruby application.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "open3"
|
|
8
|
+
|
|
9
|
+
root = File.expand_path("..", __dir__)
|
|
10
|
+
lib_dir = File.join(root, "lib")
|
|
11
|
+
ext_lib_dir = File.join(lib_dir, "landlock")
|
|
12
|
+
|
|
13
|
+
$LOAD_PATH.unshift(lib_dir)
|
|
14
|
+
$LOAD_PATH.unshift(ext_lib_dir)
|
|
15
|
+
|
|
16
|
+
require "landlock"
|
|
17
|
+
|
|
18
|
+
module LandlockBench
|
|
19
|
+
ROOT = File.expand_path("..", __dir__)
|
|
20
|
+
DEFAULT_SAMPLES = Integer(ENV.fetch("SAMPLES", 7))
|
|
21
|
+
DEFAULT_ITERATIONS = Integer(ENV.fetch("ITERATIONS", 25_000))
|
|
22
|
+
DIR_ITERATIONS = Integer(ENV.fetch("DIR_ITERATIONS", 2_000))
|
|
23
|
+
SETUP_SAMPLES = Integer(ENV.fetch("SETUP_SAMPLES", 25))
|
|
24
|
+
|
|
25
|
+
WORKLOADS = [
|
|
26
|
+
["cpu_loop", "integer arithmetic loop"],
|
|
27
|
+
["file_stat", "File.stat on an allowed file"],
|
|
28
|
+
["file_read", "File.binread of a small allowed file"],
|
|
29
|
+
["dir_scan", "Dir.foreach over an allowed directory"]
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def monotonic_ns
|
|
35
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def measure
|
|
39
|
+
started = monotonic_ns
|
|
40
|
+
yield
|
|
41
|
+
monotonic_ns - started
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def runtime_paths
|
|
45
|
+
[
|
|
46
|
+
File.dirname(RbConfig.ruby),
|
|
47
|
+
RbConfig::CONFIG["libdir"],
|
|
48
|
+
RbConfig::CONFIG["archlibdir"],
|
|
49
|
+
"/usr",
|
|
50
|
+
"/lib",
|
|
51
|
+
"/lib64",
|
|
52
|
+
"/etc"
|
|
53
|
+
].compact.uniq.select { |path| File.exist?(path) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def prepare_workspace
|
|
57
|
+
Dir.mktmpdir("landlock-bench") do |dir|
|
|
58
|
+
file = File.join(dir, "small.txt")
|
|
59
|
+
entries = File.join(dir, "entries")
|
|
60
|
+
Dir.mkdir(entries)
|
|
61
|
+
File.binwrite(file, "x" * 1024)
|
|
62
|
+
100.times { |index| File.binwrite(File.join(entries, "entry-#{index}.txt"), "x") }
|
|
63
|
+
|
|
64
|
+
yield({ "dir" => dir, "file" => file, "entries" => entries })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def child_command(payload)
|
|
69
|
+
[RbConfig.ruby, __FILE__, "--child", JSON.generate(payload)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def run_child(payload)
|
|
73
|
+
stdout, stderr, status = Open3.capture3(*child_command(payload), chdir: ROOT)
|
|
74
|
+
unless status.success?
|
|
75
|
+
abort "bench child failed (#{status.exitstatus})\nSTDOUT:\n#{stdout}\nSTDERR:\n#{stderr}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
JSON.parse(stdout)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def run_parent
|
|
82
|
+
puts "Landlock performance benchmark"
|
|
83
|
+
puts "Ruby: #{RUBY_DESCRIPTION}"
|
|
84
|
+
puts "Landlock ABI: #{Landlock.abi_version}"
|
|
85
|
+
puts "Samples: #{DEFAULT_SAMPLES}, iterations: #{DEFAULT_ITERATIONS}"
|
|
86
|
+
puts
|
|
87
|
+
|
|
88
|
+
prepare_workspace do |workspace|
|
|
89
|
+
read_paths = (runtime_paths + [workspace.fetch("dir")]).uniq
|
|
90
|
+
common = {
|
|
91
|
+
mode: "workloads",
|
|
92
|
+
iterations: DEFAULT_ITERATIONS,
|
|
93
|
+
dir_iterations: DIR_ITERATIONS,
|
|
94
|
+
read_paths: read_paths,
|
|
95
|
+
workspace: workspace
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
baseline = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: false)) }
|
|
99
|
+
|
|
100
|
+
unless Landlock.supported?
|
|
101
|
+
puts "Landlock is not supported on this host; only baseline timings were collected."
|
|
102
|
+
print_workload_table(baseline, nil)
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
sandbox = collect_samples(DEFAULT_SAMPLES) { run_child(common.merge(sandbox: true)) }
|
|
107
|
+
setup = collect_samples(SETUP_SAMPLES) do
|
|
108
|
+
run_child(mode: "setup", read_paths: read_paths).fetch("setup_ns")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
print_workload_table(baseline, sandbox)
|
|
112
|
+
puts
|
|
113
|
+
puts "Setup cost (create ruleset, add read rules, restrict current process):"
|
|
114
|
+
puts " median #{format_ms(median(setup))} (#{SETUP_SAMPLES} samples)"
|
|
115
|
+
puts
|
|
116
|
+
puts "Lower is better. Negative deltas mean the sandboxed sample was faster in this run."
|
|
117
|
+
puts "Expect small differences to be noise; compare medians across repeated runs."
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def collect_samples(count)
|
|
122
|
+
Array.new(count) { yield }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def print_workload_table(baseline, sandbox)
|
|
126
|
+
puts format("%-12s %14s %14s %12s %10s", "workload", "baseline", "landlocked", "delta", "delta %")
|
|
127
|
+
puts "-" * 68
|
|
128
|
+
|
|
129
|
+
WORKLOADS.each do |name, description|
|
|
130
|
+
base = median(baseline.map { |sample| sample.fetch(name) })
|
|
131
|
+
if sandbox
|
|
132
|
+
locked = median(sandbox.map { |sample| sample.fetch(name) })
|
|
133
|
+
delta = locked - base
|
|
134
|
+
pct = base.positive? ? (delta.to_f / base * 100.0) : 0.0
|
|
135
|
+
puts format(
|
|
136
|
+
"%-12s %14s %14s %12s %9.2f%%",
|
|
137
|
+
name,
|
|
138
|
+
format_ms(base),
|
|
139
|
+
format_ms(locked),
|
|
140
|
+
format_ms(delta),
|
|
141
|
+
pct
|
|
142
|
+
)
|
|
143
|
+
else
|
|
144
|
+
puts format("%-12s %14s %14s %12s %10s", name, format_ms(base), "n/a", "n/a", "n/a")
|
|
145
|
+
end
|
|
146
|
+
puts " #{description}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def median(values)
|
|
151
|
+
sorted = values.sort
|
|
152
|
+
midpoint = sorted.length / 2
|
|
153
|
+
return sorted.fetch(midpoint) if sorted.length.odd?
|
|
154
|
+
|
|
155
|
+
(sorted.fetch(midpoint - 1) + sorted.fetch(midpoint)) / 2.0
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def format_ms(ns)
|
|
159
|
+
format("%.3f ms", ns.to_f / 1_000_000.0)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def run_child_mode(payload)
|
|
163
|
+
case payload.fetch("mode")
|
|
164
|
+
when "setup"
|
|
165
|
+
puts JSON.generate("setup_ns" => measure { restrict_for(payload) })
|
|
166
|
+
when "workloads"
|
|
167
|
+
restrict_for(payload) if payload.fetch("sandbox")
|
|
168
|
+
puts JSON.generate(run_workloads(payload))
|
|
169
|
+
else
|
|
170
|
+
raise ArgumentError, "unknown child mode: #{payload.fetch("mode").inspect}"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def restrict_for(payload)
|
|
175
|
+
Landlock.restrict!(read: payload.fetch("read_paths"))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def run_workloads(payload)
|
|
179
|
+
iterations = Integer(payload.fetch("iterations"))
|
|
180
|
+
dir_iterations = Integer(payload.fetch("dir_iterations"))
|
|
181
|
+
workspace = payload.fetch("workspace")
|
|
182
|
+
file = workspace.fetch("file")
|
|
183
|
+
entries = workspace.fetch("entries")
|
|
184
|
+
sink = 0
|
|
185
|
+
|
|
186
|
+
GC.disable
|
|
187
|
+
{
|
|
188
|
+
"cpu_loop" => measure do
|
|
189
|
+
iterations.times { |index| sink ^= ((index * 31) & 0xffff) }
|
|
190
|
+
end,
|
|
191
|
+
"file_stat" => measure do
|
|
192
|
+
iterations.times { sink ^= File.stat(file).size }
|
|
193
|
+
end,
|
|
194
|
+
"file_read" => measure do
|
|
195
|
+
iterations.times { sink ^= File.binread(file).bytesize }
|
|
196
|
+
end,
|
|
197
|
+
"dir_scan" => measure do
|
|
198
|
+
dir_iterations.times do
|
|
199
|
+
Dir.foreach(entries) { |entry| sink ^= entry.bytesize }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
}.merge("sink" => sink)
|
|
203
|
+
ensure
|
|
204
|
+
GC.enable
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if ARGV.first == "--child"
|
|
209
|
+
LandlockBench.run_child_mode(JSON.parse(ARGV.fetch(1)))
|
|
210
|
+
else
|
|
211
|
+
LandlockBench.run_parent
|
|
212
|
+
end
|