Module: SafeImage::Runner

Defined in:
lib/safe_image/runner.rb

Constant Summary collapse

DEFAULT_TIMEOUT =
20
MAX_OUTPUT_BYTES =
512 * 1024
TERMINATE_GRACE_SECONDS =

Give well-behaved tools a short flush window after TERM before KILL keeps the timeout hard without leaking process groups.

0.2
TRUSTED_PATH =
"/usr/bin:/bin:/usr/local/bin".freeze
ALLOWED_ENV_KEYS =
%w[LANG LC_ALL LC_CTYPE TZ].freeze
IMAGEMAGICK_POLICY_PATH =
File.expand_path("imagemagick_policy", __dir__)
IMAGEMAGICK_POLICY_FILE =
File.join(IMAGEMAGICK_POLICY_PATH, "policy.xml").freeze
BASE_ENV =
{
  "PATH" => TRUSTED_PATH,
  "VIPS_BLOCK_UNTRUSTED" => "1",
  # Cap glibc's per-thread malloc arenas. Multithreaded tools (oxipng's
  # rayon pool, ImageMagick's OpenMP) otherwise reserve an arena per thread
  # — up to 8x64MB of *address space* per core — which, combined with the
  # sandbox's RLIMIT_AS memory cap, spuriously fails the tool under
  # concurrency even though real memory use is tiny. AS counts reservations,
  # not RSS; bounding arenas is the standard mitigation and costs nothing
  # for these compute-bound tools.
  "MALLOC_ARENA_MAX" => "2"
}.freeze

Class Method Summary collapse

Class Method Details

.available?(name) ⇒ Boolean

Returns:

  • (Boolean)


197
198
199
# File 'lib/safe_image/runner.rb', line 197

def available?(name)
  !!resolve_executable(name)
end

.command_env(tmpdir, env = {}) ⇒ Object



173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/safe_image/runner.rb', line 173

def command_env(tmpdir, env = {})
  allowed =
    env.each_with_object({}) do |(key, value), hash|
      key = key.to_s
      hash[key] = value.to_s if ALLOWED_ENV_KEYS.include?(key)
    end

  BASE_ENV.merge(
    "MAGICK_CONFIGURE_PATH" => IMAGEMAGICK_POLICY_PATH,
    "MAGICK_TEMPORARY_PATH" => tmpdir,
    "HOME" => tmpdir,
    "XDG_CACHE_HOME" => tmpdir,
    "TMPDIR" => tmpdir
  ).merge(allowed)
end

.ensure_imagemagick_policy!Object

Raises:



189
190
191
# File 'lib/safe_image/runner.rb', line 189

def ensure_imagemagick_policy!
  raise Error, "missing ImageMagick policy: #{IMAGEMAGICK_POLICY_FILE}" unless File.file?(IMAGEMAGICK_POLICY_FILE)
end

.imagemagick_command?(name) ⇒ Boolean

Returns:

  • (Boolean)


193
194
195
# File 'lib/safe_image/runner.rb', line 193

def imagemagick_command?(name)
  %w[magick convert identify compare].include?(name.to_s)
end

.kill_process_group(pid) ⇒ Object



162
163
164
165
166
167
168
169
170
171
# File 'lib/safe_image/runner.rb', line 162

def kill_process_group(pid)
  Process.kill("TERM", -pid)
rescue Errno::ESRCH, Errno::EPERM
ensure
  begin
    sleep TERMINATE_GRACE_SECONDS
    Process.kill("KILL", -pid)
  rescue Errno::ESRCH, Errno::EPERM
  end
end

.resolve_executable(name) ⇒ Object



205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/safe_image/runner.rb', line 205

def resolve_executable(name)
  name = name.to_s
  return name if name.include?(File::SEPARATOR) && File.file?(name) && File.executable?(name)

  TRUSTED_PATH
    .split(File::PATH_SEPARATOR)
    .each do |dir|
      path = File.join(dir, name)
      return path if File.file?(path) && File.executable?(path)
    end

  nil
end

.resolve_executable!(name) ⇒ Object



201
202
203
# File 'lib/safe_image/runner.rb', line 201

def resolve_executable!(name)
  resolve_executable(name) || raise(UnsupportedFormatError, "missing executable: #{name}")
end

.run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: []) ⇒ Object

Raises:

  • (ArgumentError)


57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/safe_image/runner.rb', line 57

def run!(argv, timeout: DEFAULT_TIMEOUT, env: {}, sandbox: false, read: [], write: [])
  raise ArgumentError, "empty command" if argv.nil? || argv.empty?
  argv = argv.map(&:to_s)
  argv[0] = resolve_executable!(argv[0])
  ensure_imagemagick_policy! if imagemagick_command?(File.basename(argv[0]))

  Dir.mktmpdir("safe-image-command-") do |tmpdir|
    child_env = command_env(tmpdir, env)

    if sandbox || SafeImage.sandbox?
      return Sandbox.capture_command!(argv, read: read, write: [*write, tmpdir], timeout: timeout, env: child_env)
    end

    return run_process!(argv, child_env, timeout: timeout)
  end
end

.run_process!(argv, child_env, timeout:) ⇒ Object

Raises:



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/safe_image/runner.rb', line 74

def run_process!(argv, child_env, timeout:)
  stdout = +"".b
  stderr = +"".b
  status = nil

  Open3.popen3(child_env, *argv, unsetenv_others: true, pgroup: true) do |stdin, out, err, wait_thr|
    stdin.close
    deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
    streams = { out => stdout, err => stderr }

    until streams.empty?
      remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
      if remaining <= 0
        kill_process_group(wait_thr.pid)
        raise CommandError.new(
                "command timed out after #{timeout}s",
                command: argv,
                stdout: stdout,
                stderr: stderr,
                category: :timeout
              )
      end

      readable, = IO.select(streams.keys, nil, nil, remaining)
      next unless readable

      readable.each do |io|
        begin
          chunk = io.read_nonblock(16 * 1024)
          buffer = streams.fetch(io)
          buffer << chunk
          if buffer.bytesize > MAX_OUTPUT_BYTES
            kill_process_group(wait_thr.pid)
            raise CommandError.new(
                    "command output exceeded #{MAX_OUTPUT_BYTES} bytes",
                    command: argv,
                    stdout: stdout,
                    stderr: stderr,
                    category: :output_limit
                  )
          end
        rescue IO::WaitReadable
          next
        rescue EOFError
          streams.delete(io)
          io.close
        end
      end
    end

    # The read loop above exits as soon as both pipes hit EOF, which can
    # happen while the child is still alive (it closed/redirected its
    # standard streams but keeps running, possibly via a grandchild).
    # Bound the final wait against the same deadline so the timeout is a
    # hard ceiling rather than something a child can close its way out of.
    until status
      remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
      if remaining <= 0
        kill_process_group(wait_thr.pid)
        raise CommandError.new(
                "command timed out after #{timeout}s",
                command: argv,
                stdout: stdout,
                stderr: stderr,
                category: :timeout
              )
      end
      status = wait_thr.join(remaining)&.value
    end
  rescue CommandError
    raise
  rescue Exception
    kill_process_group(wait_thr.pid) if wait_thr
    raise
  end

  return stdout, stderr if status&.success?

  raise CommandError.new(
          "command failed: #{argv.first} exited #{status&.exitstatus}",
          command: argv,
          status: status&.exitstatus,
          stdout: stdout,
          stderr: stderr,
          category: :exit_status
        )
end