Module: SafeImage::Runner

Defined in:
lib/safe_image/runner.rb

Constant Summary collapse

DEFAULT_TIMEOUT =
20
MAX_OUTPUT_BYTES =
512 * 1024
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"
}.freeze

Class Method Summary collapse

Class Method Details

.available?(name) ⇒ Boolean

Returns:

  • (Boolean)


154
155
156
# File 'lib/safe_image/runner.rb', line 154

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

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



131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/safe_image/runner.rb', line 131

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:



146
147
148
# File 'lib/safe_image/runner.rb', line 146

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)


150
151
152
# File 'lib/safe_image/runner.rb', line 150

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

.kill_process_group(pid) ⇒ Object



120
121
122
123
124
125
126
127
128
129
# File 'lib/safe_image/runner.rb', line 120

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

.resolve_executable(name) ⇒ Object



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

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



158
159
160
# File 'lib/safe_image/runner.rb', line 158

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)


34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/safe_image/runner.rb', line 34

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:



51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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
# File 'lib/safe_image/runner.rb', line 51

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)
      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)
          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)
      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
  )
end