cheetah 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (7) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG +37 -2
  3. data/README.md +94 -14
  4. data/VERSION +1 -1
  5. data/lib/cheetah/version.rb +4 -1
  6. data/lib/cheetah.rb +242 -107
  7. metadata +18 -43
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bd4cbe464be1d5ee56434c1a380d958cf0cbcb40095d8f21fcfb3cf7b2320db2
4
+ data.tar.gz: 3572805d086b24911c1feb6169b5911723a918fcf1ca0547d2f28984e0827ade
5
+ SHA512:
6
+ metadata.gz: 032f86dbf253379e1663e01d03f8a5a013a13829e4bb5b9c3f880d73611a2c92176e51086402b604cfd20a30d28dfa1571766b9fa2e4eb5377e4c12a6bda1b39
7
+ data.tar.gz: 1ecf8c2ff66d2eef73b658f925bbb5a7c6292861ce4fd52a9f2ed6fab91e49f3ba25fe5cd84f8da294b6340887872c9ef5100f2799fa8f8c319afbcead8ced3e
data/CHANGELOG CHANGED
@@ -1,11 +1,46 @@
1
+ 1.0.0 (2021-11-30)
2
+ ------------------
3
+
4
+ * Add support for ruby 3.0
5
+ As side effect now Recorder#record_status receive additional parameter
6
+
7
+ 0.5.2 (2020-01-06)
8
+ ------------------
9
+
10
+ * If listed in allowed_exitstatus, log exit code as Info, not as Error
11
+ (bsc#1153749)
12
+ * Added support for ruby 2.7
13
+
14
+ 0.5.1 (2019-10-16)
15
+ ------------------
16
+
17
+ * Implement closing open fds after call to fork (bsc#1151960). This will work
18
+ only in linux system with mounted /proc. For other Unixes it works as before.
19
+ * drop support for ruby that is EOL (2.3 and lower)
20
+ * Added support for ruby 2.4, 2.5, 2.6
21
+
22
+ 0.5.0 (2015-12-18)
23
+ ------------------
24
+
25
+ * Added chroot option for executing in different system root.
26
+ * Added ENV overwrite option.
27
+ * Allowed to specify known exit codes that are not errors.
28
+ * Documented how to execute in different working directory.
29
+ * Allowed passing nil as :stdin to be same as :stdout and :strerr.
30
+ * Converted parameters for command to strings with `.to_s`.
31
+ * Adapted testsuite to new rspec.
32
+ * Updated documentation with various fixes.
33
+ * Dropped support for Ruby 1.9.3.
34
+ * Added support for Ruby 2.1 and 2.2.
35
+
1
36
  0.4.0 (2013-11-21)
2
37
  ------------------
3
38
 
4
39
  * Implemented incremental logging. The input and both outputs of the executed
5
40
  command are now logged one-by-line by the default recorder. A custom recorder
6
41
  can record them on even finer granularity.
7
- * Dropped support of Ruby 1.8.7.
8
- * Added support of Ruby 2.0.0.
42
+ * Dropped support for Ruby 1.8.7.
43
+ * Added support for Ruby 2.0.0.
9
44
  * Internal code improvements.
10
45
 
11
46
  0.3.0 (2012-06-21)
data/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  Cheetah
2
2
  =======
3
+ [![Travis Build](https://travis-ci.org/openSUSE/cheetah.svg?branch=master)](https://travis-ci.org/openSUSE/cheetah)
4
+ [![Code Climate](https://codeclimate.com/github/openSUSE/cheetah/badges/gpa.svg)](https://codeclimate.com/github/openSUSE/cheetah)
5
+ [![Coverage Status](https://img.shields.io/coveralls/openSUSE/cheetah.svg)](https://coveralls.io/r/openSUSE/cheetah?branch=master)
6
+
3
7
 
4
8
  Your swiss army knife for executing external commands in Ruby safely and
5
9
  conveniently.
@@ -9,11 +13,11 @@ Examples
9
13
 
10
14
  ```ruby
11
15
  # Run a command and capture its output
12
- files = Cheetah.run("ls", "-la", :stdout => :capture)
16
+ files = Cheetah.run("ls", "-la", stdout: :capture)
13
17
 
14
18
  # Run a command and capture its output into a stream
15
19
  File.open("files.txt", "w") do |stdout|
16
- Cheetah.run("ls", "-la", :stdout => stdout)
20
+ Cheetah.run("ls", "-la", stdout: stdout)
17
21
  end
18
22
 
19
23
  # Run a command and handle errors
@@ -22,7 +26,7 @@ begin
22
26
  rescue Cheetah::ExecutionFailed => e
23
27
  puts e.message
24
28
  puts "Standard output: #{e.stdout}"
25
- puts "Error ouptut: #{e.stderr}"
29
+ puts "Error output: #{e.stderr}"
26
30
  end
27
31
  ```
28
32
 
@@ -34,7 +38,11 @@ Features
34
38
  * Piping commands together
35
39
  * 100% secure (shell expansion is impossible by design)
36
40
  * Raises exceptions on errors (no more manual status code checks)
41
+ but allows to specify which non-zero codes are not an error
42
+ * Thread-safety
43
+ * Allows overriding environment variables
37
44
  * Optional logging for easy debugging
45
+ * Running on changed root ( requires chroot permission )
38
46
 
39
47
  Non-features
40
48
  ------------
@@ -63,13 +71,16 @@ To run a command, just specify it together with its arguments:
63
71
 
64
72
  ```ruby
65
73
  Cheetah.run("tar", "xzf", "foo.tar.gz")
74
+
75
+ Cheetah converts each argument to a string using `#to_s`.
76
+
66
77
  ```
67
78
  ### Passing Input
68
79
 
69
80
  Using the `:stdin` option you can pass a string to command's standard input:
70
81
 
71
82
  ```ruby
72
- Cheetah.run("python", :stdin => source_code)
83
+ Cheetah.run("python", stdin: source_code)
73
84
  ```
74
85
 
75
86
  If the input is big you may want to avoid passing it in one huge string. In that
@@ -78,7 +89,7 @@ input from it gradually.
78
89
 
79
90
  ```ruby
80
91
  File.open("huge_program.py") do |stdin|
81
- Cheetah.run("python", :stdin => stdin)
92
+ Cheetah.run("python", stdin: stdin)
82
93
  end
83
94
  ```
84
95
 
@@ -88,7 +99,7 @@ To capture command's standard output, set the `:stdout` option to `:capture`.
88
99
  You will receive the output as a return value of the call:
89
100
 
90
101
  ```ruby
91
- files = Cheetah.run("ls", "-la", :stdout => :capture)
102
+ files = Cheetah.run("ls", "-la", stdout: :capture)
92
103
  ```
93
104
 
94
105
  The same technique works with the error output — just use the `:stderr` option.
@@ -96,7 +107,7 @@ If you specify capturing of both outputs, the return value will be a two-element
96
107
  array:
97
108
 
98
109
  ```ruby
99
- results, errors = Cheetah.run("grep", "-r", "User", ".", :stdout => :capture, :stderr => :capture)
110
+ results, errors = Cheetah.run("grep", "-r", "User", ".", stdout: => :capture, stderr: => :capture)
100
111
  ```
101
112
 
102
113
  If the output is big you may want to avoid capturing it into a huge string. In
@@ -105,7 +116,7 @@ command will write its output into it gradually.
105
116
 
106
117
  ```ruby
107
118
  File.open("files.txt", "w") do |stdout|
108
- Cheetah.run("ls", "-la", :stdout => stdout)
119
+ Cheetah.run("ls", "-la", stdout: stdout)
109
120
  end
110
121
  ```
111
122
 
@@ -115,12 +126,12 @@ You can pipe multiple commands together and execute them as one. Just specify
115
126
  the commands together with their arguments as arrays:
116
127
 
117
128
  ```ruby
118
- processes = Cheetah.run(["ps", "aux"], ["grep", "ruby"], :stdout => :capture)
129
+ processes = Cheetah.run(["ps", "aux"], ["grep", "ruby"], stdout: :capture)
119
130
  ```
120
131
 
121
132
  ### Error Handling
122
133
 
123
- If the command can't be executed for some reason or returns a non-zero exit
134
+ If the command can't be executed for some reason or returns an unexpected non-zero exit
124
135
  status, Cheetah raises an exception with detailed information about the failure:
125
136
 
126
137
  ```ruby
@@ -130,7 +141,8 @@ begin
130
141
  rescue Cheetah::ExecutionFailed => e
131
142
  puts e.message
132
143
  puts "Standard output: #{e.stdout}"
133
- puts "Error ouptut: #{e.stderr}"
144
+ puts "Error output: #{e.stderr}"
145
+ puts "Exit status: #{e.status.exitstatus}"
134
146
  end
135
147
  ```
136
148
  ### Logging
@@ -139,7 +151,55 @@ For debugging purposes, you can use a logger. Cheetah will log the command, its
139
151
  status, input and both outputs to it:
140
152
 
141
153
  ```ruby
142
- Cheetah.run("ls -l", :logger => logger)
154
+ Cheetah.run("ls -l", logger: logger)
155
+ ```
156
+
157
+ ### Overwriting env
158
+
159
+ If the command needs adapted environment variables, use the :env option.
160
+ Passed hash is used to update existing env (for details see ENV.update).
161
+ Nil value means unset variable. Environment is restored to its original state after
162
+ running the command.
163
+
164
+ ```ruby
165
+ Cheetah.run("env", env: { "LC_ALL" => "C" })
166
+ ```
167
+
168
+ ### Expecting Non-zero Exit Status
169
+
170
+ If command is expected to return valid a non-zero exit status like `grep` command
171
+ which return `1` if given regexp is not found, then option `:allowed_exitstatus`
172
+ can be used:
173
+
174
+ ```ruby
175
+ # Run a command, handle exitstatus and handle errors
176
+ begin
177
+ exitstatus = Cheetah.run("grep", "userA", "/etc/passwd", allowed_exitstatus: 1)
178
+ if exitstates == 0
179
+ puts "found"
180
+ else
181
+ puts "not found"
182
+ end
183
+ rescue Cheetah::ExecutionFailed => e
184
+ puts e.message
185
+ puts "Standard output: #{e.stdout}"
186
+ puts "Error output: #{e.stderr}"
187
+ puts "Exit status: #{e.status.exitstatus}"
188
+ end
189
+ ```
190
+
191
+ Exit status is returned as last element of result. If it is only captured thing,
192
+ then it is return without array.
193
+ Supported input for `allowed_exitstatus` are anything supporting include, fixnum
194
+ or nil for no allowed existatus.
195
+
196
+ ```ruby
197
+ # allowed inputs
198
+ allowed_exitstatus: 1
199
+ allowed_exitstatus: 1..5
200
+ allowed_exitstatus: [1, 2]
201
+ allowed_exitstatus: object_with_include_method
202
+ allowed_exitstatus: nil
143
203
  ```
144
204
 
145
205
  ### Setting Defaults
@@ -149,13 +209,33 @@ To avoid repetition, you can set global default value of any option passed too
149
209
 
150
210
  ```ruby
151
211
  # If you're tired of passing the :logger option all the time...
152
- Cheetah.default_options = { :logger = my_logger }
212
+ Cheetah.default_options = { :logger => my_logger }
153
213
  Cheetah.run("./configure")
154
214
  Cheetah.run("make")
155
215
  Cheetah.run("make", "install")
156
216
  Cheetah.default_options = {}
157
217
  ```
158
218
 
219
+ ### Changing Working Directory
220
+
221
+ If diferent working directory is needed for running program, then suggested
222
+ usage is to enclose call into `Dir.chdir` method.
223
+
224
+ ```ruby
225
+ Dir.chdir("/workspace") do
226
+ Cheetah.run("make")
227
+ end
228
+ ```
229
+
230
+ ### Changing System Root
231
+
232
+ If a command needs to be executed in different system root then the `:chroot`
233
+ option can be used:
234
+
235
+ ```ruby
236
+ Cheetah.run("/usr/bin/inspect", chroot: "/mnt/target_system")
237
+ ```
238
+
159
239
  ### More Information
160
240
 
161
241
  For more information, see the
@@ -164,7 +244,7 @@ For more information, see the
164
244
  Compatibility
165
245
  -------------
166
246
 
167
- Cheetah should run well on any Unix system with Ruby 1.9.3 or 2.0.0. Non-Unix
247
+ Cheetah should run well on any Unix system with Ruby 2.0.0, 2.1 and 2.2. Non-Unix
168
248
  systems and different Ruby implementations/versions may work too but they were
169
249
  not tested.
170
250
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 1.0.0
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cheetah namespace
1
4
  module Cheetah
2
5
  # Cheetah version (uses [semantic versioning](http://semver.org/)).
3
- VERSION = File.read(File.dirname(__FILE__) + "/../../VERSION").strip
6
+ VERSION = File.read("#{File.dirname(__FILE__)}/../../VERSION").strip
4
7
  end
data/lib/cheetah.rb CHANGED
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "abstract_method"
2
4
  require "logger"
3
5
  require "shellwords"
4
6
  require "stringio"
5
7
 
6
- require File.expand_path(File.dirname(__FILE__) + "/cheetah/version")
8
+ require File.expand_path("#{File.dirname(__FILE__)}/cheetah/version")
7
9
 
8
10
  # Your swiss army knife for executing external commands in Ruby safely and
9
11
  # conveniently.
@@ -22,11 +24,11 @@ require File.expand_path(File.dirname(__FILE__) + "/cheetah/version")
22
24
  # * Handling of interactive commands
23
25
  #
24
26
  # @example Run a command and capture its output
25
- # files = Cheetah.run("ls", "-la", :stdout => :capture)
27
+ # files = Cheetah.run("ls", "-la", stdout: :capture)
26
28
  #
27
29
  # @example Run a command and capture its output into a stream
28
30
  # File.open("files.txt", "w") do |stdout|
29
- # Cheetah.run("ls", "-la", :stdout => stdout)
31
+ # Cheetah.run("ls", "-la", stdout: stdout)
30
32
  # end
31
33
  #
32
34
  # @example Run a command and handle errors
@@ -119,17 +121,22 @@ module Cheetah
119
121
  #
120
122
  # @abstract
121
123
  # @param [Process::Status] status the executed command exit status
124
+ # @param [Boolean] allowed_status whether the exit code is in the list of allowed exit codes
122
125
  abstract_method :record_status
123
126
  end
124
127
 
125
128
  # A recorder that does not record anyting. Used by {Cheetah.run} when no
126
129
  # logger is passed.
127
130
  class NullRecorder < Recorder
128
- def record_commands(commands); end
129
- def record_stdin(stdin); end
130
- def record_stdout(stdout); end
131
- def record_stderr(stderr); end
132
- def record_status(status); end
131
+ def record_commands(_commands); end
132
+
133
+ def record_stdin(_stdin); end
134
+
135
+ def record_stdout(_stdout); end
136
+
137
+ def record_stderr(_stderr); end
138
+
139
+ def record_status(_status, _allowed_status); end
133
140
  end
134
141
 
135
142
  # A default recorder. It uses the `Logger::INFO` level for normal messages and
@@ -138,16 +145,18 @@ module Cheetah
138
145
  class DefaultRecorder < Recorder
139
146
  # @private
140
147
  STREAM_INFO = {
141
- :stdin => { :name => "Standard input", :method => :info },
142
- :stdout => { :name => "Standard output", :method => :info },
143
- :stderr => { :name => "Error output", :method => :error }
144
- }
148
+ stdin: { name: "Standard input", method: :info },
149
+ stdout: { name: "Standard output", method: :info },
150
+ stderr: { name: "Error output", method: :error }
151
+ }.freeze
145
152
 
146
153
  def initialize(logger)
154
+ super()
155
+
147
156
  @logger = logger
148
157
 
149
- @stream_used = { :stdin => false, :stdout => false, :stderr => false }
150
- @stream_buffer = { :stdin => "", :stdout => "", :stderr => "" }
158
+ @stream_used = { stdin: false, stdout: false, stderr: false }
159
+ @stream_buffer = { stdin: +"", stdout: +"", stderr: +"" }
151
160
  end
152
161
 
153
162
  def record_commands(commands)
@@ -166,24 +175,25 @@ module Cheetah
166
175
  log_stream_increment(:stderr, stderr)
167
176
  end
168
177
 
169
- def record_status(status)
178
+ def record_status(status, allowed_status)
170
179
  log_stream_remainder(:stdin)
171
180
  log_stream_remainder(:stdout)
172
181
  log_stream_remainder(:stderr)
173
182
 
174
- @logger.send status.success? ? :info : :error,
175
- "Status: #{status.exitstatus}"
183
+ @logger.send allowed_status ? :info : :error,
184
+ "Status: #{status.exitstatus}"
176
185
  end
177
186
 
178
187
  protected
179
188
 
180
189
  def format_commands(commands)
181
- '"' + commands.map { |c| Shellwords.join(c) }.join(" | ") + '"'
190
+ "\"#{commands.map { |c| Shellwords.join(c) }.join(' | ')}\""
182
191
  end
183
192
 
184
193
  def log_stream_increment(stream, data)
185
194
  @stream_buffer[stream] + data =~ /\A((?:.*\n)*)(.*)\z/
186
- lines, rest = $1, $2
195
+ lines = Regexp.last_match(1)
196
+ rest = Regexp.last_match(2)
187
197
 
188
198
  lines.each_line { |l| log_stream_line(stream, l) }
189
199
 
@@ -192,9 +202,9 @@ module Cheetah
192
202
  end
193
203
 
194
204
  def log_stream_remainder(stream)
195
- if @stream_used[stream] && !@stream_buffer[stream].empty?
196
- log_stream_line(stream, @stream_buffer[stream])
197
- end
205
+ return if !@stream_used[stream] || @stream_buffer[stream].empty?
206
+
207
+ log_stream_line(stream, @stream_buffer[stream])
198
208
  end
199
209
 
200
210
  def log_stream_line(stream, line)
@@ -207,11 +217,13 @@ module Cheetah
207
217
 
208
218
  # @private
209
219
  BUILTIN_DEFAULT_OPTIONS = {
210
- :stdin => "",
211
- :stdout => nil,
212
- :stderr => nil,
213
- :logger => nil
214
- }
220
+ stdin: "",
221
+ stdout: nil,
222
+ stderr: nil,
223
+ logger: nil,
224
+ env: {},
225
+ chroot: "/"
226
+ }.freeze
215
227
 
216
228
  READ = 0 # @private
217
229
  WRITE = 1 # @private
@@ -225,7 +237,7 @@ module Cheetah
225
237
  # By default, no values are specified here.
226
238
  #
227
239
  # @example Setting a logger once for execution of multiple commands
228
- # Cheetah.default_options = { :logger = my_logger }
240
+ # Cheetah.default_options = { logger: my_logger }
229
241
  # Cheetah.run("./configure")
230
242
  # Cheetah.run("make")
231
243
  # Cheetah.run("make", "install")
@@ -244,7 +256,7 @@ module Cheetah
244
256
  # multiple command case, the execution succeeds if the last command can be
245
257
  # executed and returns a zero exit status.)
246
258
  #
247
- # Commands and their arguments never undergo shell expansion they are
259
+ # Commands and their arguments never undergo shell expansion - they are
248
260
  # passed directly to the operating system. While this may create some
249
261
  # inconvenience in certain cases, it eliminates a whole class of security
250
262
  # bugs.
@@ -296,6 +308,14 @@ module Cheetah
296
308
  # execution
297
309
  # @option options [Recorder, nil] :recorder (DefaultRecorder.new) recorder
298
310
  # to handle the command execution logging
311
+ # @option options [Fixnum, .include?, nil] :allowed_exitstatus (nil)
312
+ # Allows to specify allowed exit codes that do not cause exception. It
313
+ # adds as last element of result exitstatus.
314
+ # @option options [Hash] :env ({})
315
+ # Allows to update ENV for the time of running the command. if key maps to nil value it
316
+ # is deleted from ENV.
317
+ # @option options [String] :chroot ("/")
318
+ # Allows to run on different system root.
299
319
  #
300
320
  # @example
301
321
  # Cheetah.run("tar", "xzf", "foo.tar.gz")
@@ -325,16 +345,16 @@ module Cheetah
325
345
  # in the first variant
326
346
  #
327
347
  # @example
328
- # processes = Cheetah.run(["ps", "aux"], ["grep", "ruby"], :stdout => :capture)
348
+ # processes = Cheetah.run(["ps", "aux"], ["grep", "ruby"], stdout: :capture)
329
349
  #
330
350
  # @raise [ExecutionFailed] when the execution fails
331
351
  #
332
352
  # @example Run a command and capture its output
333
- # files = Cheetah.run("ls", "-la", :stdout => capture)
353
+ # files = Cheetah.run("ls", "-la", stdout: :capture)
334
354
  #
335
355
  # @example Run a command and capture its output into a stream
336
356
  # File.open("files.txt", "w") do |stdout|
337
- # Cheetah.run("ls", "-la", :stdout => stdout)
357
+ # Cheetah.run("ls", "-la", stdout: stdout)
338
358
  # end
339
359
  #
340
360
  # @example Run a command and handle errors
@@ -345,10 +365,35 @@ module Cheetah
345
365
  # puts "Standard output: #{e.stdout}"
346
366
  # puts "Error ouptut: #{e.stderr}"
347
367
  # end
368
+ #
369
+ # @example Run a command with expected false and handle errors
370
+ # begin
371
+ # # exit code 1 for grep mean not found
372
+ # result = Cheetah.run("grep", "userA", "/etc/passwd", allowed_exitstatus: 1)
373
+ # if result == 0
374
+ # puts "found"
375
+ # else
376
+ # puts "not found"
377
+ # end
378
+ # rescue Cheetah::ExecutionFailed => e
379
+ # puts e.message
380
+ # puts "Standard output: #{e.stdout}"
381
+ # puts "Error ouptut: #{e.stderr}"
382
+ # end
383
+ #
384
+ # @example more complex example with allowed_exitstatus
385
+ # stdout, exitcode = Cheetah.run("cmd", stdout: :capture, allowed_exitstatus: 1..5)
386
+ #
387
+
348
388
  def run(*args)
349
389
  options = args.last.is_a?(Hash) ? args.pop : {}
350
390
  options = BUILTIN_DEFAULT_OPTIONS.merge(@default_options).merge(options)
351
391
 
392
+ options[:stdin] ||= "" # allow passing nil stdin see issue gh#11
393
+ if !options[:allowed_exitstatus].respond_to?(:include?)
394
+ options[:allowed_exitstatus] = Array(options[:allowed_exitstatus])
395
+ end
396
+
352
397
  streamed = compute_streamed(options)
353
398
  streams = build_streams(options, streamed)
354
399
  commands = build_commands(args)
@@ -356,39 +401,63 @@ module Cheetah
356
401
 
357
402
  recorder.record_commands(commands)
358
403
 
359
- pid, pipes = fork_commands(commands)
404
+ pid, pipes = fork_commands(commands, options)
360
405
  select_loop(streams, pipes, recorder)
361
- pid, status = Process.wait2(pid)
406
+ _pid, status = Process.wait2(pid)
407
+
408
+ # when more exit status are allowed, then pass it below that it did
409
+ # not fail (bsc#1153749)
410
+ success = allowed_status?(status, options)
362
411
 
363
412
  begin
364
- check_errors(commands, status, streams, streamed)
413
+ report_errors(commands, status, streams, streamed) if !success
365
414
  ensure
366
- recorder.record_status(status)
415
+ # backward compatibility for recorders with just single parameter
416
+ if recorder.method(:record_status).arity == 1
417
+ recorder.record_status(status)
418
+ else
419
+ recorder.record_status(status, success)
420
+ end
367
421
  end
368
422
 
369
- build_result(streams, options)
423
+ build_result(streams, status, options)
370
424
  end
371
425
 
372
426
  private
373
427
 
428
+ def allowed_status?(status, options)
429
+ exit_status = status.exitstatus
430
+ return exit_status.zero? unless allowed_exitstatus?(options)
431
+
432
+ options[:allowed_exitstatus].include?(exit_status)
433
+ end
434
+
374
435
  # Parts of Cheetah.run
375
436
 
437
+ def with_env(env, &block)
438
+ old_env = ENV.to_hash
439
+ ENV.update(env)
440
+ block.call
441
+ ensure
442
+ ENV.replace(old_env)
443
+ end
444
+
376
445
  def compute_streamed(options)
377
446
  # The assumption for :stdout and :stderr is that anything except :capture
378
447
  # and nil is an IO-like object. We avoid detecting it directly to allow
379
448
  # passing StringIO, mocks, etc.
380
449
  {
381
- :stdin => !options[:stdin].is_a?(String),
382
- :stdout => ![nil, :capture].include?(options[:stdout]),
383
- :stderr => ![nil, :capture].include?(options[:stderr])
450
+ stdin: !options[:stdin].is_a?(String),
451
+ stdout: ![nil, :capture].include?(options[:stdout]),
452
+ stderr: ![nil, :capture].include?(options[:stderr])
384
453
  }
385
454
  end
386
455
 
387
456
  def build_streams(options, streamed)
388
457
  {
389
- :stdin => streamed[:stdin] ? options[:stdin] : StringIO.new(options[:stdin]),
390
- :stdout => streamed[:stdout] ? options[:stdout] : StringIO.new(""),
391
- :stderr => streamed[:stderr] ? options[:stderr] : StringIO.new("")
458
+ stdin: streamed[:stdin] ? options[:stdin] : StringIO.new(options[:stdin]),
459
+ stdout: streamed[:stdout] ? options[:stdout] : StringIO.new(+""),
460
+ stderr: streamed[:stderr] ? options[:stderr] : StringIO.new(+"")
392
461
  }
393
462
  end
394
463
 
@@ -410,7 +479,8 @@ module Cheetah
410
479
  # The following code ensures that the result consistently (in all three
411
480
  # cases) contains an array of arrays specifying commands and their
412
481
  # arguments.
413
- args.all? { |a| a.is_a?(Array) } ? args : [args]
482
+ commands = args.all? { |a| a.is_a?(Array) } ? args : [args]
483
+ commands.map { |c| c.map(&:to_s) }
414
484
  end
415
485
 
416
486
  def build_recorder(options)
@@ -421,54 +491,106 @@ module Cheetah
421
491
  end
422
492
  end
423
493
 
424
- def fork_commands_recursive(commands, pipes)
425
- fork do
426
- begin
427
- if commands.size == 1
428
- pipes[:stdin][WRITE].close
429
- STDIN.reopen(pipes[:stdin][READ])
430
- pipes[:stdin][READ].close
431
- else
432
- pipe_to_child = IO.pipe
433
-
434
- fork_commands_recursive(commands[0..-2], {
435
- :stdin => pipes[:stdin],
436
- :stdout => pipe_to_child,
437
- :stderr => pipes[:stderr]
438
- })
439
-
440
- pipes[:stdin][READ].close
441
- pipes[:stdin][WRITE].close
442
-
443
- pipe_to_child[WRITE].close
444
- STDIN.reopen(pipe_to_child[READ])
445
- pipe_to_child[READ].close
446
- end
494
+ # Reopen *stream* to write **into** the writing half of *pipe*
495
+ # and close the reading half of *pipe*.
496
+ # @param pipe [Array<IO>] a pair of IOs as returned from IO.pipe
497
+ # @param stream [IO]
498
+ def into_pipe(stream, pipe)
499
+ stream.reopen(pipe[WRITE])
500
+ pipe[WRITE].close
501
+ pipe[READ].close
502
+ end
503
+
504
+ # Reopen *stream* to read **from** the reading half of *pipe*
505
+ # and close the writing half of *pipe*.
506
+ # @param pipe [Array<IO>] a pair of IOs as returned from IO.pipe
507
+ # @param stream [IO]
508
+ def from_pipe(stream, pipe)
509
+ stream.reopen(pipe[READ])
510
+ pipe[READ].close
511
+ pipe[WRITE].close
512
+ end
513
+
514
+ def chroot_step(options)
515
+ return options if [nil, "/"].include?(options[:chroot])
447
516
 
448
- pipes[:stdout][READ].close
449
- STDOUT.reopen(pipes[:stdout][WRITE])
450
- pipes[:stdout][WRITE].close
517
+ options = options.dup
518
+ # delete chroot option otherwise in pipe will chroot each fork recursively
519
+ root = options.delete(:chroot)
520
+ Dir.chroot(root)
521
+ # curdir can be outside chroot which is considered as security problem
522
+ Dir.chdir("/")
451
523
 
452
- pipes[:stderr][READ].close
453
- STDERR.reopen(pipes[:stderr][WRITE])
454
- pipes[:stderr][WRITE].close
524
+ options
525
+ end
526
+
527
+ def fork_commands_recursive(commands, pipes, options)
528
+ fork do
529
+ # support chrooting
530
+ options = chroot_step(options)
531
+
532
+ if commands.size == 1
533
+ from_pipe($stdin, pipes[:stdin])
534
+ else
535
+ pipe_to_child = IO.pipe
536
+
537
+ fork_commands_recursive(commands[0..-2],
538
+ {
539
+ stdin: pipes[:stdin],
540
+ stdout: pipe_to_child,
541
+ stderr: pipes[:stderr]
542
+ },
543
+ options)
544
+
545
+ pipes[:stdin][READ].close
546
+ pipes[:stdin][WRITE].close
547
+
548
+ from_pipe($stdin, pipe_to_child)
549
+ end
455
550
 
456
- # All file descriptors from 3 above should be closed here, but since I
457
- # don't know about any way how to detect the maximum file descriptor
458
- # number portably in Ruby, I didn't implement it. Patches welcome.
551
+ into_pipe($stdout, pipes[:stdout])
552
+ into_pipe($stderr, pipes[:stderr])
459
553
 
460
- command, *args = commands.last
554
+ close_fds
555
+
556
+ command, *args = commands.last
557
+ with_env(options[:env]) do
461
558
  exec([command, command], *args)
462
- rescue SystemCallError => e
463
- exit!(127)
464
559
  end
560
+ rescue SystemCallError => e
561
+ # depends when failed, if pipe is already redirected or not, so lets find it
562
+ output = pipes[:stderr][WRITE].closed? ? $stderr : pipes[:stderr][WRITE]
563
+ output.puts e.message
564
+
565
+ exit!(127)
465
566
  end
466
567
  end
467
568
 
468
- def fork_commands(commands)
469
- pipes = { :stdin => IO.pipe, :stdout => IO.pipe, :stderr => IO.pipe }
569
+ # closes all open fds starting with 3 and above
570
+ def close_fds
571
+ # NOTE: this will work only if unix has /proc filesystem. If it does not
572
+ # have it, it won't close other fds.
573
+ Dir.glob("/proc/self/fd/*").each do |path|
574
+ fd = File.basename(path).to_i
575
+ next if (0..2).include?(fd)
470
576
 
471
- pid = fork_commands_recursive(commands, pipes)
577
+ # here we intentionally ignore some failures when fd close failed
578
+ # rubocop:disable Lint/SuppressedException
579
+ begin
580
+ IO.new(fd).close
581
+ # Ruby reserves some fds for its VM and it result in this exception
582
+ rescue ArgumentError
583
+ # Ignore if close failed with invalid FD
584
+ rescue Errno::EBADF
585
+ end
586
+ # rubocop:enable Lint/SuppressedException
587
+ end
588
+ end
589
+
590
+ def fork_commands(commands, options)
591
+ pipes = { stdin: IO.pipe, stdout: IO.pipe, stderr: IO.pipe }
592
+
593
+ pid = fork_commands_recursive(commands, pipes, options)
472
594
 
473
595
  [
474
596
  pipes[:stdin][READ],
@@ -508,11 +630,9 @@ module Cheetah
508
630
  break if pipes_readable.empty? && pipes_writable.empty?
509
631
 
510
632
  ios_read, ios_write, ios_error = select(pipes_readable, pipes_writable,
511
- pipes_readable + pipes_writable)
633
+ pipes_readable + pipes_writable)
512
634
 
513
- if !ios_error.empty?
514
- raise IOError, "Error when communicating with executed program."
515
- end
635
+ raise IOError, "Error when communicating with executed program." if !ios_error.empty?
516
636
 
517
637
  ios_read.each do |pipe|
518
638
  begin
@@ -540,46 +660,61 @@ module Cheetah
540
660
  end
541
661
  end
542
662
 
543
- def check_errors(commands, status, streams, streamed)
663
+ def report_errors(commands, status, streams, streamed)
544
664
  return if status.success?
545
665
 
546
666
  stderr_part = if streamed[:stderr]
547
- " (error output streamed away)"
548
- elsif streams[:stderr].string.empty?
549
- " (no error output)"
550
- else
551
- lines = streams[:stderr].string.split("\n")
552
- ": " + lines.first + (lines.size > 1 ? " (...)" : "")
553
- end
667
+ " (error output streamed away)"
668
+ elsif streams[:stderr].string.empty?
669
+ " (no error output)"
670
+ else
671
+ lines = streams[:stderr].string.split("\n")
672
+ ": #{lines.first}#{lines.size > 1 ? ' (...)' : ''}"
673
+ end
554
674
 
555
675
  raise ExecutionFailed.new(
556
676
  commands,
557
677
  status,
558
678
  streamed[:stdout] ? nil : streams[:stdout].string,
559
679
  streamed[:stderr] ? nil : streams[:stderr].string,
560
- "Execution of #{format_commands(commands)} " +
680
+ "Execution of #{format_commands(commands)} " \
561
681
  "failed with status #{status.exitstatus}#{stderr_part}."
562
682
  )
563
683
  end
564
684
 
565
- def build_result(streams, options)
566
- case [options[:stdout] == :capture, options[:stderr] == :capture]
567
- when [false, false]
568
- nil
569
- when [true, false]
570
- streams[:stdout].string
571
- when [false, true]
572
- streams[:stderr].string
573
- when [true, true]
574
- [streams[:stdout].string, streams[:stderr].string]
685
+ def build_result(streams, status, options)
686
+ res = case [options[:stdout] == :capture, options[:stderr] == :capture]
687
+ when [false, false]
688
+ nil
689
+ when [true, false]
690
+ streams[:stdout].string
691
+ when [false, true]
692
+ streams[:stderr].string
693
+ when [true, true]
694
+ [streams[:stdout].string, streams[:stderr].string]
695
+ end
696
+
697
+ if allowed_exitstatus?(options)
698
+ if res.nil?
699
+ res = status.exitstatus
700
+ else
701
+ res = Array(res)
702
+ res << status.exitstatus
703
+ end
575
704
  end
705
+
706
+ res
707
+ end
708
+
709
+ def allowed_exitstatus?(options)
710
+ # more exit status allowed for non array or non empty array
711
+ !options[:allowed_exitstatus].is_a?(Array) || !options[:allowed_exitstatus].empty?
576
712
  end
577
713
 
578
714
  def format_commands(commands)
579
- '"' + commands.map { |c| Shellwords.join(c) }.join(" | ") + '"'
715
+ "\"#{commands.map { |c| Shellwords.join(c) }.join(' | ')}\""
580
716
  end
581
717
  end
582
718
 
583
719
  self.default_options = {}
584
720
  end
585
-
metadata CHANGED
@@ -1,80 +1,57 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cheetah
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
5
- prerelease:
4
+ version: 1.0.0
6
5
  platform: ruby
7
6
  authors:
8
7
  - David Majda
9
8
  autorequire:
10
9
  bindir: bin
11
10
  cert_chain: []
12
- date: 2013-11-21 00:00:00.000000000 Z
11
+ date: 2021-12-01 00:00:00.000000000 Z
13
12
  dependencies:
14
13
  - !ruby/object:Gem::Dependency
15
14
  name: abstract_method
16
15
  requirement: !ruby/object:Gem::Requirement
17
- none: false
18
16
  requirements:
19
- - - ~>
17
+ - - "~>"
20
18
  - !ruby/object:Gem::Version
21
19
  version: '1.2'
22
20
  type: :runtime
23
21
  prerelease: false
24
22
  version_requirements: !ruby/object:Gem::Requirement
25
- none: false
26
23
  requirements:
27
- - - ~>
24
+ - - "~>"
28
25
  - !ruby/object:Gem::Version
29
26
  version: '1.2'
30
27
  - !ruby/object:Gem::Dependency
31
28
  name: rspec
32
29
  requirement: !ruby/object:Gem::Requirement
33
- none: false
34
30
  requirements:
35
- - - ! '>='
31
+ - - "~>"
36
32
  - !ruby/object:Gem::Version
37
- version: '0'
33
+ version: '3.3'
38
34
  type: :development
39
35
  prerelease: false
40
36
  version_requirements: !ruby/object:Gem::Requirement
41
- none: false
42
37
  requirements:
43
- - - ! '>='
38
+ - - "~>"
44
39
  - !ruby/object:Gem::Version
45
- version: '0'
46
- - !ruby/object:Gem::Dependency
47
- name: redcarpet
48
- requirement: !ruby/object:Gem::Requirement
49
- none: false
50
- requirements:
51
- - - ! '>='
52
- - !ruby/object:Gem::Version
53
- version: '0'
54
- type: :development
55
- prerelease: false
56
- version_requirements: !ruby/object:Gem::Requirement
57
- none: false
58
- requirements:
59
- - - ! '>='
60
- - !ruby/object:Gem::Version
61
- version: '0'
40
+ version: '3.3'
62
41
  - !ruby/object:Gem::Dependency
63
42
  name: yard
64
43
  requirement: !ruby/object:Gem::Requirement
65
- none: false
66
44
  requirements:
67
- - - ! '>='
45
+ - - ">="
68
46
  - !ruby/object:Gem::Version
69
- version: '0'
47
+ version: 0.9.11
70
48
  type: :development
71
49
  prerelease: false
72
50
  version_requirements: !ruby/object:Gem::Requirement
73
- none: false
74
51
  requirements:
75
- - - ! '>='
52
+ - - ">="
76
53
  - !ruby/object:Gem::Version
77
- version: '0'
54
+ version: 0.9.11
78
55
  description: Your swiss army knife for executing external commands in Ruby safely
79
56
  and conveniently.
80
57
  email: dmajda@suse.de
@@ -91,28 +68,26 @@ files:
91
68
  homepage: https://github.com/openSUSE/cheetah
92
69
  licenses:
93
70
  - MIT
71
+ metadata: {}
94
72
  post_install_message:
95
73
  rdoc_options: []
96
74
  require_paths:
97
75
  - lib
98
76
  required_ruby_version: !ruby/object:Gem::Requirement
99
- none: false
100
77
  requirements:
101
- - - ! '>='
78
+ - - ">="
102
79
  - !ruby/object:Gem::Version
103
- version: '0'
80
+ version: '2.5'
104
81
  required_rubygems_version: !ruby/object:Gem::Requirement
105
- none: false
106
82
  requirements:
107
- - - ! '>='
83
+ - - ">="
108
84
  - !ruby/object:Gem::Version
109
85
  version: '0'
110
86
  requirements: []
111
87
  rubyforge_project:
112
- rubygems_version: 1.8.23
88
+ rubygems_version: 2.7.6.3
113
89
  signing_key:
114
- specification_version: 3
90
+ specification_version: 4
115
91
  summary: Your swiss army knife for executing external commands in Ruby safely and
116
92
  conveniently.
117
93
  test_files: []
118
- has_rdoc: