bwrap 1.0.0 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -8,6 +8,12 @@ require_relative "library"
8
8
  #
9
9
  # @see Config::Features
10
10
  class Bwrap::Args::Features < Hash
11
+ # Requires are here so there is no extra trickiness with namespaces.
12
+ #
13
+ # Feature implementations are not meant to be used outside of this class anyway.
14
+ require_relative "features/binds_base"
15
+ require_relative "features/ruby_binds"
16
+
11
17
  include Bwrap::Output
12
18
 
13
19
  # Implementation for Bash feature set.
@@ -45,44 +51,6 @@ class Bwrap::Args::Features < Hash
45
51
  end
46
52
  end
47
53
 
48
- # Implementation for Ruby feature set.
49
- #
50
- # @api private
51
- class RubyBinds
52
- # Returns mounts needed by Ruby feature set.
53
- attr_reader :mounts
54
-
55
- # Bind system paths so scripts works inside sandbox.
56
- def sitedir_mounts
57
- mounts = []
58
- mounts << "--ro-bind" << RbConfig::CONFIG["sitedir"] << RbConfig::CONFIG["sitedir"]
59
- mounts << "--ro-bind" << RbConfig::CONFIG["rubyhdrdir"] << RbConfig::CONFIG["rubyhdrdir"]
60
- mounts << "--ro-bind" << RbConfig::CONFIG["rubylibdir"] << RbConfig::CONFIG["rubylibdir"]
61
- mounts << "--ro-bind" << RbConfig::CONFIG["vendordir"] << RbConfig::CONFIG["vendordir"]
62
-
63
- mounts
64
- end
65
-
66
- # Create binds for required system libraries.
67
- #
68
- # These are in path like /usr/lib64/ruby/2.5.0/x86_64-linux-gnu/,
69
- # and as they are mostly shared libraries, they may have some extra
70
- # dependencies that also need to be bound inside the sandbox.
71
- def stdlib_mounts stdlib
72
- library_mounts = []
73
- library = Bwrap::Args::Library.new
74
- stdlib.each do |lib|
75
- path = "#{RbConfig::CONFIG["rubyarchdir"]}/#{lib}.so"
76
-
77
- library.needed_libraries(path).each do |requisite_library|
78
- library_mounts << "--ro-bind" << requisite_library << requisite_library
79
- end
80
- end
81
-
82
- library_mounts
83
- end
84
- end
85
-
86
54
  # `Array` of parameters passed to bwrap.
87
55
  attr_writer :args
88
56
 
@@ -104,7 +72,7 @@ class Bwrap::Args::Features < Hash
104
72
 
105
73
  binds = BashBinds.new
106
74
 
107
- @args.append binds.bash_mounts
75
+ @args.add :feature_binds, binds.bash_mounts
108
76
  end
109
77
 
110
78
  private def nscd_binds
@@ -112,7 +80,7 @@ class Bwrap::Args::Features < Hash
112
80
 
113
81
  binds = NscdBinds.new
114
82
 
115
- @args.append binds.custom_binds
83
+ @args.add :feature_binds, binds.custom_binds
116
84
  end
117
85
 
118
86
  # @note This does not allow development headers needed for compilation for now.
@@ -120,9 +88,9 @@ class Bwrap::Args::Features < Hash
120
88
  private def ruby_binds
121
89
  return unless @config.features.ruby.enabled?
122
90
 
123
- binds = RubyBinds.new
91
+ binds = RubyBinds.new @config
124
92
 
125
- @args.append binds.sitedir_mounts
126
- @args.append binds.stdlib_mounts(@config.features.ruby.stdlib)
93
+ @args.add :feature_binds, binds.sitedir_mounts
94
+ @args.add :feature_binds, binds.stdlib_mounts(@config.features.ruby.stdlib)
127
95
  end
128
96
  end
@@ -66,8 +66,6 @@ class Bwrap::Args::Library
66
66
  @needed_libraries
67
67
  end
68
68
 
69
- # Used by {Bwrap::Args::Bind#libs_command_requires}.
70
- #
71
69
  # @param binary_paths [String, Array] one or more paths to be resolved
72
70
  def needed_libraries binary_paths
73
71
  trace "Finding libraries #{binary_paths} requires"
@@ -105,7 +103,7 @@ class Bwrap::Args::Library
105
103
  libraries = libraries_line.split ","
106
104
 
