# Copyright (c) HashiCorp, Inc.
# SPDX-License-Identifier: BUSL-1.1

require 'thread'

require 'childprocess'
require 'log4r'

require 'vagrant/util/io'
require 'vagrant/util/platform'
require 'vagrant/util/safe_chdir'
require 'vagrant/util/which'

module Vagrant
  module Util
    # Execute a command in a subprocess, gathering the results and
    # exit status.
    #
    # This class also allows you to read the data as it is outputted
    # from the subprocess in real time, by simply passing a block to
    # the execute method.
    class Subprocess
      # Convenience method for executing a method.
      def self.execute(*command, &block)
        new(*command).execute(&block)
      end

      def initialize(*command)
        @options = command.last.is_a?(Hash) ? command.pop : {}
        @command = command.dup
        @command[0] = Which.which(@command[0]) if !File.file?(@command[0])
        if !@command[0]
          raise Errors::CommandUnavailableWindows, file: command[0] if Platform.windows?
          raise Errors::CommandUnavailable, file: command[0]
        end

        @logger  = Log4r::Logger.new("vagrant::util::subprocess")
      end

      # @return [TrueClass, FalseClass] subprocess is currently running
      def running?
        !!(@process && @process.alive?)
      end

      # Stop the subprocess if running
      #
      # @return [TrueClass] FalseClass] true if process was running and stopped
      def stop
        if @process && @process.alive?
          @process.stop
          true
        else
          false
        end
      end

      # Start the process
      #
      # @return [Result]
      def execute
        # Get the timeout, if we have one
        timeout = @options[:timeout]

        # Get the working directory
        workdir = @options[:workdir] || Dir.pwd

        # Get what we're interested in being notified about
        notify  = @options[:notify] || []
        notify  = [notify] if !notify.is_a?(Array)
        if notify.empty? && block_given?
          # If a block is given, subscribers must be given, otherwise the
          # block is never called. This is usually NOT what you want, so this
          # is an error.
          message = "A list of notify subscriptions must be given if a block is given"
          raise ArgumentError, message
        end

        # Let's get some more useful booleans that we access a lot so
        # we're not constantly calling an `include` check
        notify_table = {}
        notify_table[:stderr] = notify.include?(:stderr)
        notify_table[:stdout] = notify.include?(:stdout)
        notify_stdin  = notify.include?(:stdin)

        # Build the ChildProcess
        @logger.info("Starting process: #{@command.inspect}")
        @process = process = ChildProcess.build(*@command)

        # Create the pipes so we can read the output in real time as
        # we execute the command.
        stdout, stdout_writer = ::IO.pipe
        stderr, stderr_writer = ::IO.pipe
        process.io.stdout = stdout_writer
        process.io.stderr = stderr_writer
        process.duplex = true
        process.leader = true if @options[:detach]
        process.detach = true if @options[:detach]

        # Special installer-related things
        if Vagrant.in_installer?
          installer_dir = Vagrant.installer_embedded_dir.to_s.downcase

          # If we're in an installer on Mac and we're executing a command
          # in the installer context, then force DYLD_LIBRARY_PATH to look
          # at our libs first.
          if Platform.darwin?
            if @command[0].downcase.include?(installer_dir)
              @logger.info("Command in the installer. Specifying DYLD_LIBRARY_PATH...")
              process.environment["DYLD_LIBRARY_PATH"] =
                "#{installer_dir}/lib:#{ENV["DYLD_LIBRARY_PATH"]}"
            else
              @logger.debug("Command not in installer, not touching env vars.")
            end

            if File.setuid?(@command[0]) || File.setgid?(@command[0])
              @logger.info("Command is setuid/setgid, clearing DYLD_LIBRARY_PATH")
              process.environment["DYLD_LIBRARY_PATH"] = ""
            end
          end

          # If the command that is being run is not inside the installer, reset
          # the original environment - this is required for shelling out to
          # other subprocesses that depend on environment variables (like Ruby
          # and $GEM_PATH for example)
          internal = [installer_dir, Vagrant.user_data_path.to_s.downcase].
            any? { |path| @command[0].downcase.include?(path) }
          if !internal
            @logger.info("Command not in installer, restoring original environment...")
            jailbreak(process.environment)
          end

          # If running within an AppImage and calling external executable. When
          # executable is external set the LD_LIBRARY_PATH to host values.
          if ENV["VAGRANT_APPIMAGE"]
            embed_path = Pathname.new(Vagrant.installer_embedded_dir).expand_path.to_s
            exec_path = Pathname.new(@command[0]).expand_path.to_s
            if !exec_path.start_with?(embed_path) && ENV["VAGRANT_APPIMAGE_HOST_LD_LIBRARY_PATH"]
              @logger.info("Detected AppImage environment and request to external binary. Updating library path.")
              @logger.debug("Setting LD_LIBRARY_PATH to #{ENV["VAGRANT_APPIMAGE_HOST_LD_LIBRARY_PATH"]}")
              process.environment["LD_LIBRARY_PATH"] = ENV["VAGRANT_APPIMAGE_HOST_LD_LIBRARY_PATH"].to_s
            end
          end
        else
          @logger.info("Vagrant not running in installer, restoring original environment...")
          jailbreak(process.environment)
        end

        # Set the environment on the process if we must
        if @options[:env]
          @options[:env].each do |k, v|
            process.environment[k] = v
          end
        end

        # Start the process
        begin
          SafeChdir.safe_chdir(workdir) do
            process.start
          end
        rescue ChildProcess::LaunchError => ex
          # Raise our own version of the error so that users of the class
          # don't need to be aware of ChildProcess
          raise LaunchError.new(ex.message)
        end

        # If running with the detach option, no need to capture IO or
        # ensure program exists.
        if @options[:detach]
          return
        end

        # Make sure the stdin does not buffer
        process.io.stdin.sync = true

        if RUBY_PLATFORM != "java"
          # On Java, we have to close after. See down the method...
          # Otherwise, we close the writers right here, since we're
          # not on the writing side.
          stdout_writer.close
          stderr_writer.close
        end

        # Create a dictionary to store all the output we see.
        io_data = { stdout: "", stderr: "" }

        # Record the start time for timeout purposes
        start_time = Time.now.to_i

        open_readers = [stdout, stderr]
        open_writers = notify_stdin ? [process.io.stdin] : []
        @logger.debug("Selecting on IO")
        while true
          results = ::IO.select(open_readers, open_writers, nil, 0.1)
          results ||= []
          readers = results[0]
          writers = results[1]

          # Check if we have exceeded our timeout
          raise TimeoutExceeded, process.pid if timeout && (Time.now.to_i - start_time) > timeout

          # Check the readers to see if they're ready
          if readers && !readers.empty?
            readers.each do |r|
              # Read from the IO object
              data = IO.read_until_block(r)

              # We don't need to do anything if the data is empty
              next if data.empty?

              io_name = r == stdout ? :stdout : :stderr
              @logger.trace("#{io_name}: #{data.chomp}")

              io_data[io_name] += data
              yield io_name, data if block_given? && notify_table[io_name]
            end
          end

          # Break out if the process exited. We have to do this before
          # attempting to write to stdin otherwise we'll get a broken pipe
          # error.
          break if process.exited?

          # Check the writers to see if they're ready, and notify any listeners
          if writers && !writers.empty? && block_given?
            yield :stdin, process.io.stdin

            # if the callback closed stdin, we should remove it, because
            # IO.select() will throw if called with a closed io.
            if process.io.stdin.closed?
              open_writers = []
            end
          end
        end

        # Wait for the process to end.
        begin
          remaining = (timeout || 32000) - (Time.now.to_i - start_time)
          remaining = 0 if remaining < 0
          @logger.debug("Waiting for process to exit. Remaining to timeout: #{remaining}")

          process.poll_for_exit(remaining)
        rescue ChildProcess::TimeoutError
          raise TimeoutExceeded, process.pid
        end

        @logger.debug("Exit status: #{process.exit_code}")

        # Read the final output data, since it is possible we missed a small
        # amount of text between the time we last read data and when the
        # process exited.
        [stdout, stderr].each do |io|
          # Read the extra data, ignoring if there isn't any
          extra_data = IO.read_until_block(io)
          next if extra_data == ""

          # Log it out and accumulate
          io_name = io == stdout ? :stdout : :stderr
          io_data[io_name] += extra_data
          @logger.trace("#{io_name}: #{extra_data.chomp}")

          # Yield to any listeners any remaining data
          yield io_name, extra_data if block_given? && notify_table[io_name]
        end

        if RUBY_PLATFORM == "java"
          # On JRuby, we need to close the writers after the process,
          # for some reason. See GH-711.
          stdout_writer.close
          stderr_writer.close
        end

        # Return an exit status container
        return Result.new(process.exit_code, io_data[:stdout], io_data[:stderr])
      ensure
        if process && process.alive? && !@options[:detach]
          # Make sure no matter what happens, the process exits
          process.stop(2)
        end
      end

      protected

      # An error which raises when a process fails to start
      class LaunchError < StandardError; end

      # An error which occurs when the process doesn't end within
      # the given timeout.
      class TimeoutExceeded < StandardError
        attr_reader :pid

        def initialize(pid)
          super()
          @pid = pid
        end
      end

      # Container class to store the results of executing a subprocess.
      class Result
        attr_reader :exit_code
        attr_reader :stdout
        attr_reader :stderr

        def initialize(exit_code, stdout, stderr)
          @exit_code = exit_code
          @stdout    = stdout
          @stderr    = stderr
        end
      end

      private

      # This is, quite possibly, the saddest function in all of Vagrant.
      #
      # If a user is running Vagrant via Bundler (but not via the official
      # installer), we want to reset to the "original" environment so that when
      # shelling out to other Ruby processes (specifically), the original
      # environment is restored. This is super important for things like
      # rbenv and chruby, who rely on environment variables to locate gems, but
      # Bundler stomps on those environment variables like an angry T-Rex after
      # watching Jurassic Park 2 and realizing they replaced you with CGI.
      #
      # If a user is running in Vagrant via the official installer, BUT trying
      # to execute a subprocess *outside* of the installer, we want to reset to
      # the "original" environment. In this case, the Vagrant installer actually
      # knows what the original environment was and replaces it completely.
      #
      # Finally, we reset any Bundler-specific environment variables, since the
      # subprocess being called could, itself, be Bundler. And Bundler does not
      # behave very nicely in these circumstances.
      #
      # This function was added in Vagrant 1.7.3, but there is a failsafe
      # because the author doesn't trust himself that this functionality won't
      # break existing assumptions, so users can specify
      # `VAGRANT_SKIP_SUBPROCESS_JAILBREAK` and none of the above will happen.
      #
      # This function modifies the given hash in place!
      #
      # @return [nil]
      def jailbreak(env = {})
        return if ENV.key?("VAGRANT_SKIP_SUBPROCESS_JAILBREAK")

        if defined?(::Bundler) && defined?(::Bundler::ORIGINAL_ENV)
          env.replace(::Bundler::ORIGINAL_ENV)
        end
        env.merge!(Vagrant.original_env)

        # Bundler does this, so I guess we should as well, since I think it
        # other subprocesses that use Bundler will reload it
        env["MANPATH"] = ENV["BUNDLE_ORIG_MANPATH"]

        # Replace all current environment BUNDLE_ variables to nil
        ENV.each do |k,_|
          env[k] = nil if k[0,7] == "BUNDLE_"
        end

        # If RUBYOPT was set, unset it with Bundler
        if ENV.key?("RUBYOPT")
          env["RUBYOPT"] = ENV["RUBYOPT"].sub("-rbundler/setup", "")
        end

        nil
      end
    end
  end
end
