# Manipulation of LDIF data.
#
# $Id: ldif.rb,v 1.11 2005/03/03 01:32:07 ianmacd Exp $
#
# Copyright (C) 2005 Ian Macdonald <ian@caliban.org>
#
module LDAP
# Record objects are embodiments of LDAP operations. They possess a DN,
# a change type (*LDAP_MOD_ADD*, *LDAP_MOD_DELETE* or *LDAP_MOD_REPLACE*
# [any of which can be logically AND'ed with *LDAP_MOD_BVALUES*]), a hash of
# attributes and value arrays, a hash of modification operations (useful
# only when the change type is *LDAP_MOD_REPLACE*) and an array of
# LDAP controls.
#
# The Record class's primary use is as a transitional medium for LDIF
# operations parsed by the LDAP::LDIF module. You are unlikely to want to
# use it in application code.
#
class Record
attr_reader :dn, :change_type, :attrs, :mods, :controls
def initialize(dn, change_type, attrs, mods=nil, ctls=nil)
@dn = dn
@change_type = change_type
@attrs = attrs
@mods = mods
@controls = ctls
end
# Send the operation embodied in the Record object to the LDAP::Conn
# object specified in +conn+.
#
def send( conn )
if @change_type == :MODRDN
# TODO: How do we deal with 'newsuperior'?
# The LDAP API's ldap_modrdn2_s() function doesn't seem to use it.
return conn.modrdn( @dn, @attrs['newrdn'], @attrs['deleteoldrdn'] )
end
# Mask out the LDAP_MOD_BVALUES bit, as it's irrelevant here.
case @change_type & ~LDAP_MOD_BVALUES
when LDAP_MOD_ADD
@controls == [] ? conn.add( @dn, @attrs ) :
conn.add_ext( @dn, @attrs, @controls, [] )
when LDAP_MOD_DELETE
@controls == [] ? conn.delete( @dn ) :
conn.delete_ext( @dn, @controls, [] )
when LDAP_MOD_REPLACE
@controls == [] ? conn.modify( @dn, @mods ) :
conn.modify_ext( @dn, @mods, @controls, [] )
end
self
end
# Remove common operational attributes from a Record object. This is
# useful if you have Record objects formed from LDIF data that contained
# operational attributes. Using LDAP::Record#send to send such an object
# to an LDAP server is likely to meet with an exception unless the data is
# first cleaned.
#
# In addition, attributes with duplicate values are pruned, as this can
# also result in an exception.
#
def clean
# TODO: These operational attributes are those commonly used by
# OpenLDAP 2.2. Others should probably be supported.
#
%w[ creatorsname createtimestamp modifiersname modifytimestamp
entrycsn entryuuid structuralobjectclass ].each do |attr|
@attrs.delete( attr )
end
# Clean out duplicate attribute values.
@attrs.each_key { |k| @attrs[k].uniq! }
self
end
end
# This module provides the ability to process LDIF entries and files.
#
module LDIF
LINE_LENGTH = 77
private
class Entry < String; end
class Mod < String; end
class LDIFError < LDAP::Error; end
# return *true* if +str+ contains a character with an ASCII value > 127 or
# a NUL, LF or CR. Otherwise, *false* is returned.
#
def LDIF.unsafe_char?( str )
# This could be written as a single regex, but this is faster.
str =~ /^[ :]/ || str =~ /[\x00-\x1f\x7f-\xff]/
end
# Perform Base64 decoding of +str+. If +concat+ is *true*, LF characters
# are stripped.
#
def LDIF.base64_encode( str, concat=false )
str = [ str ].pack( 'm' )
str.gsub!( /\n/, '' ) if concat
str
end
# Perform Base64 encoding of +str+.
#
def LDIF.base64_decode( str )
str.unpack( 'm*' )[0]
end
# Read a file from the URL +url+. At this time, the only type of URL
# supported is the +file://+ URL.
#
def LDIF.read_file( url )
unless url.sub!( %r(^file://), '' )
raise ArgumentError, "Bad external file reference: #{url}"
end
# Slurp an external file.
# TODO: Support other URL types in the future.
File.open( url ).readlines( nil )[0]
end
# This converts an attribute and array of values to LDIF.
#
def LDIF.to_ldif( attr, vals )
ldif = ''
vals.each do |val|
sep = ':'
if unsafe_char?( val )
sep = '::'
val = base64_encode( val, true )
end
firstline_len = LINE_LENGTH - ( "%s%s " % [ attr, sep ] ).length
ldif << "%s%s %s\n" % [ attr, sep, val.slice!( 0..firstline_len ) ]
while val.length > 0
ldif << " %s\n" % val.slice!( 0..LINE_LENGTH - 1 )
end
end
ldif
end
public
# Parse the LDIF entry contained in +lines+ and return an LDAP::Record
# object. +lines+ should be an object that responds to each, such as a
# string or an array of lines, separated by \n characters.
#
def LDIF.parse_entry( lines )
header = true
comment = false
change_type = nil
sep = nil
attr = nil
bvalues = []
controls = nil
hash = {}
mods = {}
mod_type = nil
lines.each do |line|
# Skip (continued) comments.
if line =~ /^#/ || ( comment && line[0..0] == ' ' )
comment = true
next
end
# Skip blank lines.
next if line =~ /^$/
# Reset mod type if this entry has more than one mod to make.
# A '-' continuation is only valid if we've already had a
# 'changetype: modify' line.
if line =~ /^-$/ && change_type == LDAP_MOD_REPLACE
next
end
line.chomp!
# N.B. Attributes and values can be separated by one or two colons,
# or one colon and a '<'. Either of these is then followed by zero
# or one spaces.
if md = line.match( /^[^ ].*?((:[:<]?) ?)/ )
# If previous value was Base64-encoded and is not continued,
# we need to decode it now.
if sep == '::'
if mod_type
mods[mod_type][attr][-1] =
base64_decode( mods[mod_type][attr][-1] )
bvalues << attr if unsafe_char?( mods[mod_type][attr][-1] )
else
hash[attr][-1] = base64_decode( hash[attr][-1] )
bvalues << attr if unsafe_char?( hash[attr][-1] )
end
end
# Found a attr/value line.
attr, val = line.split( md[1], 2 )
attr.downcase!
# Attribute must be ldap-oid / (ALPHA *(attr-type-chars))
if attr !~ /^(?:(?:\d+\.)*\d+|[[:alnum:]-]+)(?:;[[:alnum:]-]+)*$/
raise LDIFError, "Invalid attribute: #{attr}"
end
if attr == 'dn'
header = false
change_type = nil
controls = []
end
sep = md[2]
val = read_file( val ) if sep == ':<'
case attr
when 'version'
# Check the LDIF version.
if header
if val != '1'
raise LDIFError, "Unsupported LDIF version: #{val}"
else
header = false
next
end
end
when 'changetype'
change_type = case val
when 'add' then LDAP_MOD_ADD
when 'delete' then LDAP_MOD_DELETE
when 'modify' then LDAP_MOD_REPLACE
when /^modr?dn$/ then :MODRDN
end
raise LDIFError, "Invalid change type: #{attr}" unless change_type
when 'add', 'delete', 'replace'
unless change_type == LDAP_MOD_REPLACE
raise LDIFError, "Cannot #{attr} here."
end
mod_type = case attr
when 'add' then LDAP_MOD_ADD
when 'delete' then LDAP_MOD_DELETE
when 'replace' then LDAP_MOD_REPLACE
end
mods[mod_type] ||= {}
mods[mod_type][val] ||= []
when 'control'
oid, criticality = val.split( / /, 2 )
unless oid =~ /(?:\d+\.)*\d+/
raise LDIFError, "Bad control OID: #{oid}"
end
if criticality
md = criticality.match( /(:[:<]?) ?/ )
ctl_sep = md[1] if md
criticality, value = criticality.split( /:[:<]? ?/, 2 )
if criticality !~ /^(?:true|false)$/
raise LDIFError, "Bad control criticality: #{criticality}"
end
# Convert 'true' or 'false'. to_boolean would be nice. :-)
criticality = eval( criticality )
end
if value
value = base64_decode( value ) if ctl_sep == '::'
value = read_file( value ) if ctl_sep == ':<'
value = Control.encode( value )
end
controls << Control.new( oid, value, criticality )
else
# Convert modrdn's deleteoldrdn from '1' to true, anything else
# to false. Should probably raise an exception if not '0' or '1'.
#
if change_type == :MODRDN && attr == 'deleteoldrdn'
val = val == '1' ? true : false
end
if change_type == LDAP_MOD_REPLACE
mods[mod_type][attr] << val
else
hash[attr] ||= []
hash[attr] << val
end
comment = false
# Make a note of this attribute if value is binary.
bvalues << attr if unsafe_char?( val )
end
else
# Check last line's separator: if not a binary value, the
# continuation line must be indented. If a comment makes it this
# far, that's also an error.
#
if sep == ':' && line[0..0] != ' ' || comment
raise LDIFError, "Improperly continued line: #{line}"
end
# OK; this is a valid continuation line.
# Append line except for initial space.
line[0] = '' if line[0..0] == ' '
if change_type == LDAP_MOD_REPLACE
# Append to last value of current mod type.
mods[mod_type][attr][-1] << line
else
# Append to last value.
hash[attr][-1] << line
end
end
end
# If last value in LDIF entry was Base64-encoded, we need to decode
# it now.
if sep == '::'
if mod_type
mods[mod_type][attr][-1] =
base64_decode( mods[mod_type][attr][-1] )
bvalues << attr if unsafe_char?( mods[mod_type][attr][-1] )
else
hash[attr][-1] = base64_decode( hash[attr][-1] )
bvalues << attr if unsafe_char?( hash[attr][-1] )
end
end
# Remove and remember DN.
dn = hash.delete( 'dn' )[0]
# This doesn't really matter, but let's be anal about it, because it's
# not an attribute and doesn't belong here.
bvalues.delete( 'dn' )
# If there's no change type, it's just plain LDIF data, so we'll treat
# it like an addition.
change_type ||= LDAP_MOD_ADD
case change_type
when LDAP_MOD_ADD
mods[LDAP_MOD_ADD] = []
hash.each do |attr,val|
if bvalues.include?( attr )
ct = LDAP_MOD_ADD | LDAP_MOD_BVALUES
else
ct = LDAP_MOD_ADD
end
mods[LDAP_MOD_ADD] << LDAP.mod( ct, attr, val )
end
when LDAP_MOD_DELETE
# Nothing to do.
when LDAP_MOD_REPLACE
raise LDIFError, "mods should not be empty" if mods == {}
new_mods = {}
mods.each do |mod_type,attrs|
attrs.each_key do |attr|
if bvalues.include?( attr )
mt = mod_type | LDAP_MOD_BVALUES
else
mt = mod_type
end
new_mods[mt] ||= {}
new_mods[mt][attr] = mods[mod_type][attr]
end
end
mods = new_mods
when :MODRDN
# Nothing to do.
end
Record.new( dn, change_type, hash, mods, controls )
end
# Open and parse a file containing LDIF entries. +file+ should be a string
# containing the path to the file. If +sort+ is true, the resulting array
# of LDAP::Record objects will be sorted on DN length, which can be useful
# to avoid a later attempt to process an entry whose parent does not yet
# exist. This can easily happen if your LDIF file is unordered, which is
# likely if it was produced with a tool such as <em>slapcat(8)</em>.
#
# If a block is given, each LDAP::Record object will be yielded to the
# block and *nil* will be returned instead of the array. This is much less
# memory-intensive when parsing a large LDIF file.
#
def LDIF.parse_file( file, sort=false ) # :yield: record
File.open( file ) do |f|
entries = []
entry = false
header = true
version = false
while line = f.gets
if line =~ /^dn:/
header = false
if entry && ! version
if block_given?
yield parse_entry( entry )
else
entries << parse_entry( entry )
end
end
if version
entry << line
version = false
else
entry = [ line ]
end
next
end
if header && line.downcase =~ /^version/
entry = [ line ]
version = true
next
end
entry << line
end
if block_given?
yield parse_entry( entry )
nil
else
entries << parse_entry( entry )
# Sort entries if sorting has been requested.
entries.sort! { |x,y| x.dn.length <=> y.dn.length } if sort
entries
end
end
end
# Given the DN, +dn+, convert a single LDAP::Mod or an array of
# LDAP::Mod objects, given in +mods+, to LDIF.
#
def LDIF.mods_to_ldif( dn, *mods )
ldif = "dn: %s\nchangetype: modify\n" % dn
plural = false
mods.flatten.each do |mod|
# TODO: Need to dynamically assemble this case statement to add
# OpenLDAP's increment change type, etc.
change_type = case mod.mod_op & ~LDAP_MOD_BVALUES
when LDAP_MOD_ADD then 'add'
when LDAP_MOD_DELETE then 'delete'
when LDAP_MOD_REPLACE then 'replace'
end
ldif << "-\n" if plural
ldif << LDIF.to_ldif( change_type, mod.mod_type )
ldif << LDIF.to_ldif( mod.mod_type, mod.mod_vals )
plural = true
end
LDIF::Mod.new( ldif )
end
end
class Entry
# Convert an LDAP::Entry to LDIF.
#
def to_ldif
ldif = "dn: %s\n" % get_dn
get_attributes.each do |attr|
get_values( attr ).each do |val|
ldif << LDIF.to_ldif( attr, [ val ] )
end
end
LDIF::Entry.new( ldif )
end
alias_method :to_s, :to_ldif
end
class Mod
# Convert an LDAP::Mod with the DN given in +dn+ to LDIF.
#
def to_ldif( dn )
ldif = "dn: %s\n" % dn
# TODO: Need to dynamically assemble this case statement to add
# OpenLDAP's increment change type, etc.
case mod_op & ~LDAP_MOD_BVALUES
when LDAP_MOD_ADD
ldif << "changetype: add\n"
when LDAP_MOD_DELETE
ldif << "changetype: delete\n"
when LDAP_MOD_REPLACE
return LDIF.mods_to_ldif( dn, self )
end
ldif << LDIF.to_ldif( mod_type, mod_vals )
LDIF::Mod.new( ldif )
end
alias_method :to_s, :to_ldif
end
end
syntax highlighted by Code2HTML, v. 0.9.1