#
# Copyright:: Copyright (c) 2016 GitLab 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_relative 'helper'

class CertificateHelper
  include ShellOutHelper

  def initialize(trusted_cert_dir, omnibus_cert_dir, user_dir)
    @trusted_certs_dir = trusted_cert_dir
    @omnibus_certs_dir = omnibus_cert_dir
    @directory_hash_file = File.join(user_dir, "trusted-certs-directory-hash")
  end

  def whitelisted_files
    [
      File.join(@omnibus_certs_dir, "README"),
      File.join(@omnibus_certs_dir, "cacert.pem")
    ]
  end

  def is_x509_certificate?(file)
    return false unless valid?(file)

    begin
      OpenSSL::X509::Certificate.new(File.read(file)) # DER- or PEM-encoded
      true
    rescue OpenSSL::X509::CertificateError => e
      warn("ERROR: " + file + ": OpenSSL error: " + e.message + "!")
      false
    rescue StandardError => e
      warn(e.message)
      false
    end
  end

  # If the number of files between the two directories is different
  # something got added so trigger the run
  def new_certificate_added?
    return true unless File.exist?(@directory_hash_file)

    stored_hash = File.read(@directory_hash_file)
    trusted_certs_dir_hash != stored_hash
  end

  def trusted_certs_dir_hash
    files = Dir[File.join(@trusted_certs_dir, "*"), File.join(@omnibus_certs_dir, "*")]
    files_modification_time = files.map { |name| File.stat(name).mtime if valid?(name) }
    Digest::SHA1.hexdigest(files_modification_time.join)
  end

  # Get all files in /opt/gitlab/embedded/ssl/certs
  # - "cacert.pem", "README" -> ignore
  # - if valid certificate
  #   - if symlink
  #     - remove broken symlinks
  #     - ignore if pointing to /etc/gitlab/trusted-certs
  #     - ignore because it might be a symlink user created
  #   - else
  #     - copy to trusted-certs dir
  # - else (not valid)
  #   raise and error
  def move_existing_certificates
    Dir.glob(File.join(@omnibus_certs_dir, "*")) do |file|
      next if !valid?(file) || whitelisted?(file)

      if is_x509_certificate?(file)
        move_certificate(file)
      else
        raise_msg(file)
      end
    end
  end

  def whitelisted?(file)
    whitelisted_files.include?(file) || whitelisted_files.include?(File.realpath(file))
  end

  def valid?(file)
    exists = File.exist?(file)
    FileUtils.rm_f(file) if File.symlink?(file) && !exists

    exists
  end

  def move_certificate(file)
    return if exists_in_trusted?(file)

    # Move the certs to the trusted certs directory if it is located within our managed certs directory
    # Otherwise copy the cert to the trusted certs directory
    realpath = File.realpath(file)
    if realpath.start_with?(@omnibus_certs_dir)
      FileUtils.mv(realpath, @trusted_certs_dir, force: true)
    else
      FileUtils.cp(realpath, @trusted_certs_dir)
    end

    FileUtils.rm_f(file) if File.symlink?(file)
    puts "\n Moving #{realpath}"
  end

  def exists_in_trusted?(file)
    trusted_path = File.join(@trusted_certs_dir, File.basename(file))

    (File.symlink?(file) && File.readlink(file).start_with?(@trusted_certs_dir)) ||
      (File.exist?(trusted_path) && FileUtils.identical?(file, trusted_path))
  end

  def link_certificates
    update_permissions
    rehash_status = rehash
    unless rehash_status.zero?
      LoggingHelper.warning("Rehashing of trusted certificates present in `/etc/gitlab/trusted-certs` failed. If on a FIPS-enabled machine, ensure `openssl` or `c_rehash` binary is available in $PATH.")
      return
    end
    link_to_omnibus_ssl_directory
    log_directory_hash
  end

  # openssl_rehash ran so we now have valid hashed names
  # Skip all files that are not symlinks
  # If they are symlinks, make sure they are valid certificates
  def link_to_omnibus_ssl_directory
    Dir.glob(File.join(@trusted_certs_dir, "*")) do |trusted_cert|
      if File.symlink?(trusted_cert) && is_x509_certificate?(trusted_cert)
        hash_name = File.basename(trusted_cert)
        certificate_path = File.realpath(trusted_cert)
        symlink_path = File.join(@omnibus_certs_dir, hash_name)

        puts "\n Linking #{hash_name} from #{certificate_path}"

        FileUtils.ln_s certificate_path, symlink_path unless File.exist?(symlink_path)
      end
    end
  end

  def update_permissions
    files_directories = Dir.glob(File.join(@trusted_certs_dir, '*'))

    # Only operate on files
    file_list = files_directories.reject { |f| File.directory?(f) }
    FileUtils.chmod(0644, file_list)
  end

  def rehash
    result = do_shell_out_with_embedded_path("openssl rehash #{@trusted_certs_dir}")

    # Fallback to `c_rehash` for FIPS systems that do not support `openssl rehash`.
    # NOTES:
    # - OpenSSL still exits with 0 on invalid commands.
    # - This fallback can be removed once we drop support for AmazonLinux 2 and
    #   handle multiple certificates gracefully with `openssl rehash`.
    stderr_output = result.stderr.downcase
    # From https://github.com/openssl/openssl/blob/a5cd06f7fff3b4484946812191097b5e080b7610/apps/rehash.c#L267-L275
    openssl_rehash_invalid = stderr_output.include?('invalid command') || stderr_output.include?('does not contain exactly one certificate')
    result = do_shell_out_with_embedded_path("c_rehash rehash #{@trusted_certs_dir}") if openssl_rehash_invalid

    result.exitstatus
  end

  def log_directory_hash
    File.write(@directory_hash_file, trusted_certs_dir_hash)
  end

  def raise_msg(file)
    raise "ERROR: Not a certificate: #{File.realpath(file)}. Move it from #{File.realpath('..', file)} to a different location and reconfigure again."
  end
end
