Files
discourse-hledger/lib/hledger/runner.rb
T
2026-09-27 10:31:47 +02:00

164 lines
3.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, keyword_init: true) do
def success?
success
end
end
READ_CHUNK = 16_384
def initialize(path: nil, timeout: nil, max_output: 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
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" => ENV["PATH"].to_s,
"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,
}
status = nil
timed_out = false
stdout = +""
stderr = +""
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 = out_reader.value
stderr = err_reader.value
end
Result.new(
success: status&.success? && !timed_out,
stdout: stdout,
stderr: stderr,
exit_status: status&.exitstatus,
timed_out: timed_out,
)
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 = +""
while (chunk = io.read(READ_CHUNK))
break if chunk.empty?
remaining = @max_output - buffer.bytesize
buffer << chunk.byteslice(0, remaining) if remaining.positive?
end
buffer
rescue IOError, Errno::EPIPE
buffer
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)
end
end
end