107
105
  (@needed_libraries & libraries).each do |library|
108
- verb "Binding #{library} as dependency of #{binary_path}"
106
+ debug "Binding #{library} as dependency of #{binary_path}"
109
107
  end
110
108
 
111
109
  @needed_libraries |= libraries
@@ -9,25 +9,25 @@ module Bwrap::Args::Mount
9
9
  return unless @config.root
10
10
 
11
11
  debug "Binding #{@config.root} as /"
12
- @args.append %W{ --bind #{@config.root} / }
13
- @args.append %w{ --chdir / }
12
+ @args.add :root_mount, "--bind", @config.root, "/"
13
+ @args.add :root_mount, "--chdir", "/"
14
14
  end
15
15
 
16
16
  # Arguments for mounting devtmpfs to /dev.
17
17
  private def dev_mount
18
18
  debug "Mounting new devtmpfs to /dev"
19
- @args.append %w{ --dev /dev }
19
+ @args.add :dev_mounts, "--dev", "/dev"
20
20
  end
21
21
 
22
22
  # Arguments for mounting procfs to /proc.
23
23
  private def proc_mount
24
24
  debug "Mounting new procfs to /proc"
25
- @args.append %w{ --proc /proc }
25
+ @args.add :proc_mount, "--proc", "/proc"
26
26
  end
27
27
 
28
28
  # Arguments for mounting tmpfs to /tmp.
29
29
  private def tmp_as_tmpfs
30
30
  debug "Mounting tmpfs to /tmp"
31
- @args.append %w{ --tmpfs /tmp }
31
+ @args.add :tmp_mount, "--tmpfs", "/tmp"
32
32
  end
33
33
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bwrap/output"
4
+ require_relative "args"
5
+
6
+ # Network related binds.
7
+ class Bwrap::Args::Network
8
+ include Bwrap::Output
9
+
10
+ # Instance of {Config}.
11
+ attr_writer :config
12
+
13
+ # @param args [Bwrap::Args::Args] Arguments to be passed to bwrap.
14
+ def initialize args
15
+ @args = args
16
+ end
17
+
18
+ # Arguments to set hostname to whatever is configured.
19
+ def hostname
20
+ return unless @config.hostname
21
+
22
+ debug "Setting hostname to #{@config.hostname}"
23
+ @args.add :hostname, %W{ --hostname #{@config.hostname} }
24
+ end
25
+
26
+ # Arguments to read-only bind /etc/resolv.conf.
27
+ def resolv_conf
28
+ # We can’t really bind symlinks, so let’s resolve real path to resolv.conf, in case it is symlinked.
29
+ source_resolv_conf = Pathname.new "/etc/resolv.conf"
30
+ source_resolv_conf = source_resolv_conf.realpath
31
+
32
+ debug "Binding #{source_resolv_conf} as /etc/resolv.conf"
33
+ @args.add :resolv_conf, %W{ --ro-bind #{source_resolv_conf} /etc/resolv.conf }
34
+ end
35
+
36
+ # Arguments to allow network connection inside sandbox.
37
+ def share_net
38
+ return unless @config.share_net
39
+
40
+ verb "Sharing network"
41
+ @args.add :network, %w{ --share-net }
42
+ end
43
+ end
data/lib/bwrap/bwrap.rb CHANGED
@@ -55,7 +55,9 @@ class Bwrap::Bwrap
55
55
  construct = Bwrap::Args::Construct.new
56
56
  construct.command = command
57
57
  construct.config = @config
58
- bwrap_args = construct.construct_bwrap_args
58
+
59
+ construct.calculate
60
+ bwrap_args = construct.bwrap_arguments
59
61
 
60
62
  exec_command = [ "bwrap" ]
61
63
  exec_command += bwrap_args
@@ -89,7 +91,9 @@ class Bwrap::Bwrap
89
91
  construct = Bwrap::Args::Construct.new
90
92
  construct.command = command
91
93
  construct.config = config
92
- bwrap_args = construct.construct_bwrap_args
94
+
95
+ construct.calculate
96
+ bwrap_args = construct.bwrap_arguments
93
97
 
94
98
  exec_command = [ "bwrap" ]
95
99
  exec_command += bwrap_args
@@ -134,6 +138,9 @@ class Bwrap::Bwrap
134
138
  opt :trace,
135
139
  "Show trace output (noisiest, probably not useful for most of time)",
136
140
  short: :none
141
+ opt [ :quiet, :silent ],
142
+ "Hides notice messages. Warnings and errors are still shown.",
143
+ short: :none
137
144
  opt :version,
138
145
  "Print version and exit",
139
146
  short: "V"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @abstract
4
+ #
5
+ # Base of all features.
6
+ class Bwrap::Config::Features::Base
7
+ # @param features [Bwrap::Config::Features] Instance of features object in {Config}
8
+ def initialize features
9
+ @features = features
10
+ end
11
+
12
+ # Checks if the feature has been enabled.
13
+ #
14
+ # @return [Boolean] whether feature is enabled
15
+ def enabled?
16
+ @enabled
17
+ end
18
+
19
+ # Enable the feature.
20
+ def enable
21
+ @enabled = true
22
+ end
23
+
24
+ # Disable the feature.
25
+ def disable
26
+ @enabled = false
27
+ end
28
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bwrap/execution"
4
+
5
+ # Defines Ruby feature set.
6
+ #
7
+ # Implies {Nscd} feature.
8
+ class Bwrap::Config::Features::Ruby < Bwrap::Config::Features::Base
9
+ include Bwrap::Execution
10
+
11
+ def initialize features
12
+ super features
13
+
14
+ @gem_env_paths = true
15
+ @stdlib = []
16
+ end
17
+
18
+ # @return true if bindirs from “gem environment” should be added to sandbox.
19
+ def gem_env_paths?
20
+ @gem_env_paths
21
+ end
22
+
23
+ # Enable Ruby feature set.
24
+ #
25
+ # Among others, binds `RbConfig::CONFIG["sitedir"]` so scripts works.
26
+ #
27
+ # @note This does not allow development headers needed for compilation for now.
28
+ # I’ll look at it after I have an use for it.
29
+ #
30
+ # @note Also enables {Nscd} feature.
31
+ def enable
32
+ super
33
+
34
+ @features.nscd.enable
35
+ end
36
+
37
+ # @return [Pathname|nil] path to Ruby interpreter.
38
+ def interpreter
39
+ @features.mime&.executable_path
40
+ end
41
+
42
+ # @return [Hash(String, String)] RbConfig::CONFIG from interpreter returned by {#interpreter}
43
+ def ruby_config
44
+ unless interpreter
45
+ raise "Interpreter is not set, so ruby_config() can’t be called yet."
46
+ end
47
+
48
+ return @ruby_config if @ruby_config
49
+
50
+ script = %q{RbConfig::CONFIG.each { |k, v| puts "#{k}=#{v}" }}
51
+ config_string = execvalue %W{ #{interpreter} -e #{script} }
52
+
53
+ ruby_config = {}
54
+ config_string.split("\n").each do |line|
55
+ key, value = line.split "=", 2
56
+ ruby_config[key] = value
57
+ end
58
+
59
+ @ruby_config = ruby_config
60
+ end
61
+
62
+ # Extra libraries to be loaded from script’s interpreter’s `RbConfig::CONFIG["rubyarchdir"]`.
63
+ #
64
+ # @note Existence of library paths are checked here, and not in {#stdlib=},
65
+ # to avoid error about missing interpreter, as it is set in {Bwrap::Bwrap#run},
66
+ # and config is meant to be created beforehand.
67
+ #
68
+ # @note This is only required to be called if extra dependencies are necessary.
69
+ # For example, psych.so requires libyaml.so.
70
+ #
71
+ # @return [Array] list of needed libraries.
72
+ def stdlib
73
+ # Just a little check to have error earlier.
74
+ @stdlib.each do |lib|
75
+ unless File.exist? "#{ruby_config["rubyarchdir"]}/#{lib}.so"
76
+ raise "Library “#{lib}” passed to Bwrap::Config::Ruby.stdlib= does not exist."
77
+ end
78
+ end
79
+
80
+ @stdlib
81
+ end
82
+
83
+ def stdlib= libs
84
+ @stdlib = libs
85
+ end
86
+ end
@@ -3,32 +3,14 @@
3
3
  class Bwrap::Config
4
4
  # Methods to enable or disable feature sets to control various aspects of sandboxing.
5
5
  class Features
6
- # @abstract
6
+ # Requires are here so there is no extra trickiness with namespaces.
7
7
  #
8
- # Base of all features.
9
- class Base
10
- # @param features [Bwrap::Config::Features] Instance of features object in {Config}
11
- def initialize features
12
- @features = features
13
- end
8
+ # Feature implementations are not meant to be used outside of this class anyway.
9
+ require_relative "features/base"
10
+ require_relative "features/ruby"
14
11
 
15
- # Checks if the feature has been enabled.
16
- #
17
- # @return [Boolean] whether feature is enabled
18
- def enabled?
19
- @enabled
20
- end
21
-
22
- # Enable the feature.
23
- def enable
24
- @enabled = true
25
- end
26
-
27
- # Disable the feature.
28
- def disable
29
- @enabled = false
30
- end
31
- end
12
+ # Instance of {Bwrap::Args::Bind::Mime}.
13
+ attr_accessor :mime
32
14
 
33
15
  # Defines Bash feature set.
34
16
  class Bash < Base
@@ -44,60 +26,6 @@ class Bwrap::Config
44
26
  # Nya.
45
27
  end
46
28
 
47
- # Defines Ruby feature set.
48
- #
49
- # Implies {Nscd} feature.
50
- class Ruby < Base
51
- # Extra libraries to be loaded from `RbConfig::CONFIG["rubyarchdir"]`.
52
- #
53
- # @note This is only required to be called if extra dependencies are necessary.
54
- # For example, psych.so requires libyaml.so.
55
- #
56
- # @return [Array] list of needed libraries.
57
- #
58
- # @overload stdlib
59
- # @overload stdlib=(libs)
60
- attr_reader :stdlib
61
-
62
- def initialize features
63
- super features
64
-
65
- @gem_env_paths = true
66
- @stdlib = []
67
- end
68
-
69
- # @return true if bindirs from “gem environment” should be added to sandbox.
70
- def gem_env_paths?
71
- @gem_env_paths
72
- end
73
-
74
- # Enable Ruby feature set.
75
- #
76
- # Among others, binds `RbConfig::CONFIG["sitedir"]` so scripts works.
77
- #
78
- # @note This does not allow development headers needed for compilation for now.
79
- # I’ll look at it after I have an use for it.
80
- #
81
- # @note Also enables {Nscd} feature.
82
- def enable
83
- super
84
-
85
- @features.nscd.enable
86
- end
87
-
88
- # @see #stdlib
89
- def stdlib= libs
90
- # Just a little check to have error earlier.
91
- libs.each do |lib|
92
- unless File.exist? "#{RbConfig::CONFIG["rubyarchdir"]}/#{lib}.so"
93
- raise "Library “#{lib}” passed to Bwrap::Config::Ruby.stdlib= does not exist."
94
- end
95
- end
96
-
97
- @stdlib = libs
98
- end
99
- end
100
-
101
29
  # @return [Bash] Instance of feature class for Bash
102
30
  def bash
103
31
  @bash ||= Bash.new self
@@ -7,6 +7,18 @@ module Bwrap::Execution
7
7
 
8
8
  # Signifies that command execution has failed.
9
9
  class ExecutionFailed < CommandError
10
+ # The command that was executed.
11
+ attr_reader :command
12
+
13
+ # Output of the command.
14
+ attr_reader :output
15
+
16
+ def initialize msg, command:, output:
17
+ @command = command
18
+ @output = output
19
+
20
+ super msg
21
+ end
10
22
  end
11
23
 
12
24
  # Thrown if given command was not found.
@@ -40,6 +40,13 @@ class Bwrap::Execution::Execute
40
40
 
41
41
  # @return formatted command.
42
42
  def self.format_command command, rootcmd:
43
+ # Flatten the command if required, so nils can be converted in more of cases.
44
+ # Flattenization is also done in executions, but they also take in account
45
+ # for example rootcmd, so they probably should be done in addition to this one.
46
+ if command.respond_to? :flatten!
47
+ command.flatten!
48
+ end
49
+
43
50
  replace_nils command
44
51
  return command if rootcmd.nil?
45
52
 
@@ -63,13 +70,17 @@ class Bwrap::Execution::Execute
63
70
  end
64
71
 
65
72
  # Checks whether execution failed and acts accordingly.
66
- def self.handle_execution_fail fail:, error:, output:
67
- return unless fail and !$CHILD_STATUS.success?
73
+ def self.handle_execution_fail fail:, error:, output:, command:
74
+ return unless fail and !execution_success?
68
75
 
69
76
  if error == :show and !output.empty?
70
77
  Bwrap::Output.warn_output "Command failed with output:\n“#{output}”"
71
78
  end
72
- raise Bwrap::Execution::ExecutionFailed, "Command execution failed.", caller
79
+
80
+ exception = Bwrap::Execution::ExecutionFailed.new "Command execution failed",
81
+ command: command,
82
+ output: output
83
+ raise exception, caller
73
84
  end
74
85
 
75
86
  # @note It makes sense for caller to just return if wait has been set and not check output.
@@ -101,6 +112,13 @@ class Bwrap::Execution::Execute
101
112
  "to add “self.prepend_rootcmd(command, rootcmd:)” method."
102
113
  end
103
114
 
115
+ # A wrapper to get status of an execution.
116
+ #
117
+ # Mainly here so test implementation is easier.
118
+ private_class_method def self.execution_success?
119
+ $CHILD_STATUS.success?
120
+ end
121
+
104
122
  # Used by `#handle_logging`.
105
123
  private_class_method def self.calculate_log_command command
106
124
  return command.dup unless command.respond_to?(:join)
@@ -5,6 +5,7 @@ require "bwrap/output"
5
5
  require_relative "exceptions"
6
6
  require_relative "execute"
7
7
  require_relative "path"
8
+ require_relative "popen2e"
8
9
 
9
10
  # Provides methods to execute commands and handle its output.
10
11
  #
@@ -61,7 +62,7 @@ module Bwrap::Execution
61
62
  end
62
63
 
63
64
  # If command is string, splat operator (the *) does not do anything. If array, it expand the arguments.
64
- # This causes spawning work correctly, as that’s how spawn() expects to have the argu
65
+ # This causes spawning work correctly, as that’s how spawn() expects to have the arguments.
65
66
  pid = spawn(env, *command, err: [ :child, :out ], out: Execute.w, unsetenv_others: clear_env)
66
67
  output = Execute.finish_execution(log: log, wait: wait, direct_output: direct_output)
67
68
  return pid unless wait
@@ -71,12 +72,35 @@ module Bwrap::Execution
71
72
  @last_status = $CHILD_STATUS
72
73
 
73
74
  output = Execute.process_output output: output
74
- Execute.handle_execution_fail fail: fail, error: error, output: output
75
+ Execute.handle_execution_fail fail: fail, error: error, output: output, command: command
75
76
  output
76
77
  ensure
77
78
  Execute.clean_variables
78
79
  end
79
80
 
81
+ # Works similarly to {Open3.popen2e}.
82
+ #
83
+ # TODO: If there will be any difference to input syntax, document those differences here.
84
+ # For now, rootcmd option has been implemented.
85
+ #
86
+ # A block is accepted, as does {Open3.popen2e}. For now, at least.
87
+ #
88
+ # TODO: Implement this so that this uses same execution things as other things here.
89
+ # This way bwrap actually can be integrated to this...
90
+ #
91
+ # TODO: Verify default log_callback is correct
92
+ #
93
+ # @warning Only array style commands are accepted. For example, `ls /`
94
+ # is not ok, but `ls` or `%w{ ls / }` is ok.
95
+ def popen2e *cmd, rootcmd: nil, log_callback: 1, log: true, &block
96
+ popen = Bwrap::Execution::Popen2e.new
97
+ popen.dry_run = @dry_run
98
+ popen.popen2e(*cmd, rootcmd: rootcmd, log_callback: log_callback, log: log, &block)
99
+ ensure
100
+ @last_status = $CHILD_STATUS
101
+ end
102
+ module_function :popen2e
103
+
80
104
  # Returns Process::Status instance of last execution.
81
105
  #
82
106
  # @note This only is able to return the status if wait is true, as otherwise caller is assumed to
@@ -20,7 +20,7 @@ module Bwrap::Execution::Path
20
20
  #
21
21
  # @yield Command appended to each path in PATH environment variable
22
22
  # @yieldparam path [String] Full path to executable
23
- def self.each_env_path command, env_path_var: ENV["PATH"]
23
+ def self.each_env_path command, env_path_var: ENV.fetch("PATH", nil)
24
24
  exts = ENV["PATHEXT"] ? ENV["PATHEXT"].split(";") : [ "" ]
25
25
 
26
26
  env_path_var.split(File::PATH_SEPARATOR).each do |env_path|
@@ -39,7 +39,7 @@ module Bwrap::Execution::Path
39
39
  # @param command [String] executable to be resolved
40
40
  # @param env_path_var [String] PATH environment variable as string.
41
41
  # Defaults to `ENV["PATH"]`
42
- private def command_available? command, env_path_var: ENV["PATH"]
42
+ private def command_available? command, env_path_var: ENV.fetch("PATH", nil)
43
43
  # Special handling for absolute paths.
44
44
  path = Pathname.new command
45
45
  if path.absolute?
@@ -60,7 +60,7 @@ module Bwrap::Execution::Path
60
60
  # Returns path to given executable.
61
61
  #
62
62
  # @param (see #command_available?)
63
- private def which command, fail: true, env_path_var: ENV["PATH"]
63
+ private def which command, fail: true, env_path_var: ENV.fetch("PATH", nil)
64
64
  # Special handling for absolute paths.
65
65
  path = Pathname.new command
66
66
  if path.absolute?
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @see {Bwrap::Execution.popen2e}
4
+ class Bwrap::Execution::Popen2e
5
+ attr_writer :dry_run
6
+
7
+ # @see {Bwrap::Execution.popen2e}
8
+ # TODO: Add a test for this (does Ruby have anything I could use here?).
9
+ #
10
+ # @note Also options accepted by {Open3.popen2e} are accepted
11
+ # here, in addition to those specified here.
12
+ def popen2e *cmd, rootcmd: nil, log_callback: 1, log: true, &block
13
+ @rootcmd = rootcmd
14
+ @log_callback = log_callback + 1 # Passing to another method, so need to add one more.
15
+ @log = log
16
+ self.opts_from_input = cmd
17
+
18
+ resolve_actual_command cmd
19
+
20
+ return if @dry_run || Bwrap::Execution::Execute.dry_run
21
+
22
+ open_pipes
23
+ popen_run(@actual_command, [ @in_read, @out_write ], [ @in_write, @out_read ], &block)
24
+ ensure
25
+ if block
26
+ @in_read.close
27
+ @in_write.close
28
+ @out_read.close
29
+ @out_write.close
30
+ end
31
+ end
32
+
33
+ # Sets @opts from `cmd` given to {#popen2e}, if any extra options have been given.
34
+ private def opts_from_input= cmd
35
+ @opts = cmd.last.is_a?(Hash) && cmd.pop.dup || {}
36
+ end
37
+
38
+ private def open_pipes
39
+ @in_read, @in_write = IO.pipe
40
+ @opts[:in] = @in_read
41
+ @in_write.sync = true
42
+
43
+ @out_read, @out_write = IO.pipe
44
+ @opts[[ :out, :err ]] = @out_write
45
+ end
46
+
47
+ # First element may be optional environment variables.
48
+ private def resolve_actual_command cmd
49
+ temp_cmd = cmd.dup
50
+
51
+ # Delete environment hash.
52
+ env_hash = temp_cmd.shift if temp_cmd.first.is_a? Hash
53
+
54
+ # Delete option hash.
55
+ option_hash = temp_cmd.pop if temp_cmd.last.is_a? Hash
56
+
57
+ temp_cmd = Bwrap::Execution::Execute.format_command temp_cmd, rootcmd: @rootcmd
58
+ Bwrap::Execution::Execute.handle_logging temp_cmd, log_callback: @log_callback, log: @log, dry_run: @dry_run
59
+
60
+ temp_cmd.unshift env_hash if env_hash
61
+ temp_cmd.push option_hash if option_hash
62
+
63
+ # If command is an array, there can’t be arrays inside the array (hashes are preserved).
64
+ # For convenience, the array is flattened here, so callers can construct commands more easily.
65
+ temp_cmd.flatten!
66
+
67
+ @actual_command = temp_cmd
68
+ end
69
+
70
+ private def popen_run cmd, child_io, parent_io
71
+ pid = spawn(*cmd, @opts)
72
+ wait_thr = Process.detach(pid)
73
+ child_io.each(&:close)
74
+ result = [ *parent_io, wait_thr ]
75
+
76
+ if defined? yield
77
+ begin
78
+ return yield(*result)
79
+ ensure
80
+ parent_io.each(&:close)
81
+ wait_thr.join
82
+ end
83
+ end
84
+
85
+ result
86
+ end
87
+ end