Files
discourse-hledger/lib/hledger/runner.rb
T

212 lines
5.4 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_cached
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
# The version is part of the report cache key, so remember it to avoid a
# `--version` subprocess on every request.
def detect_version_cached
return detect_version if @path.blank?
key = "hledger:version:#{@path}"
cached = Discourse.cache.read(key)
return cached if cached
detected = detect_version
Discourse.cache.write(key, detected, expires_in: 10.minutes) if detected
detected
end
def failure_result
Result.new(
success: false,
stdout: "",
stderr: "",
exit_status: nil,
timed_out: false,
output_limit_exceeded: false,
)
end
end
end