lib/active_ldap/schema.rb



module ActiveLdap
  class Schema
    include GetTextSupport

    def initialize(entries)
      @entries = normalize_entries(entries || {})
      @schema_info = {}
      @class_attributes_info = {}
      @cache = {}
    end

    def ids(group)
      ensure_parse(group)
      info, ids, aliases = ensure_schema_info(group)
      _ = info = aliases # for suppress a warning on Ruby 1.9.3
      ids.keys
    end

    def names(group)
      alias_map(group).keys
    end

    def exist_name?(group, name)
      alias_map(group).has_key?(normalize_schema_name(name))
    end

    def resolve_name(group, name)
      alias_map(group)[normalize_schema_name(name)]
    end

    # fetch
    #
    # This is just like LDAP::Schema#attribute except that it allows
    # look up in any of the given keys.
    # e.g.
    #  fetch('attributeTypes', 'cn', 'DESC')
    #  fetch('ldapSyntaxes', '1.3.6.1.4.1.1466.115.121.1.5', 'DESC')
    def fetch(group, id_or_name, attribute_name)
      return [] if attribute_name.empty?
      attribute_name = normalize_attribute_name(attribute_name)
      value = entry(group, id_or_name)[attribute_name]
      value ? value.dup : []
    end
    alias_method :[], :fetch

    NUMERIC_OID_RE = "\\d[\\d\\.]+"
    DESCRIPTION_RE = "[a-zA-Z][a-zA-Z\\d\\-]*"
    OID_RE = "(?:#{NUMERIC_OID_RE}|#{DESCRIPTION_RE}-oid)"
    def entry(group, id_or_name)
      return {} if group.empty? or id_or_name.empty?

      unless @entries.has_key?(group)
        raise ArgumentError, _("Unknown schema group: %s") % group
      end

      # Initialize anything that is required
      info, ids, aliases = ensure_schema_info(group)
      _ = info # for suppress a warning on Ruby 1.9.3
      id, name = determine_id_or_name(id_or_name, aliases)

      # Check already parsed options first
      return ids[id] if ids.has_key?(id)

      schemata = @entries[group] || []
      while schema = schemata.shift
        next unless /\A\s*\(\s*(#{OID_RE})\s*(.*)\s*\)\s*\z/ =~ schema
        schema_id = $1
        rest = $2

        if ids.has_key?(schema_id)
          attributes = ids[schema_id]
        else
          attributes = {}
          ids[schema_id] = attributes
        end

        parse_attributes(rest, attributes)
        (attributes["NAME"] || []).each do |v|
          normalized_name = normalize_schema_name(v)
          aliases[normalized_name] = schema_id
          id = schema_id if id.nil? and name == normalized_name
        end

        break if id == schema_id
      end

      ids[id || aliases[name]] || {}
    end

    def attribute(name)
      name = name.to_s if name.is_a?(Symbol)
      cache([:attribute, name]) do
        Attribute.new(name, self)
      end
    end

    def attributes
      cache([:attributes]) do
        names("attributeTypes").collect do |name|
          attribute(name)
        end
      end
    end

    def attribute_type(name, attribute_name)
      cache([:attribute_type, name, attribute_name]) do
        fetch("attributeTypes", name, attribute_name)
      end
    end

    def object_class(name)
      cache([:object_class, name]) do
        ObjectClass.new(name, self)
      end
    end

    def object_classes
      cache([:object_classes]) do
        names("objectClasses").collect do |name|
          object_class(name)
        end
      end
    end

    def object_class_attribute(name, attribute_name)
      cache([:object_class_attribute, name, attribute_name]) do
        fetch("objectClasses", name, attribute_name)
      end
    end

    def dit_content_rule_attribute(name, attribute_name)
      cache([:dit_content_rule_attribute, name, attribute_name]) do
        fetch("dITContentRules", name, attribute_name)
      end
    end

    def ldap_syntax(name)
      cache([:ldap_syntax, name]) do
        Syntax.new(name, self)
      end
    end

    def ldap_syntaxes
      cache([:ldap_syntaxes]) do
        ids("ldapSyntaxes").collect do |id|
          ldap_syntax(id)
        end
      end
    end

    def ldap_syntax_attribute(name, attribute_name)
      cache([:ldap_syntax_attribute, name, attribute_name]) do
        fetch("ldapSyntaxes", name, attribute_name)
      end
    end

    def dump(output=nil)
      require 'pp'
      output ||= STDOUT
      if output.respond_to?(:write)
        PP.pp(@entries, output)
      else
        open(output, "w") {|out| PP.pp(@entries, out)}
      end
      nil
    end

    private
    def cache(key)
      (@cache[key] ||= [yield])[0]
    end

    def ensure_schema_info(group)
      @schema_info[group] ||= {:ids => {}, :aliases => {}}
      info = @schema_info[group]
      [info, info[:ids], info[:aliases]]
    end

    def determine_id_or_name(id_or_name, aliases)
      if /\A[\d\.]+\z/ =~ id_or_name
        id = id_or_name
        name = nil
      else
        name = normalize_schema_name(id_or_name)
        id = aliases[name]
      end
      [id, name]
    end

    # from RFC 2252
    attribute_type_description_reserved_names =
      ["NAME", "DESC", "OBSOLETE", "SUP", "EQUALITY", "ORDERING", "SUBSTR",
       "SYNTAX", "SINGLE-VALUE", "COLLECTIVE", "NO-USER-MODIFICATION", "USAGE"]
    syntax_description_reserved_names = ["DESC"]
    object_class_description_reserved_names =
      ["NAME", "DESC", "OBSOLETE", "SUP", "ABSTRACT", "STRUCTURAL",
       "AUXILIARY", "MUST", "MAY"]
    matching_rule_description_reserved_names =
      ["NAME", "DESC", "OBSOLETE", "SYNTAX"]
    matching_rule_use_description_reserved_names =
      ["NAME", "DESC", "OBSOLETE", "APPLIES"]
    private_experiment_reserved_names = ["X-[A-Z\\-_]+"]
    reserved_names =
      (attribute_type_description_reserved_names +
       syntax_description_reserved_names +
       object_class_description_reserved_names +
       matching_rule_description_reserved_names +
       matching_rule_use_description_reserved_names +
       private_experiment_reserved_names).uniq
    RESERVED_NAMES_RE = /(?:#{reserved_names.join('|')})/

    def parse_attributes(str, attributes)
      str.scan(/([A-Z\-_]+)\s+
                (?:\(\s*(\w[\w\-;]*(?:\s+\$\s+\w[\w\-;]*)*)\s*\)|
                   \(\s*([^\)]*)\s*\)|
                   '([^\']*)'|
                   ((?!#{RESERVED_NAMES_RE})[a-zA-Z][a-zA-Z\d\-;]*)|
                   (\d[\d\.\{\}]+)|
                   ()
                )/x
               ) do |name, multi_amp, multi, string, literal, syntax, no_value|
        case
        when multi_amp
          values = multi_amp.rstrip.split(/\s*\$\s*/)
        when multi
          values = multi.scan(/\s*'([^\']*)'\s*/).collect {|value| value[0]}
        when string
          values = [string]
        when literal
          values = [literal]
        when syntax
          values = [syntax]
        when no_value
          values = ["TRUE"]
        end
        attributes[normalize_attribute_name(name)] ||= []
        attributes[normalize_attribute_name(name)].concat(values)
      end
    end

    def alias_map(group)
      ensure_parse(group)
      return {} if @schema_info[group].nil?
      @schema_info[group][:aliases] || {}
    end

    def ensure_parse(group)
      return if @entries[group].nil?
      unless @entries[group].empty?
        fetch(group, 'nonexistent', 'nonexistent')
      end
    end

    def normalize_schema_name(name)
      name.downcase.sub(/;.*$/, '')
    end

    def normalize_attribute_name(name)
      name.upcase.gsub(/_/, "-")
    end

    def default_entries
      {
        "objectClasses" => [],
        "attributeTypes" => [],
        "ldapSyntaxes" => [],
        "dITContentRules" => [],
        "matchingRules" => [],
      }
    end

    def normalize_entries(entries)
      normalized_entries = default_entries
      normalized_keys = normalized_entries.keys
      entries.each do |name, values|
        normalized_name = normalized_keys.find do |key|
          key.downcase == name
        end
        normalized_entries[normalized_name || name] = values
      end
      normalized_entries
    end

    class Entry
      include Comparable

      attr_reader :id, :name, :aliases, :description
      def initialize(name, schema, group)
        @schema = schema
        @name, *@aliases = attribute("NAME", name)
        @name ||= name
        @id = @schema.resolve_name(group, @name)
        collect_info
        @schema = nil
      end

      def eql?(other)
        self.class == other.class and
          (id == other.id or
           (id.nil? and other.nil? and name == other.name))
      end

      def hash
        id.nil? ? name.hash : id.hash
      end

      def <=>(other)
        name <=> other.name
      end

      def to_param
        name
      end
    end

    class Syntax < Entry
      attr_reader :length
      def initialize(id, schema)
        if /\{(\d+)\}\z/ =~ id
          id = $PREMATCH
          @length = Integer($1)
        else
          @length = nil
        end
        super(id, schema, "ldapSyntaxes")
        @id = id
        @name = nil if @name == @id
        @built_in_syntax = Syntaxes[@id]
      end

      def binary?
        return true if @built_in_syntax and @built_in_syntax.binary?
        binary_transfer_required? or !human_readable?
      end

      def binary_transfer_required?
        @binary_transfer_required
      end

      def human_readable?
        @human_readable
      end

      def valid?(value)
        validate(value).nil?
      end

      def validate(value)
        if @built_in_syntax
          @built_in_syntax.validate(value)
        else
          nil
        end
      end

      def type_cast(value)
        if @built_in_syntax
          @built_in_syntax.type_cast(value)
        else
          value
        end
      end

      def normalize_value(value)
        if @built_in_syntax
          @built_in_syntax.normalize_value(value)
        else
          value
        end
      end

      def <=>(other)
        id <=> other.id
      end

      def to_param
        id
      end

      private
      def attribute(attribute_name, name=@name)
        @schema.ldap_syntax_attribute(name, attribute_name)
      end

      def collect_info
        @description = attribute("DESC")[0]
        @binary_transfer_required =
          (attribute('X-BINARY-TRANSFER-REQUIRED')[0] == 'TRUE')
        @human_readable = (attribute('X-NOT-HUMAN-READABLE')[0] != 'TRUE')
      end
    end

    class Attribute < Entry
      include GetTextSupport
      include HumanReadable

      attr_reader :super_attribute
      def initialize(name, schema)
        super(name, schema, "attributeTypes")
      end

      # read_only?
      #
      # Returns true if an attribute is read-only
      # NO-USER-MODIFICATION
      def read_only?
        @read_only
      end

      # single_value?
      #
      # Returns true if an attribute can only have one
      # value defined
      # SINGLE-VALUE
      def single_value?
        @single_value
      end

      # binary?
      #
      # Returns true if the given attribute's syntax is binary syntax,
      # X-NOT-HUMAN-READABLE or X-BINARY-TRANSFER-REQUIRED
      def binary?
        @binary
      end

      # Sets binary encoding to value if the given attribute's syntax
      # is binary syntax. Does nothing otherwise.
      # @return [void]
      def apply_encoding(value)
        return unless binary?
        case value
        when Hash
          value.each_value do |sub_value|
            apply_encoding(sub_value)
          end
        when Array
          value.each do |sub_value|
            apply_encoding(sub_value)
          end
        else
          return unless value.respond_to?(:force_encoding)
          value.force_encoding("ASCII-8BIT")
        end
      end

      # binary_required?
      #
      # Returns true if the value MUST be transferred in binary
      def binary_required?
        @binary_required
      end

      # directory_operation?
      #
      # Returns true if an attribute is directory operation.
      # It means that USAGE contains directoryOperation.
      def directory_operation?
        @directory_operation
      end

      def syntax
        @derived_syntax
      end

      def valid?(value)
        validate(value).nil?
      end

      def validate(value)
        error_info = validate_each_value(value)
        return error_info if error_info
        begin
          normalize_value(value)
          nil
        rescue AttributeValueInvalid
          [$!.message]
        end
      end

      def type_cast(value)
        send_to_syntax(value, :type_cast, value)
      end

      def normalize_value(value)
        normalize_value_internal(value, false)
      end

      def syntax_description
        send_to_syntax(nil, :description)
      end

      def human_attribute_name
        self.class.human_attribute_name(self)
      end

      def human_attribute_description
        self.class.human_attribute_description(self)
      end

      def to_hash
        {
          :read_only => read_only?,
          :single_value => single_value?,
          :binary => binary?,
          :binary_required => binary_required?,
          :directory_operation => directory_operation?,
          :syntax => syntax,
          :syntax_description => syntax_description,
        }
      end

      private
      def attribute(attribute_name, name=@name)
        @schema.attribute_type(name, attribute_name)
      end

      def collect_info
        @description = attribute("DESC")[0]
        @super_attribute = attribute("SUP")[0]
        if @super_attribute
          @super_attribute = @schema.attribute(@super_attribute)
          @super_attribute = nil if @super_attribute.id.nil?
        end
        @read_only = attribute('NO-USER-MODIFICATION')[0] == 'TRUE'
        @single_value = attribute('SINGLE-VALUE')[0] == 'TRUE'
        @syntax = attribute("SYNTAX")[0]
        @syntax = @schema.ldap_syntax(@syntax) if @syntax
        if @syntax
          @binary_required = @syntax.binary_transfer_required?
          @binary = @syntax.binary?
          @derived_syntax = @syntax
        else
          @binary_required = false
          @binary = false
          @derived_syntax = nil
          @derived_syntax = @super_attribute.syntax if @super_attribute
        end
        @directory_operation = attribute("USAGE").include?("directoryOperation")
      end

      def send_to_syntax(default_value, method_name, *args)
        _syntax = syntax
        if _syntax
          _syntax.send(method_name, *args)
        else
          default_value
        end
      end

      def validate_each_value(value, option=nil)
        failed_reason = nil
        case value
        when Hash
          original_option = option
          value.each do |sub_option, val|
            opt = [original_option, sub_option].compact.join(";")
            failed_reason, option = validate_each_value(val, opt)
            break if failed_reason
          end
        when Array
          original_option = option
          value.each do |val|
            failed_reason, option = validate_each_value(val, original_option)
            break if failed_reason
          end
        else
          failed_reason = send_to_syntax(nil, :validate, value)
        end
        return nil if failed_reason.nil?
        [failed_reason, option]
      end

      def normalize_value_internal(value, have_binary_mark)
        case value
        when Array
          normalize_array_value(value, have_binary_mark)
        when Hash
          normalize_hash_value(value, have_binary_mark)
        else
          if value.nil?
            value = []
          else
            value = send_to_syntax(value, :normalize_value, value)
          end
          if !have_binary_mark and binary_required?
            [{'binary' => value}]
          else
            value.is_a?(Array) ? value : [value]
          end
        end
      end

      def normalize_array_value(value, have_binary_mark)
        if single_value? and value.reject {|v| v.is_a?(Hash)}.size > 1
          format = _("Attribute %s can only have a single value: %s")
          message = format % [human_attribute_name, value.inspect]
          raise AttributeValueInvalid.new(self, value, message)
        end
        if value.empty?
          if !have_binary_mark and binary_required?
            [{'binary' => value}]
          else
            value
          end
        else
          value.collect do |entry|
            normalize_value_internal(entry, have_binary_mark)[0]
          end
        end
      end

      def normalize_hash_value(value, have_binary_mark)
        if value.size > 1
          format = _("Attribute %s: Hash must have one key-value pair only: %s")
          message = format % [human_attribute_name, value.inspect]
          raise AttributeValueInvalid.new(self, value, message)
        end

        if !have_binary_mark and binary_required? and !have_binary_key?(value)
          [append_binary_key(value)]
        else
          key = value.keys[0]
          have_binary_mark ||= key == "binary"
          [{key => normalize_value_internal(value.values[0], have_binary_mark)}]
        end
      end

      def have_binary_key?(hash)
        key, value = hash.to_a[0]
        return true if key == "binary"
        return have_binary_key?(value) if value.is_a?(Hash)
        false
      end

      def append_binary_key(hash)
        key, value = hash.to_a[0]
        if value.is_a?(Hash)
          append_binary_key(value)
        else
          hash.merge(key => {"binary" => value})
        end
      end
    end

    class ObjectClass < Entry
      attr_reader :super_classes
      def initialize(name, schema)
        super(name, schema, "objectClasses")
      end

      def super_class?(object_class)
        @super_classes.include?(object_class)
      end

      def must(include_super_class=true)
        if include_super_class
          @all_must
        else
          @must
        end
      end

      def may(include_super_class=true)
        if include_super_class
          @all_may
        else
          @may
        end
      end

      private
      def collect_info
        @description = attribute("DESC")[0]
        @super_classes = collect_super_classes
        @must, @may, @all_must, @all_may = collect_attributes
      end

      def collect_super_classes
        super_classes = attribute('SUP')
        loop do
          start_size = super_classes.size
          new_super_classes = []
          super_classes.each do |super_class|
            new_super_classes.concat(attribute('SUP', super_class))
          end

          super_classes.concat(new_super_classes)
          super_classes.uniq!
          break if super_classes.size == start_size
        end
        super_classes.collect do |name|
          @schema.object_class(name)
        end
      end

      UNWRITABLE_MUST_ATTRIBUTES = ["nTSecurityDescriptor"]
      def collect_attributes
        must = attribute('MUST').reject do |name|
          UNWRITABLE_MUST_ATTRIBUTES.include?(name)
        end.uniq
        must = must.collect {|name| @schema.attribute(name)}
        may = attribute('MAY').uniq.collect {|name| @schema.attribute(name)}

        all_must = must.dup
        all_may = may.dup
        @super_classes.each do |super_class|
          all_must.concat(super_class.must(false))
          all_may.concat(super_class.may(false))
        end

        # Clean out the dupes.
        all_must.uniq!
        all_may.uniq!

        [must, may, all_must, all_may]
      end

      def attribute(attribute_name, name=@name)
        @schema.object_class_attribute(name, attribute_name) +
          @schema.dit_content_rule_attribute(name, attribute_name)
      end
    end
  end
end

require 'active_ldap/schema/syntaxes'