require "socket"
require "openssl"

# :nodoc:
module OpenSSL::SSL::HostnameValidation
  enum Result
    Error
    MalformedCertificate
    MatchFound
    MatchNotFound
    NoSANPresent
  end

  # Matches hostname against Subject Alternate Name (SAN) entries of the
  # certificate.
  #
  # The Common Name (CN) entry will only be used if no SAN entries are present
  # in the certificate, as per
  # [RFC 6125, Section 6.4.4](https://tools.ietf.org/html/rfc6125#section-6.4.4).
  def self.validate_hostname(hostname : String, server_cert : LibCrypto::X509) : Result
    return Result::Error if server_cert.null?
    result = matches_subject_alternative_name(hostname, server_cert)
    result = matches_common_name(hostname, server_cert) if result.no_san_present?
    result
  end

  # Matches hostname against Subject Alternate Name (SAN) entries of certificate.
  #
  # Adapted from https://wiki.openssl.org/index.php/Hostname_validation
  def self.matches_subject_alternative_name(hostname, server_cert : LibCrypto::X509) : Result
    san_names = LibCrypto.x509_get_ext_d2i(server_cert, LibCrypto::NID_subject_alt_name, nil, nil)
    return Result::NoSANPresent if san_names.null?

    begin
      LibCrypto.sk_num(san_names).times do |i|
        current_name = LibCrypto.sk_value(san_names, i).as(LibCrypto::GENERAL_NAME*).value

        case current_name.type
        when LibCrypto::GEN_DNS
          dns_name = LibCrypto.asn1_string_data(current_name.value)
          dns_name_len = LibCrypto.asn1_string_length(current_name.value)
          return Result::MalformedCertificate if dns_name_len != LibC.strlen(dns_name)

          pattern = String.new(dns_name, dns_name_len)
          return Result::MatchFound if matches_hostname?(pattern, hostname)
        when LibCrypto::GEN_IPADD
          data = Slice.new(LibCrypto.asn1_string_data(current_name.value), LibCrypto.asn1_string_length(current_name.value))

          case data.size
          when 4
            if v4_fields = ::Socket::IPAddress.parse_v4_fields?(hostname)
              return Result::MatchFound if v4_fields.to_slice == data
            end
          when 16
            if v6_fields = ::Socket::IPAddress.parse_v6_fields?(hostname)
              {% if IO::ByteFormat::NetworkEndian != IO::ByteFormat::SystemEndian %}
                v6_fields.map! &.byte_swap
              {% end %}
              return Result::MatchFound if v6_fields.to_slice.to_unsafe_bytes == data
            end
          else
            # not a length we expect
          end
        else
          # not a type we expect
        end
      end

      Result::MatchNotFound
    ensure
      LibCrypto.sk_pop_free(san_names, ->(ptr : Void*) {
        LibCrypto.sk_free(ptr)
      })
    end
  end

  # Matches hostname from Common Name (CN) entry of certificate. Should only be
  # called if no SAN entries could be found in certificate.
  #
  # Adapted from https://wiki.openssl.org/index.php/Hostname_validation
  def self.matches_common_name(hostname, server_cert : LibCrypto::X509) : Result
    subject = LibCrypto.x509_get_subject_name(server_cert)

    index = LibCrypto.x509_name_get_index_by_nid(subject, LibCrypto::NID_commonName, -1)
    return Result::Error if index < 0

    name_entry = LibCrypto.x509_name_get_entry(subject, index)
    return Result::Error if name_entry.null?

    asn1 = LibCrypto.x509_name_entry_get_data(name_entry)
    return Result::Error if asn1.null?

    str = LibCrypto.asn1_string_data(asn1)
    str_len = LibCrypto.asn1_string_length(asn1)
    return Result::MalformedCertificate if str_len != LibC.strlen(str)

    common_name = String.new(str, str_len)
    return Result::MatchFound if matches_hostname?(common_name, hostname)

    Result::MatchNotFound
  end

  # Matches a hostname against a wildcard pattern.
  #
  # The hostname must be an exact match or use a wildcard following
  # [RFC 6125, section 6.4.3](http://tools.ietf.org/html/rfc6125#section-6.4.3)
  # and [RFC 6125, section 7.2](http://tools.ietf.org/html/rfc6125#section-7.2)
  #
  # IDNA domains must be given in their punycode encoding, and no wildcard match
  # will be attempted if the left-most label is an IDNA label. For example
  # `*.xn--kcry6tjko.example.org` will match `foo.xn--kcry6tjko.example.org` but
  # `xn--*.example.org` won't match `xn--kcry6tjko.example.org`.
  #
  # No wildcard match is attempted for IP addresses. The hostname and patterns
  # are normalized to skip trailing dots (like browsers do).
  #
  # To be compatible with OpenSSL `X509_check_host` a leading dot will match any
  # subdomain. For example `.example.org` will match both `foo.example.com` and
  # `bar.foo.example.com`.
  #
  # Adapted from cURL:
  # Copyright (C) 1998 - 2014, Daniel Stenberg, <daniel@haxx.se>, et al.
  # https://github.com/curl/curl/blob/curl-7_41_0/lib/hostcheck.c
  def self.matches_hostname?(pattern, hostname) : Bool
    pattern = pattern.chomp('.').downcase
    hostname = hostname.chomp('.').downcase

    # leading dot matches any subdomain (openssl 1.0.2 compatibility)
    if pattern[0] == '.'
      return hostname.ends_with?(pattern)
    end

    unless wildcard = pattern.index('*')
      return pattern == hostname
    end

    # fail match when hostname is an IP address
    if ::Socket::IPAddress.valid?(hostname)
      return false
    end

    unless pattern_label_len = pattern.index('.')
      return false
    end

    # only the first label should be considered for wildcard match
    # need at least 2 dots in pattern to avoid too wide wildcard match
    # no wildcard match in IDNA label
    if wildcard > pattern_label_len || pattern.count('.') < 2 || pattern.starts_with?("xn--")
      return pattern == hostname
    end

    unless hostname_label_len = hostname.index('.')
      return false
    end

    # the wildcard must match at least 1 char, so the first label must be at least
    # the same size than pattern first label
    if hostname_label_len < pattern_label_len
      return false
    end

    # domains don't match
    if pattern[pattern_label_len..-1] != hostname[hostname_label_len..-1]
      return false
    end

    # wildcard match
    suffix = pattern_label_len - (wildcard + 1)
    pattern[0, wildcard] == hostname[0, wildcard] &&
      pattern[wildcard + 1, suffix + 1] == hostname[hostname_label_len - suffix, suffix + 1]
  end
end
