# frozen_string_literal: true

# Provides utility functions to help interface Puppet to SELinux.
#
# This requires the very new SELinux Ruby bindings.  These bindings closely
# mirror the SELinux C library interface.
#
# Support for the command line tools is not provided because the performance
# was abysmal.  At this time (2008-11-02) the only distribution providing
# these Ruby SELinux bindings which I am aware of is Fedora (in libselinux-ruby).

Puppet.features.selinux? # check, but continue even if it's not

require 'pathname'

module Puppet::Util::SELinux
  S_IFREG = 0o100000
  S_IFDIR = 0o040000
  S_IFLNK = 0o120000

  def self.selinux_support?
    return false unless defined?(Selinux)
    if Selinux.is_selinux_enabled == 1
      return true
    end

    false
  end

  def selinux_support?
    Puppet::Util::SELinux.selinux_support?
  end

  # Retrieve and return the full context of the file.  If we don't have
  # SELinux support or if the SELinux call fails then return nil.
  def get_selinux_current_context(file)
    return nil unless selinux_support?

    retval = Selinux.lgetfilecon(file)
    if retval == -1
      return nil
    end

    retval[1]
  end

  # Retrieve and return the default context of the file.  If we don't have
  # SELinux support or if the SELinux call fails to file a default then return nil.
  # @deprecated matchpathcon is a deprecated method, selabel_lookup is preferred
  def get_selinux_default_context(file, resource_ensure = nil)
    return nil unless selinux_support?
    # If the filesystem has no support for SELinux labels, return a default of nil
    # instead of what matchpathcon would return
    return nil unless selinux_label_support?(file)

    # If the file exists we should pass the mode to matchpathcon for the most specific
    # matching.  If not, we can pass a mode of 0.
    mode = file_mode(file, resource_ensure)

    retval = Selinux.matchpathcon(file, mode)
    retval == -1 ? nil : retval[1]
  end

  # Retrieve and return the default context of the file using an selinux handle.
  # If we don't have SELinux support or if the SELinux call fails to file a
  # default then return nil.
  def get_selinux_default_context_with_handle(file, handle, resource_ensure = nil)
    return nil unless selinux_support?
    # If the filesystem has no support for SELinux labels, return a default of nil
    # instead of what selabel_lookup would return
    return nil unless selinux_label_support?(file)

    # Handle is needed for selabel_lookup
    raise ArgumentError, _("Cannot get default context with nil handle") unless handle

    # If the file exists we should pass the mode to selabel_lookup for the most specific
    # matching.  If not, we can pass a mode of 0.
    mode = file_mode(file, resource_ensure)

    retval = Selinux.selabel_lookup(handle, file, mode)
    retval == -1 ? nil : retval[1]
  end

  # Take the full SELinux context returned from the tools and parse it
  # out to the three (or four) component parts.  Supports :seluser, :selrole,
  # :seltype, and on systems with range support, :selrange.
  def parse_selinux_context(component, context)
    if context.nil? or context == "unlabeled"
      return nil
    end

    components = /^([^\s:]+):([^\s:]+):([^\s:]+)(?::([\sa-zA-Z0-9:,._-]+))?$/.match(context)
    unless components
      raise Puppet::Error, _("Invalid context to parse: %{context}") % { context: context }
    end

    case component
    when :seluser
      components[1]
    when :selrole
      components[2]
    when :seltype
      components[3]
    when :selrange
      components[4]
    else
      raise Puppet::Error, _("Invalid SELinux parameter type")
    end
  end

  # This updates the actual SELinux label on the file.  You can update
  # only a single component or update the entire context.
  # The caveat is that since setting a partial context makes no sense the
  # file has to already exist.  Puppet (via the File resource) will always
  # just try to set components, even if all values are specified by the manifest.
  # I believe that the OS should always provide at least a fall-through context
  # though on any well-running system.
  def set_selinux_context(file, value, component = false)
    return nil unless selinux_support? && selinux_label_support?(file)

    if component
      # Must first get existing context to replace a single component
      context = Selinux.lgetfilecon(file)[1]
      if context == -1
        # We can't set partial context components when no context exists
        # unless/until we can find a way to make Puppet call this method
        # once for all selinux file label attributes.
        Puppet.warning _("Can't set SELinux context on file unless the file already has some kind of context")
        return nil
      end
      context = context.split(':')
      case component
      when :seluser
        context[0] = value
      when :selrole
        context[1] = value
      when :seltype
        context[2] = value
      when :selrange
        context[3] = value
      else
        raise ArgumentError, _("set_selinux_context component must be one of :seluser, :selrole, :seltype, or :selrange")
      end
      context = context.join(':')
    else
      context = value
    end

    retval = Selinux.lsetfilecon(file, context)
    if retval == 0
      true
    else
      Puppet.warning _("Failed to set SELinux context %{context} on %{file}") % { context: context, file: file }
      false
    end
  end

  # Since this call relies on get_selinux_default_context it also needs a
  # full non-relative path to the file.  Fortunately, that seems to be all
  # Puppet uses.  This will set the file's SELinux context to the policy's
  # default context (if any) if it differs from the context currently on
  # the file.
  def set_selinux_default_context(file, resource_ensure = nil)
    new_context = get_selinux_default_context(file, resource_ensure)
    return nil unless new_context

    cur_context = get_selinux_current_context(file)
    if new_context != cur_context
      set_selinux_context(file, new_context)
      return new_context
    end
    nil
  end

  ##
  # selinux_category_to_label is an internal method that converts all
  # selinux categories to their internal representation, avoiding
  # potential issues when mcstransd is not functional.
  #
  # It is not marked private because it is needed by File's
  # selcontext.rb, but it is not intended for use outside of Puppet's
  # code.
  #
  # @param category [String] An selinux category, such as "s0" or "SystemLow"
  #
  # @return [String] the numeric category name, such as "s0"
  def selinux_category_to_label(category)
    # We don't cache this, but there's already a ton of duplicate work
    # in the selinux handling code.

    path = Selinux.selinux_translations_path
    begin
      File.open(path).each do |line|
        line.strip!
        next if line.empty?
        next if line[0] == "#" # skip comments

        line.gsub!(/[[:space:]]+/m, '')
        mapping = line.split("=", 2)
        if category == mapping[1]
          return mapping[0]
        end
      end
    rescue SystemCallError => ex
      log_exception(ex)
      raise Puppet::Error, _("Could not open SELinux category translation file %{path}.") % { context: context }
    end

    category
  end

  ########################################################################
  # Internal helper methods from here on in, kids.  Don't fiddle.
  private

  # Check filesystem a path resides on for SELinux support against
  # whitelist of known-good filesystems.
  # Returns true if the filesystem can support SELinux labels and
  # false if not.
  def selinux_label_support?(file)
    fstype = find_fs(file)
    return false if fstype.nil?

    filesystems = %w[ext2 ext3 ext4 gfs gfs2 xfs jfs btrfs tmpfs zfs]
    filesystems.include?(fstype)
  end

  # Get mode file type bits set based on ensure on
  # the file resource. This helps SELinux determine
  # what context a new resource being created should have.
  def get_create_mode(resource_ensure)
    mode = 0
    case resource_ensure
    when :present, :file
      mode |= S_IFREG
    when :directory
      mode |= S_IFDIR
    when :link
      mode |= S_IFLNK
    end
    mode
  end

  # If the file/directory/symlink exists, return its mode. Otherwise, get the default mode
  # that should be used to create the file/directory/symlink taking into account the desired
  # file type specified in +resource_ensure+.
  def file_mode(file, resource_ensure)
    filestat = file_lstat(file)
    filestat.mode
  rescue Errno::EACCES
    0
  rescue Errno::ENOENT
    if resource_ensure
      get_create_mode(resource_ensure)
    else
      0
    end
  end

  # Internal helper function to read and parse /proc/mounts
  def read_mounts
    mounts = ''.dup
    begin
      if File.method_defined? "read_nonblock"
        # If possible we use read_nonblock in a loop rather than read to work-
        # a linux kernel bug.  See ticket #1963 for details.
        mountfh = File.new("/proc/mounts")
        loop do
          mounts += mountfh.read_nonblock(1024)
        end
      else
        # Otherwise we shell out and let cat do it for us
        mountfh = IO.popen("/bin/cat /proc/mounts")
        mounts = mountfh.read
      end
    rescue EOFError
      # that's expected
    rescue
      return nil
    ensure
      mountfh.close if mountfh
    end

    mntpoint = {}

    # Read all entries in /proc/mounts.  The second column is the
    # mountpoint and the third column is the filesystem type.
    # We skip rootfs because it is always mounted at /
    mounts.each_line do |line|
      params = line.split(' ')
      next if params[2] == 'rootfs'

      mntpoint[params[1]] = params[2]
    end
    mntpoint
  end

  # Internal helper function to return which type of filesystem a given file
  # path resides on
  def find_fs(path)
    mounts = read_mounts
    return nil unless mounts

    # cleanpath eliminates useless parts of the path (like '.', or '..', or
    # multiple slashes), without touching the filesystem, and without
    # following symbolic links.  This gives the right (logical) tree to follow
    # while we try and figure out what file-system the target lives on.
    path = Pathname(path).cleanpath
    unless path.absolute?
      raise Puppet::DevError, _("got a relative path in SELinux find_fs: %{path}") % { path: path }
    end

    # Now, walk up the tree until we find a match for that path in the hash.
    path.ascend do |segment|
      return mounts[segment.to_s] if mounts.has_key?(segment.to_s)
    end

    # Should never be reached...
    mounts['/']
  end

  ##
  # file_lstat is an internal, private method to allow precise stubbing and
  # mocking without affecting the rest of the system.
  #
  # @return [File::Stat] File.lstat result
  def file_lstat(path)
    Puppet::FileSystem.lstat(path)
  end
  private :file_lstat
end
