#
# Copyright 2012-2019, Chef Software Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "fileutils"
require "mixlib/shellout"
require "ostruct"
require "pathname"
require "omnibus/whitelist"

module Omnibus
  class Builder
    include Cleanroom
    include Digestable
    include Instrumentation
    include Logging
    include Templating
    include Util

    #
    # Since builder is also a proxy object to software, we dynamically re-define
    # all the methods that exist on {Software} as proxy methhods here. This
    # permits developers to use {Software} methods as if they were directly
    # part of this DSL.
    #
    Software.exposed_methods.each do |name, _|
      define_method(name) do |*args, &block|
        software.send(name, *args, &block)
      end
      expose(name)
    end

    #
    # @return [Software]
    #   the software definition that created this builder
    #
    attr_reader :software

    #
    # Create a new builder object for evaluation.
    #
    # @param [Software] software
    #   the software definition that created this builder
    #
    def initialize(software)
      @software = software
    end

    #
    # @!group System DSL methods
    #
    # The following DSL methods are available from within build blocks.
    # --------------------------------------------------

    #
    # Execute the given command string or command arguments.
    #
    # @example
    #   command 'make install', env: { 'PATH' => '/my/custom/path' }
    #
    # @param [String] command
    #   the command to execute
    # @param [Hash] options
    #   a list of options to pass to the +Mixlib::ShellOut+ instance when it is
    #   executed
    #
    # @return [void]
    #
    def command(command, options = {})
      warn_for_shell_commands(command)

      build_commands << BuildCommand.new("Execute: `#{command}'") do
        shellout!(command, options)
      end
    end
    expose :command

    #
    # Execute the given make command. When present, this method will prefer the
    # use of +gmake+ over +make+. If applicable, this method will also set
    # the `MAKE=gmake` environment variable when gmake is to be preferred.
    #
    # On windows you need to have the msys-base package (or some equivalent)
    # before you can invoke this.
    #
    # @example With no arguments
    #   make
    #
    # @example With arguments
    #   make 'install'
    #
    # @example With custom make bin
    #   make 'install', bin: '/path/to/custom/make'
    #
    # @param (see #command)
    # @return (see #command)
    #
    def make(*args)
      options = args.last.is_a?(Hash) ? args.pop : {}

      make = options.delete(:bin) ||
        # Prefer gmake on non-windows environments.
        if !windows? && Omnibus.which("gmake")
          env = options.delete(:env) || {}
          env = { "MAKE" => "gmake" }.merge(env)
          options[:env] = env
          "gmake"
        else
          "make"
        end

      options[:in_msys_bash] = true
      make_cmd = ([make] + args).join(" ").strip
      command(make_cmd, options)
    end
    expose :make

    # Run a prexisting "./configure" script that was generated by autotools.
    # On windows, this will run configure within an msys bash shell with
    # the given arguments. --build is also set on your behalf based on
    # windows_arch. A default prefix of "#{install_bin}/embedded" is
    # appended. It is important to set --build rather than --host because
    # by default, --build also sets --host but it doesn't trigger
    # "cross-compilation" mode in most configure scripts. Triggering this mode
    # can confuse certain software projects like Ruby which depend on the
    # build platform in its mkmf scripts.
    #
    # @example With no arguments
    #   configure
    # On POSIX systems, this results in:
    #   ./configure --prefix=/path/to/embedded
    # On Windows 64-bit, this results in:
    #   ./configure --build=x86_64-w64-mingw32 --prefix=C:/path/to/embedded
    # Note that the windows case uses a windows compabile path with forward
    # slashes - not an msys path. Ensure that the final Makefile is happy
    # with this and doesn't perform path gymnastics on it. Don't pass
    # \\ paths unless you particularly enjoy discovering exactly how many
    # times configure and the Makefile it generates sends your path back
    # and forth through bash/sh, mingw32 native binaries and msys binaries
    # and how many backslashes it takes for you to quit software development.
    #
    # @example With custom arguments
    #   configure '--enable-shared'
    #
    # @example With custom location of configure bin
    #   configure '--enable-shared', bin: '../foo/configure'
    # The path to configure must be a "unix-y" path for both windows and posix
    # as this path is run under an msys bash shell on windows. Prefer relative
    # paths lest you incur the wrath of the msys path gods for they are not
    # kind, just or benevolent.
    #
    # @param (see #command)
    # @return (see #command)
    #
    def configure(*args)
      options = args.last.is_a?(Hash) ? args.pop : {}

      configure = options.delete(:bin) || "./configure"
      configure_cmd = [configure]

      # Pass the host platform as well. Different versions of config.guess
      # arrive at differently terrible wild ass guesses for what MSYSTEM=MINGW64
      # means. This can be anything from x86_64-pc-mingw64 to i686-pc-mingw32
      # which doesn't even make any sense...
      if windows?
        platform = windows_arch_i386? ? "i686-w64-mingw32" : "x86_64-w64-mingw32"
        configure_cmd << "--build=#{platform}"
      end

      # Accept a prefix override if provided. Can be set to '' to suppress
      # this functionality.
      prefix = options.delete(:prefix) || "#{install_dir}/embedded"
      configure_cmd << "--prefix=#{prefix}" if prefix && prefix != ""

      configure_cmd.concat args
      configure_cmd = configure_cmd.join(" ").strip

      options[:in_msys_bash] = true
      command(configure_cmd, options)
    end
    expose :configure

    #
    # Apply the patch by the given name. This method will search all possible
    # locations for a patch (such as {Config#software_gems}).
    #
    # On windows, you must have the the patch package installed before you can
    # invoke this.
    #
    # @example
    #   patch source: 'ncurses-clang.patch'
    #
    # @example
    #   patch source: 'patch-ad', plevel: 0
    #
    # @param [Hash] options
    #   the list of options
    #
    # @option options [String] :source
    #   the name of the patch to apply
    # @option options [Fixnum] :plevel
    #   the level to apply the patch
    # @option options [String] :target
    #   the destination to apply the patch
    #
    # @return (see #command)
    #
    def patch(options = {})
      source = options.delete(:source)
      plevel = options.delete(:plevel) || 1
      target = options.delete(:target)

      locations, patch_path = find_file("config/patches", source)

      unless patch_path
        raise MissingPatch.new(source, locations)
      end

      # Using absolute paths to the patch when invoking patch from within msys
      # is going to end is tears and table-flips. Use relative paths instead.
      # It's windows - we don't reasonably expect symlinks to show up any-time
      # soon and if you're using junction points, you're on your own.
      clean_patch_path = patch_path
      if windows?
        clean_patch_path = Pathname.new(patch_path).relative_path_from(
          Pathname.new(software.project_dir)).to_s
      end

      if target
        patch_cmd = "cat #{clean_patch_path} | patch -p#{plevel} #{target}"
      else
        patch_cmd = "patch -p#{plevel} -i #{clean_patch_path}"
      end

      patches << patch_path
      options[:in_msys_bash] = true
      build_commands << BuildCommand.new("Apply patch `#{source}'") do
        shellout!(patch_cmd, options)
      end
    end
    expose :patch

    #
    # The maximum number of workers suitable for this system.
    #
    # @see (Config#workers)
    #
    def workers
      Config.workers
    end
    expose :workers

    #
    # (see Util#windows_safe_path)
    #
    # Most internal Ruby methods will handle this automatically, but the
    # +command+ method is unable to do so.
    #
    # @example
    #   command "#{windows_safe_path(install_dir)}\\embedded\\bin\\gem"
    #
    def windows_safe_path(*pieces)
      super
    end
    expose :windows_safe_path

    #
    # (see Util#compiler_safe_path)
    #
    # Some compilers require paths to be formatted in certain ways. This helper
    # takes in the standard Omnibus-style path and ensures that it is passed
    # correctly.
    #
    # @example
    #   configure ["--prefix=#{compiler_safe_path(install_dir, "embedded")}"]
    #
    def compiler_safe_path(*pieces)
      super
    end
    expose :compiler_safe_path

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @!group Ruby DSL methods
    #
    # The following DSL methods are available from within build blocks and
    # expose Ruby DSL methods.
    # --------------------------------------------------

    #
    # Execute the given Ruby command or script against the embedded Ruby.
    #
    # @example
    #   ruby 'setup.rb'
    #
    # @param (see #command)
    # @return (see #command)
    #
    def ruby(command, options = {})
      build_commands << BuildCommand.new("ruby `#{command}'") do
        bin = embedded_bin("ruby")
        shellout!("#{bin} #{command}", options)
      end
    end
    expose :ruby

    #
    # Execute the given Rubygem command against the embedded Rubygems.
    #
    # @example
    #   gem 'install chef'
    #
    # @param (see #command)
    # @return (see #command)
    #
    def gem(command, options = {})
      build_commands << BuildCommand.new("gem `#{command}'") do
        bin = embedded_bin("gem")
        shellout!("#{bin} #{command}", options)
      end
    end
    expose :gem

    #
    # Execute the given bundle command against the embedded Ruby's bundler. This
    # command assumes the +bundler+ gem is installed and in the embedded Ruby.
    # You should add a dependency on the +bundler+ software definition if you
    # want to use this command.
    #
    # @example
    #   bundle 'install'
    #
    # @param (see #command)
    # @return (see #command)
    #
    def bundle(command, options = {})
      build_commands << BuildCommand.new("bundle `#{command}'") do
        bin = embedded_bin("bundle")
        shellout!("#{bin} #{command}", options)
      end
    end
    expose :bundle

    #
    # Execute the given appbundler command against the embedded Ruby's
    # appbundler. This command assumes the +appbundle+ gem is installed and
    # in the embedded Ruby. You should add a dependency on the +appbundler+
    # software definition if you want to use this command.
    #
    # @example
    #   appbundle 'chef'
    #
    # @param software_name [String]
    #  The omnibus software definition name that you want to appbundle.  We
    #  assume that this software definition is a gem that already has `bundle
    #  install` ran on it so it has a Gemfile.lock to appbundle.
    # @param (see #command)
    # @return (see #command)
    #
    def appbundle(software_name, lockdir: nil, gem: nil, without: nil, extra_bin_files: nil , **options)
      build_commands << BuildCommand.new("appbundle `#{software_name}'") do
        bin_dir            = "#{install_dir}/bin"
        appbundler_bin     = embedded_bin("appbundler")

        lockdir ||=
          begin
            app_software = project.softwares.find do |p|
              p.name == software_name
            end
            if app_software.nil?
              raise "could not find software definition for #{software_name}, add a dependency to it, or pass a lockdir argument to appbundle command."
            end
            app_software.project_dir
          end

        command = [ appbundler_bin, "'#{lockdir}'", "'#{bin_dir}'" ]

        # This option is almost entirely for support of ChefDK and enables transitive gemfile lock construction in order
        # to be able to decouple the dev gems for all the different components of ChefDK.  AKA:  don't use it outside of
        # ChefDK.  You should also explicitly specify the lockdir when going down this road.
        command << [ "'#{gem}'" ] if gem

        # FIXME: appbundler lacks support for this argument when not also specifying the gem (2-arg appbundling lacks support)
        # (if you really need this bug fixed, though, fix it in appbundler, don't try using the 3-arg version to try to
        # get `--without` support, you will likely wind up going down a sad path).
        command << [ "--without", without.join(",") ] unless without.nil?

        command << [ "--extra-bin-files", extra_bin_files.join(",") ] unless extra_bin_files.nil? || extra_bin_files.empty?

        # Ensure the main bin dir exists
        FileUtils.mkdir_p(bin_dir)

        shellout!(command.join(" "), options)
      end
    end
    expose :appbundle

    #
    # Execute the given Rake command against the embedded Ruby's rake. This
    # command assumes the +rake+ gem has been installed.
    #
    # @example
    #   rake 'test'
    #
    # @param (see #command)
    # @return (see #command)
    #
    def rake(command, options = {})
      build_commands << BuildCommand.new("rake `#{command}'") do
        bin = embedded_bin("rake")
        shellout!("#{bin} #{command}", options)
      end
    end
    expose :rake

    #
    # Execute the given Ruby block at runtime. The block is captured as-is and
    # no validation is performed. As a general rule, you should avoid this
    # method unless you know what you are doing.
    #
    # @example
    #   block do
    #     # Some complex operation
    #   end
    #
    # @example
    #   block 'Named operation' do
    #     # The above name can be used in log output to identify the operation
    #   end
    #
    # @param (see #command)
    # @return (see #command)
    #
    def block(name = "<Dynamic Ruby block>", &proc)
      build_commands << BuildCommand.new(name, &proc)
    end
    expose :block

    #
    # Render the erb template by the given name. This method will search all
    # possible locations for an erb template (such as {Config#software_gems}).
    #
    # @example
    #   erb source: 'example.erb',
    #       dest:   '/path/on/disk/to/render'
    #
    # @example
    #   erb source: 'example.erb',
    #       dest:   '/path/on/disk/to/render',
    #       vars:   { foo: 'bar' },
    #       mode:   '0755'
    #
    # @param [Hash] options
    #   the list of options
    #
    # @option options [String] :source
    #   the name of the patch to apply
    # @option options [String] :dest
    #   the path on disk where the erb should be rendered
    # @option options [Hash] :vars
    #   the list of variables to pass to the ERB rendering
    # @option options [String] :mode
    #   the file mode for the rendered template (default varies by system)
    #
    # @return (see #command)
    #
    def erb(options = {})
      source = options.delete(:source)
      dest   = options.delete(:dest)
      mode   = options.delete(:mode) || 0644
      vars   = options.delete(:vars) || {}

      raise "Missing required option `:source'!" unless source
      raise "Missing required option `:dest'!"   unless dest

      locations, source_path = find_file("config/templates", source)

      unless source_path
        raise MissingTemplate.new(source, locations)
      end

      erbs << source_path

      block "Render erb `#{source}'" do
        render_template(source_path,
          destination: dest,
          mode:        mode,
          variables:   vars
        )
      end
    end
    expose :erb

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @!group File system DSL methods
    #
    # The following DSL methods are available from within build blocks that
    # mutate the file system.
    #
    # **These commands are run from inside {Software#project_dir}, so exercise
    # good judgement when using relative paths!**
    # --------------------------------------------------

    #
    # Make a directory at runtime. This method uses the equivalent of +mkdir -p+
    # under the covers.
    #
    # @param [String] directory
    #   the name or path of the directory to create
    # @param [Hash] options
    #   the list of options to pass to the underlying +FileUtils+ call
    #
    # @return (see #command)
    #
    def mkdir(directory, options = {})
      build_commands << BuildCommand.new("mkdir `#{directory}'") do
        Dir.chdir(software.project_dir) do
          FileUtils.mkdir_p(directory, options)
        end
      end
    end
    expose :mkdir

    #
    # Touch the given filepath at runtime. This method will also ensure the
    # containing directory exists first.
    #
    # @param [String] file
    #   the path of the file to touch
    # @param (see #mkdir)
    #
    # @return (see #command)
    #
    def touch(file, options = {})
      build_commands << BuildCommand.new("touch `#{file}'") do
        Dir.chdir(software.project_dir) do
          parent = File.dirname(file)
          FileUtils.mkdir_p(parent) unless File.directory?(parent)

          FileUtils.touch(file, options)
        end
      end
    end
    expose :touch

    #
    # Delete the given file or directory on the system. This method uses the
    # equivalent of +rm -rf+, so you may pass in a specific file or a glob of
    # files.
    #
    # @param [String] path
    #   the path of the file to delete
    # @param (see #mkdir)
    #
    # @return (see #command)
    #
    def delete(path, options = {})
      build_commands << BuildCommand.new("delete `#{path}'") do
        Dir.chdir(software.project_dir) do
          FileSyncer.glob(path).each do |file|
            FileUtils.rm_rf(file, options)
          end
        end
      end
    end
    expose :delete

    #
    # Strip symbols from the given file or directory on the system. This method uses
    # find and passes the matched files to strip through xargs, ignoring errors.
    # So one may pass in a specific file/directory or a glob of files.
    #
    # @param [String] path
    #   the path of the file(s) to strip
    #
    # @return (see #command)
    #
    def strip(path)
      regexp_ends = ".*(" + IGNORED_ENDINGS.map { |e| e.gsub(/\./, '\.') }.join("|") + ")$"
      regexp_patterns = IGNORED_PATTERNS.map { |e| ".*" + e.gsub(/\//, '\/') + ".*" }.join("|")
      regexp = regexp_ends + "|" + regexp_patterns

      # Do not actually care if strip runs on non-strippable file, as its a no-op.  Hence the `|| true` appended.
      # Do want to avoid stripping files unneccessarily so as not to slow down build process.
      find_command = "find #{path}/ -type f -regextype posix-extended ! -regex \"#{regexp}\" | xargs strip || true"
      options = { in_msys_bash: true }
      command(find_command, options)
    end
    expose :strip

    #
    # Copy the given source to the destination. This method accepts a single
    # file or a file pattern to match.
    #
    # @param [String] source
    #   the path on disk to copy from
    # @param [String] destination
    #   the path on disk to copy to
    # @param (see #mkdir)
    #
    # @return (see #command)
    #
    def copy(source, destination, options = {})
      command = "copy `#{source}' to `#{destination}'"
      build_commands << BuildCommand.new(command) do
        Dir.chdir(software.project_dir) do
          files = FileSyncer.glob(source)
          if files.empty?
            log.warn(log_key) { "no matched files for glob #{command}" }
          else
            files.each do |file|
              FileUtils.cp_r(file, destination, options)
            end
          end
        end
      end
    end
    expose :copy

    #
    # Move the given source to the destination. This method accepts a single
    # file or a file pattern to match
    #
    # @param [String] source
    #   the path on disk to move from
    # @param [String] destination
    #   the path on disk to move to
    # @param (see #mkdir)
    #
    # @return (see #command)
    #
    def move(source, destination, options = {})
      command = "move `#{source}' to `#{destination}'"
      build_commands << BuildCommand.new(command) do
        Dir.chdir(software.project_dir) do
          files = FileSyncer.glob(source)
          if files.empty?
            log.warn(log_key) { "no matched files for glob #{command}" }
          else
            files.each do |file|
              FileUtils.mv(file, destination, options)
            end
          end
        end
      end
    end
    expose :move

    #
    # Link the given source to the destination. This method accepts a single
    # file or a file pattern to match
    #
    # @param [String] source
    #   the path on disk to link from
    # @param [String] destination
    #   the path on disk to link to
    # @param (see #mkdir)
    #
    # @return (see #command)
    #
    def link(source, destination, options = {})
      command = "link `#{source}' to `#{destination}'"
      build_commands << BuildCommand.new(command) do
        Dir.chdir(software.project_dir) do
          if options.delete(:unchecked)
            FileUtils.ln_s(source, destination, options)
          else
            files = FileSyncer.glob(source)
            if files.empty?
              log.warn(log_key) { "no matched files for glob #{command}" }
            else
              files.each do |file|
                FileUtils.ln_s(file, destination, options)
              end
            end
          end
        end
      end
    end
    expose :link

    #
    # (see FileSyncer.sync)
    #
    # @example
    #   sync "#{project_dir}/**/*.rb", "#{install_dir}/ruby_files"
    #
    # @example
    #   sync project_dir, "#{install_dir}/files", exclude: '.git'
    #
    def sync(source, destination, options = {})
      build_commands << BuildCommand.new("sync `#{source}' to `#{destination}'") do
        Dir.chdir(software.project_dir) do
          FileSyncer.sync(source, destination, options)
        end
      end
    end
    expose :sync

    #
    # Helper method to update config_guess in the software's source
    # directory.
    # You should add a dependency on the +config_guess+ software definition if you
    # want to use this command.
    # @param [Hash] options
    #   Supported options are:
    #     target [String] subdirectory under the software source to copy
    #       config.guess.to. Default: "."
    #     install [Array<Symbol>] parts of config.guess to copy.
    #       Default: [:config_guess, :config_sub]
    def update_config_guess(target: ".", install: [:config_guess, :config_sub])
      build_commands << BuildCommand.new("update_config_guess `target: #{target} install: #{install.inspect}'") do
        config_guess_dir = "#{install_dir}/embedded/lib/config_guess"
        %w{config.guess config.sub}.each do |c|
          unless File.exist?(File.join(config_guess_dir, c))
            raise "Can not find #{c}. Make sure you add a dependency on 'config_guess' in your software definition"
          end
        end

        destination = File.join(software.project_dir, target)
        FileUtils.mkdir_p(destination)

        FileUtils.cp_r("#{config_guess_dir}/config.guess", destination) if install.include? :config_guess
        FileUtils.cp_r("#{config_guess_dir}/config.sub", destination) if install.include? :config_sub
      end
    end
    expose :update_config_guess

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @!group Public API
    #
    # The following methods are considered part of the public API for a
    # builder. All DSL methods are also considered part of the public API.
    # --------------------------------------------------

    #
    # Execute all the {BuildCommand} instances, in order, for this builder.
    #
    # @return [void]
    #
    def build
      log.info(log_key) { "Starting build" }
      shasum # ensure shashum is calculated before build since the build can alter the shasum
      log.internal(log_key) { "Cached builder checksum before build: #{shasum}" }
      if software.overridden?
        log.info(log_key) do
          "Version overridden from #{software.default_version || "n/a"} to "\
          "#{software.version}"
        end
      end

      measure("Build #{software.name}") do
        build_commands.each do |command|
          execute(command)
        end
      end

      log.info(log_key) { "Finished build" }
    end

    #
    # The shasum for this builder object. The shasum is calculated using the
    # following:
    #
    #   - The descriptions of all {BuildCommand} objects
    #   - The digest of all patch files on disk
    #   - The digest of all erb files on disk
    #
    # @return [String]
    #
    def shasum
      @shasum ||= begin
        digest = Digest::SHA256.new

        build_commands.each do |build_command|
          update_with_string(digest, build_command.description)
        end

        patches.each do |patch_path|
          update_with_file_contents(digest, patch_path)
        end

        erbs.each do |erb_path|
          update_with_file_contents(digest, erb_path)
        end

        digest.hexdigest
      end
    end

    #
    # @!endgroup
    # --------------------------------------------------

    private

    #
    # The **in-order** list of {BuildCommand} for this builder.
    #
    # @return [Array<BuildCommand>]
    #
    def build_commands
      @build_commands ||= []
    end

    #
    # The list of paths to patch files on disk. This is used in the calculation
    # of the shasum.
    #
    # @return [Array<String>]
    #
    def patches
      @patches ||= []
    end

    #
    # The list of paths to erb files on disk. This is used in the calculation
    # of the shasum.
    #
    # @return [Array<String>]
    #
    def erbs
      @erbs ||= []
    end

    #
    # This is a helper method that wraps {Util#shellout!} for the purposes of
    # setting the +:cwd+ value.
    #
    # It also accepts an :in_msys_bash option which controls whether the
    # given command is wrapped and run with bash.exe -c on windows.
    #
    # @see (Util#shellout!)
    #
    def shellout!(command_string, options = {})
      # Make sure the PWD is set to the correct directory
      # Also make a clone of options so that we can mangle it safely below.
      options = { cwd: software.project_dir }.merge(options)

      # Set the log level to :info so users will see build commands
      options[:log_level] ||= :info

      # Set the live stream to :debug so users will see build output
      options[:live_stream] ||= log.live_stream(:debug)

      # Use Util's shellout
      super(command_string, options)
    end

    #
    # Execute the given command object. This method also wraps the following
    # operations:
    #
    #   - Reset bundler's environment using {with_clean_env}
    #   - Instrument (time/measure) the individual command's execution
    #   - Retry failed commands in accordance with {Config#build_retries}
    #
    # @param [BuildCommand] command
    #   the command object to build
    #
    def execute(command)
      with_clean_env do
        measure(command.description) do
          with_retries do
            command.run(self)
          end
        end
      end
    end

    #
    # Execute the given block with (n) reties defined by {Config#build_retries}.
    # This method will only retry for the following exceptions:
    #
    #   - +CommandFailed+
    #   - +CommandTimeout+
    #
    # @param [Proc] block
    #   the block to execute
    #
    def with_retries(&block)
      tries = Config.build_retries
      delay = 5
      exceptions = [CommandFailed, CommandTimeout]

      begin
        yield
      rescue *exceptions => e
        if tries <= 0
          raise e
        else
          delay *= 2

          log.warn(log_key) do
            label = "#{(Config.build_retries - tries) + 1}/#{Config.build_retries}"
            "[#{label}] Failed to execute command. Retrying in #{delay} seconds..."
          end

          sleep(delay)
          tries -= 1
          retry
        end
      end
    end

    #
    # Execute the given command, removing any Ruby-specific environment
    # variables. This is an "enhanced" version of +Bundler.with_clean_env+,
    # which only removes Bundler-specific values. We need to remove all
    # values, specifically:
    #
    # - _ORIGINAL_GEM_PATH
    # - GEM_PATH
    # - GEM_HOME
    # - GEM_ROOT
    # - BUNDLE_BIN_PATH
    # - BUNDLE_GEMFILE
    # - RUBYLIB
    # - RUBYOPT
    # - RUBY_ENGINE
    # - RUBY_ROOT
    # - RUBY_VERSION
    #
    # The original environment restored at the end of this call.
    #
    # @param [Proc] block
    #   the block to execute with the cleaned environment
    #
    def with_clean_env(&block)
      original = ENV.to_hash

      ENV.delete("_ORIGINAL_GEM_PATH")
      ENV.delete_if { |k, _| k.start_with?("BUNDLER_") }
      ENV.delete_if { |k, _| k.start_with?("BUNDLE_") }
      ENV.delete_if { |k, _| k.start_with?("GEM_") }
      ENV.delete_if { |k, _| k.start_with?("RUBY") }

      yield
    ensure
      ENV.replace(original.to_hash)
    end

    #
    # Find a file amonst all local files, "remote" local files, and
    # {Config#software_gems}.
    #
    # @param [String] path
    #   the path to find the file
    # @param [String] source
    #   the source name of the file to find
    #
    # @return [Array<Array<String>, String, nil>]
    #   an array where the first entry is the list of candidate paths searched,
    #   and the second entry is the first occurence of the matched file (or
    #   +nil+) if one does not exist.
    #
    def find_file(path, source)
      # Search for patches just like we search for software
      candidate_paths = Omnibus.possible_paths_for(path).map do |directory|
        File.join(directory, software.name, source)
      end

      file = candidate_paths.find { |path| File.exist?(path) }

      [candidate_paths, file]
    end

    #
    # The log key for this class, overriden to incorporate the software name.
    #
    # @return [String]
    #
    def log_key
      @log_key ||= "#{super}: #{software.name}"
    end

    #
    # Inspect the given command and warn if the command "looks" like it is a
    # shell command that has a DSL method. (like +command 'cp'+ versus +copy+).
    #
    # @param [String] command
    #   the command to check
    #
    # @return [void]
    #
    def warn_for_shell_commands(command)
      case command
      when /^cp /i
        log.warn(log_key) { "Detected command `cp'. Consider using the `copy' DSL method." }
      when /^rubocopy /i
        log.warn(log_key) { "Detected command `rubocopy'. Consider using the `sync' DSL method." }
      when /^mv /i
        log.warn(log_key) { "Detected command `mv'. Consider using the `move' DSL method." }
      when /^rm /i
        log.warn(log_key) { "Detected command `rm'. Consider using the `delete' DSL method." }
      when /^remove /i
        log.warn(log_key) { "Detected command `remove'. Consider using the `delete' DSL method." }
      when /^rsync /i
        log.warn(log_key) { "Detected command `rsync'. Consider using the `sync' DSL method." }
      when /^strip /i
        log.warn(log_key) { "Detected command `strip'. Consider using the `strip' DSL method." }
      end
    end

    #
    # This is an internal wrapper around a command executed on the system. The
    # block could contain a Ruby command (such as +FileUtils.rm_rf('/')+), or it
    # could contain a call to shell out to the system.
    #
    class BuildCommand
      attr_reader :description

      #
      # Create a new BuildCommand object.
      #
      # @param [String] description
      #   a unique identifier for this build command - it will be used for
      #   logging and timing labels
      # @param [Proc] block
      #   the block to capture
      #
      def initialize(description, &block)
        @description, @block = description, block
      end

      #
      # Execute the build command against the given object. Because BuildCommand
      # objects could reference internal DSL methods, this method requires you
      # pass in an object against which to +instance_eval+ the block. Otherwise,
      # you would be severly restricted in the commands avaiable to you via the
      # DSL.
      #
      # @param [Builder] builder
      #   the builder to +instance_eval+ against
      #
      def run(builder)
        builder.instance_eval(&@block)
      end
    end
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "fileutils"

module Omnibus
  class Packager::Base
    include Cleanroom
    include Digestable
    include Instrumentation
    include Logging
    include NullArgumentable
    include Sugarable
    include Templating
    include Util

    # The {Project} instance that we are packaging
    attr_reader :project

    class << self
      #
      # Set the unique of this packager.
      #
      # @see {#id}
      #
      # @param [Symbol] name
      #   the id
      #
      def id(name)
        class_eval <<-EOH, __FILE__, __LINE__
          def id
            :#{name}
          end
        EOH
      end

      # The commands/steps use to setup the filesystem.
      def setup(&block)
        block ? @setup = block : @setup
      end

      # The commands/steps to build the package.
      def build(&block)
        block ? @build = block : @build
      end
    end

    #
    # Create a new packager object.
    #
    # @param [Project] project
    #
    def initialize(project)
      @project = project
    end

    #
    # The unique identifier for this class - this is used in file paths and
    # packager searching, so please do not change unless you know what you are
    # doing!
    #
    # @abstract Subclasses should define the +id+ attribute.
    #
    # @return [Symbol]
    #
    def id
      raise NotImplementedError
    end

    #
    # The ending name of this package on disk. This method is used to generate
    # metadata about the package after it is built.
    #
    # @abstract
    #
    # @return [String]
    #
    def package_name
      raise NotImplementedError
    end

    #
    # The list of files to exclude when syncing files. This comes from the list
    # of project exclusions and includes "common" SCM directories (like +.git+).
    #
    # @return [Array<String>]
    #
    def exclusions
      project.exclusions + %w{
        **/.git
        **/.hg
        **/.svn
        **/.gitkeep
      }
    end

    #
    # @!group DSL methods
    # --------------------------------------------------

    #
    # Retrieve the path at which the project will be installed by the
    # generated package.
    #
    # @return [String]
    #
    def install_dir
      project.install_dir
    end
    expose :install_dir

    #
    # (see Util#windows_safe_path)
    #
    expose :windows_safe_path

    #
    # Skip this packager during build process
    #
    # @example
    #   skip_package true
    #
    # @param [TrueClass, FalseClass] value
    #   whether to delay validation or not
    #
    # @return [TrueClass, FalseClass]
    #   whether to skip this packager type or not
    def skip_packager(val = false)
      unless val.is_a?(TrueClass) || val.is_a?(FalseClass)
        raise InvalidValue.new(:skip_packager, "be TrueClass or FalseClass")
      end
      @skip_package ||= val
    end
    expose :skip_packager

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # Execute this packager by running the following phases in order:
    #
    #   - setup
    #   - build
    #
    def run!
      # Ensure the package directory exists
      create_directory(Config.package_dir)

      measure("Packaging time") do
        # Run the setup and build sequences
        instance_eval(&self.class.setup) if self.class.setup
        instance_eval(&self.class.build) if self.class.build

        # Render the metadata
        Metadata.generate(package_path, project)

        # Ensure the temporary directory is removed at the end of a successful
        # run. Without removal, successful builds will "leak" in /tmp and cause
        # increased disk usage.
        #
        # Instead of having this as an +ensure+ block, failed builds will persist
        # this directory so developers can go poke around and figure out why the
        # build failed.
        remove_directory(staging_dir)
      end
    end

    #
    # The path to the rendered package on disk. This is calculated by
    # combining the {Config#package_dir} with the name of the package, as
    # calculated by one of the subclasses.
    #
    # @return [String]
    #
    def package_path
      File.expand_path(File.join(Config.package_dir, package_name))
    end

    #
    # The path to the staging dir on disk.
    #
    # @return [String]
    #
    def staging_dir
      @staging_dir ||= Dir.mktmpdir(project.package_name)
    end

    #
    # Helper method to produce staging paths
    #
    # @return [String]
    #
    def staging_dir_path(file_name)
      File.join(staging_dir, file_name)
    end

    #
    # @!group Resource methods
    # --------------------------------------------------

    #
    # The preferred path to a resource on disk with the given +name+. This
    # method will perform an "intelligent" search for a resource by first
    # looking in the local project expected {#resources_path}, and then falling
    # back to Omnibus' files.
    #
    # @example When the resource exists locally
    #   resource_path("spec.erb") #=> "/path/to/project/resources/rpm/spec.erb"
    #
    # @example When the resource does not exist locally
    #   resource_path("spec.erb") #=> "/omnibus-x.y.z/resources/rpm/spec.erb"
    #
    # @param [String] name
    #   the name of the resource on disk to find
    #
    def resource_path(name)
      local = File.join(resources_path, name)

      if File.exist?(local)
        log.info(log_key) { "Using local resource `#{name}' from `#{local}'" }
        local
      else
        log.debug(log_key) { "Using vendored resource `#{name}'" }
        Omnibus.source_root.join("resources/#{id}/#{name}").to_s
      end
    end

    #
    # The path where this packager's resources reside on disk. This is the
    # given {Project#resources_path} combined with the packager's {#id}.
    #
    # @example RPM packager
    #   resources_path #=> "/path/to/project/resources/rpm"
    #
    # @return [String]
    #
    def resources_path
      File.expand_path("#{project.resources_path}/#{id}")
    end

    #
    # @!endgroup
    # --------------------------------------------------
  end
end


#
# Copyright 2014-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the ''License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "rubygems/package"
require "zlib"

module Omnibus
  class Compressor::TGZ < Compressor::Base
    id :tgz

    setup do
      # Copy the compiled package (.deb, .rpm, .pkg) into the staging directory
      copy_file(packager.package_path, "#{staging_dir}/")
    end

    build do
      write_tgz
    end

    #
    # @!group DSL methods
    # --------------------------------------------------

    #
    # Set or return the level of compression to use when generating the zipped
    # tarball. Default: max compression.
    #
    # @example
    #   compression_level 9
    #
    # @param [Fixnum] val
    #   the compression level to use
    #
    # @return [Fixnum]
    #
    def compression_level(val = NULL)
      if null?(val)
        @compression_level || Zlib::BEST_COMPRESSION
      else
        unless val.is_a?(Integer)
          raise InvalidValue.new(:compression_level, "be an Integer")
        end

        unless val.between?(1, 9)
          raise InvalidValue.new(:compression_level, "be between 1-9")
        end

        @compression_level = val
      end
    end
    expose :compression_level

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @see Base#package_name
    #
    def package_name
      "#{packager.package_name}.tar.gz"
    end

    #
    # Write the tar.gz to disk, reading in 1024 bytes at a time to reduce
    # memory usage.
    #
    # @return [void]
    #
    def write_tgz
      # Grab the contents of the gzipped tarball for reading
      contents = gzipped_tarball

      # Write the .tar.gz into the staging directory
      File.open("#{staging_dir}/#{package_name}", "wb") do |tgz|
        while chunk = contents.read(1024)
          tgz.write(chunk)
        end
      end

      # Copy the .tar.gz into the package directory
      FileSyncer.glob("#{staging_dir}/*.tar.gz").each do |tgz|
        copy_file(tgz, Config.package_dir)
      end
    end

    #
    # Create an in-memory tarball from the given packager.
    #
    # @return [StringIO]
    #
    def tarball
      tarfile = StringIO.new("")
      Gem::Package::TarWriter.new(tarfile) do |tar|
        path = "#{staging_dir}/#{packager.package_name}"
        name = packager.package_name
        mode = File.stat(path).mode

        tar.add_file(name, mode) do |tf|
          File.open(path, "rb") do |file|
            tf.write(file.read)
          end
        end
      end

      tarfile.rewind
      tarfile
    end

    #
    # Create the gzipped tarball. See {#tarball} for how the tarball is
    # constructed. This method uses maximum gzip compression, unless the user
    # specifies a different compression level.
    #
    # @return [StringIO]
    #
    def gzipped_tarball
      gz = StringIO.new("")
      z = Zlib::GzipWriter.new(gz, compression_level)
      z.write(tarball.string)
      z.close

      # z was closed to write the gzip footer, so
      # now we need a new StringIO
      StringIO.new(gz.string)
    end
  end
end


#
# Copyright 2012-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "fileutils"
require "omnibus/download_helpers"

module Omnibus
  class NetFetcher < Fetcher
    include DownloadHelpers

    # Use 7-zip to extract 7z/zip for Windows
    WIN_7Z_EXTENSIONS = %w{.7z .zip}.freeze

    # tar probably has compression scheme linked in, otherwise for tarballs
    COMPRESSED_TAR_EXTENSIONS = %w{.tar.gz .tgz tar.bz2 .tar.xz .txz .tar.lzma}.freeze
    TAR_EXTENSIONS = COMPRESSED_TAR_EXTENSIONS + [".tar"]

    ALL_EXTENSIONS = WIN_7Z_EXTENSIONS + TAR_EXTENSIONS

    # Digest types used for verifying file checksums
    DIGESTS = [:sha512, :sha256, :sha1, :md5].freeze

    #
    # A fetch is required if the downloaded_file (such as a tarball) does not
    # exist on disk, or if the checksum of the downloaded file is different
    # than the given checksum.
    #
    # @return [true, false]
    #
    def fetch_required?
      !(File.exist?(downloaded_file) && digest(downloaded_file, digest_type) == checksum)
    end

    #
    # The version identifier for this remote location. This is computed using
    # the name of the software, the version of the software, and the checksum.
    #
    # @return [String]
    #
    def version_guid
      "#{digest_type}:#{checksum}"
    end

    #
    # Clean the project directory if it exists and actually extract
    # the downloaded file.
    #
    # @return [true, false]
    #   true if the project directory was removed, false otherwise
    #
    def clean
      needs_cleaning = File.exist?(project_dir)
      if needs_cleaning
        log.info(log_key) { "Cleaning project directory `#{project_dir}'" }
        FileUtils.rm_rf(project_dir)
      end
      create_required_directories
      deploy
      needs_cleaning
    end

    #
    # Fetch the given software definition. This method **always** fetches the
    # file, even if it already exists on disk! You should use {#fetch_required?}
    # to guard against this check in your implementation.
    #
    # @return [void]
    #
    def fetch
      log.info(log_key) { "Downloading from `#{download_url}'" }

      create_required_directories
      download
      verify_checksum!
    end

    #
    # The version for this item in the cache. This is the digest of downloaded
    # file and the URL where it was downloaded from.
    #
    # This method is called *before* clean but *after* fetch. Do not ever
    # use the contents of the project_dir here.
    #
    # @return [String]
    #
    def version_for_cache
      "download_url:#{source[:url]}|#{digest_type}:#{checksum}"
    end

    #
    # Returned the resolved version for the manifest.  Since this is a
    # remote URL, there is no resolution, the version is what we said
    # it is.
    #
    # @return [String]
    #
    def self.resolve_version(version, source)
      version
    end

    #
    # The path on disk to the downloaded asset. The filename is defined by
    # +source :cached_name+. If ommited, then it comes from the software's
    # +source :url+ value
    #
    # @return [String]
    #
    def downloaded_file
      filename = source[:cached_name] if source[:cached_name]
      filename ||= File.basename(source[:url], "?*")
      File.join(Config.cache_dir, filename)
    end

    #
    # The checksum as defined by the user in the software definition.
    #
    # @return [String]
    #
    def checksum
      source[digest_type]
    end

    private

    #
    # The URL from which to download the software - this comes from the
    # software's +source :url+ value.
    #
    # If S3 caching is enabled, this is the download URL for the software from
    # the S3 bucket as defined in the {Config}.
    #
    # @return [String]
    #
    def download_url
      if Config.use_s3_caching
        S3Cache.url_for(self)
      else
        source[:url]
      end
    end

    #
    # Download the given file using Ruby's +OpenURI+ implementation. This method
    # may emit warnings as defined in software definitions using the +:warning+
    # key.
    #
    # @return [void]
    #
    def download
      log.warn(log_key) { source[:warning] } if source.key?(:warning)

      options = {}

      if source[:unsafe]
        log.warn(log_key) { "Permitting unsafe redirects!" }
        options[:allow_unsafe_redirects] = true
      end

      # Set the cookie if one was given
      options["Cookie"] = source[:cookie] if source[:cookie]
      options["Authorization"] = source[:authorization] if source[:authorization]

      download_file!(download_url, downloaded_file, options)
    end

    #
    # Extract the downloaded file, using the magical logic based off of the
    # ending file extension. In the rare event the file cannot be extracted, it
    # is copied over as a raw file.
    #
    def deploy
      if downloaded_file.end_with?(*ALL_EXTENSIONS)
        log.info(log_key) { "Extracting `#{safe_downloaded_file}' to `#{safe_project_dir}'" }
        extract
      else
        log.info(log_key) { "`#{safe_downloaded_file}' is not an archive - copying to `#{safe_project_dir}'" }

        if File.directory?(downloaded_file)
          # If the file itself was a directory, copy the whole thing over. This
          # seems unlikely, because I do not think it is a possible to download
          # a folder, but better safe than sorry.
          FileUtils.cp_r("#{downloaded_file}/.", project_dir)
        else
          # In the more likely case that we got a "regular" file, we want that
          # file to live **inside** the project directory. project_dir should already
          # exist due to create_required_directories
          FileUtils.cp(downloaded_file, project_dir)
        end
      end
    end

    #
    # Extracts the downloaded archive file into project_dir.
    #
    # On windows, this is a fuster cluck and we allow users to specify the
    # preferred extractor to be used. The default is to use tar. User overrides
    # can be set in source[:extract] as:
    #   :tar - use tar.exe and fail on errors (default strategy).
    #   :seven_zip - use 7zip for all tar/compressed tar files on windows.
    #   :lax_tar - use tar.exe on windows but ignore errors.
    #
    # Both 7z and bsdtar have issues on windows.
    #
    # 7z cannot extract and untar at the same time. You need to extract to a
    # temporary location and then extract again into project_dir.
    #
    # 7z also doesn't handle symlinks well. A symlink to a non-existent
    # location simply results in a text file with the target path written in
    # it. It does this without throwing any errors.
    #
    # bsdtar will exit(1) if it is encounters symlinks on windows. So we can't
    # use shellout! directly.
    #
    # bsdtar will also exit(1) and fail to overwrite files at the destination
    # during extraction if a file already exists at the destination and is
    # marked read-only. This used to be a problem when we weren't properly
    # cleaning an existing project_dir. It should be less of a problem now...
    # but who knows.
    #
    def extract
      # Only used by tar
      compression_switch = ""
      compression_switch = "z"        if downloaded_file.end_with?("gz")
      compression_switch = "--lzma -" if downloaded_file.end_with?("lzma")
      compression_switch = "j"        if downloaded_file.end_with?("bz2")
      compression_switch = "J"        if downloaded_file.end_with?("xz")

      if Ohai["platform"] == "windows"
        if downloaded_file.end_with?(*TAR_EXTENSIONS) && source[:extract] != :seven_zip
          returns = [0]
          returns << 1 if source[:extract] == :lax_tar

          shellout!("tar #{compression_switch}xf #{safe_downloaded_file} -C#{safe_project_dir}", returns: returns)
        elsif downloaded_file.end_with?(*COMPRESSED_TAR_EXTENSIONS)
          Dir.mktmpdir do |temp_dir|
            log.debug(log_key) { "Temporarily extracting `#{safe_downloaded_file}' to `#{temp_dir}'" }

            shellout!("7z.exe x #{safe_downloaded_file} -o#{windows_safe_path(temp_dir)} -r -y")

            fname = File.basename(downloaded_file, File.extname(downloaded_file))
            fname << ".tar" if downloaded_file.end_with?("tgz", "txz")
            next_file = windows_safe_path(File.join(temp_dir, fname))

            log.debug(log_key) { "Temporarily extracting `#{next_file}' to `#{safe_project_dir}'" }
            shellout!("7z.exe x #{next_file} -o#{safe_project_dir} -r -y")
          end
        else
          shellout!("7z.exe x #{safe_downloaded_file} -o#{safe_project_dir} -r -y")
        end
      elsif downloaded_file.end_with?(".7z")
        shellout!("7z x #{safe_downloaded_file} -o#{safe_project_dir} -r -y")
      elsif downloaded_file.end_with?(".zip")
        shellout!("unzip #{safe_downloaded_file} -d #{safe_project_dir}")
      else
        shellout!("#{tar} #{compression_switch}xf #{safe_downloaded_file} -C#{safe_project_dir}")
      end
    end

    #
    # The digest type defined in the software definition
    #
    # @raise [ChecksumMissing]
    #   if the checksum does not exist
    #
    # @return [Symbol]
    #
    def digest_type
      DIGESTS.each do |digest|
        return digest if source.key? digest
      end
      raise ChecksumMissing.new(self)
    end

    #
    # Verify the downloaded file has the correct checksum.
    #
    # @raise [ChecksumMismatch]
    #   if the checksum does not match
    #
    def verify_checksum!
      log.info(log_key) { "Verifying checksum" }

      expected = checksum
      actual   = digest(downloaded_file, digest_type)

      if expected != actual
        raise ChecksumMismatch.new(self, expected, actual)
      end
    end

    def safe_project_dir
      windows_safe_path(project_dir)
    end

    def safe_downloaded_file
      windows_safe_path(downloaded_file)
    end

    #
    # The command to use for extracting this piece of software.
    #
    # @return [[String]]
    #
    def extract_command
    end

    #
    # Primitively determine whether we should use gtar or tar to untar a file.
    # If gtar is present, we will use gtar (AIX). Otherwise, we fallback to tar.
    #
    # @return [String]
    #
    def tar
      Omnibus.which("gtar") ? "gtar" : "tar"
    end
  end
end


#
# Copyright 2016-2018 Chef Software, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

module Omnibus
  class Packager::WindowsBase < Packager::Base
    DEFAULT_TIMESTAMP_SERVERS = ["http://timestamp.digicert.com",
                                 "http://timestamp.verisign.com/scripts/timestamp.dll"].freeze

    #
    # Set the signing certificate name
    #
    # @example
    #   signing_identity 'FooCert'
    #   signing_identity 'FooCert', store: 'BarStore'
    #
    # @param [String] thumbprint
    #   the thumbprint of the certificate in the certificate store
    # @param [Hash<Symbol, String>] params
    #   an optional hash that defines the parameters for the singing identity
    #
    # @option params [String] :store (My)
    #   The name of the certificate store which contains the certificate
    # @option params [Array<String>, String] :timestamp_servers
    #   A trusted timestamp server or a list of truested timestamp servers to
    #   be tried. They are tried in the order provided.
    # @option params [TrueClass, FalseClass] :machine_store (false)
    #   If set to true, the local machine store will be searched for a valid
    #   certificate. Otherwise, the current user store is used
    #
    #   Setting nothing will default to trying ['http://timestamp.digicert.com',
    #   'http://timestamp.verisign.com/scripts/timestamp.dll']
    #
    # @return [Hash{:thumbprint => String, :store => String, :timestamp_servers => Array[String]}]
    #
    def signing_identity(thumbprint = NULL, params = NULL)
      unless null?(thumbprint)
        @signing_identity = {}
        unless thumbprint.is_a?(String)
          raise InvalidValue.new(:signing_identity, "be a String")
        end

        @signing_identity[:thumbprint] = thumbprint

        if !null?(params)
          unless params.is_a?(Hash)
            raise InvalidValue.new(:params, "be a Hash")
          end

          valid_keys = [:store, :timestamp_servers, :machine_store, :algorithm]
          invalid_keys = params.keys - valid_keys
          unless invalid_keys.empty?
            raise InvalidValue.new(:params, "contain keys from [#{valid_keys.join(', ')}]. "\
                                   "Found invalid keys [#{invalid_keys.join(', ')}]")
          end

          if !params[:machine_store].nil? && !(
             params[:machine_store].is_a?(TrueClass) ||
             params[:machine_store].is_a?(FalseClass))
            raise InvalidValue.new(:params, "contain key :machine_store of type TrueClass or FalseClass")
          end
        else
          params = {}
        end

        @signing_identity[:store] = params[:store] || "My"
        @signing_identity[:algorithm] = params[:algorithm] || "SHA256"
        servers = params[:timestamp_servers] || DEFAULT_TIMESTAMP_SERVERS
        @signing_identity[:timestamp_servers] = [servers].flatten
        @signing_identity[:machine_store] = params[:machine_store] || false
      end

      @signing_identity
    end
    expose :signing_identity

    def thumbprint
      signing_identity[:thumbprint]
    end

    def algorithm
      signing_identity[:algorithm]
    end

    def cert_store_name
      signing_identity[:store]
    end

    def timestamp_servers
      signing_identity[:timestamp_servers]
    end

    def machine_store?
      signing_identity[:machine_store]
    end

    #
    # Iterates through available timestamp servers and tries to sign
    # the file with with each server, stopping after the first to succeed.
    # If none succeed, an exception is raised.
    #
    def sign_package(package_file)
      success = false
      timestamp_servers.each do |ts|
        success = try_sign(package_file, ts)
        break if success
      end
      raise FailedToSignWindowsPackage.new if !success
    end

    def try_sign(package_file, url)
      cmd = Array.new.tap do |arr|
        arr << "signtool.exe"
        arr << "sign /v"
        arr << "/t #{url}"
        arr << "/fd #{algorithm}"
        arr << "/sm" if machine_store?
        arr << "/s #{cert_store_name}"
        arr << "/sha1 #{thumbprint}"
        arr << "/d #{project.package_name}"
        arr << "\"#{package_file}\""
      end.join(" ")
      status = shellout(cmd)
      if status.exitstatus != 0
        log.warn(log_key) do
          <<-EOH.strip
                Failed to add timestamp with timeserver #{url}

                STDOUT
                ------
                #{status.stdout}

                STDERR
                ------
                #{status.stderr}
          EOH
        end
      end
      status.exitstatus == 0
    end

    #
    # Get the certificate subject of the signing identity
    #
    # @return [String]
    #
    def certificate_subject
      return "CN=#{project.package_name}" unless signing_identity
      store = machine_store? ? "LocalMachine" : "CurrentUser"
      cmd = Array.new.tap do |arr|
        arr << "powershell.exe"
        arr << "-ExecutionPolicy Bypass"
        arr << "-NoProfile"
        arr << "-Command (Get-Item Cert:/#{store}/#{cert_store_name}/#{thumbprint}).Subject"
      end.join(" ")

      shellout!(cmd).stdout.strip
    end

    #
    # Parse and return the version from the {Project#build_version}.
    #
    # A project's +build_version+ looks something like:
    #
    #     dev builds => 11.14.0-alpha.1+20140501194641.git.94.561b564
    #                => 0.0.0+20140506165802.1
    #
    #     rel builds => 11.14.0.alpha.1 || 11.14.0
    #
    # The appx and msi version specs expects a version that looks like X.Y.Z.W where
    # X, Y, Z & W are all 32 bit integers.
    #
    # @return [String]
    #
    def windows_package_version
      major, minor, patch = project.build_version.split(/[.+-]/)
      [major, minor, patch, project.build_iteration].join(".")
    end
  end
end


#
# Copyright 2012-2017, Chef Software Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require "fileutils"
require "uri"
require "omnibus/manifest_entry"

module Omnibus
  class Software
    class << self
      #
      # @param [Project] project
      #   the project that loaded this software definition
      # @param [String] name
      #   the path to the software definition to load from disk
      #
      # @return [Software]
      #
      def load(project, name, manifest)
        loaded_softwares["#{project.name}:#{name}"] ||= begin
          filepath = Omnibus.software_path(name)

          if filepath.nil?
            raise MissingSoftware.new(name)
          else
            log.internal(log_key) do
              "Loading software `#{name}' from `#{filepath}' using overrides from #{project.name}."
            end
          end

          instance = new(project, filepath, manifest)
          instance.evaluate_file(filepath)
          instance.load_dependencies

          # Add the loaded component to the library
          project.library.component_added(instance)

          instance
        end
      end

      #
      # Reset cached software information.
      #
      def reset!
        @loaded_softwares = nil
      end

      private

      #
      # The list of softwares loaded thus far.
      #
      # @return [Hash<String, Software>]
      #
      def loaded_softwares
        @loaded_softwares ||= {}
      end
    end

    include Cleanroom
    include Digestable
    include Logging
    include NullArgumentable
    include Sugarable
    include Util

    attr_reader :manifest

    #
    # Create a new software object.
    #
    # @param [Project] project
    #   the Omnibus project that instantiated this software definition
    # @param [String] filepath
    #   the path to where this software definition lives on disk
    # @param [String] manifest
    #   the user-supplied software manifest
    #
    # @return [Software]
    #
    def initialize(project, filepath = nil, manifest = nil)
      unless project.is_a?(Project)
        raise ArgumentError,
          "`project' must be a kind of `Omnibus::Project', but was `#{project.class.inspect}'!"
      end

      # Magical methods
      @filepath = filepath
      @project  = project
      @manifest = manifest

      # Overrides
      @overrides = NULL
    end

    def manifest_entry
      @manifest_entry ||= if manifest
                            log.info(log_key) { "Using user-supplied manifest entry for #{name}" }
                            manifest.entry_for(name)
                          else
                            log.info(log_key) { "Resolving manifest entry for #{name}" }
                            to_manifest_entry
                          end
    end

    #
    # Compare two software projects (by name).
    #
    # @return [1, 0, -1]
    #
    def <=>(other)
      name <=> other.name
    end

    #
    # @!group DSL methods
    #
    # The following DSL methods are available from within software definitions.
    # --------------------------------------------------

    #
    # The project that created this software.
    #
    # @return [Project]
    #
    def project
      @project
    end
    expose :project

    #
    # **[Required]** Sets or retreives the name of the software.
    #
    # @raise [MissingRequiredAttribute]
    #
    # @example
    #   name 'libxslt'
    #
    # @param [String] val
    #   name of the Software
    #
    # @return [String]
    #
    def name(val = NULL)
      if null?(val)
        @name || raise(MissingRequiredAttribute.new(self, :name, "libxslt"))
      else
        @name = val
      end
    end
    expose :name

    #
    # Sets the description of the software.
    #
    # @example
    #   description 'Installs libxslt'
    #
    # @param [String] val
    #   the description of the software
    #
    # @return [String]
    #
    def description(val = NULL)
      if null?(val)
        @description
      else
        @description = val
      end
    end
    expose :description

    #
    # Sets the maintainer of the software.  Currently this is for
    # human consumption only and the tool doesn't do anything with it.
    #
    # @example
    #   maintainer "Joe Bob <joeb@chef.io>"
    #
    # @param [String] val
    #   the maintainer of this sofware def
    #
    # @return [String]
    #
    def maintainer(val = NULL)
      if null?(val)
        @maintainer
      else
        @description = val
      end
    end
    expose :maintainer

    #
    # Add a software dependency to this software.
    #
    # @example
    #   dependency 'libxml2'
    #   dependency 'libpng'
    #
    # @param [String] val
    #   the name of a software dependency
    #
    # @return [Array<String>]
    #   the list of current dependencies
    #
    def dependency(val)
      dependencies << val
      dependencies.dup
    end
    expose :dependency

    #
    # Set or retrieve the source for the software.
    #
    # @raise [InvalidValue]
    #   if the parameter is not a Hash
    # @raise [InvalidValue]
    #   if the hash includes extraneous keys
    # @raise [InvalidValue]
    #   if the hash declares keys that cannot work together
    #   (like +:git+ and +:path+)
    #
    # @example
    #   source url: 'http://ftp.gnu.org/gnu/autoconf/autoconf-2.68.tar.gz',
    #          md5: 'c3b5247592ce694f7097873aa07d66fe'
    #
    # @param [Hash<Symbol, String>] val
    #   a single key/pair that defines the kind of source and a path specifier
    #
    # @option val [String] :git (nil)
    #   a git URL
    # @option val [String] :github (nil)
    #   a github ORG/REPO pair (e.g. chef/chef) that will be transformed to https://github.com/ORG/REPO.git
    # @option val [String] :url (nil)
    #   general URL
    # @option val [String] :path (nil)
    #   a fully-qualified local file system path
    # @option val [String] :md5 (nil)
    #   the MD5 checksum of the downloaded artifact
    # @option val [String] :sha1 (nil)
    #   the SHA1 checksum of the downloaded artifact
    # @option val [String] :sha256 (nil)
    #   the SHA256 checksum of the downloaded artifact
    # @option val [String] :sha512 (nil)
    #   the SHA512 checksum of the downloaded artifact
    #
    # Only used in net_fetcher:
    #
    # @option val [String] :cookie (nil)
    #   a cookie to set
    # @option val [String] :authorization (nil)
    #   an authorization header to set
    # @option val [String] :warning (nil)
    #   a warning message to print when downloading
    # @option val [Symbol] :extract (nil)
    #   either :tar, :lax_tar :seven_zip
    #
    # Only used in path_fetcher:
    #
    # @option val [Hash] :options (nil)
    #   flags/options that are passed through to file_syncer in path_fetcher
    #
    # Only used in git_fetcher:
    #
    # @option val [Boolean] :submodules (false)
    #   clone git submodules
    #
    # If multiple checksum types are provided, only the strongest will be used.
    #
    # @return [Hash]
    #
    def source(val = NULL)
      unless null?(val)
        unless val.is_a?(Hash)
          raise InvalidValue.new(:source,
            "be a kind of `Hash', but was `#{val.class.inspect}'")
        end

        val = canonicalize_source(val)

        extra_keys = val.keys - [
          :git, :file, :path, :url, # fetcher types
          :md5, :sha1, :sha256, :sha512, # hash type - common to all fetchers
          :cookie, :warning, :unsafe, :extract, :cached_name, :authorization, # used by net_fetcher
          :options, # used by path_fetcher
          :submodules # used by git_fetcher
        ]
        unless extra_keys.empty?
          raise InvalidValue.new(:source,
            "only include valid keys. Invalid keys: #{extra_keys.inspect}")
        end

        duplicate_keys = val.keys & [:git, :file, :path, :url]
        unless duplicate_keys.size < 2
          raise InvalidValue.new(:source,
            "not include duplicate keys. Duplicate keys: #{duplicate_keys.inspect}")
        end

        @source ||= {}
        @source.merge!(val)
      end

      override = canonicalize_source(overrides[:source])
      apply_overrides(:source, override)
    end
    expose :source

    #
    # Set or retrieve the {#default_version} of the software to build.
    #
    # @example
    #   default_version '1.2.3'
    #
    # @param [String] val
    #   the default version to set for the software.
    #   For a git source, the default version may be a git ref (e.g. tag, branch name, or sha).
    #
    # @return [String]
    #
    def default_version(val = NULL)
      if null?(val)
        @version
      else
        @version = val
      end
    end
    expose :default_version

    #
    # Set or retrieve the {#license} of the software to build.
    #
    # @example
    #   license 'Apache 2.0'
    #
    # @param [String] val
    #   the license to set for the software.
    #
    # @return [String]
    #
    def license(val = NULL)
      if null?(val)
        @license || "Unspecified"
      else
        @license = val
      end
    end
    expose :license

    #
    # Set or retrieve the location of a {#license_file}
    # of the software.  It can either be a relative path inside
    # the source package or a URL.
    #
    #
    # @example
    #   license_file 'LICENSES/artistic.txt'
    #
    # @param [String] val
    #   the location of the license file for the software.
    #
    # @return [String]
    #
    def license_file(file)
      license_files << file
      license_files.dup
    end
    expose :license_file

    #
    # Skip collecting licenses of transitive dependencies for this software
    #
    # @example
    #   skip_transitive_dependency_licensing true
    #
    # @param [Boolean] val
    #   set or reset transitive dependency license collection
    #
    # @return [Boolean]
    #
    def skip_transitive_dependency_licensing(val = NULL)
      if null?(val)
        @skip_transitive_dependency_licensing || false
      else
        @skip_transitive_dependency_licensing = val
      end
    end
    expose :skip_transitive_dependency_licensing

    #
    # Set or retrieve licensing information of the dependencies.
    # The information set is not validated or inspected. It is directly
    #   passed to LicenseScout.
    #
    # @example
    # dependency_licenses [
    #   {
    #     dependency_name: "logstash-output-websocket",
    #     dependency_manager: "logstash_plugin",
    #     license: "Apache-2.0",
    #     license_file: [
    #       "relative/path/to/license/file",
    #       "https://download.elastic.co/logstash/LICENSE"
    #     ]
    #   },
    #   ...
    # ]
    #
    # @param [Hash] val
    # @param [Array<Hash>] val
    #   dependency license information.
    # @return [Array<Hash>]
    #
    def dependency_licenses(val = NULL)
      if null?(val)
        @dependency_licenses || nil
      else
        @dependency_licenses = Array(val)
      end
    end
    expose :dependency_licenses

    #
    # Evaluate a block only if the version matches.
    #
    # @example
    #   version '1.2.3' do
    #     source path: '/local/path/to/software-1.2.3'
    #   end
    #
    # @param [String] val
    #   the version of the software
    # @param [Proc] block
    #   the block to run if the version we are building matches the argument
    #
    # @return [String, Proc]
    #
    def version(val = NULL, &block)
      final_version = apply_overrides(:version)

      if block_given?
        if val.equal?(NULL)
          raise InvalidValue.new(:version,
            "pass a block when given a version argument")
        else
          if val == final_version
            #
            # Unfortunately we need to make a specific logic here for license files.
            # We support multiple calls `license_file` and we support overriding the
            # license files inside a version block. We can not differentiate whether
            # `license_file` is being called from a version block or not. So we need
            # to check if the license files are being overridden during the call to
            # block.
            #
            # If so we use the new set, otherwise we restore the old license files.
            #
            current_license_files = @license_files
            @license_files = []

            yield

            new_license_files = @license_files

            if new_license_files.empty?
              @license_files = current_license_files
            end
          end
        end
      end

      return if final_version.nil?

      begin
        Chef::Sugar::Constraints::Version.new(final_version)
      rescue ArgumentError
        log.warn(log_key) do
          "Version #{final_version} for software #{name} was not parseable. " \
          "Comparison methods such as #satisfies? will not be available for this version."
        end
        final_version
      end
    end
    expose :version

    #
    # Add a file to the healthcheck whitelist.
    #
    # @example
    #   whitelist_file '/path/to/file'
    #
    # @param [String, Regexp] file
    #   the name of a file to ignore in the healthcheck
    #
    # @return [Array<String>]
    #   the list of currently whitelisted files
    #
    def whitelist_file(file)
      file = Regexp.new(file) unless file.kind_of?(Regexp)
      whitelist_files << file
      whitelist_files.dup
    end
    expose :whitelist_file

    #
    # The path relative to fetch_dir where relevant project files are
    # stored. This applies to all sources.
    #
    # Any command executed in the build step are run after cwd-ing into
    # this path. The default is to stay at the top level of fetch_dir
    # where the source tar-ball/git repo/file/directory has been staged.
    #
    # @example
    #   relative_path 'example-1.2.3'
    #
    # @param [String] val
    #   the relative path inside the source directory. default: '.'
    #
    # Due to back-compat reasons, relative_path works completely
    # differently for anything other than tar-balls/archives. In those
    # situations, the source is checked out rooted at relative_path
    # instead 'cause reasons.
    # TODO: Fix this in omnibus 6.
    #
    # @return [String]
    #
    def relative_path(val = NULL)
      if null?(val)
        @relative_path || "."
      else
        @relative_path = val
      end
    end
    expose :relative_path

    #
    # The path where the extracted software lives. All build commands
    # associated with this software definition are run for under this path.
    #
    # Why is it called project_dir when this is a software definition, I hear
    # you cry. Because history and reasons. This really is a location
    # underneath the global omnibus source directory that you have focused
    # into using relative_path above.
    #
    # These are not the only files your project fetches. They are merely the
    # files that your project cares about. A source tarball may contain more
    # directories that are not under your project_dir.
    #
    # @return [String]
    #
    def project_dir
      File.expand_path("#{fetch_dir}/#{relative_path}")
    end
    expose :project_dir

    #
    # The path where the software will be built.
    #
    # @return [String]
    #
    def build_dir
      File.expand_path("#{Config.build_dir}/#{project.name}")
    end
    expose :build_dir

    #
    # The directory where this software is installed on disk.
    #
    # @example
    #   { 'PATH' => "#{install_dir}/embedded/bin:#{ENV["PATH"]}", }
    #
    # @return [String]
    #
    def install_dir
      @project.install_dir
    end
    expose :install_dir

    #
    # Define a series of {Builder} DSL commands that are executed to build the
    # software.
    #
    # @see Builder
    #
    # @param [Proc] block
    #   a block of build commands
    #
    # @return [Proc]
    #   the build block
    #
    def build(&block)
      builder.evaluate(&block)
    end
    expose :build

    #
    # The path to the downloaded file from a NetFetcher.
    #
    # @deprecated There is no replacement for this DSL method
    #
    def project_file
      if fetcher && fetcher.is_a?(NetFetcher)
        log.deprecated(log_key) do
          "project_file (DSL). This is a property of the NetFetcher and will " \
          "not be publically exposed in the next major release. In general, " \
          "you should not be using this method in your software definitions " \
          "as it is an internal implementation detail of the NetFetcher. If " \
          "you disagree with this statement, you should open an issue on the " \
          "Omnibus repository on GitHub an explain your use case. For now, " \
          "I will return the path to the downloaded file on disk, but please " \
          "rethink the problem you are trying to solve :)."
        end

        fetcher.downloaded_file
      else
        log.warn(log_key) do
          "Cannot retrieve a `project_file' for software `#{name}'. This " \
          "attribute is actually an internal representation that is unique " \
          "to the NetFetcher class and requires the use of a `source' " \
          "attribute that is declared using a `:url' key. For backwards-" \
          "compatability, I will return `nil', but this is most likely not " \
          "your desired behavior."
        end

        nil
      end
    end
    expose :project_file

    #
    # Add standard compiler flags to the environment hash to produce omnibus
    # binaries (correct RPATH, etc).
    #
    # Supported options:
    #    :aix => :use_gcc    force using gcc/g++ compilers on aix
    #
    # @param [Hash] env
    # @param [Hash] opts
    #
    # @return [Hash]
    #
    def with_standard_compiler_flags(env = {}, opts = {})
      env ||= {}
      opts ||= {}
      compiler_flags =
        case Ohai["platform"]
        when "aix"
          {
            "CC" => "xlc_r -q64",
            "CXX" => "xlC_r -q64",
            "CFLAGS" => "-q64 -I#{install_dir}/embedded/include -D_LARGE_FILES -O",
            "LDFLAGS" => "-q64 -L#{install_dir}/embedded/lib -Wl,-blibpath:#{install_dir}/embedded/lib:/usr/lib:/lib",
            "LD" => "ld -b64",
            "OBJECT_MODE" => "64",
            "ARFLAGS" => "-X64 cru",
          }
        when "solaris2"
          if platform_version.satisfies?("<= 5.10")
            solaris_flags = {
              # this override is due to a bug in libtool documented here:
              # http://lists.gnu.org/archive/html/bug-libtool/2005-10/msg00004.html
              "CC" => "gcc -static-libgcc",
              "LDFLAGS" => "-R#{install_dir}/embedded/lib -L#{install_dir}/embedded/lib -static-libgcc",
              "CFLAGS" => "-I#{install_dir}/embedded/include -O2",
            }
          elsif platform_version.satisfies?(">= 5.11")
            solaris_flags = {
              "CC" => "gcc -m64 -static-libgcc",
              "LDFLAGS" => "-Wl,-rpath,#{install_dir}/embedded/lib -L#{install_dir}/embedded/lib -static-libgcc",
              "CFLAGS" => "-I#{install_dir}/embedded/include -O2",
            }
          end
          solaris_flags
        when "freebsd"
          {
            "CC" => "clang",
            "CXX" => "clang++",
            "LDFLAGS" => "-L#{install_dir}/embedded/lib",
            "CFLAGS" => "-I#{install_dir}/embedded/include -O2",
          }
        when "suse"
          suse_flags = {
            "LDFLAGS" => "-Wl,-rpath,#{install_dir}/embedded/lib -L#{install_dir}/embedded/lib",
            "CFLAGS" => "-I#{install_dir}/embedded/include -O2",
          }
          # Enable gcc version 4.8 if it is available
          if which("gcc-4.8") && platform_version.satisfies?("< 12")
            suse_flags["CC"] = "gcc-4.8"
            suse_flags["CXX"] = "g++-4.8"
          end
          suse_flags
        when "windows"
          arch_flag = windows_arch_i386? ? "-m32" : "-m64"
          opt_flag = windows_arch_i386? ? "-march=i686" : "-march=x86-64"
          {
            "LDFLAGS" => "-L#{install_dir}/embedded/lib #{arch_flag} -fno-lto",
            # We do not wish to enable SSE even though we target i686 because
            # of a stack alignment issue with some libraries. We have not
            # exactly ascertained the cause but some compiled library/binary
            # violates gcc's assumption that the stack is going to be 16-byte
            # aligned which is just fine as long as one is pushing 32-bit
            # values from general purpose registers but stuff hits the fan as
            # soon as gcc emits aligned SSE xmm register spills which generate
            # GPEs and terminate the application very rudely with very little
            # to debug with.
            "CFLAGS" => "-I#{install_dir}/embedded/include #{arch_flag} -O3 #{opt_flag}",
          }
        else
          {
            "LDFLAGS" => "-Wl,-rpath,#{install_dir}/embedded/lib -L#{install_dir}/embedded/lib",
            "CFLAGS" => "-I#{install_dir}/embedded/include -O2",
          }
        end

      # merge LD_RUN_PATH into the environment.  most unix distros will fall
      # back to this if there is no LDFLAGS passed to the linker that sets
      # the rpath.  the LDFLAGS -R or -Wl,-rpath will override this, but in
      # some cases software may drop our LDFLAGS or think it knows better
      # and edit them, and we *really* want the rpath setting and do know
      # better.  in that case LD_RUN_PATH will probably survive whatever
      # edits the configure script does
      extra_linker_flags = {
        "LD_RUN_PATH" => "#{install_dir}/embedded/lib",
      }

      if solaris2?
        ld_options = "-R#{install_dir}/embedded/lib"

        if platform_version.satisfies?("<= 5.10")
          # in order to provide compatibility for earlier versions of libc on solaris 10,
          # we need to specify a mapfile that restricts the version of system libraries
          # used. See http://docs.oracle.com/cd/E23824_01/html/819-0690/chapter5-1.html
          # for more information
          # use the mapfile if it exists, otherwise ignore it
          mapfile_path = File.expand_path(Config.solaris_linker_mapfile, Config.project_root)
          ld_options << " -M #{mapfile_path}" if File.exist?(mapfile_path)
        end

        # solaris linker can also use LD_OPTIONS, so we throw the kitchen sink against
        # the linker, to find every way to make it use our rpath. This is also required
        # to use the aforementioned mapfile.
        extra_linker_flags["LD_OPTIONS"] = ld_options
      end

      env.merge(compiler_flags)
        .merge(extra_linker_flags).
        # always want to favor pkg-config from embedded location to not hose
        # configure scripts which try to be too clever and ignore our explicit
        # CFLAGS and LDFLAGS in favor of pkg-config info
        merge({ "PKG_CONFIG_PATH" => "#{install_dir}/embedded/lib/pkgconfig" }).
        # Set default values for CXXFLAGS and CPPFLAGS.
        merge("CXXFLAGS" => compiler_flags["CFLAGS"])
        .merge("CPPFLAGS" => compiler_flags["CFLAGS"])
        .merge("OMNIBUS_INSTALL_DIR" => install_dir)
    end
    expose :with_standard_compiler_flags

    #
    # A PATH variable format string representing the current PATH with the
    # project's embedded/bin directory prepended. The correct path separator
    # for the platform is used to join the paths.
    #
    # @param [Hash] env
    #
    # @return [Hash]
    #
    def with_embedded_path(env = {})
      paths = ["#{install_dir}/bin", "#{install_dir}/embedded/bin"]
      path_value = prepend_path(paths)
      env.merge(path_key => path_value)
    end
    expose :with_embedded_path

    #
    # Returns the platform safe full path under embedded/bin directory to the
    # given binary
    #
    # @param [String] bin
    #   Name of the binary under embedded/bin
    #
    # @return [String]
    #
    def embedded_bin(bin)
      windows_safe_path("#{install_dir}/embedded/bin/#{bin}")
    end
    expose :embedded_bin

    #
    # A PATH variable format string representing the current PATH with the
    # given path prepended. The correct path separator
    # for the platform is used to join the paths.
    #
    # @param [Array<String>] paths
    #
    # @return [String]
    #
    def prepend_path(*paths)
      path_values = Array(paths)
      path_values << ENV[path_key]

      separator = File::PATH_SEPARATOR || ":"
      path_values.join(separator)
    end
    expose :prepend_path

    #
    # A proxy method to the underlying Ohai system.
    #
    # @example
    #   ohai['platform_family']
    #
    # @return [Ohai]
    #
    def ohai
      Ohai
    end
    expose :ohai

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # @!group Public API
    #
    # In addition to the DSL methods, the following methods are considered to
    # be the "public API" for a software.
    # --------------------------------------------------

    #
    # Recursively load all the dependencies for this software.
    #
    # @return [true]
    #
    def load_dependencies
      dependencies.each do |dependency|
        Software.load(project, dependency, manifest)
      end

      true
    end

    #
    # The builder object for this software definition.
    #
    # @return [Builder]
    #
    def builder
      @builder ||= Builder.new(self)
    end

    def to_manifest_entry
      Omnibus::ManifestEntry.new(name, {
                                   source_type: source_type,
                                   described_version: version,
                                   locked_version: Fetcher.resolve_version(version, source),
                                   locked_source: source,
                                   license: license,
      })
    end

    #
    # Fetch the software definition using the appropriate fetcher. This may
    # fetch the software from a local path location, git location, or download
    # the software from a remote URL (HTTP(s)/FTP)
    #
    # @return [true, false]
    #   true if the software was fetched, false if it was cached
    #
    def fetch
      if fetcher.fetch_required?
        fetcher.fetch
        true
      else
        false
      end
    end

    #
    # The list of software dependencies for this software. These is the software
    # that comprises your software, and is distinct from runtime dependencies.
    #
    # @see #dependency
    #
    # @param [Array<String>]
    #
    # @return [Array<String>]
    #
    def dependencies
      @dependencies ||= []
    end

    #
    # The list of files to ignore in the healthcheck.
    #
    # @return [Array<String>]
    #
    def whitelist_files
      @whitelist_files ||= []
    end

    #
    # The list of license files for this software
    #
    # @see #license_file
    #
    # @return [Array<String>]
    #
    def license_files
      @license_files ||= []
    end

    #
    # The path (on disk) where this software came from. Warning: this can be
    # +nil+ if a software was dynamically created!
    #
    # @return [String, nil]
    #
    def filepath
      @filepath
    end

    #
    # The repo-level and project-level overrides for the software.
    #
    # @return [Hash]
    #
    def overrides
      if null?(@overrides)
        # lazily initialized because we need the 'name' to be parsed first
        @overrides = {}
        @overrides = project.overrides[name.to_sym].dup if project.overrides[name.to_sym]
      end

      @overrides
    end
    expose :overrides

    #
    # Determine if this software version overridden externally, relative to the
    # version declared within the software DSL file?
    #
    # @return [true, false]
    #
    def overridden?
      # NOTE: using instance variables to bypass accessors that enforce overrides
      @overrides.key?(:version) && (@overrides[:version] != @version)
    end

    #
    # @!endgroup
    # --------------------------------------------------

    #
    # Path to where any source is extracted to.
    #
    # Files in a source directory are staged underneath here. Files from
    # a url are fetched and extracted here. Look outside this directory
    # at your own peril.
    #
    # @return [String] the full absolute path to the software root fetch
    #   directory.
    #
    def fetch_dir(val = NULL)
      if null?(val)
        @fetch_dir || File.expand_path("#{Config.source_dir}/#{name}")
      else
        @fetch_dir = val
      end
    end

    # @todo see comments on {Omnibus::Fetcher#without_caching_for}
    def version_guid
      fetcher.version_guid
    end

    # Returns the version to be used in cache.
    def version_for_cache
      @version_for_cache ||= if fetcher.version_for_cache
                               fetcher.version_for_cache
                             elsif version
                               version
                             else
                               log.warn(log_key) do
                                 "No version given! This is probably a bad thing. I am going to " \
                                 "assume the version `0.0.0', but that is most certainly not your " \
                                 "desired behavior. If git caching seems off, this is probably why."
                               end

                               "0.0.0"
                             end
    end

    #
    # The fetcher for this software
    #
    # This is where we handle all the crazy back-compat on relative_path.
    # All fetchers in omnibus 4 use relative_path incorrectly. net_fetcher was
    # the only one to use to sensibly, and even then only if fetch_dir was
    # Config.source_dir and the source was an archive. Therefore, to not break
    # everyone ever, we will still pass project_dir for all other fetchers.
    # There is still one issue where other omnibus software (such as the
    # appbundler dsl) currently assume that fetch_dir the same as source_dir.
    # Therefore, we make one extra concession - when relative_path is set in a
    # software definition to be the same as name (a very common scenario), we
    # land the source into the fetch directory instead of project_dir. This
    # is to avoid fiddling with the appbundler dsl until it gets sorted out.
    #
    # @return [Fetcher]
    #
    def fetcher
      @fetcher ||=
        if source_type == :url && File.basename(source[:url], "?*").end_with?(*NetFetcher::ALL_EXTENSIONS)
          Fetcher.fetcher_class_for_source(source).new(manifest_entry, fetch_dir, build_dir)
        else
          Fetcher.fetcher_class_for_source(source).new(manifest_entry, project_dir, build_dir)
        end
    end

    #
    # The type of source specified for this software defintion.
    #
    # @return [Symbol]
    #
    def source_type
      if source
        if source[:url]
          :url
        elsif source[:git]
          :git
        elsif source[:file]
          :file
        elsif source[:path]
          :path
        end
      else
        :project_local
      end
    end

    #
    # Build the software package. If git caching is turned on (see
    # {Config#use_git_caching}), the build is restored according to the
    # documented restoration procedure in the git cache. If the build cannot
    # be restored (if the tag does not exist), the actual build steps are
    # executed.
    #
    # @param [Array<#execute_pre_build, #execute_post_build>] build_wrappers
    #   Build wrappers inject behavior before or after the software is built.
    #   They can be any object that implements `#execute_pre_build` and
    #   `#execute_post_build`, taking this Software as an argument. Note that
    #   these callbacks are only triggered when the software actually gets
    #   built; if the build is skipped by the git cache, the callbacks DO NOT
    #   run.
    #
    # @return [true]
    #
    def build_me(build_wrappers = [])
      if Config.use_git_caching
        if project.dirty?
          log.info(log_key) do
            "Building because `#{project.culprit.name}' dirtied the cache"
          end
          execute_build(build_wrappers)
        elsif git_cache.restore
          log.info(log_key) { "Restored from cache" }
        else
          log.info(log_key) { "Could not restore from cache" }
          execute_build(build_wrappers)
          project.dirty!(self)
        end
      else
        log.debug(log_key) { "Forcing build because git caching is off" }
        execute_build(build_wrappers)
      end

      project.build_version_dsl.resolve(self)
      true
    end

    #
    # The unique "hash" for this software.
    #
    # @see (#shasum)
    #
    # @return [Fixnum]
    #
    def hash
      shasum.hash
    end

    #
    # Determine if two softwares are identical.
    #
    # @param [Software] other
    #
    # @return [true, false]
    #
    def ==(other)
      hash == other.hash
    end
    alias_method :eql?, :==

    #
    # The unique SHA256 for this sofware definition.
    #
    # A software is defined by its parent project's shasum, its own name, its
    # version_for_cache, and any overrides (as JSON). Additionally, if provided,
    # the actual file contents are included in the SHA to ensure uniqueness.
    #
    # @return [String]
    #
    def shasum
      @shasum ||= begin
        digest = Digest::SHA256.new

        update_with_string(digest, project.shasum)
        update_with_string(digest, builder.shasum)
        update_with_string(digest, name)
        update_with_string(digest, version_for_cache)
        update_with_string(digest, FFI_Yajl::Encoder.encode(overrides))

        if filepath && File.exist?(filepath)
          update_with_file_contents(digest, filepath)
        else
          update_with_string(digest, "<DYNAMIC>")
        end

        digest.hexdigest
      end
    end

    private

    #
    # The git caching implementation for this software.
    #
    # @return [GitCache]
    #
    def git_cache
      @git_cache ||= GitCache.new(self)
    end

    #
    # Apply overrides in the @overrides hash that mask instance variables
    # that are set by parsing the DSL
    #
    def apply_overrides(attr, override = overrides[attr])
      val = instance_variable_get(:"@#{attr}")
      if val.is_a?(Hash) || override.is_a?(Hash)
        val ||= {}
        override ||= {}
        val.merge(override)
      else
        override || val
      end
    end

    #
    # Transform github -> git in source
    #
    def canonicalize_source(source)
      if source.is_a?(Hash) && source[:github]
        source = source.dup
        source[:git] = "https://github.com/#{source[:github]}.git"
        source.delete(:github)
      end
      source
    end

    #
    # Actually build this software, executing the steps provided in the
    # {#build} block and dirtying the cache.
    #
    # @param [Array<#execute_pre_build, #execute_post_build>] build_wrappers
    #   Build wrappers inject behavior before or after the software is built.
    #   They can be any object that implements `#execute_pre_build` and
    #   `#execute_post_build`
    #
    # @return [void]
    #
    def execute_build(build_wrappers)
      fetcher.clean

      build_wrappers.each { |wrapper| wrapper.execute_pre_build(self) }
      builder.build
      build_wrappers.each { |wrapper| wrapper.execute_post_build(self) }

      if Config.use_git_caching
        git_cache.incremental
        log.info(log_key) { "Dirtied the cache" }
      end
    end

    #
    # The log key for this software, including its name.
    #
    # @return [String]
    #
    def log_key
      @log_key ||= "#{super}: #{name}"
    end

    def to_s
      "#{name}[#{filepath}]"
    end
  end
end