#
# Author:: Phil Dibowitz <phil@ipom.com>
# Author:: Adam Jacob <adam@chef.io>
# Author:: Kurt Yoder (ktyopscode@yoderhome.com)
# Author:: Deepali Jagtap (<deepali.jagtap@clogeny.com>)
# Author:: Prabhu Das (<prabhu.das@clogeny.com>)
# Author:: Isa Farnik (<isa@chef.io>)
# Author:: James Gartrell (<jgartrel@gmail.com>)
# Copyright:: Copyright (c) 2008-2020 Chef Software, Inc.
# Copyright:: Copyright (c) 2015 Facebook, Inc.
# License:: Apache License, Version 2.0
#
# 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 "set"

Ohai.plugin(:Filesystem) do
  provides "filesystem"

  def find_device(name)
    %w{/dev /dev/mapper}.each do |dir|
      path = File.join(dir, name)
      return path if File.exist?(path)
    end
    name
  end

  def parse_line(line, cmdtype)
    case cmdtype
    when "lsblk"
      regex = /NAME="(\S+).*?" UUID="(\S*)" LABEL="(\S*)" FSTYPE="(\S*)"/
      if line =~ regex
        dev = $1
        dev = find_device(dev) unless dev.start_with?("/")
        uuid = $2
        label = $3
        fs_type = $4
        return { dev: dev, uuid: uuid, label: label, fs_type: fs_type }
      end
    when "blkid"
      bits = line.split
      dev = bits.shift.split(":")[0]
      f = { dev: dev }
      bits.each do |keyval|
        if keyval =~ /(\S+)="(\S+)"/
          key = $1.downcase.to_sym
          key = :fs_type if key == :type
          f[key] = $2
        end
      end
      return f
    end
    nil
  end

  def generate_device_view(fs)
    view = {}
    fs.each_value do |entry|
      view[entry[:device]] ||= Mash.new
      entry.each do |key, val|
        next if %w{device mount}.include?(key)

        view[entry[:device]][key] = val
      end
      view[entry[:device]][:mounts] ||= []
      if entry[:mount]
        view[entry[:device]][:mounts] << entry[:mount]
      end
    end
    view
  end

  def generate_mountpoint_view(fs)
    view = {}
    fs.each_value do |entry|
      next unless entry[:mount]

      view[entry[:mount]] ||= Mash.new
      entry.each do |key, val|
        next if %w{mount device}.include?(key)

        view[entry[:mount]][key] = val
      end
      view[entry[:mount]][:devices] ||= []
      if entry[:device]
        view[entry[:mount]][:devices] << entry[:device]
      end
    end
    view
  end

  def generate_deprecated_windows_view(fs)
    view = generate_mountpoint_view(fs)
    view.each do |mp, entry|
      view[mp].delete("devices")
    end
    view
  end

  def parse_common_df(out)
    fs = {}
    out.each_line do |line|
      case line
      when /^Filesystem\s+1024-blocks/
        next
      when /^(.+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+\%)\s+(.+)$/
        key = "#{$1},#{$6}"
        fs[key] = Mash.new
        fs[key][:device] = $1
        fs[key][:kb_size] = $2
        fs[key][:kb_used] = $3
        fs[key][:kb_available] = $4
        fs[key][:percent_used] = $5
        fs[key][:mount] = $6
      end
    end
    fs
  end

  def run_with_check(bin, &block)
    yield
  rescue Ohai::Exceptions::Exec => e
    unless Ohai.config[:plugin][:filesystem][:allow_partial_data]
      raise e
    end

    logger.warn("Plugin Filesystem: #{bin} binary is not available. Some data will not be available.")
  end

  ### Windows specific methods BEGINS
  # Drive types
  DRIVE_TYPE ||= %w{unknown no_root_dir removable local network cd ram}.freeze

  # Volume encryption or decryption status
  #
  # @see https://docs.microsoft.com/en-us/windows/desktop/SecProv/getconversionstatus-win32-encryptablevolume#parameters
  #
  CONVERSION_STATUS ||= %w{
    FullyDecrypted FullyEncrypted EncryptionInProgress
    DecryptionInProgress EncryptionPaused DecryptionPaused
  }.freeze

  # Returns a Mash loaded with logical details
  #
  # Uses Win32_LogicalDisk and logical_properties to return general details of volumes.
  #
  # Returns an empty Mash in case of any WMI exception.
  #
  # @see https://docs.microsoft.com/en-us/windows/desktop/CIMWin32Prov/win32-logicaldisk
  #
  # @return [Mash]
  #
  def logical_info
    wmi = WmiLite::Wmi.new("Root\\CIMV2")

    # TODO: We should really be parsing Win32_Volume and Win32_MountPoint.
    disks = wmi.instances_of("Win32_LogicalDisk")
    logical_properties(disks)
  rescue WmiLite::WmiException
    Ohai::Log.debug("Unable to access Win32_LogicalDisk. Skipping logical details")
    Mash.new
  end

  # Returns a Mash loaded with encryption details
  #
  # Uses Win32_EncryptableVolume and encryption_properties to return encryption details of volumes.
  #
  # Returns an empty Mash in case of any WMI exception.
  #
  # @note We are fetching Encryption Status only as of now
  #
  # @see https://msdn.microsoft.com/en-us/library/windows/desktop/aa376483(v=vs.85).aspx
  #
  # @return [Mash]
  #
  def encryptable_info
    wmi = WmiLite::Wmi.new("Root\\CIMV2\\Security\\MicrosoftVolumeEncryption")
    disks = wmi.instances_of("Win32_EncryptableVolume")
    encryption_properties(disks)
  rescue WmiLite::WmiException
    Ohai::Log.debug("Unable to access Win32_EncryptableVolume. Skipping encryptable details")
    Mash.new
  end

  # Refines and calculates logical properties out of given instances.
  #
  # Note that :device here is the same as Volume name and there for compatibility with other OSes.
  #
  # @param [WmiLite::Wmi::Instance] disks
  #
  # @return [Mash] Each drive containing following properties:
  #
  #  * :kb_size (Integer)
  #  * :kb_available (Integer)
  #  * :kb_used (Integer)
  #  * :percent_used (Integer)
  #  * :mount (String)
  #  * :fs_type (String)
  #  * :volume_name (String)
  #  * :device (String)
  #
  def logical_properties(disks)
    properties = Mash.new
    disks.each do |disk|
      property = Mash.new
      # In windows the closest thing we have to a device is the volume name
      # and the "mountpoint" is the drive letter...
      device = disk["volumename"].to_s.downcase
      mount = disk["deviceid"]
      property[:kb_size] = disk["size"] ? disk["size"].to_i / 1000 : 0
      property[:kb_available] = disk["freespace"].to_i / 1000
      property[:kb_used] = property[:kb_size] - property[:kb_available]
      property[:percent_used] = (property[:kb_size] == 0 ? 0 : (property[:kb_used] * 100 / property[:kb_size]))
      property[:mount] = mount
      property[:fs_type] = disk["filesystem"].to_s.downcase
      property[:drive_type] = disk["drivetype"].to_i
      property[:drive_type_string] = DRIVE_TYPE[disk["drivetype"].to_i]
      property[:drive_type_human] = disk["description"].to_s
      property[:volume_name] = disk["volumename"].to_s
      property[:device] = device

      key = "#{device},#{mount}"
      properties[key] = property
    end
    properties
  end

  # Refines and calculates encryption properties out of given instances
  #
  # @param [WmiLite::Wmi::Instance] disks
  #
  # @return [Mash] Each drive containing following properties:
  #
  #  * :encryption_status (String)
  #
  def encryption_properties(disks)
    properties = Mash.new
    disks.each do |disk|
      property = Mash.new
      property[:encryption_status] = disk["conversionstatus"] ? CONVERSION_STATUS[disk["conversionstatus"]] : ""
      key = disk["driveletter"]
      properties[key] = property
    end
    properties
  end

  # Merges all the various properties of filesystems
  #
  # @param [Array<Mash>] disks_info
  #   Array of the Mashes containing disk properties
  #
  # @return [Mash]
  #
  def merge_info(logical_info, encryption_info)
    fs = Mash.new

    encryption_keys_used = Set.new
    logical_info.each do |key, info|
      if encryption_info[info["mount"]]
        encryption_keys_used.add(info["mount"])
        fs[key] = info.merge(encryption_info[info["mount"]])
      else
        fs[key] = info.dup
      end
    end
    left_enc = encryption_info.reject { |x| encryption_keys_used.include?(x) }
    left_enc.each do |key, info|
      fs[",#{key}"] = info
    end
    fs
  end

  collect_data(:linux) do
    fs = Mash.new

    # Grab filesystem data from df
    run_with_check("df") do
      so = shell_out("df -P")
      fs.merge!(parse_common_df(so.stdout))

      # Grab filesystem inode data from df
      so = shell_out("df -iP")
      so.stdout.each_line do |line|
        case line
        when /^Filesystem\s+Inodes/
          next
        when /^(.+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+\%)\s+(.+)$/
          key = "#{$1},#{$6}"
          fs[key] ||= Mash.new
          fs[key][:device] = $1
          fs[key][:total_inodes] = $2
          fs[key][:inodes_used] = $3
          fs[key][:inodes_available] = $4
          fs[key][:inodes_percent_used] = $5
          fs[key][:mount] = $6
        end
      end
    end

    # Grab mount information from /bin/mount
    run_with_check("mount") do
      so = shell_out("mount")
      so.stdout.each_line do |line|
        if line =~ /^(.+?) on (.+?) type (.+?) \((.+?)\)$/
          key = "#{$1},#{$2}"
          fs[key] ||= Mash.new
          fs[key][:device] = $1
          fs[key][:mount] = $2
          fs[key][:fs_type] = $3
          fs[key][:mount_options] = $4.split(",")
        end
      end
    end

    # We used to try to decide if we wanted to run lsblk or blkid
    # but they each have a variety of cases were they fail to report
    # data. For example, there are a variety of cases where lsblk won't
    # report unmounted filesystems, but blkid will. And vise-versa. Sweet.
    # So for reliability, we'll run both, if we have them.

    lsblk = which("lsblk")
    blkid = which("blkid")
    cmds = []
    # These should be in order of preference... first writer wins.
    if lsblk
      cmds << "#{lsblk} -n -P -o NAME,UUID,LABEL,FSTYPE"
    end
    if blkid
      cmds << blkid
    end

    cmds.each do |cmd|
      cmdtype = File.basename(cmd.split.first)
      # setting the timeout here for `lsblk` and `blkid` commands to 60
      # this is to allow machines with large amounts of attached LUNs
      # to respond back to the command successfully
      run_with_check(cmdtype) do
        so = shell_out(cmd, timeout: 60)
        so.stdout.each_line do |line|
          parsed = parse_line(line, cmdtype)
          next if parsed.nil?

          # lsblk lists each device once, so we need to update all entries
          # in the hash that are related to this device
          keys_to_update = []
          fs.each_key do |key|
            keys_to_update << key if key.start_with?("#{parsed[:dev]},")
          end

          if keys_to_update.empty?
            key = "#{parsed[:dev]},"
            fs[key] = Mash.new
            fs[key][:device] = parsed[:dev]
            keys_to_update << key
          end

          keys_to_update.each do |k|
            %i{fs_type uuid label}.each do |subkey|
              if parsed[subkey] && !parsed[subkey].empty?
                fs[k][subkey] = parsed[subkey]
              end
            end
          end
        end
      end
    end

    # Grab any missing mount information from /proc/mounts
    if File.exist?("/proc/mounts")
      mounts = ""
      # Due to https://tickets.opscode.com/browse/OHAI-196
      # we have to non-block read dev files. Ew.
      f = File.open("/proc/mounts")
      loop do

        data = f.read_nonblock(4096)
        mounts << data
      # We should just catch EOFError, but the kernel had a period of
      # bugginess with reading virtual files, so we're being extra
      # cautious here, catching all exceptions, and then we'll read
      # whatever data we might have
      rescue Exception
        break

      end
      f.close
      mounts.each_line do |line|
        if line =~ /^(\S+) (\S+) (\S+) (\S+) \S+ \S+$/
          key = "#{$1},#{$2}"
          next if fs.key?(key)

          fs[key] = Mash.new
          fs[key][:device] = $1
          fs[key][:mount] = $2
          fs[key][:fs_type] = $3
          fs[key][:mount_options] = $4.split(",")
        end
      end
    end

    by_pair = fs
    by_device = generate_device_view(fs)
    by_mountpoint = generate_mountpoint_view(fs)

    fs_data = Mash.new
    fs_data["by_device"] = by_device
    fs_data["by_mountpoint"] = by_mountpoint
    fs_data["by_pair"] = by_pair

    # Set the filesystem data
    filesystem fs_data
  end

  collect_data(:freebsd, :openbsd, :netbsd, :dragonflybsd) do
    fs = Mash.new

    # Grab filesystem data from df
    run_with_check("df") do
      so = shell_out("df")
      fs.merge!(parse_common_df(so.stdout))

      so = shell_out("df -iP")
      so.stdout.lines do |line|
        case line
        when /^Filesystem/ # skip the header
          next
        when /^(\S+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\%\s+(\d+)\s+(\d+)\s+(\d+)%\s+(\S+)$/
          key = "#{$1},#{$9}"
          fs[key] ||= Mash.new
          fs[key][:device] = $1
          fs[key][:inodes_used] = $6
          fs[key][:inodes_available] = $7
          fs[key][:total_inodes] = ($6.to_i + $7.to_i).to_s
          fs[key][:inodes_percent_used] = $8
          fs[key][:mount] = $9
        end
      end
    end

    # Grab mount information from mount
    run_with_check("mount") do
      so = shell_out("mount -l")
      so.stdout.lines do |line|
        if line =~ /^(.+?) on (.+?) \((.+?), (.+?)\)$/
          key = "#{$1},#{$2}"
          fs[key] ||= Mash.new
          fs[key][:device] = $1
          fs[key][:mount] = $2
          fs[key][:fs_type] = $3
          fs[key][:mount_options] = $4.split(/,\s*/)
        end
      end
    end

    # create views
    by_pair = fs
    by_device = generate_device_view(fs)
    by_mountpoint = generate_mountpoint_view(fs)

    fs_data = Mash.new
    fs_data["by_device"] = by_device
    fs_data["by_mountpoint"] = by_mountpoint
    fs_data["by_pair"] = by_pair

    # @todo in Chef 17 the filesystem2 part of this goes away
    filesystem fs_data
    filesystem2 fs_data
  end

  collect_data(:darwin) do
    fs = Mash.new
    block_size = 0
    # on new versions of OSX, -i is default, on old versions it's not, so
    # specifying it gets consistent output
    run_with_check("df") do
      so = shell_out("df -i")
      so.stdout.each_line do |line|
        case line
        when /^Filesystem\s+(\d+)-/
          block_size = $1.to_i
          next
        when /^(.+?)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+\%)\s+(\d+)\s+(\d+)\s+(\d+%)\s+(.+)$/
          key = "#{$1},#{$9}"
          fs[key] = Mash.new
          fs[key][:block_size] = block_size
          fs[key][:device] = $1
          fs[key][:kb_size] = ($2.to_i / (1024 / block_size)).to_s
          fs[key][:kb_used] = ($3.to_i / (1024 / block_size)).to_s
          fs[key][:kb_available] = ($4.to_i / (1024 / block_size)).to_s
          fs[key][:percent_used] = $5
          fs[key][:inodes_used] = $6
          fs[key][:inodes_available] = $7
          fs[key][:total_inodes] = ($6.to_i + $7.to_i).to_s
          fs[key][:inodes_percent_used] = $8
          fs[key][:mount] = $9
        end
      end
    end

    run_with_check("mount") do
      so = shell_out("mount")
      so.stdout.lines do |line|
        if line =~ /^(.+?) on (.+?) \((.+?), (.+?)\)$/
          key = "#{$1},#{$2}"
          fs[key] ||= Mash.new
          fs[key][:mount] = $2
          fs[key][:fs_type] = $3
          fs[key][:mount_options] = $4.split(/,\s*/)
        end
      end
    end

    by_pair = fs
    by_device = generate_device_view(fs)
    by_mountpoint = generate_mountpoint_view(fs)

    fs_data = Mash.new
    fs_data["by_device"] = by_device
    fs_data["by_mountpoint"] = by_mountpoint
    fs_data["by_pair"] = by_pair

    filesystem fs_data
  end

  collect_data(:solaris2) do
    fs = Mash.new

    # Grab filesystem data from df
    run_with_check("df") do
      so = shell_out("df -Pka")
      fs.merge!(parse_common_df(so.stdout))

      # Grab file system type from df (must be done separately)
      so = shell_out("df -na")
      so.stdout.lines do |line|
        next unless line =~ /^(.+?)\s*: (\S+)\s*$/

        mount = $1
        fs.each do |key, fs_attributes|
          next unless fs_attributes[:mount] == mount

          fs[key][:fs_type] = $2
        end
      end
    end

    # Grab mount information from /bin/mount
    run_with_check("mount") do
      so = shell_out("mount")
      so.stdout.lines do |line|
        next unless line =~ /^(.+?) on (.+?) (.+?) on (.+?)$/

        key = "#{$2},#{$1}"
        fs[key] ||= Mash.new
        fs[key][:mount] = $1
        fs[key][:mount_time] = $4 # $4 must come before "split", else it becomes nil
        fs[key][:mount_options] = $3.split("/")
      end
    end

    # Grab any zfs data from "zfs get"
    zfs = Mash.new
    zfs_get = "zfs get -p -H all"
    run_with_check("zfs") do
      so = shell_out(zfs_get)
      so.stdout.lines do |line|
        next unless line =~ /^([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/

        filesystem = $1
        property = $2
        value = $3
        source = $4.chomp
        zfs[filesystem] ||= Mash.new
        # if this fs doesn't exist, put in the bare minimum
        zfs[filesystem][property] = {
          value: value,
          source: source,
        }
      end
    end

    zfs.each do |fs_name, attributes|
      mountpoint = attributes[:mountpoint][:value] if attributes[:mountpoint]
      key = "#{fs_name},#{mountpoint}"
      fs[key] ||= Mash.new
      fs[key][:fs_type] = "zfs"
      fs[key][:mount] = mountpoint if mountpoint
      fs[key][:device] = fs_name
      fs[key][:zfs_properties] = attributes
      # find all zfs parents
      parents = fs_name.split("/")
      zfs_parents = []
      (0..parents.length - 1).to_a.each do |parent_index|
        next_parent = parents[0..parent_index].join("/")
        zfs_parents.push(next_parent)
      end
      zfs_parents.pop
      fs[key][:zfs_parents] = zfs_parents
      fs[key][:zfs_zpool] = (zfs_parents.length == 0)
    end

    # create views
    by_pair = fs
    by_device = generate_device_view(fs)
    by_mountpoint = generate_mountpoint_view(fs)

    fs_data = Mash.new
    fs_data["by_device"] = by_device
    fs_data["by_mountpoint"] = by_mountpoint
    fs_data["by_pair"] = by_pair

    # @todo in Chef 17 the filesystem2 plugin goes away
    filesystem fs_data
    filesystem2 fs_data
  end

  collect_data(:aix) do
    def parse_df_or_mount(shell_out)
      oldie = Mash.new

      shell_out.lines.each do |line|
        fields = line.split
        case line
        # headers and horizontal rules to skip
        when /^\s*(node|---|^Filesystem\s+1024-blocks)/
          next
        # strictly a df entry
        when /^(.+?)\s+([0-9-]+)\s+([0-9-]+)\s+([0-9-]+)\s+([0-9-]+\%*)\s+(.+)$/
          if $1 == "Global"
            dev = "#{$1}:#{$6}"
          else
            dev = $1
          end
          mountpoint = $6
          key = "#{dev},#{mountpoint}"
          oldie[key] ||= Mash.new
          oldie[key][:kb_size] = $2
          oldie[key][:kb_used] = $3
          oldie[key][:kb_available] = $4
          oldie[key][:percent_used] = $5
          oldie[key][:mount] = mountpoint
          oldie[key][:device] = dev
        # an entry starting with 'G' or / (E.G. /tmp or /var)
        when %r{^\s*(G.*?|/\w)}
          if fields[0] == "Global"
            dev = fields[0] + ":" + fields[1]
          else
            dev = fields[0]
          end
          mountpoint = fields[1]
          key = "#{dev},#{mountpoint}"
          oldie[key] ||= Mash.new
          oldie[key][:mount] = mountpoint
          oldie[key][:fs_type] = fields[2]
          oldie[key][:mount_options] = fields[6].split(",")
          oldie[key][:device] = dev
        # entries occupying the 'Node' column parsed here
        else
          dev = fields[0] + ":" + fields[1]
          mountpoint = fields[2]
          key = "#{dev},#{mountpoint}"
          oldie[key] ||= Mash.new
          oldie[key][:mount] = mountpoint
          oldie[key][:device] = dev
          oldie[key][:fs_type] = fields[3]
          oldie[key][:mount_options] = fields[7].split(",")
        end
      end
      oldie
    end

    def collect_old_version(shell_outs)
      mount_hash = parse_df_or_mount shell_outs[:mount]
      df_hash    = parse_df_or_mount shell_outs[:df_Pk]

      mount_hash.each do |key, hash|
        df_hash[key].merge!(hash) if df_hash.key?(key)
      end

      mount_hash.merge(df_hash)
    end

    # Cache the command output
    shell_outs = Mash.new

    run_with_check("mount") do
      shell_outs[:mount] = shell_out("mount").stdout
    end

    run_with_check("df") do
      shell_outs[:df_Pk] = shell_out("df -Pk").stdout
    end

    fs = collect_old_version(shell_outs)
    by_pair = fs
    by_device = generate_device_view(fs)
    by_mountpoint = generate_mountpoint_view(fs)

    fs_data = Mash.new
    fs_data["by_device"] = by_device
    fs_data["by_mountpoint"] = by_mountpoint
    fs_data["by_pair"] = by_pair

    # @todo in Chef 17 the filesystem2 plugin goes away here
    filesystem fs_data
    filesystem2 fs_data
  end

  collect_data(:windows) do
    require "wmi-lite/wmi"
    require_relative "../mash"

    fs = merge_info(logical_info, encryptable_info)

    by_pair = fs
    by_device = generate_device_view(fs)
    by_mountpoint = generate_mountpoint_view(fs)

    fs_data = Mash.new
    fs_data["by_device"] = by_device
    fs_data["by_mountpoint"] = by_mountpoint
    fs_data["by_pair"] = by_pair

    # Set the filesystem data - Windows didn't do the conversion when everyone
    # else did, so 15 will have both be the new API and 16 will drop the old API
    filesystem generate_deprecated_windows_view(fs)
    filesystem2 fs_data
  end
end