landlock 0.1.1 → 0.3
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 +40 -0
- data/README.md +77 -6
- data/benchmark/landlock_overhead.rb +9 -30
- data/ext/landlock/bin/safe_exec_helper.c +602 -0
- data/ext/landlock/extconf.rb +33 -0
- data/ext/landlock/landlock.c +40 -174
- data/ext/landlock/landlock_native.h +168 -0
- data/ext/landlock/seccomp_deny_network.h +176 -0
- data/lib/landlock/env.rb +31 -0
- data/lib/landlock/errors.rb +32 -0
- data/lib/landlock/execution.rb +238 -0
- data/lib/landlock/native.rb +38 -0
- data/lib/landlock/policy.rb +161 -0
- data/lib/landlock/process_io.rb +249 -0
- data/lib/landlock/result.rb +43 -0
- data/lib/landlock/rights.rb +48 -0
- data/lib/landlock/rlimits.rb +40 -0
- data/lib/landlock/runner/fork.rb +171 -0
- data/lib/landlock/runner/native.rb +225 -0
- data/lib/landlock/runner.rb +28 -0
- data/lib/landlock/validation.rb +59 -0
- data/lib/landlock/version.rb +1 -1
- data/lib/landlock.rb +25 -245
- metadata +53 -9
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "env"
|
|
4
|
+
require_relative "native"
|
|
5
|
+
require_relative "policy"
|
|
6
|
+
require_relative "result"
|
|
7
|
+
require_relative "rlimits"
|
|
8
|
+
require_relative "runner"
|
|
9
|
+
require_relative "validation"
|
|
10
|
+
|
|
11
|
+
module Landlock
|
|
12
|
+
module Execution
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def exec(
|
|
16
|
+
argv,
|
|
17
|
+
read: [],
|
|
18
|
+
write: [],
|
|
19
|
+
execute: [],
|
|
20
|
+
connect_tcp: [],
|
|
21
|
+
bind_tcp: [],
|
|
22
|
+
paths: [],
|
|
23
|
+
scope: [],
|
|
24
|
+
chdir: nil,
|
|
25
|
+
env: nil,
|
|
26
|
+
unsetenv_others: false,
|
|
27
|
+
close_others: true,
|
|
28
|
+
allow_all_known: false
|
|
29
|
+
)
|
|
30
|
+
pid =
|
|
31
|
+
spawn(
|
|
32
|
+
argv,
|
|
33
|
+
read:,
|
|
34
|
+
write:,
|
|
35
|
+
execute:,
|
|
36
|
+
connect_tcp:,
|
|
37
|
+
bind_tcp:,
|
|
38
|
+
paths:,
|
|
39
|
+
scope:,
|
|
40
|
+
chdir:,
|
|
41
|
+
env:,
|
|
42
|
+
unsetenv_others:,
|
|
43
|
+
close_others:,
|
|
44
|
+
allow_all_known:
|
|
45
|
+
)
|
|
46
|
+
_, status = ::Process.wait2(pid)
|
|
47
|
+
status
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def spawn(
|
|
51
|
+
argv,
|
|
52
|
+
read: [],
|
|
53
|
+
write: [],
|
|
54
|
+
execute: [],
|
|
55
|
+
connect_tcp: [],
|
|
56
|
+
bind_tcp: [],
|
|
57
|
+
paths: [],
|
|
58
|
+
scope: [],
|
|
59
|
+
chdir: nil,
|
|
60
|
+
env: nil,
|
|
61
|
+
unsetenv_others: false,
|
|
62
|
+
close_others: true,
|
|
63
|
+
allow_all_known: false
|
|
64
|
+
)
|
|
65
|
+
argv = Validation.normalize_argv(argv).map(&:to_s)
|
|
66
|
+
ensure_landlock_supported!
|
|
67
|
+
env = Env.normalize(env)
|
|
68
|
+
policy =
|
|
69
|
+
prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:)
|
|
70
|
+
validate_landlock_restriction!(**policy)
|
|
71
|
+
|
|
72
|
+
spawn_with_runner(argv, **policy, chdir:, env:, unsetenv_others:, close_others:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def capture(argv, **options)
|
|
76
|
+
capture_with(argv, raise_on_failure: false, **options)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def capture!(argv, **options)
|
|
80
|
+
capture_with(argv, raise_on_failure: true, **options)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def capture_with(
|
|
84
|
+
argv,
|
|
85
|
+
read: [],
|
|
86
|
+
write: [],
|
|
87
|
+
execute: [],
|
|
88
|
+
connect_tcp: [],
|
|
89
|
+
bind_tcp: [],
|
|
90
|
+
paths: [],
|
|
91
|
+
scope: [],
|
|
92
|
+
chdir: nil,
|
|
93
|
+
env: nil,
|
|
94
|
+
unsetenv_others: false,
|
|
95
|
+
close_others: true,
|
|
96
|
+
allow_all_known: false,
|
|
97
|
+
timeout: nil,
|
|
98
|
+
stdin: nil,
|
|
99
|
+
rlimits: {},
|
|
100
|
+
seccomp_deny_network: false,
|
|
101
|
+
max_output_bytes: nil,
|
|
102
|
+
truncate_output: false,
|
|
103
|
+
success_status_codes: [0],
|
|
104
|
+
failure_message: "",
|
|
105
|
+
raise_on_failure:
|
|
106
|
+
)
|
|
107
|
+
argv = Validation.normalize_argv(argv).map(&:to_s)
|
|
108
|
+
ensure_landlock_supported!
|
|
109
|
+
max_output_bytes = Validation.validate_output_limit!(max_output_bytes)
|
|
110
|
+
timeout = Validation.validate_timeout!(timeout)
|
|
111
|
+
normalized_rlimits = Rlimits.normalize(rlimits)
|
|
112
|
+
env = Env.normalize(env)
|
|
113
|
+
policy =
|
|
114
|
+
prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:)
|
|
115
|
+
validate_capture_restriction!(**policy, seccomp_deny_network:, rlimits: normalized_rlimits)
|
|
116
|
+
|
|
117
|
+
result =
|
|
118
|
+
call_with_runner(
|
|
119
|
+
argv,
|
|
120
|
+
**policy,
|
|
121
|
+
chdir:,
|
|
122
|
+
env:,
|
|
123
|
+
unsetenv_others:,
|
|
124
|
+
close_others:,
|
|
125
|
+
timeout:,
|
|
126
|
+
stdin:,
|
|
127
|
+
rlimits: normalized_rlimits,
|
|
128
|
+
seccomp_deny_network:,
|
|
129
|
+
max_output_bytes:,
|
|
130
|
+
truncate_output:
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if raise_on_failure &&
|
|
134
|
+
(result.timed_out? || !result.status.exited? || !success_status_codes.include?(result.status.exitstatus))
|
|
135
|
+
message = [argv.join(" "), failure_message, result.stderr].filter { |part| part.to_s != "" }.join("\n")
|
|
136
|
+
raise CommandError.new(message, stdout: result.stdout, stderr: result.stderr, status: result.status, result:)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
result
|
|
140
|
+
rescue OutputTooLargeError => e
|
|
141
|
+
message = [argv&.join(" "), failure_message, e.message].filter { |part| part.to_s != "" }.join("\n")
|
|
142
|
+
result = e.result
|
|
143
|
+
raise CommandError.new(
|
|
144
|
+
message,
|
|
145
|
+
stdout: result&.stdout.to_s,
|
|
146
|
+
stderr: result&.stderr.to_s,
|
|
147
|
+
status: result&.status,
|
|
148
|
+
result:
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def spawn_with_runner(argv, **options)
|
|
153
|
+
if Runner::Native.available?
|
|
154
|
+
begin
|
|
155
|
+
return Runner::Native.spawn(argv, **options)
|
|
156
|
+
rescue Errno::E2BIG
|
|
157
|
+
return Runner::Fork.spawn(argv, **options)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Runner::Fork.spawn(argv, **options)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def call_with_runner(argv, **options)
|
|
165
|
+
if Runner::Native.available?
|
|
166
|
+
begin
|
|
167
|
+
return Runner::Native.call(argv, **options)
|
|
168
|
+
rescue Errno::E2BIG
|
|
169
|
+
return Runner::Fork.call(argv, **options)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Runner::Fork.call(argv, **options)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ensure_landlock_supported!
|
|
177
|
+
raise UnsupportedError, "Linux Landlock is unavailable" unless Native.abi_version.positive?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def prepare_policy(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, chdir:, allow_all_known:)
|
|
181
|
+
connect_tcp = Validation.normalize_ports(connect_tcp, :connect_tcp)
|
|
182
|
+
bind_tcp = Validation.normalize_ports(bind_tcp, :bind_tcp)
|
|
183
|
+
read, write, execute, paths = validate_policy_paths!(read:, write:, execute:, paths:, chdir:)
|
|
184
|
+
{ read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known: }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def validate_landlock_restriction!(
|
|
188
|
+
read:,
|
|
189
|
+
write:,
|
|
190
|
+
execute:,
|
|
191
|
+
connect_tcp:,
|
|
192
|
+
bind_tcp:,
|
|
193
|
+
paths:,
|
|
194
|
+
scope:,
|
|
195
|
+
allow_all_known:
|
|
196
|
+
)
|
|
197
|
+
return if Policy.requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
|
|
198
|
+
|
|
199
|
+
raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def validate_capture_restriction!(
|
|
203
|
+
read:,
|
|
204
|
+
write:,
|
|
205
|
+
execute:,
|
|
206
|
+
connect_tcp:,
|
|
207
|
+
bind_tcp:,
|
|
208
|
+
paths:,
|
|
209
|
+
scope:,
|
|
210
|
+
allow_all_known:,
|
|
211
|
+
seccomp_deny_network:,
|
|
212
|
+
rlimits:
|
|
213
|
+
)
|
|
214
|
+
return if Policy.requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
|
|
215
|
+
return if seccomp_deny_network
|
|
216
|
+
return if Array(rlimits).any?
|
|
217
|
+
|
|
218
|
+
raise ArgumentError, "empty capture policy: provide Landlock rules, seccomp_deny_network, or rlimits"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def validate_policy_paths!(read:, write:, execute:, paths:, chdir:)
|
|
222
|
+
base = chdir ? File.expand_path(chdir) : Dir.pwd
|
|
223
|
+
abi = Native.abi_version
|
|
224
|
+
read = Validation.validate_existing_paths(read, :read, chdir:)
|
|
225
|
+
write = Validation.validate_existing_paths(write, :write, chdir:)
|
|
226
|
+
execute = Validation.validate_existing_paths(execute, :execute, chdir:)
|
|
227
|
+
paths =
|
|
228
|
+
Array(paths).map do |rule|
|
|
229
|
+
path, rights = Policy.normalize_path_rule(rule)
|
|
230
|
+
Validation.validate_existing_path!(path, :path, base)
|
|
231
|
+
Policy.path_rule_access_mask(File.expand_path(path, base), rights, abi)
|
|
232
|
+
{ path: path.to_s, rights: }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
[read, write, execute, paths]
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "landlock"
|
|
5
|
+
|
|
6
|
+
module Landlock
|
|
7
|
+
module Native
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def abi_version
|
|
11
|
+
Landlock.abi_version
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_ruleset(fs_handled, net_handled, scoped)
|
|
15
|
+
Landlock.__send__(:_create_ruleset, fs_handled, net_handled, scoped)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_path_rule(fd, path, access_mask)
|
|
19
|
+
Landlock.__send__(:_add_path_rule, fd, path, access_mask)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_net_rule(fd, port, access_mask)
|
|
23
|
+
Landlock.__send__(:_add_net_rule, fd, port, access_mask)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def restrict_self(fd)
|
|
27
|
+
Landlock.__send__(:_restrict_self, fd)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def close_fd(fd)
|
|
31
|
+
Landlock.__send__(:_close_fd, fd)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def seccomp_deny_network!
|
|
35
|
+
Landlock.seccomp_deny_network!
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "native"
|
|
4
|
+
require_relative "rights"
|
|
5
|
+
|
|
6
|
+
module Landlock
|
|
7
|
+
module Policy
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def restrict!(
|
|
11
|
+
read: [],
|
|
12
|
+
write: [],
|
|
13
|
+
execute: [],
|
|
14
|
+
connect_tcp: [],
|
|
15
|
+
bind_tcp: [],
|
|
16
|
+
paths: [],
|
|
17
|
+
scope: [],
|
|
18
|
+
allow_all_known: false
|
|
19
|
+
)
|
|
20
|
+
abi = Native.abi_version
|
|
21
|
+
raise UnsupportedError, "Linux Landlock is unavailable" unless abi.positive?
|
|
22
|
+
|
|
23
|
+
fs_handled =
|
|
24
|
+
(
|
|
25
|
+
if allow_all_known
|
|
26
|
+
fs_rights_for_abi(abi)
|
|
27
|
+
else
|
|
28
|
+
handled_fs_for(read:, write:, execute:, paths:, abi:)
|
|
29
|
+
end
|
|
30
|
+
)
|
|
31
|
+
net_handled = handled_net_for(connect_tcp:, bind_tcp:, abi:)
|
|
32
|
+
scoped = scope_for(scope:, abi:)
|
|
33
|
+
|
|
34
|
+
if fs_handled.zero? && net_handled.zero? && scoped.zero?
|
|
35
|
+
raise ArgumentError, "empty Landlock policy: provide filesystem paths, TCP ports, or scopes"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
fd = Native.create_ruleset(fs_handled, net_handled, scoped)
|
|
39
|
+
begin
|
|
40
|
+
add_path_rules(fd, read, READ_RIGHTS, abi)
|
|
41
|
+
add_path_rules(fd, execute, EXEC_RIGHTS, abi)
|
|
42
|
+
add_path_rules(fd, write, WRITE_RIGHTS, abi)
|
|
43
|
+
|
|
44
|
+
Array(paths).each do |rule|
|
|
45
|
+
path, rights = normalize_path_rule(rule)
|
|
46
|
+
expanded_path = File.expand_path(path)
|
|
47
|
+
access_mask = path_rule_access_mask(expanded_path, rights, abi)
|
|
48
|
+
|
|
49
|
+
Native.add_path_rule(fd, expanded_path, access_mask)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
add_net_rules(fd, connect_tcp, [:connect_tcp], abi)
|
|
53
|
+
add_net_rules(fd, bind_tcp, [:bind_tcp], abi)
|
|
54
|
+
|
|
55
|
+
Native.restrict_self(fd)
|
|
56
|
+
ensure
|
|
57
|
+
Native.close_fd(fd) if fd && fd >= 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def requested?(read:, write:, execute:, connect_tcp:, bind_tcp:, paths:, scope:, allow_all_known:)
|
|
64
|
+
allow_all_known || Array(read).any? || Array(write).any? || Array(execute).any? || Array(connect_tcp).any? ||
|
|
65
|
+
Array(bind_tcp).any? || Array(paths).any? || Array(scope).any?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def path_rights(path, rights)
|
|
69
|
+
File.directory?(path) ? rights : Array(rights) & FILE_PATH_RIGHTS
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def path_rule_access_mask(path, rights, abi)
|
|
73
|
+
mask(path_rights(path, rights), FS_RIGHTS, abi).tap do |access_mask|
|
|
74
|
+
raise ArgumentError, "path rule has no effective rights: #{path}" if access_mask.zero?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_path_rules(fd, paths, rights, abi)
|
|
79
|
+
Array(paths).each do |path|
|
|
80
|
+
expanded_path = File.expand_path(path)
|
|
81
|
+
access_mask = mask(path_rights(expanded_path, rights), FS_RIGHTS, abi)
|
|
82
|
+
next if access_mask.zero?
|
|
83
|
+
|
|
84
|
+
Native.add_path_rule(fd, expanded_path, access_mask)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_net_rules(fd, ports, rights, abi)
|
|
89
|
+
ports = Array(ports)
|
|
90
|
+
return if ports.empty?
|
|
91
|
+
raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4
|
|
92
|
+
|
|
93
|
+
access_mask = mask(rights, NET_RIGHTS, abi)
|
|
94
|
+
return if access_mask.zero?
|
|
95
|
+
|
|
96
|
+
ports.each { |port| Native.add_net_rule(fd, Integer(port), access_mask) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_path_rule(rule)
|
|
100
|
+
case rule
|
|
101
|
+
when Hash
|
|
102
|
+
[rule.fetch(:path), Array(rule.fetch(:rights))]
|
|
103
|
+
when Array
|
|
104
|
+
[rule.fetch(0), Array(rule.fetch(1))]
|
|
105
|
+
else
|
|
106
|
+
raise ArgumentError, "path rule must be {path:, rights:} or [path, rights]"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def mask(names, table, abi)
|
|
111
|
+
Array(names).reduce(0) do |bits, name|
|
|
112
|
+
bit = table.fetch(name.to_sym) { raise ArgumentError, "unknown Landlock right: #{name.inspect}" }
|
|
113
|
+
next bits if bit == ACCESS_FS_REFER && abi < 2
|
|
114
|
+
next bits if bit == ACCESS_FS_TRUNCATE && abi < 3
|
|
115
|
+
next bits if bit == ACCESS_FS_IOCTL_DEV && abi < 5
|
|
116
|
+
|
|
117
|
+
bits | bit
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def fs_rights_for_abi(abi)
|
|
122
|
+
rights = FS_RIGHTS.values.reduce(0, :|)
|
|
123
|
+
rights &= ~ACCESS_FS_REFER if abi < 2
|
|
124
|
+
rights &= ~ACCESS_FS_TRUNCATE if abi < 3
|
|
125
|
+
rights &= ~ACCESS_FS_IOCTL_DEV if abi < 5
|
|
126
|
+
rights
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handled_fs_for(read:, write:, execute:, paths:, abi:)
|
|
130
|
+
bits = 0
|
|
131
|
+
bits |= mask(READ_RIGHTS, FS_RIGHTS, abi) unless Array(read).empty?
|
|
132
|
+
bits |= mask(EXEC_RIGHTS, FS_RIGHTS, abi) unless Array(execute).empty?
|
|
133
|
+
bits |= mask(WRITE_RIGHTS, FS_RIGHTS, abi) unless Array(write).empty?
|
|
134
|
+
Array(paths).each do |rule|
|
|
135
|
+
path, rights = normalize_path_rule(rule)
|
|
136
|
+
bits |= path_rule_access_mask(File.expand_path(path), rights, abi)
|
|
137
|
+
end
|
|
138
|
+
bits
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def handled_net_for(connect_tcp:, bind_tcp:, abi:)
|
|
142
|
+
bits = 0
|
|
143
|
+
bits |= ACCESS_NET_CONNECT_TCP unless Array(connect_tcp).empty?
|
|
144
|
+
bits |= ACCESS_NET_BIND_TCP unless Array(bind_tcp).empty?
|
|
145
|
+
return 0 if bits.zero?
|
|
146
|
+
|
|
147
|
+
raise UnsupportedError, "Landlock network rules require ABI v4+; running ABI v#{abi}" if abi < 4
|
|
148
|
+
|
|
149
|
+
bits
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def scope_for(scope:, abi:)
|
|
153
|
+
bits = mask(scope, SCOPE_FLAGS, abi)
|
|
154
|
+
return 0 if bits.zero?
|
|
155
|
+
|
|
156
|
+
raise UnsupportedError, "Landlock scopes require ABI v6+; running ABI v#{abi}" if abi < 6
|
|
157
|
+
|
|
158
|
+
bits
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "result"
|
|
5
|
+
|
|
6
|
+
module Landlock
|
|
7
|
+
READ_CHUNK_BYTES = 16 * 1024
|
|
8
|
+
PROCESS_POLL_SECONDS = 0.1
|
|
9
|
+
STDIN_THREAD_JOIN_SECONDS = 0.1
|
|
10
|
+
POST_TIMEOUT_DRAIN_SECONDS = 0.05
|
|
11
|
+
|
|
12
|
+
module ProcessIO
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def complete_pipe_capture(
|
|
16
|
+
pid,
|
|
17
|
+
stdout_reader,
|
|
18
|
+
stderr_reader,
|
|
19
|
+
stdin_writer,
|
|
20
|
+
stdin,
|
|
21
|
+
timeout,
|
|
22
|
+
max_output_bytes,
|
|
23
|
+
truncate_output
|
|
24
|
+
)
|
|
25
|
+
stdin_thread = write_input(stdin_writer, stdin)
|
|
26
|
+
|
|
27
|
+
stdout = +"".b
|
|
28
|
+
stderr = +"".b
|
|
29
|
+
state = { bytes: 0, truncated: false }
|
|
30
|
+
begin
|
|
31
|
+
status, timed_out =
|
|
32
|
+
read_and_wait(
|
|
33
|
+
pid,
|
|
34
|
+
{ stdout_reader => stdout, stderr_reader => stderr },
|
|
35
|
+
timeout,
|
|
36
|
+
max_output_bytes,
|
|
37
|
+
truncate_output,
|
|
38
|
+
state
|
|
39
|
+
)
|
|
40
|
+
rescue OutputTooLargeError => error
|
|
41
|
+
status ||= wait_for_pid(pid)
|
|
42
|
+
error.result = capture_result(stdout, stderr, status, output_truncated: true, timed_out:)
|
|
43
|
+
raise
|
|
44
|
+
ensure
|
|
45
|
+
finish_input_thread(stdin_thread, stdin_writer)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
capture_result(stdout, stderr, status, output_truncated: state[:truncated], timed_out:)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def capture_result(stdout, stderr, status, output_truncated:, timed_out:)
|
|
52
|
+
stdout.force_encoding(Encoding.default_external)
|
|
53
|
+
stderr.force_encoding(Encoding.default_external)
|
|
54
|
+
CaptureResult.new(stdout:, stderr:, status:, output_truncated:, timed_out:)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def write_input(io, input)
|
|
58
|
+
return io.close if input.nil?
|
|
59
|
+
|
|
60
|
+
Thread.new do
|
|
61
|
+
Thread.current.report_on_exception = false
|
|
62
|
+
begin
|
|
63
|
+
if input.respond_to?(:read)
|
|
64
|
+
while (chunk = input.read(READ_CHUNK_BYTES))
|
|
65
|
+
io.write(chunk)
|
|
66
|
+
end
|
|
67
|
+
else
|
|
68
|
+
io.write(input.to_s)
|
|
69
|
+
end
|
|
70
|
+
rescue Errno::EPIPE, IOError
|
|
71
|
+
ensure
|
|
72
|
+
io.close unless io.closed?
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def finish_input_thread(thread, io)
|
|
78
|
+
close_stream(io)
|
|
79
|
+
return unless thread
|
|
80
|
+
|
|
81
|
+
if thread.join(STDIN_THREAD_JOIN_SECONDS)
|
|
82
|
+
thread.value
|
|
83
|
+
else
|
|
84
|
+
thread.kill
|
|
85
|
+
thread.join(STDIN_THREAD_JOIN_SECONDS)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def read_and_wait(pid, streams, timeout, max_output_bytes, truncate_output, state)
|
|
90
|
+
deadline = timeout ? ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout : nil
|
|
91
|
+
timed_out = false
|
|
92
|
+
status = nil
|
|
93
|
+
|
|
94
|
+
until streams.empty? && status
|
|
95
|
+
if deadline
|
|
96
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
97
|
+
if remaining <= 0
|
|
98
|
+
timed_out = true
|
|
99
|
+
terminate_process(pid)
|
|
100
|
+
status = wait_for_pid(pid)
|
|
101
|
+
drain_streams_until(
|
|
102
|
+
streams,
|
|
103
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + POST_TIMEOUT_DRAIN_SECONDS,
|
|
104
|
+
max_output_bytes,
|
|
105
|
+
truncate_output,
|
|
106
|
+
state,
|
|
107
|
+
pid
|
|
108
|
+
)
|
|
109
|
+
close_streams(streams)
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
status ||= poll_pid(pid)
|
|
115
|
+
|
|
116
|
+
break if streams.empty? && status
|
|
117
|
+
|
|
118
|
+
wait =
|
|
119
|
+
(
|
|
120
|
+
if deadline
|
|
121
|
+
[deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC), PROCESS_POLL_SECONDS].min
|
|
122
|
+
else
|
|
123
|
+
PROCESS_POLL_SECONDS
|
|
124
|
+
end
|
|
125
|
+
)
|
|
126
|
+
wait = 0 if wait.negative?
|
|
127
|
+
if streams.empty?
|
|
128
|
+
sleep wait
|
|
129
|
+
next
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
readable, = IO.select(streams.keys, nil, nil, wait)
|
|
133
|
+
next unless readable
|
|
134
|
+
|
|
135
|
+
readable.each do |io|
|
|
136
|
+
begin
|
|
137
|
+
chunk = io.read_nonblock(READ_CHUNK_BYTES)
|
|
138
|
+
append_output_chunk(streams.fetch(io), chunk, state, max_output_bytes, truncate_output, pid)
|
|
139
|
+
rescue IO::WaitReadable
|
|
140
|
+
next
|
|
141
|
+
rescue EOFError
|
|
142
|
+
streams.delete(io)
|
|
143
|
+
io.close
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
status ||= wait_for_pid(pid)
|
|
149
|
+
[status, timed_out]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def poll_pid(pid)
|
|
153
|
+
result = ::Process.wait2(pid, ::Process::WNOHANG)
|
|
154
|
+
result&.last
|
|
155
|
+
rescue Errno::ECHILD
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def wait_for_pid(pid)
|
|
160
|
+
::Process.wait2(pid).last
|
|
161
|
+
rescue Errno::ECHILD
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def close_stream(io)
|
|
166
|
+
io.close unless io.closed?
|
|
167
|
+
rescue IOError
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read_available_streams(streams, max_output_bytes, truncate_output, state, pid)
|
|
171
|
+
readable, = IO.select(streams.keys, nil, nil, 0)
|
|
172
|
+
return false unless readable
|
|
173
|
+
|
|
174
|
+
readable.each do |io|
|
|
175
|
+
begin
|
|
176
|
+
chunk = io.read_nonblock(READ_CHUNK_BYTES)
|
|
177
|
+
append_output_chunk(streams.fetch(io), chunk, state, max_output_bytes, truncate_output, pid)
|
|
178
|
+
rescue IO::WaitReadable
|
|
179
|
+
next
|
|
180
|
+
rescue EOFError
|
|
181
|
+
streams.delete(io)
|
|
182
|
+
io.close
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def drain_streams_until(streams, drain_deadline, max_output_bytes, truncate_output, state, pid)
|
|
190
|
+
while streams.any? && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < drain_deadline
|
|
191
|
+
break unless read_available_streams(streams, max_output_bytes, truncate_output, state, pid)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def close_streams(streams)
|
|
196
|
+
streams.keys.each do |io|
|
|
197
|
+
streams.delete(io)
|
|
198
|
+
io.close unless io.closed?
|
|
199
|
+
rescue IOError
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def append_output_chunk(
|
|
204
|
+
buffer,
|
|
205
|
+
chunk,
|
|
206
|
+
state,
|
|
207
|
+
max_output_bytes,
|
|
208
|
+
truncate_output,
|
|
209
|
+
pid,
|
|
210
|
+
output_too_large_error: Landlock::OutputTooLargeError
|
|
211
|
+
)
|
|
212
|
+
return buffer << chunk if max_output_bytes.nil?
|
|
213
|
+
|
|
214
|
+
chunk_to_append = chunk
|
|
215
|
+
over_limit = false
|
|
216
|
+
remaining_bytes = max_output_bytes - state[:bytes]
|
|
217
|
+
if remaining_bytes <= 0
|
|
218
|
+
chunk_to_append = ""
|
|
219
|
+
over_limit = true
|
|
220
|
+
elsif chunk.bytesize > remaining_bytes
|
|
221
|
+
chunk_to_append = chunk.byteslice(0, remaining_bytes)
|
|
222
|
+
over_limit = true
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
state[:bytes] += chunk.bytesize
|
|
226
|
+
state[:truncated] = true if over_limit
|
|
227
|
+
buffer << chunk_to_append
|
|
228
|
+
return unless over_limit
|
|
229
|
+
|
|
230
|
+
terminate_process(pid)
|
|
231
|
+
raise output_too_large_error, "Process output exceeded #{max_output_bytes} bytes" unless truncate_output
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def terminate_process(pid)
|
|
235
|
+
signal_process("TERM", pid)
|
|
236
|
+
sleep 0.5
|
|
237
|
+
signal_process("KILL", pid)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def signal_process(signal, pid)
|
|
241
|
+
::Process.kill(signal, -pid)
|
|
242
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
243
|
+
begin
|
|
244
|
+
::Process.kill(signal, pid)
|
|
245
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|