From 06ea3240553dd057b3e0e9cc5d90bccd86120a74 Mon Sep 17 00:00:00 2001 From: Austin Ziegler Date: Sat, 27 Mar 2010 14:49:14 -0400 Subject: [PATCH] Cleanup of Dataset and Entry. --- lib/net/ldap.rb | 8 +- lib/net/ldap/dataset.rb | 199 ++++++++++++++------ lib/net/ldap/entry.rb | 404 +++++++++++++++++----------------------- test/test_ldif.rb | 51 ++--- 4 files changed, 343 insertions(+), 319 deletions(-) diff --git a/lib/net/ldap.rb b/lib/net/ldap.rb index d4ae2d4..ee64c47 100644 --- a/lib/net/ldap.rb +++ b/lib/net/ldap.rb @@ -1041,7 +1041,7 @@ class Net::LDAP :attributes => [ :namingContexts, :supportedLdapVersion, :altServer, :supportedControl, :supportedExtension, :supportedFeatures, :supportedSASLMechanisms]) - (rs and rs.first) or Entry.new + (rs and rs.first) or Net::LDAP::Entry.new end # Return the root Subschema record from the LDAP server as a @@ -1072,16 +1072,16 @@ class Net::LDAP rs = search(:ignore_server_caps => true, :base => "", :scope => SearchScope_BaseObject, :attributes => [:subschemaSubentry]) - return Entry.new unless (rs and rs.first) + return Net::LDAP::Entry.new unless (rs and rs.first) subschema_name = rs.first.subschemasubentry - return Entry.new unless (subschema_name and subschema_name.first) + return Net::LDAP::Entry.new unless (subschema_name and subschema_name.first) rs = search(:ignore_server_caps => true, :base => subschema_name.first, :scope => SearchScope_BaseObject, :filter => "objectclass=subschema", :attributes => [:objectclasses, :attributetypes]) - (rs and rs.first) or Entry.new + (rs and rs.first) or Net::LDAP::Entry.new end #-- diff --git a/lib/net/ldap/dataset.rb b/lib/net/ldap/dataset.rb index 19c1490..342c2e4 100644 --- a/lib/net/ldap/dataset.rb +++ b/lib/net/ldap/dataset.rb @@ -20,80 +20,155 @@ # #--------------------------------------------------------------------------- -module Net - class LDAP - class Dataset < Hash - attr_reader :comments +## +# An LDAP Dataset. Used primarily as an intermediate format for converting +# to and from LDIF strings and Net::LDAP::Entry objects. +class Net::LDAP::Dataset < Hash + ## + # Dataset object comments. + attr_reader :comments - class IOFilter - def initialize(io) - @io = io - end - def gets - s = @io.gets - s.chomp if s + class << self + class ChompedIO #:nodoc: + def initialize(io) + @io = io + end + def gets + s = @io.gets + s.chomp if s + end + end + + ## + # Reads an object that returns data line-wise (using #gets) and parses + # LDIF data into a Dataset object. + def read_ldif(io) #:yields: entry-type, value Used mostly for debugging. + ds = Net::LDAP::Dataset.new + io = ChompedIO.new(io) + + line = io.gets + dn = nil + + while line + new_line = io.gets + + if new_line =~ /^[\s]+/ + line << " " << $' + else + nextline = new_line + + if line =~ /^#/ + ds.comments << line + yield :comment, line if block_given? + elsif line =~ /^dn:[\s]*/i + dn = $' + ds[dn] = Hash.new { |k,v| k[v] = [] } + yield :dn, dn if block_given? + elsif line.empty? + dn = nil + yield :end, nil if block_given? + elsif line =~ /^([^:]+):([\:]?)[\s]*/ + # $1 is the attribute name + # $2 is a colon iff the attr-value is base-64 encoded + # $' is the attr-value + # Avoid the Base64 class because not all Ruby versions have it. + attrvalue = ($2 == ":") ? $'.unpack('m').shift : $' + ds[dn][$1.downcase.to_sym] << attrvalue + yield :attr, [$1.downcase.to_sym, attrvalue] if block_given? + end + + line = nextline end end - def self.read_ldif io - ds = Dataset.new - io = IOFilter.new(io) + ds + end - line = io.gets - dn = nil + ## + # Creates a Dataset object from an Entry object. Used mostly to assist + # with the conversion of + def from_entry(entry) + dataset = Net::LDAP::Dataset.new + hash = { } + entry.each_attribute do |attribute, value| + next if attribute == :dn + hash[attribute] = value + end + dataset[entry.dn] = hash + dataset + end + end - while line - new_line = io.gets - if new_line =~ /^[\s]+/ - line << " " << $' + def initialize(*args, &block) #:nodoc: + super + @comments = [] + end + + ## + # Outputs an LDAP Dataset as an array of strings representing LDIF + # entries. + def to_ldif + ary = [] + ary += @comments unless @comments.empty? + keys.sort.each do |dn| + ary << "dn: #{dn}" + + attributes = self[dn].keys.map { |attr| attr.to_s }.sort + attributes.each do |attr| + self[dn][attr.to_sym].each do |value| + if attr == "userpassword" or value_is_binary?(value) + value = [value].pack("m").chomp.gsub(/\n/m, "\n ") + ary << "#{attr}:: #{value}" else - nextline = new_line - - if line =~ /^\#/ - ds.comments << line - elsif line =~ /^dn:[\s]*/i - dn = $' - ds[dn] = Hash.new {|k,v| k[v] = []} - elsif line.length == 0 - dn = nil - elsif line =~ /^([^:]+):([\:]?)[\s]*/ - # $1 is the attribute name - # $2 is a colon iff the attr-value is base-64 encoded - # $' is the attr-value - # Avoid the Base64 class because not all Ruby versions have it. - attrvalue = ($2 == ":") ? $'.unpack('m').shift : $' - ds[dn][$1.downcase.intern] << attrvalue - end - - line = nextline + ary << "#{attr}: #{value}" end end - - ds end + ary << "" + end + block_given? and ary.each { |line| yield line} - def initialize - @comments = [] + ary + end + + ## + # Outputs an LDAP Dataset as an LDIF string. + def to_ldif_string + to_ldif.join("\n") + end + + ## + # Convert the parsed LDIF objects to Net::LDAP::Entry objects. + def to_entries + ary = [] + keys.each do |dn| + entry = Net::LDAP::Entry.new(dn) + self[dn].each do |attr, value| + entry[attr] = value end + ary << entry + end + ary + end - - def to_ldif - ary = [] - ary += (@comments || []) - keys.sort.each do |dn| - ary << "dn: #{dn}" - - self[dn].keys.map {|sym| sym.to_s}.sort.each do |attr| - self[dn][attr.intern].each {|val| ary << "#{attr}: #{val}" } - end - - ary << "" - end - block_given? and ary.each {|line| yield line} - ary - end - - end - end + # This is an internal convenience method to determine if a value requires + # base64-encoding before conversion to LDIF output. The standard approach + # in most LDAP tools is to check whether the value is a password, or if + # the first or last bytes are non-printable. Microsoft Active Directory, + # on the other hand, sometimes sends values that are binary in the middle. + # + # In the worst cases, this could be a nasty performance killer, which is + # why we handle the simplest cases first. Ideally, we would also test the + # first/last byte, but it's a bit harder to do this in a way that's + # compatible with both 1.8.6 and 1.8.7. + def value_is_binary?(value) + value = value.to_s + return true if value[0] == ?: or value[0] == ?< + value.each_byte { |byte| return true if (byte < 32) || (byte > 126) } + false + end + private :value_is_binary? end + +require 'net/ldap/entry' unless defined? Net::LDAP::Entry diff --git a/lib/net/ldap/entry.rb b/lib/net/ldap/entry.rb index 11daf2f..539ba0f 100644 --- a/lib/net/ldap/entry.rb +++ b/lib/net/ldap/entry.rb @@ -21,246 +21,188 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA # #--------------------------------------------------------------------------- + +## +# Objects of this class represent individual entries in an LDAP directory. +# User code generally does not instantiate this class. Net::LDAP#search +# provides objects of this class to user code, either as block parameters or +# as return values. # +# In LDAP-land, an "entry" is a collection of attributes that are uniquely +# and globally identified by a DN ("Distinguished Name"). Attributes are +# identified by short, descriptive words or phrases. Although a directory is +# free to implement any attribute name, most of them follow rigorous +# standards so that the range of commonly-encountered attribute names is not +# large. +# +# An attribute name is case-insensitive. Most directories also restrict the +# range of characters allowed in attribute names. To simplify handling +# attribute names, Net::LDAP::Entry internally converts them to a standard +# format. Therefore, the methods which take attribute names can take Strings +# or Symbols, and work correctly regardless of case or capitalization. +# +# An attribute consists of zero or more data items called values. An +# entry is the combination of a unique DN, a set of attribute names, and a +# (possibly-empty) array of values for each attribute. +# +# Class Net::LDAP::Entry provides convenience methods for dealing with LDAP +# entries. In addition to the methods documented below, you may access +# individual attributes of an entry simply by giving the attribute name as +# the name of a method call. For example: +# +# ldap.search( ... ) do |entry| +# puts "Common name: #{entry.cn}" +# puts "Email addresses:" +# entry.mail.each {|ma| puts ma} +# end +# +# If you use this technique to access an attribute that is not present in a +# particular Entry object, a NoMethodError exception will be raised. +# +#-- +# Ugly problem to fix someday: We key off the internal hash with a canonical +# form of the attribute name: convert to a string, downcase, then take the +# symbol. Unfortunately we do this in at least three places. Should do it in +# ONE place. +class Net::LDAP::Entry + ## + # This constructor is not generally called by user code. + def initialize(dn = nil) #:nodoc: + @myhash = {} + @myhash[:dn] = [dn] + end -module Net -class LDAP + ## + # Use the LDIF format for Marshal serialization. + def _dump(depth) #:nodoc: + to_ldif + end + ## + # Use the LDIF format for Marshal serialization. + def self._load(entry) #:nodoc: + from_single_ldif_string(entry) + end - # Objects of this class represent individual entries in an LDAP directory. - # User code generally does not instantiate this class. Net::LDAP#search - # provides objects of this class to user code, either as block parameters or - # as return values. + class << self + ## + # Converts a single LDIF entry string into an Entry object. Useful for + # Marshal serialization. If a string with multiple LDIF entries is + # provided, an exception will be raised. + def from_single_ldif_string(ldif) + ds = Net::LDAP::Dataset.read_ldif(::StringIO.new(ldif)) + + return nil if ds.empty? + + raise Net::LDAP::LdapError, "Too many LDIF entries" unless ds.size == 1 + + entry = ds.to_entries.first + + return nil if entry.dn.nil? + entry + end + + ## + # Canonicalizes an LDAP attribute name as a \Symbol. The name is + # lowercased and, if present, a trailing equals sign is removed. + def attribute_name(name) + name = name.to_s.downcase + name = name[0..-2] if name[-1] == ?= + name.to_sym + end + end + + ## + # Sets or replaces the array of values for the provided attribute. The + # attribute name is canonicalized prior to assignment. # - # In LDAP-land, an "entry" is a collection of attributes that are uniquely - # and globally identified by a DN ("Distinguished Name"). Attributes are - # identified by short, descriptive words or phrases. Although a directory is - # free to implement any attribute name, most of them follow rigorous - # standards so that the range of commonly-encountered attribute names is not - # large. + # When an attribute is set using this, that attribute is now made + # accessible through methods as well. # - # An attribute name is case-insensitive. Most directories also restrict the - # range of characters allowed in attribute names. To simplify handling - # attribute names, Net::LDAP::Entry internally converts them to a standard - # format. Therefore, the methods which take attribute names can take Strings - # or Symbols, and work correctly regardless of case or capitalization. + # entry = Net::LDAP::Entry.new("dc=com") + # entry.foo # => NoMethodError + # entry["foo"] = 12345 # => [12345] + # entry.foo # => [12345] + def []=(name, value) + @myhash[self.class.attribute_name(name)] = Kernel::Array(value) + end + + ## + # Reads the array of values for the provided attribute. The attribute name + # is canonicalized prior to reading. Returns an empty array if the + # attribute does not exist. + def [](name) + name = self.class.attribute_name(name) + @myhash[name] || [] + end + + ## + # Returns the first distinguished name (dn) of the Entry as a \String. + def dn + self[:dn].first.to_s + end + + ## + # Returns an array of the attribute names present in the Entry. + def attribute_names + @myhash.keys + end + + ## + # Accesses each of the attributes present in the Entry. # - # An attribute consists of zero or more data items called values. An - # entry is the combination of a unique DN, a set of attribute names, and a - # (possibly-empty) array of values for each attribute. - # - # Class Net::LDAP::Entry provides convenience methods for dealing with LDAP - # entries. In addition to the methods documented below, you may access - # individual attributes of an entry simply by giving the attribute name as - # the name of a method call. For example: - # - # ldap.search( ... ) do |entry| - # puts "Common name: #{entry.cn}" - # puts "Email addresses:" - # entry.mail.each {|ma| puts ma} - # end - # - # If you use this technique to access an attribute that is not present in a - # particular Entry object, a NoMethodError exception will be raised. - # - #-- - # Ugly problem to fix someday: We key off the internal hash with a canonical - # form of the attribute name: convert to a string, downcase, then take the - # symbol. Unfortunately we do this in at least three places. Should do it in - # ONE place. - # - class Entry - # This constructor is not generally called by user code. - # - def initialize dn = nil # :nodoc: - @myhash = {} - @myhash[:dn] = [dn] - end - - def _dump depth - to_ldif - end - - class << self - def _load entry - from_single_ldif_string entry - end - end - - #-- - # Discovered bug, 26Aug06: I noticed that we're not converting the - # incoming value to an array if it isn't already one. - def []=(name, value) # :nodoc: - sym = attribute_name(name) - value = [value] unless value.is_a?(Array) - @myhash[sym] = value - end - - #-- - # We have to deal with this one as we do with []= because this one and not - # the other one gets called in formulations like entry["CN"] << cn. - # - def [](name) # :nodoc: - name = attribute_name(name) unless name.is_a?(Symbol) - @myhash[name] || [] - end - - # Returns the dn of the Entry as a String. - def dn - self[:dn][0].to_s - end - - # Returns an array of the attribute names present in the Entry. - def attribute_names - @myhash.keys - end - - # Accesses each of the attributes present in the Entry. - # Calls a user-supplied block with each attribute in turn, - # passing two arguments to the block: a Symbol giving - # the name of the attribute, and a (possibly empty) - # Array of data values. - # - def each - if block_given? - attribute_names.each {|a| - attr_name,values = a,self[a] - yield attr_name, values - } - end - end - - alias_method :each_attribute, :each - - # Converts the Entry to a String, representing the - # Entry's attributes in LDIF format. - #-- - def to_ldif - ary = [] - ary << "dn: #{dn}\n" - v2 = "" # temp value, save on GC - each_attribute do |k,v| - unless k == :dn - v.each {|v1| - v2 = if (k == :userpassword) || is_attribute_value_binary?(v1) - ": #{Base64.encode64(v1).chomp.gsub(/\n/m,"\n ")}" - else - " #{v1}" - end - ary << "#{k}:#{v2}\n" - } - end - end - ary << "\n" - ary.join - end - - #-- - # TODO, doesn't support broken lines. - # It generates a SINGLE Entry object from an incoming LDIF stream which is - # of course useless for big LDIF streams that encode many objects. - # - # DO NOT DOCUMENT THIS METHOD UNTIL THESE RESTRICTIONS ARE LIFTED. - # - # As it is, it's useful for unmarshalling objects that we create, but not - # for reading arbitrary LDIF files. Eventually, we should have a class - # method that parses large LDIF streams into individual LDIF blocks - # (delimited by blank lines) and passes them here. - # - class << self - def from_single_ldif_string ldif - entry = Entry.new - entry[:dn] = [] - ldif.split(/\r?\n/m).each {|line| - break if line.length == 0 - if line =~ /\A([\w]+):(:?)[\s]*/ - entry[$1] <<= if $2 == ':' - Base64.decode64($') - else - $' - end - end - } - entry.dn ? entry : nil - end - end - - #-- - # Part of the support for getter and setter style access to attributes. - # - def respond_to?(sym) - name = attribute_name(sym) - return true if valid_attribute?(name) - return super - end - - #-- - # Supports getter and setter style access for all the attributes that this - # entry holds. - # - def method_missing sym, *args, &block # :nodoc: - name = attribute_name(sym) - - if valid_attribute? name - if setter?(sym) && args.size == 1 - value = args.first - value = [value] unless value.instance_of?(Array) - self[name]= value - - return value - elsif args.empty? - return self[name] - end - end - - super - end - - def write - end - - private - - #-- - # Internal convenience method. It seems like the standard - # approach in most LDAP tools to base64 encode an attribute - # value if its first or last byte is nonprintable, or if - # it's a password. But that turns out to be not nearly good - # enough. There are plenty of A/D attributes that are binary - # in the middle. This is probably a nasty performance killer. - def is_attribute_value_binary? value - v = value.to_s - v.each_byte {|byt| - return true if (byt < 32) || (byt > 126) + # Calls a user-supplied block with each attribute in turn, passing two + # arguments to the block: a Symbol giving the name of the attribute, and a + # (possibly empty) \Array of data values. + def each # :yields: attribute-name, data-values-array + if block_given? + attribute_names.each {|a| + attr_name,values = a,self[a] + yield attr_name, values } - if v[0..0] == ':' or v[0..0] == '<' - return true + end + end + alias_method :each_attribute, :each + + ## + # Converts the Entry to an LDIF-formatted String + def to_ldif + Net::LDAP::Dataset.from_entry(self).to_ldif_string + end + + def respond_to?(sym) #:nodoc: + return true if valid_attribute?(self.class.attribute_name(sym)) + return super + end + + def method_missing(sym, *args, &block) #:nodoc: + name = self.class.attribute_name(sym) + + if valid_attribute?(name ) + if setter?(sym) && args.size == 1 + value = args.first + value = Array(value) + self[name]= value + return value + elsif args.empty? + return self[name] end - false end - - # Returns the symbol that can be used to access the attribute that - # sym_or_str designates. - # - def attribute_name(sym_or_str) - str = sym_or_str.to_s.downcase - - # Does str match 'something='? Still only returns :something - return str[0...-1].to_sym if str.size>1 && str[-1] == ?= - return str.to_sym - end - - # Given a valid attribute symbol, returns true. - # - def valid_attribute?(attr_name) - attribute_names.include?(attr_name) - end - - def setter?(sym) - sym.to_s[-1] == ?= - end - end # class Entry + super + end -end # class LDAP -end # module Net + # Given a valid attribute symbol, returns true. + def valid_attribute?(attr_name) + attribute_names.include?(attr_name) + end + private :valid_attribute? + + # Returns true if the symbol ends with an equal sign. + def setter?(sym) + sym.to_s[-1] == ?= + end + private :setter? +end # class Entry + +require 'net/ldap/dataset' unless defined? Net::LDAP::Dataset diff --git a/test/test_ldif.rb b/test/test_ldif.rb index 2f7340f..b2c9f8c 100644 --- a/test/test_ldif.rb +++ b/test/test_ldif.rb @@ -7,53 +7,60 @@ require 'digest/sha1' require 'base64' class TestLdif < Test::Unit::TestCase - TestLdifFilename = "#{File.dirname(__FILE__)}/testdata.ldif" def test_empty_ldif - ds = Net::LDAP::Dataset.read_ldif( StringIO.new ) - assert_equal( true, ds.empty? ) + ds = Net::LDAP::Dataset.read_ldif(StringIO.new) + assert_equal(true, ds.empty?) end def test_ldif_with_comments str = ["# Hello from LDIF-land", "# This is an unterminated comment"] - io = StringIO.new( str[0] + "\r\n" + str[1] ) - ds = Net::LDAP::Dataset::read_ldif( io ) - assert_equal( str, ds.comments ) + io = StringIO.new(str[0] + "\r\n" + str[1]) + ds = Net::LDAP::Dataset::read_ldif(io) + assert_equal(str, ds.comments) end def test_ldif_with_password psw = "goldbricks" hashed_psw = "{SHA}" + Base64::encode64(Digest::SHA1.digest(psw)).chomp - ldif_encoded = Base64::encode64( hashed_psw ).chomp - ds = Net::LDAP::Dataset::read_ldif( StringIO.new( "dn: Goldbrick\r\nuserPassword:: #{ldif_encoded}\r\n\r\n" )) + ldif_encoded = Base64::encode64(hashed_psw).chomp + ds = Net::LDAP::Dataset::read_ldif(StringIO.new("dn: Goldbrick\r\nuserPassword:: #{ldif_encoded}\r\n\r\n")) recovered_psw = ds["Goldbrick"][:userpassword].shift - assert_equal( hashed_psw, recovered_psw ) + assert_equal(hashed_psw, recovered_psw) end def test_ldif_with_continuation_lines - ds = Net::LDAP::Dataset::read_ldif( StringIO.new( "dn: abcdefg\r\n hijklmn\r\n\r\n" )) - assert_equal( true, ds.has_key?( "abcdefg hijklmn" )) + ds = Net::LDAP::Dataset::read_ldif(StringIO.new("dn: abcdefg\r\n hijklmn\r\n\r\n")) + assert_equal(true, ds.has_key?("abcdefg hijklmn")) end # TODO, INADEQUATE. We need some more tests # to verify the content. def test_ldif - File.open( TestLdifFilename, "r" ) {|f| - ds = Net::LDAP::Dataset::read_ldif( f ) - assert_equal( 13, ds.length ) + File.open(TestLdifFilename, "r") {|f| + ds = Net::LDAP::Dataset::read_ldif(f) + assert_equal(13, ds.length) } end - # TODO, need some tests. # Must test folded lines and base64-encoded lines as well as normal ones. - #def test_to_ldif - # File.open( TestLdifFilename, "r" ) {|f| - # ds = Net::LDAP::Dataset::read_ldif( f ) - # ds.to_ldif - # assert_equal( true, false ) # REMOVE WHEN WE HAVE SOME TESTS HERE. - # } - #end + def test_to_ldif + data = File.open(TestLdifFilename, "rb") { |f| f.read } + io = StringIO.new(data) + entries = data.grep(/^dn:\s*/) { $'.chomp } + dn_entries = entries.dup + + ds = Net::LDAP::Dataset::read_ldif(io) { |type, value| + case type + when :dn + assert_equal(dn_entries.first, value) + dn_entries.shift + end + } + assert_equal(entries.size, ds.size) + assert_equal(entries.sort, ds.to_ldif.grep(/^dn:\s*/) { $'.chomp }) + end end