lib/sprockets/digest_utils.rb



# frozen_string_literal: true
require 'digest/sha1'
require 'digest/sha2'
require 'set'

module Sprockets
  # Internal: Hash functions and digest related utilities. Mixed into
  # Environment.
  module DigestUtils
    extend self

    # Internal: Default digest class.
    #
    # Returns a Digest::Base subclass.
    def digest_class
      Digest::SHA256
    end

    # Internal: Maps digest bytesize to the digest class.
    DIGEST_SIZES = {
      20 => Digest::SHA1,
      32 => Digest::SHA256,
      48 => Digest::SHA384,
      64 => Digest::SHA512
    }

    # Internal: Detect digest class hash algorithm for digest bytes.
    #
    # While not elegant, all the supported digests have a unique bytesize.
    #
    # Returns Digest::Base or nil.
    def detect_digest_class(bytes)
      DIGEST_SIZES[bytes.bytesize]
    end

    ADD_VALUE_TO_DIGEST = {
      String     => ->(val, digest) { digest << val },
      FalseClass => ->(val, digest) { digest << 'FalseClass'.freeze },
      TrueClass  => ->(val, digest) { digest << 'TrueClass'.freeze  },
      NilClass   => ->(val, digest) { digest << 'NilClass'.freeze   },

      Symbol => ->(val, digest) {
        digest << 'Symbol'.freeze
        digest << val.to_s
      },
      Integer => ->(val, digest) {
        digest << 'Integer'.freeze
        digest << val.to_s
      },
      Array => ->(val, digest) {
        digest << 'Array'.freeze
        val.each do |element|
          ADD_VALUE_TO_DIGEST[element.class].call(element, digest)
        end
      },
      Hash => ->(val, digest) {
        digest << 'Hash'.freeze
        val.sort.each do |array|
          ADD_VALUE_TO_DIGEST[Array].call(array, digest)
        end
      },
      Set => ->(val, digest) {
        digest << 'Set'.freeze
        ADD_VALUE_TO_DIGEST[Array].call(val, digest)
      },
      Encoding => ->(val, digest) {
        digest << 'Encoding'.freeze
        digest << val.name
      }
    }

    ADD_VALUE_TO_DIGEST.compare_by_identity.rehash

    ADD_VALUE_TO_DIGEST.default_proc = ->(_, val) {
      raise TypeError, "couldn't digest #{ val }"
    }
    private_constant :ADD_VALUE_TO_DIGEST

    # Internal: Generate a hexdigest for a nested JSON serializable object.
    #
    # This is used for generating cache keys, so its pretty important its
    # wicked fast. Microbenchmarks away!
    #
    # obj - A JSON serializable object.
    #
    # Returns a String digest of the object.
    def digest(obj)
      build_digest(obj).digest
    end

    # Internal: Generate a hexdigest for a nested JSON serializable object.
    #
    # The same as `pack_hexdigest(digest(obj))`.
    #
    # obj - A JSON serializable object.
    #
    # Returns a String digest of the object.
    def hexdigest(obj)
      build_digest(obj).hexdigest!
    end

    # Internal: Pack a binary digest to a hex encoded string.
    #
    # bin - String bytes
    #
    # Returns hex String.
    def pack_hexdigest(bin)
      bin.unpack('H*'.freeze).first
    end

    # Internal: Unpack a hex encoded digest string into binary bytes.
    #
    # hex - String hex
    #
    # Returns binary String.
    def unpack_hexdigest(hex)
      [hex].pack('H*')
    end

    # Internal: Pack a binary digest to a base64 encoded string.
    #
    # bin - String bytes
    #
    # Returns base64 String.
    def pack_base64digest(bin)
      [bin].pack('m0')
    end

    # Internal: Pack a binary digest to a urlsafe base64 encoded string.
    #
    # bin - String bytes
    #
    # Returns urlsafe base64 String.
    def pack_urlsafe_base64digest(bin)
      str = pack_base64digest(bin)
      str.tr!('+/'.freeze, '-_'.freeze)
      str.tr!('='.freeze, ''.freeze)
      str
    end

    # Internal: Maps digest class to the CSP hash algorithm name.
    HASH_ALGORITHMS = {
      Digest::SHA256 => 'sha256'.freeze,
      Digest::SHA384 => 'sha384'.freeze,
      Digest::SHA512 => 'sha512'.freeze
    }

    # Public: Generate hash for use in the `integrity` attribute of an asset tag
    # as per the subresource integrity specification.
    #
    # digest - The String byte digest of the asset content.
    #
    # Returns a String or nil if hash algorithm is incompatible.
    def integrity_uri(digest)
      case digest
      when Digest::Base
        digest_class = digest.class
        digest = digest.digest
      when String
        digest_class = DIGEST_SIZES[digest.bytesize]
      else
        raise TypeError, "unknown digest: #{digest.inspect}"
      end

      if hash_name = HASH_ALGORITHMS[digest_class]
        "#{hash_name}-#{pack_base64digest(digest)}"
      end
    end

    # Public: Generate hash for use in the `integrity` attribute of an asset tag
    # as per the subresource integrity specification.
    #
    # digest - The String hexbyte digest of the asset content.
    #
    # Returns a String or nil if hash algorithm is incompatible.
    def hexdigest_integrity_uri(hexdigest)
      integrity_uri(unpack_hexdigest(hexdigest))
    end

    # Internal: Checks an asset name for a valid digest
    #
    # name - The name of the asset
    #
    # Returns true if the name contains a digest like string and .digested before the extension
    def already_digested?(name)
      return name =~ /-([0-9a-zA-Z]{7,128})\.digested/
    end

    private
      def build_digest(obj)
        digest = digest_class.new

        ADD_VALUE_TO_DIGEST[obj.class].call(obj, digest)
        digest
      end
  end
end