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

require "rbconfig"
require "shellwords"
require "tempfile"
require "tmpdir"
require "log4r"

require "vagrant/util/subprocess"
require "vagrant/util/powershell"
require "vagrant/util/which"

module Vagrant
  module Util
    # This class just contains some platform checking code.
    class Platform
      class << self

        # Detect architecture of host system
        #
        # @return [String]
        def architecture
          if !defined?(@_host_architecture)
            if ENV["VAGRANT_HOST_ARCHITECTURE"].to_s != ""
              return @_host_architecture = ENV["VAGRANT_HOST_ARCHITECTURE"]
            end

            @_host_architecture = case RbConfig::CONFIG["target_cpu"]
              when "amd64", "x86_64", "x64"
                "amd64"
              when "386", "i386", "x86"
                "i386"
              when "arm64", "aarch64"
                "arm64"
              else
                RbConfig::CONFIG["target_cpu"]
              end
          end
          @_host_architecture
        end

        def logger
          if !defined?(@_logger)
            @_logger = Log4r::Logger.new("vagrant::util::platform")
          end
          @_logger
        end

        def cygwin?
          if !defined?(@_cygwin)
            @_cygwin = ENV["VAGRANT_DETECTED_OS"].to_s.downcase.include?("cygwin") ||
              platform.include?("cygwin") ||
              ENV["OSTYPE"].to_s.downcase.include?("cygwin")
          end
          @_cygwin
        end

        def msys?
          if !defined?(@_msys)
            @_msys = ENV["VAGRANT_DETECTED_OS"].to_s.downcase.include?("msys") ||
              platform.include?("msys") ||
              ENV["OSTYPE"].to_s.downcase.include?("msys")
          end
          @_msys
        end

        def wsl?
          if !defined?(@_wsl)
            @_wsl = false
            SilenceWarnings.silence! do
              # Find 'microsoft' in /proc/version indicative of WSL
              if File.file?('/proc/version')
                osversion = File.open('/proc/version', &:gets)
                if osversion.downcase.include?("microsoft")
                  @_wsl = true
                end
              end
            end
          end
          @_wsl
        end

        [:darwin, :bsd, :freebsd, :linux, :solaris].each do |type|
          define_method("#{type}?") do
            platform.include?(type.to_s)
          end
        end

        def windows?
          return @_windows if defined?(@_windows)
          @_windows = %w[mingw mswin].any? { |t| platform.include?(t) }
          return @_windows
        end

        # Checks if the user running Vagrant on Windows has administrative
        # privileges.
        #
        # From: https://support.microsoft.com/en-us/kb/243330
        # SID: S-1-5-19
        #
        # @return [Boolean]
        def windows_admin?
          return @_windows_admin if defined?(@_windows_admin)

          @_windows_admin = -> {
            ps_cmd = '(new-object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)'
            output = Vagrant::Util::PowerShell.execute_cmd(ps_cmd)
            return output == 'True'
          }.call

          return @_windows_admin
        end

        # Checks if Hyper-V is accessible to the local user. It will check
        # if user is in the "Hyper-V Administrators" group, is a Domain
        # administrator, and finally will run a manual interaction with
        # Hyper-V to determine if Hyper-V is usable for the current user.
        #
        # From: https://support.microsoft.com/en-us/kb/243330
        # SID: S-1-5-32-578
        # Name: BUILTIN\Hyper-V Administrators
        # SID: S-1-5-21DOMAIN-512
        # Name: Domain Admins
        #
        # @return [Boolean]
        def windows_hyperv_admin?
          return @_windows_hyperv_admin if defined?(@_windows_hyperv_admin)

          if ENV["VAGRANT_IS_HYPERV_ADMIN"]
            return @_windows_hyperv_admin = true
          end

          ps_cmd = "Write-Output ([System.Security.Principal.WindowsIdentity]::GetCurrent().Groups | " \
            "Select-Object Value | ConvertTo-JSON)"
          output = Vagrant::Util::PowerShell.execute_cmd(ps_cmd)
          if output
            groups = begin
                       JSON.load(output)
                     rescue JSON::ParserError
                       []
                     end
            admin_group = groups.detect do |g|
              g["Value"].to_s == "S-1-5-32-578" ||
                (g["Value"].start_with?("S-1-5-21") && g["Value"].to_s.end_with?("-512"))
            end

            if admin_group
              return @_windows_hyperv_admin = true
            end
          end

          ps_cmd = "$x = (Get-VMHost).Name; if($x -eq [System.Net.Dns]::GetHostName()){ Write-Output 'true'}"
          output = Vagrant::Util::PowerShell.execute_cmd(ps_cmd)
          result = output == "true"

          return @_windows_hyperv_admin = result
        end

        # Checks if Hyper-V is enabled on the host system and returns true
        # if enabled.
        #
        # @return [Boolean]
        def windows_hyperv_enabled?
          return @_windows_hyperv_enabled if defined?(@_windows_hyperv_enabled)

          @_windows_hyperv_enabled = -> {
            check_commands = Array.new.tap do |c|
              c << "(Get-WindowsOptionalFeature -FeatureName Microsoft-Hyper-V-Hypervisor -Online).State"
              c << "(Get-WindowsFeature -FeatureName Microsoft-Hyper-V-Hypervisor).State"
            end
            check_commands.each do |ps_cmd|
              begin
                output = Vagrant::Util::PowerShell.execute_cmd(ps_cmd)
                return true if output == "Enabled"
              rescue Errors::PowerShellInvalidVersion
                logger.warn("Invalid PowerShell version detected during Hyper-V enable check")
                return false
              rescue Errors::PowerShellError
                logger.warn("Powershell command not found or error on execution of command")
                return false
              end
            end
            return false
          }.call

          return @_windows_hyperv_enabled
        end

        # This takes any path and converts it from a Windows path to a
        # Cygwin style path.
        #
        # @param [String] path
        # @return [String]
        def cygwin_path(path)
          begin
            cygpath = Vagrant::Util::Which.which("cygpath")
            if cygpath.nil?
              # If Which can't find it, just attempt to invoke it directly
              cygpath = "cygpath"
            else
              cygpath.gsub!("/", '\\')
            end

            process = Subprocess.execute(
              cygpath, "-u", "-a", path.to_s)
            return process.stdout.chomp
          rescue Errors::CommandUnavailableWindows => e
            # Sometimes cygpath isn't available (msys). Instead, do what we
            # can with bash tricks.
            process = Subprocess.execute(
              "bash",
              "--noprofile",
              "--norc",
              "-c", "cd #{Shellwords.escape(path)} && pwd")
            return process.stdout.chomp
          end
        end

        # This takes any path and converts it from a Windows path to a
        # msys style path.
        #
        # @param [String] path
        # @return [String]
        def msys_path(path)
          begin
            # We have to revert to the old env
            # path here, otherwise it looks like
            # msys2 ends up using the wrong cygpath
            # binary and ends up with a `/cygdrive`
            # when it doesn't exist in msys2
            original_path_env = ENV['PATH']
            ENV['PATH'] = ENV['VAGRANT_OLD_ENV_PATH']
            cygwin_path(path)
          ensure
            ENV['PATH'] = original_path_env
          end
        end

        # This takes any path and converts it to a full-length Windows
        # path on Windows machines in Cygwin.
        #
        # @return [String]
        def cygwin_windows_path(path)
          return path if !cygwin?

          # Replace all "\" with "/", otherwise cygpath doesn't work.
          path = unix_windows_path(path)

          # Call out to cygpath and gather the result
          process = Subprocess.execute("cygpath", "-w", "-l", "-a", path.to_s)
          return process.stdout.chomp
        end

        # This takes any path and converts Windows-style path separators
        # to Unix-like path separators.
        # @return [String]
        def unix_windows_path(path)
          path.gsub("\\", "/")
        end

        # This checks if the filesystem is case sensitive. This is not a
        # 100% correct check, since it is possible that the temporary
        # directory runs a different filesystem than the root directory.
        # However, this works in many cases.
        def fs_case_sensitive?
          return @_fs_case_sensitive if defined?(@_fs_case_sensitive)
          @_fs_case_sensitive = Dir.mktmpdir("vagrant-fs-case-sensitive") do |dir|
            tmp_file = File.join(dir, "FILE")
            File.open(tmp_file, "w") do |f|
              f.write("foo")
            end

            # The filesystem is case sensitive if the lowercased version
            # of the filename is NOT reported as existing.
            !File.file?(File.join(dir, "file"))
          end
          return @_fs_case_sensitive
        end

        # This expands the path and ensures proper casing of each part
        # of the path.
        def fs_real_path(path, **opts)
          path = Pathname.new(File.expand_path(path))

          if path.exist? && !fs_case_sensitive?
            # If the path contains a Windows short path, then we attempt to
            # expand. The require below is embedded here since it requires
            # windows to work.
            if windows? && path.to_s =~ /~\d(\/|\\)/
              require_relative "windows_path"
              path = Pathname.new(WindowsPath.longname(path.to_s))
            end

            # Build up all the parts of the path
            original = []
            while !path.root?
              original.unshift(path.basename.to_s)
              path = path.parent
            end

            # Traverse each part and join it into the resulting path
            original.each do |single|
              Dir.entries(path).each do |entry|
                begin
                  single = single.encode("filesystem").to_s
                rescue ArgumentError => err
                  Vagrant.global_logger.warn("path encoding failed - part=#{single} err=#{err.class} msg=#{err}")
                  # NOTE: Depending on the Windows environment the above
                  # encode will generate an "input string invalid" when
                  # attempting to encode. If that happens, continue on
                end
                if entry.downcase == single.downcase
                  path = path.join(entry)
                end
              end
            end
          end

          if windows?
            # Fix the drive letter to be uppercase.
            path = path.to_s
            if path[1] == ":"
              path[0] = path[0].upcase
            end

            path = Pathname.new(path)
          end

          path
        end

        # Converts a given path to UNC format by adding a prefix and converting slashes.
        # @param [String] path Path to convert to UNC for Windows
        # @return [String]
        def windows_unc_path(path)
          path = path.gsub("/", "\\")

          # Convert to UNC path
          if path =~ /^[a-zA-Z]:\\?$/
            # If the path is just a drive letter, then return that as-is
            path + "\\"
          elsif path.start_with?("\\\\")
            # If the path already starts with `\\` assume UNC and return as-is
            path
          else
            "\\\\?\\" + path.gsub("/", "\\")
          end
        end

        # Returns a boolean noting whether the terminal supports color.
        # output.
        def terminal_supports_colors?
          return @_terminal_supports_colors if defined?(@_terminal_supports_colors)
          @_terminal_supports_colors = -> {
            if windows?
              return true if ENV.key?("ANSICON")
              return true if cygwin?
              return true if ENV["TERM"] == "cygwin"
              return false
            end

            return true
          }.call
          return @_terminal_supports_colors
        end

        def platform
          return @_platform if defined?(@_platform)
          @_platform = RbConfig::CONFIG["host_os"].downcase
          return @_platform
        end

        # Determine if given path is within the WSL rootfs. Returns
        # true if within the subsystem, or false if outside the subsystem.
        #
        # @param [String] path Path to check
        # @return [Boolean] path is within subsystem
        def wsl_path?(path)
          wsl? && !path.to_s.downcase.start_with?("/mnt/")
        end

        # Compute the path to rootfs of currently active WSL.
        #
        # @return [String] A path to rootfs of a current WSL instance.
        def wsl_rootfs
          return @_wsl_rootfs if defined?(@_wsl_rootfs)

          if wsl?
            # Mark our filesystem with a temporary file having an unique name.
            marker = Tempfile.new(Time.now.to_i.to_s)
            logger = Log4r::Logger.new("vagrant::util::platform::wsl")

            # Check for lxrun installation first
            lxrun_path = [wsl_windows_appdata_local, "lxss"].join("\\")
            paths = [lxrun_path]

            logger.debug("checking registry for WSL installation path")
            paths += PowerShell.execute_cmd(
              '(Get-ChildItem HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss ' \
                '| ForEach-Object {Get-ItemProperty $_.PSPath}).BasePath').to_s.split("\r\n").map(&:strip)
            paths.delete_if{|path| path.to_s.empty?}

            paths.each do |path|
              # Lowercase the drive letter, skip the next symbol (which is a
              # colon from a Windows path) and convert path to UNIX style.
              check_path = "/mnt/#{path[0, 1].downcase}#{path[2..-1].tr('\\', '/')}/rootfs"
              begin
                process = Subprocess.execute("wslpath", "-u", "-a", path)
                check_path = "#{process.stdout.chomp}/rootfs" if process.exit_code == 0
              rescue Errors::CommandUnavailable => e
                # pass
              end

              logger.debug("checking `#{path}` for current WSL instance")
              begin
                # https://blogs.msdn.microsoft.com/wsl/2016/06/15/wsl-file-system-support
                # Current WSL instance doesn't have an access to its mount from
                # within itself despite all others are available. That's the
                # hacky way we're using to determine current instance.
                # For example we have three WSL instances:
                # A -> C:\User\USER\AppData\Local\Packages\A\LocalState\rootfs
                # B -> C:\User\USER\AppData\Local\Packages\B\LocalState\rootfs
                # C -> C:\User\USER\AppData\Local\Packages\C\LocalState\rootfs
                # If we're in "A" WSL at the moment, then its path will not be
                # accessible since it's mounted for exactly the instance we're
                # in. All others can be opened.
                Dir.open(check_path) do |fs|
                  # A fallback for a case if our trick will stop working. For
                  # that we've created a temporary file with an unique name in
                  # a current WSL and now seeking it among all WSL.
                  if File.exist?("#{fs.path}/#{marker.path}")
                    @_wsl_rootfs = path
                    break
                  end
                end
              rescue Errno::EACCES
                @_wsl_rootfs = path
                # You can create and simultaneously run multiple WSL instances,
                # comment out the "break", run this script within each one and
                # it'll return only single value.
                break
              rescue Errno::ENOENT
                # Warn about data discrepancy between Winreg and file system
                # states. For the sake of justice, it's worth mentioning that
                # it is possible only when someone will manually break WSL by
                # removing a directory of its base path (kinda "stupid WSL
                # uninstallation by removing hidden and system directory").
                logger.warn("WSL instance at `#{path} is broken or no longer exists")
              end
              # All other exceptions have to be raised since they will mean
              # something unpredictably terrible.
            end

            marker.close!

            raise Vagrant::Errors::WSLRootFsNotFoundError if @_wsl_rootfs.nil?
          end

          # Attach the rootfs leaf to the path
          if @_wsl_rootfs != lxrun_path
            @_wsl_rootfs = "#{@_wsl_rootfs}\\rootfs"
          end

          logger.debug("detected `#{@_wsl_rootfs}` as current WSL instance")

          @_wsl_rootfs
        end

        # Convert a WSL path to the local Windows path. This is useful
        # for conversion when calling out to Windows executables from
        # the WSL
        #
        # @param [String, Pathname] path Path to convert
        # @return [String]
        def wsl_to_windows_path(path)
          path = path.to_s
          if wsl? && wsl_windows_access? && !path.match(/^[a-zA-Z]:/)
            path = File.expand_path(path)
            begin
              process = Subprocess.execute("wslpath", "-w", "-a", path)
              return process.stdout.chomp if process.exit_code == 0
            rescue Errors::CommandUnavailable => e
              # pass
            end
            if wsl_path?(path)
              parts = path.split("/")
              parts.delete_if(&:empty?)
              root_path = wsl_rootfs
              # lxrun splits home separate so we need to account
              # for it's specialness here when we build the path
              if root_path.end_with?("lxss") && !(["root", "home"].include?(parts.first))
                root_path = "#{root_path}\\rootfs"
              end
              path = [root_path, *parts].join("\\")
            else
              path = path.sub("/mnt/", "")
              parts = path.split("/")
              parts.first << ":"
              path = parts.join("\\")
            end
          end
          path
        end

        # Takes a windows path and formats it to the
        # 'unix' style (i.e. `/cygdrive/c` or `/c/`)
        #
        # @param [Pathname, String] path Path to convert
        # @param [Hash] hash of arguments
        # @return [String]
        def format_windows_path(path, *args)
          path = cygwin_path(path) if cygwin?
          path = msys_path(path) if msys?
          path = wsl_to_windows_path(path) if wsl?
          if windows? || wsl?
            path = windows_unc_path(path) if !args.include?(:disable_unc)
          end

          path
        end

        # Automatically convert a given path to a Windows path. Will only
        # be applied if running on a Windows host. If running on Windows
        # host within the WSL, the actual Windows path will be returned.
        #
        # @param [Pathname, String] path Path to convert
        # @return [String]
        def windows_path(path)
          path = cygwin_windows_path(path)
          path = wsl_to_windows_path(path)
          if windows? || wsl?
            path = windows_unc_path(path)
          end
          path
        end

        # Allow Vagrant to access Vagrant managed machines outside the
        # Windows Subsystem for Linux
        #
        # @return [Boolean]
        def wsl_windows_access?
          if !defined?(@_wsl_windows_access)
            @_wsl_windows_access = wsl? && ENV["VAGRANT_WSL_ENABLE_WINDOWS_ACCESS"]
          end
          @_wsl_windows_access
        end

        # The allowed windows system path Vagrant can manage from the Windows
        # Subsystem for Linux
        #
        # @return [Pathname]
        def wsl_windows_accessible_path
          if !defined?(@_wsl_windows_accessible_path)
            access_path = ENV["VAGRANT_WSL_WINDOWS_ACCESS_USER_HOME_PATH"]
            if access_path.to_s.empty?
              begin
                process = Subprocess.execute("wslpath", "-u", "-a", wsl_windows_home)
                access_path = process.stdout.chomp if process.exit_code == 0
              rescue Errors::CommandUnavailable => e
                # pass
              end
            end
            if access_path.to_s.empty?
              access_path = wsl_windows_home.gsub("\\", "/").sub(":", "")
              access_path[0] = access_path[0].downcase
              access_path = "/mnt/#{access_path}"
            end
            @_wsl_windows_accessible_path = Pathname.new(access_path)
          end
          @_wsl_windows_accessible_path
        end

        # Checks given path to determine if Vagrant is allowed to bypass checks
        #
        # @param [String] path Path to check
        # @return [Boolean] Vagrant is allowed to bypass checks
        def wsl_windows_access_bypass?(path)
          wsl? && wsl_windows_access? &&
            path.to_s.start_with?(wsl_windows_accessible_path.to_s)
        end

        # Mount pattern for extracting local mount information
        MOUNT_PATTERN = /^(?<device>.+?) on (?<mount>.+?) type (?<type>.+?) \((?<options>.+)\)/.freeze

        # Get list of local mount paths that are DrvFs file systems
        #
        # @return [Array<String>]
        # @todo(chrisroberts): Constantize types for check
        def wsl_drvfs_mounts
          if !defined?(@_wsl_drvfs_mounts)
            @_wsl_drvfs_mounts = []
            if wsl?
              result = Util::Subprocess.execute("mount")
              result.stdout.each_line do |line|
                info = line.match(MOUNT_PATTERN)
                if info && (info[:type] == "drvfs" || info[:type] == "9p")
                  @_wsl_drvfs_mounts << info[:mount]
                end
              end
            end
          end
          @_wsl_drvfs_mounts
        end

        # Check if given path is located on DrvFs file system
        #
        # @param [String, Pathname] path Path to check
        # @return [Boolean]
        def wsl_drvfs_path?(path)
          if wsl?
            wsl_drvfs_mounts.each do |mount_path|
              return true if path.to_s.start_with?(mount_path)
            end
          end
          false
        end

        # If running within the Windows Subsystem for Linux, this will provide
        # simple setup to allow sharing of the user's VAGRANT_HOME directory
        # within the subsystem
        #
        # @param [Environment] env
        # @param [Logger] logger Optional logger to display information
        def wsl_init(env, logger=nil)
          if wsl?
            if ENV["VAGRANT_WSL_ENABLE_WINDOWS_ACCESS"]
              wsl_validate_matching_vagrant_versions!
              shared_user = ENV["VAGRANT_WSL_WINDOWS_ACCESS_USER"]
              if shared_user.to_s.empty?
                shared_user = wsl_windows_username
              end
              if logger
                logger.warn("Windows Subsystem for Linux detected. Allowing access to user: #{shared_user}")
                logger.warn("Vagrant will be allowed to control Vagrant managed machines within the user's home path.")
              end
              if ENV["VAGRANT_HOME"] || ENV["VAGRANT_WSL_DISABLE_VAGRANT_HOME"]
                logger.warn("VAGRANT_HOME environment variable already set. Not overriding!") if logger
              else
                home_path = wsl_windows_accessible_path.to_s
                ENV["VAGRANT_HOME"] = File.join(home_path, ".vagrant.d")
                if logger
                  logger.info("Overriding VAGRANT_HOME environment variable to configured windows user. (#{ENV["VAGRANT_HOME"]})")
                end
                true
              end
            else
              if env.local_data_path.to_s.start_with?("/mnt/")
                raise Vagrant::Errors::WSLVagrantAccessError
              end
            end
          end
        end

        # Fetch the Windows username currently in use
        #
        # @return [String, Nil]
        def wsl_windows_username
          if !@_wsl_windows_username
            result = Util::Subprocess.execute("cmd.exe", "/c", "echo %USERNAME%")
            if result.exit_code == 0
              @_wsl_windows_username = result.stdout.strip
            end
          end
          @_wsl_windows_username
        end

        # Fetch the Windows user home directory
        #
        # @return [String, Nil]
        def wsl_windows_home
          if !@_wsl_windows_home
            result = Util::Subprocess.execute("cmd.exe", "/c" "echo %USERPROFILE%")
            if result.exit_code == 0
              @_wsl_windows_home = result.stdout.gsub("\"", "").strip
            end
          end
          @_wsl_windows_home
        end

        # Fetch the Windows user local app data directory
        #
        # @return [String, Nil]
        def wsl_windows_appdata_local
          if !@_wsl_windows_appdata_local
            result = Util::Subprocess.execute("cmd.exe", "/c", "echo %LOCALAPPDATA%")
            if result.exit_code == 0
              @_wsl_windows_appdata_local = result.stdout.gsub("\"", "").strip
            end
          end
          @_wsl_windows_appdata_local
        end

        # Confirm Vagrant versions installed within the WSL and the Windows system
        # are the same. Raise error if they do not match.
        def wsl_validate_matching_vagrant_versions!
          valid = false
          if Util::Which.which("vagrant.exe")
            result = Util::Subprocess.execute("vagrant.exe", "--version")
            if result.exit_code == 0
              windows_version = result.stdout.match(/Vagrant (?<version>[\w.-]+)/)
              if windows_version
                windows_version = windows_version[:version].strip
                valid = windows_version == Vagrant::VERSION
              end
            end
            if !valid
              raise Vagrant::Errors::WSLVagrantVersionMismatch,
                wsl_version: Vagrant::VERSION,
                windows_version: windows_version || "unknown"
            end
          end
        end

        # systemd is in use
        def systemd?
          if !defined?(@_systemd)
            if !windows?
              result = Vagrant::Util::Subprocess.execute("ps", "-o", "comm=", "1")
              @_systemd = result.stdout.chomp == "systemd"
            else
              @_systemd = false
            end
          end
          @_systemd
        end

        # @private
        # Reset the cached values for platform. This is not considered a public
        # API and should only be used for testing.
        def reset!
          instance_variables.each(&method(:remove_instance_variable))
        end
      end
    end
  end
end
