198 lines
4.9 KiB
Ruby
198 lines
4.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "open3"
|
|
require "tmpdir"
|
|
require "fileutils"
|
|
|
|
module Hledger
|
|
# Runs the hledger binary in a locked-down child process: scrubbed
|
|
# environment, private working directory, process group, CPU/file limits and
|
|
# a hard wall-clock timeout.
|
|
class Runner
|
|
Result =
|
|
Struct.new(
|
|
:success,
|
|
:stdout,
|
|
:stderr,
|
|
:exit_status,
|
|
:timed_out,
|
|
:output_limit_exceeded,
|
|
keyword_init: true,
|
|
) do
|
|
def success?
|
|
success
|
|
end
|
|
end
|
|
|
|
READ_CHUNK = 16_384
|
|
MAX_FILE_SIZE = 8 * 1024 * 1024
|
|
|
|
def initialize(path: nil, timeout: nil, max_output: nil, memory_limit: nil)
|
|
@path = (path || SiteSetting.hledger_path).to_s
|
|
@timeout = (timeout || SiteSetting.hledger_timeout_seconds).to_i
|
|
@max_output = (max_output || SiteSetting.hledger_max_output_bytes).to_i
|
|
@memory_limit_mb = (memory_limit || SiteSetting.hledger_memory_limit_mb).to_i
|
|
end
|
|
|
|
def available?
|
|
version.present?
|
|
end
|
|
|
|
def version
|
|
return @version if defined?(@version)
|
|
|
|
@version = detect_version
|
|
end
|
|
|
|
def execute(argv, stdin_data: nil)
|
|
binary = resolve_binary
|
|
return failure_result if binary.nil?
|
|
|
|
run(binary, argv, stdin_data)
|
|
end
|
|
|
|
private
|
|
|
|
def run(binary, argv, stdin_data)
|
|
tmpdir = Dir.mktmpdir("hledger-")
|
|
env = {
|
|
"PATH" => File.dirname(binary),
|
|
"HOME" => tmpdir,
|
|
"TMPDIR" => tmpdir,
|
|
"XDG_CACHE_HOME" => tmpdir,
|
|
"XDG_CONFIG_HOME" => tmpdir,
|
|
"NO_COLOR" => "1",
|
|
"LANG" => "C.UTF-8",
|
|
"LC_ALL" => "C.UTF-8",
|
|
}
|
|
spawn_opts = {
|
|
pgroup: true,
|
|
unsetenv_others: true,
|
|
chdir: tmpdir,
|
|
rlimit_cpu: @timeout + 5,
|
|
rlimit_nofile: 64,
|
|
rlimit_fsize: MAX_FILE_SIZE,
|
|
rlimit_core: 0,
|
|
}
|
|
# hledger is a GHC binary, so cap memory via RLIMIT_DATA (which since
|
|
# Linux 4.7 also covers mmap). The GHC runtime options distro packages
|
|
# ship with are disabled ("Most RTS options are disabled").
|
|
spawn_opts[:rlimit_data] = @memory_limit_mb * 1024 * 1024 if @memory_limit_mb.positive?
|
|
|
|
status = nil
|
|
timed_out = false
|
|
stdout = +""
|
|
stderr = +""
|
|
output_limit_exceeded = false
|
|
|
|
Open3.popen3(env, binary, *argv, **spawn_opts) do |stdin, out, err, wait_thr|
|
|
writer = Thread.new { write_stdin(stdin, stdin_data) }
|
|
out_reader = Thread.new { read_capped(out) }
|
|
err_reader = Thread.new { read_capped(err) }
|
|
|
|
if wait_thr.join(@timeout)
|
|
status = wait_thr.value
|
|
else
|
|
timed_out = true
|
|
kill_group(wait_thr.pid)
|
|
wait_thr.join(2)
|
|
end
|
|
|
|
writer.join(1)
|
|
stdout, stdout_truncated = out_reader.value
|
|
stderr, stderr_truncated = err_reader.value
|
|
output_limit_exceeded = stdout_truncated || stderr_truncated
|
|
end
|
|
|
|
Result.new(
|
|
success: status&.success? && !timed_out,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
exit_status: status&.exitstatus,
|
|
timed_out: timed_out,
|
|
output_limit_exceeded: output_limit_exceeded,
|
|
)
|
|
rescue SystemCallError
|
|
failure_result
|
|
ensure
|
|
FileUtils.remove_entry(tmpdir) if tmpdir && Dir.exist?(tmpdir)
|
|
end
|
|
|
|
def write_stdin(stdin, data)
|
|
stdin.write(data) if data
|
|
stdin.close
|
|
rescue IOError, Errno::EPIPE
|
|
end
|
|
|
|
def read_capped(io)
|
|
buffer = +""
|
|
truncated = false
|
|
while (chunk = io.read(READ_CHUNK))
|
|
break if chunk.empty?
|
|
|
|
remaining = @max_output - buffer.bytesize
|
|
if remaining <= 0
|
|
truncated = true
|
|
elsif chunk.bytesize <= remaining
|
|
buffer << chunk
|
|
else
|
|
buffer << chunk.byteslice(0, remaining)
|
|
truncated = true
|
|
end
|
|
end
|
|
[buffer, truncated]
|
|
rescue IOError, Errno::EPIPE
|
|
[buffer, truncated]
|
|
ensure
|
|
io.close
|
|
end
|
|
|
|
def kill_group(pid)
|
|
Process.kill("KILL", -pid)
|
|
rescue Errno::ESRCH, Errno::EPERM
|
|
end
|
|
|
|
def resolve_binary
|
|
return @binary if defined?(@binary)
|
|
|
|
@binary =
|
|
if @path.include?(File::SEPARATOR)
|
|
File.executable?(@path) ? @path : nil
|
|
else
|
|
which(@path)
|
|
end
|
|
end
|
|
|
|
def which(name)
|
|
return nil if name.blank?
|
|
|
|
ENV["PATH"]
|
|
.to_s
|
|
.split(File::PATH_SEPARATOR)
|
|
.each do |dir|
|
|
candidate = File.join(dir, name)
|
|
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
end
|
|
nil
|
|
end
|
|
|
|
def detect_version
|
|
result = execute(["--version"])
|
|
return nil unless result.success?
|
|
|
|
result.stdout[/\d+\.\d+(?:\.\d+)?/]
|
|
end
|
|
|
|
def failure_result
|
|
Result.new(
|
|
success: false,
|
|
stdout: "",
|
|
stderr: "",
|
|
exit_status: nil,
|
|
timed_out: false,
|
|
output_limit_exceeded: false,
|
|
)
|
|
end
|
|
end
|
|
end
|