Files
server/usr/share/psa-pear/pear/php/Horde/Ldap/Ldif.php
2026-01-07 20:52:11 +01:00

822 lines
28 KiB
PHP

<?php
/**
* LDIF capabilities for Horde_Ldap.
*
* This class provides a means to convert between Horde_Ldap_Entry objects and
* LDAP entries represented in LDIF format files. Reading and writing are
* supported and manipulating of single entries or lists of entries.
*
* Usage example:
* <code>
* // Read and parse an LDIF file into Horde_Ldap_Entry objects
* // and print out the DNs. Store the entries for later use.
* $entries = array();
* $ldif = new Horde_Ldap_Ldif('test.ldif', 'r', $options);
* do {
* $entry = $ldif->readEntry();
* $dn = $entry->dn();
* echo " done building entry: $dn\n";
* $entries[] = $entry;
* } while (!$ldif->eof());
* $ldif->done();
*
* // Write those entries to another file
* $ldif = new Horde_Ldap_Ldif('test.out.ldif', 'w', $options);
* $ldif->writeEntry($entries);
* $ldif->done();
* </code>
*
* Copyright 2009 Benedikt Hallinger
* Copyright 2010-2017 Horde LLC (http://www.horde.org/)
*
* @category Horde
* @package Ldap
* @author Benedikt Hallinger <beni@php.net>
* @author Jan Schneider <jan@horde.org>
* @license http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0
* @see http://www.ietf.org/rfc/rfc2849.txt
* @todo LDAPv3 controls are not implemented yet
*/
class Horde_Ldap_Ldif
{
/**
* Options.
*
* @var array
*/
protected $_options = array('encode' => 'base64',
'change' => false,
'lowercase' => false,
'sort' => false,
'version' => null,
'wrap' => 78,
'raw' => '');
/**
* File handle for read/write.
*
* @var resource
*/
protected $_fh;
/**
* Whether we opened the file handle ourselves.
*
* @var boolean
*/
protected $_fhOpened = false;
/**
* Line counter for input file handle.
*
* @var integer
*/
protected $_inputLine = 0;
/**
* Counter for processed entries.
*
* @var integer
*/
protected $_entrynum = 0;
/**
* Mode we are working in.
*
* Either 'r', 'a' or 'w'
*
* @var string
*/
protected $_mode;
/**
* Whether the LDIF version string was already written.
*
* @var boolean
*/
protected $_versionWritten = false;
/**
* Cache for lines that have built the current entry.
*
* @var array
*/
protected $_linesCur = array();
/**
* Cache for lines that will build the next entry.
*
* @var array
*/
protected $_linesNext = array();
/**
* Constructor.
*
* Opens an LDIF file for reading or writing.
*
* $options is an associative array and may contain:
* - 'encode' (string): Some DN values in LDIF cannot be written verbatim
* and have to be encoded in some way. Possible
* values:
* - 'none': No encoding.
* - 'canonical': See {@link
* Horde_Ldap_Util::canonicalDN()}.
* - 'base64': Use base64 (default).
* - 'change' (boolean): Write entry changes to the LDIF file instead of
* the entries itself. I.e. write LDAP operations
* acting on the entries to the file instead of the
* entries contents. This writes the changes usually
* carried out by an update() to the LDIF
* file. Defaults to false.
* - 'lowercase' (boolean): Convert attribute names to lowercase when
* writing. Defaults to false.
* - 'sort' (boolean): Sort attribute names when writing entries according
* to the rule: objectclass first then all other
* attributes alphabetically sorted by attribute
* name. Defaults to false.
* - 'version' (integer): Set the LDIF version to write to the resulting
* LDIF file. According to RFC 2849 currently the
* only legal value for this option is 1. When this
* option is set Horde_Ldap_Ldif tries to adhere
* more strictly to the LDIF specification in
* RFC2489 in a few places. The default is null
* meaning no version information is written to the
* LDIF file.
* - 'wrap' (integer): Number of columns where output line wrapping shall
* occur. Default is 78. Setting it to 40 or lower
* inhibits wrapping.
* - 'raw' (string): Regular expression to denote the names of attributes
* that are to be considered binary in search results if
* writing entries. Example: 'raw' =>
* '/(?i:^jpegPhoto|;binary)/i'
*
* @param string|ressource $file Filename or file handle.
* @param string $mode Mode to open the file, either 'r', 'w'
* or 'a'.
* @param array $options Options like described above.
*
* @throws Horde_Ldap_Exception
*/
public function __construct($file, $mode = 'r', $options = array())
{
// Parse options.
foreach ($options as $option => $value) {
if (!array_key_exists($option, $this->_options)) {
throw new Horde_Ldap_Exception('Option ' . $option . ' not known');
}
$this->_options[$option] = Horde_String::lower($value);
}
// Set version.
$this->version($this->_options['version']);
// Setup file mode.
if (!preg_match('/^[rwa]$/', $mode)) {
throw new Horde_Ldap_Exception('File mode ' . $mode . ' not supported');
}
$this->_mode = $mode;
// Setup file handle.
if (is_resource($file)) {
// TODO: checks on mode possible?
$this->_fh = $file;
return;
}
switch ($mode) {
case 'r':
if (!file_exists($file)) {
throw new Horde_Ldap_Exception('Unable to open ' . $file . ' for reading: file not found');
}
if (!is_readable($file)) {
throw new Horde_Ldap_Exception('Unable to open ' . $file . ' for reading: permission denied');
}
break;
case 'w':
case 'a':
if (file_exists($file)) {
if (!is_writable($file)) {
throw new Horde_Ldap_Exception('Unable to open ' . $file . ' for writing: permission denied');
}
} else {
if (!@touch($file)) {
throw new Horde_Ldap_Exception('Unable to create ' . $file . ' for writing: permission denied');
}
}
break;
}
$this->_fh = @fopen($file, $this->_mode);
if (!$this->_fh) {
throw new Horde_Ldap_Exception('Could not open file ' . $file);
}
$this->_fhOpened = true;
}
/**
* Reads one entry from the file and return it as a Horde_Ldap_Entry
* object.
*
* @return Horde_Ldap_Entry
* @throws Horde_Ldap_Exception
*/
public function readEntry()
{
// Read fresh lines, set them as current lines and create the entry.
$attrs = $this->nextLines(true);
if (count($attrs)) {
$this->_linesCur = $attrs;
}
return $this->currentEntry();
}
/**
* Returns true when the end of the file is reached.
*
* @return boolean
*/
public function eof()
{
return feof($this->_fh);
}
/**
* Writes the entry or entries to the LDIF file.
*
* If you want to build an LDIF file containing several entries AND you
* want to call writeEntry() several times, you must open the file handle
* in append mode ('a'), otherwise you will always get the last entry only.
*
* @todo Implement operations on whole entries (adding a whole entry).
*
* @param Horde_Ldap_Entry|array $entries Entry or array of entries.
*
* @throws Horde_Ldap_Exception
*/
public function writeEntry($entries)
{
if (!is_array($entries)) {
$entries = array($entries);
}
foreach ($entries as $entry) {
$this->_entrynum++;
if (!($entry instanceof Horde_Ldap_Entry)) {
throw new Horde_Ldap_Exception('Entry ' . $this->_entrynum . ' is not an Horde_Ldap_Entry object');
}
if ($this->_options['change']) {
$this->_changeEntry($entry);
} else {
$this->_writeEntry($entry);
}
}
}
/**
* Writes an LDIF file that describes an entry change.
*
* @param Horde_Ldap_Entry $entry
*
* @throws Horde_Ldap_Exception
*/
protected function _changeEntry($entry)
{
// Fetch change information from entry.
$entry_attrs_changes = $entry->getChanges();
$num_of_changes = count($entry_attrs_changes['add'])
+ count($entry_attrs_changes['replace'])
+ count($entry_attrs_changes['delete']);
$is_changed = $num_of_changes > 0 || $entry->willBeDeleted() || $entry->willBeMoved();
// Write version if not done yet, also write DN of entry.
if ($is_changed) {
if (!$this->_versionWritten) {
$this->writeVersion();
}
$this->_writeDN($entry->currentDN());
}
// Process changes.
// TODO: consider DN add!
if ($entry->willBeDeleted()) {
$this->_writeLine('changetype: delete');
} elseif ($entry->willBeMoved()) {
$this->_writeLine('changetype: modrdn');
$olddn = Horde_Ldap_Util::explodeDN($entry->currentDN(), array('casefold' => 'none'));
array_shift($olddn);
$oldparent = implode(',', $olddn);
$newdn = Horde_Ldap_Util::explodeDN($entry->dn(), array('casefold' => 'none'));
$rdn = array_shift($newdn);
$parent = implode(',', $newdn);
$this->_writeLine('newrdn: ' . $rdn);
$this->_writeLine('deleteoldrdn: 1');
if ($parent !== $oldparent) {
$this->_writeLine('newsuperior: ' . $parent);
}
// TODO: What if the entry has attribute changes as well?
// I think we should check for that and make a dummy
// entry with the changes that is written to the LDIF file.
} elseif ($num_of_changes > 0) {
// Write attribute change data.
$this->_writeLine('changetype: modify');
foreach ($entry_attrs_changes as $changetype => $entry_attrs) {
foreach ($entry_attrs as $attr_name => $attr_values) {
$this->_writeLine("$changetype: $attr_name");
if ($attr_values !== null) {
$this->_writeAttribute($attr_name, $attr_values, $changetype);
}
$this->_writeLine('-');
}
}
}
// Finish this entry's data if we had changes.
if ($is_changed) {
$this->_finishEntry();
}
}
/**
* Writes an LDIF file that describes an entry.
*
* @param Horde_Ldap_Entry $entry
*
* @throws Horde_Ldap_Exception
*/
protected function _writeEntry($entry)
{
// Fetch attributes for further processing.
$entry_attrs = $entry->getValues();
// Sort and put objectclass attributes to first position.
if ($this->_options['sort']) {
ksort($entry_attrs);
if (isset($entry_attrs['objectclass'])) {
$oc = $entry_attrs['objectclass'];
unset($entry_attrs['objectclass']);
$entry_attrs = array_merge(array('objectclass' => $oc), $entry_attrs);
}
}
// Write data.
if (!$this->_versionWritten) {
$this->writeVersion();
}
$this->_writeDN($entry->dn());
foreach ($entry_attrs as $attr_name => $attr_values) {
$this->_writeAttribute($attr_name, $attr_values);
}
$this->_finishEntry();
}
/**
* Writes the version to LDIF.
*
* If the object's version is defined, this method allows to explicitely
* write the version before an entry is written.
*
* If not called explicitely, it gets called automatically when writing the
* first entry.
*
* @throws Horde_Ldap_Exception
*/
public function writeVersion()
{
if (!is_null($this->version())) {
$this->_writeLine('version: ' . $this->version(), 'Unable to write version');
}
$this->_versionWritten = true;
}
/**
* Returns or sets the LDIF version.
*
* If called with an argument it sets the LDIF version. According to RFC
* 2849 currently the only legal value for the version is 1.
*
* @param integer $version LDIF version to set.
*
* @return integer The current or new version.
* @throws Horde_Ldap_Exception
*/
public function version($version = null)
{
if ($version !== null) {
if ($version != 1) {
throw new Horde_Ldap_Exception('Illegal LDIF version set');
}
$this->_options['version'] = $version;
}
return $this->_options['version'];
}
/**
* Returns the file handle the Horde_Ldap_Ldif object reads from or writes
* to.
*
* You can, for example, use this to fetch the content of the LDIF file
* manually.
*
* @return resource
* @throws Horde_Ldap_Exception
*/
public function handle()
{
if (!is_resource($this->_fh)) {
throw new Horde_Ldap_Exception('Invalid file resource');
}
return $this->_fh;
}
/**
* Cleans up.
*
* This method signals that the LDIF object is no longer needed. You can
* use this to free up some memory and close the file handle. The file
* handle is only closed, if it was opened from Horde_Ldap_Ldif.
*
* @throws Horde_Ldap_Exception
*/
public function done()
{
// Close file handle if we opened it.
if ($this->_fhOpened) {
fclose($this->handle());
}
// Free variables.
foreach (array_keys(get_object_vars($this)) as $name) {
unset($this->$name);
}
}
/**
* Returns the current Horde_Ldap_Entry object.
*
* @return Horde_Ldap_Entry
* @throws Horde_Ldap_Exception
*/
public function currentEntry()
{
return $this->parseLines($this->currentLines());
}
/**
* Parse LDIF lines of one entry into an Horde_Ldap_Entry object.
*
* @todo what about file inclusions and urls?
* "jpegphoto:< file:///usr/local/directory/photos/fiona.jpg"
*
* @param array $lines LDIF lines for one entry.
*
* @return Horde_Ldap_Entry Horde_Ldap_Entry object for those lines.
* @throws Horde_Ldap_Exception
*/
public function parseLines($lines)
{
// Parse lines into an array of attributes and build the entry.
$attributes = array();
$dn = false;
foreach ($lines as $line) {
if (!preg_match('/^(\w+)(:|::|:<)\s(.+)$/', $line, $matches)) {
// Line not in "attr: value" format -> ignore. Maybe we should
// rise an error here, but this should be covered by
// nextLines() already. A problem arises, if users try to feed
// data of several entries to this method - the resulting entry
// will get wrong attributes. However, this is already
// mentioned in the method documentation above.
continue;
}
$attr = $matches[1];
$delim = $matches[2];
$data = $matches[3];
switch ($delim) {
case ':':
// Normal data.
$attributes[$attr][] = $data;
break;
case '::':
// Base64 data.
$attributes[$attr][] = base64_decode($data);
break;
case ':<':
// File inclusion
// TODO: Is this the job of the LDAP-client or the server?
throw new Horde_Ldap_Exception('File inclusions are currently not supported');
default:
throw new Horde_Ldap_Exception('Parsing error: invalid syntax at parsing entry line: ' . $line);
}
if (Horde_String::lower($attr) == 'dn') {
// DN line detected. Save possibly decoded DN.
$dn = $attributes[$attr][0];
// Remove wrongly added "dn: " attribute.
unset($attributes[$attr]);
}
}
if (!$dn) {
throw new Horde_Ldap_Exception('Parsing error: unable to detect DN for entry');
}
return Horde_Ldap_Entry::createFresh($dn, $attributes);
}
/**
* Returns the lines that generated the current Horde_Ldap_Entry object.
*
* Returns an empty array if no lines have been read so far.
*
* @return array Array of lines.
*/
public function currentLines()
{
return $this->_linesCur;
}
/**
* Returns the lines that will generate the next Horde_Ldap_Entry object.
*
* If you set $force to true you can iterate over the lines that build up
* entries manually. Otherwise, iterating is done using {@link
* readEntry()}. $force will move the file pointer forward, thus returning
* the next entry lines.
*
* Wrapped lines will be unwrapped. Comments are stripped.
*
* @param boolean $force Set this to true if you want to iterate over the
* lines manually
*
* @return array
* @throws Horde_Ldap_Exception
*/
public function nextLines($force = false)
{
// If we already have those lines, just return them, otherwise read.
if (count($this->_linesNext) == 0 || $force) {
// Empty in case something was left (if used $force).
$this->_linesNext = array();
$entry_done = false;
$fh = $this->handle();
// Are we in an comment? For wrapping purposes.
$commentmode = false;
// How many lines with data we have read?
$datalines_read = 0;
while (!$entry_done && !$this->eof()) {
$this->_inputLine++;
// Read line. Remove line endings, we want only data; this is
// okay since ending spaces should be encoded.
$data = rtrim(fgets($fh));
if ($data === false) {
// Error only, if EOF not reached after fgets() call.
if (!$this->eof()) {
throw new Horde_Ldap_Exception('Error reading from file at input line ' . $this->_inputLine);
}
break;
}
if (count($this->_linesNext) > 0 && preg_match('/^$/', $data)) {
// Entry is finished if we have an empty line after we had
// data.
$entry_done = true;
// Look ahead if the next EOF is nearby. Comments and empty
// lines at the file end may cause problems otherwise.
$current_pos = ftell($fh);
$data = fgets($fh);
while (!feof($fh)) {
if (preg_match('/^\s*$/', $data) ||
preg_match('/^#/', $data)) {
// Only empty lines or comments, continue to seek.
// TODO: Known bug: Wrappings for comments are okay
// but are treaten as error, since we do not
// honor comment mode here. This should be a
// very theoretically case, however I am
// willing to fix this if really necessary.
$this->_inputLine++;
$current_pos = ftell($fh);
$data = fgets($fh);
} else {
// Data found if non emtpy line and not a comment!!
// Rewind to position prior last read and stop
// lookahead.
fseek($fh, $current_pos);
break;
}
}
// Now we have either the file pointer at the beginning of
// a new data position or at the end of file causing feof()
// to return true.
continue;
}
// Build lines.
if (preg_match('/^version:\s(.+)$/', $data, $match)) {
// Version statement, set version.
$this->version($match[1]);
} elseif (preg_match('/^\w+::?\s.+$/', $data)) {
// Normal attribute: add line.
$commentmode = false;
$this->_linesNext[] = trim($data);
$datalines_read++;
} elseif (preg_match('/^\s(.+)$/', $data, $matches)) {
// Wrapped data: unwrap if not in comment mode.
if (!$commentmode) {
if ($datalines_read == 0) {
// First line of entry: wrapped data is illegal.
throw new Horde_Ldap_Exception('Illegal wrapping at input line ' . $this->_inputLine);
}
$this->_linesNext[] = array_pop($this->_linesNext) . trim($matches[1]);
$datalines_read++;
}
} elseif (preg_match('/^#/', $data)) {
// LDIF comments.
$commentmode = true;
} elseif (preg_match('/^\s*$/', $data)) {
// Empty line but we had no data for this entry, so just
// ignore this line.
$commentmode = false;
} else {
throw new Horde_Ldap_Exception('Invalid syntax at input line ' . $this->_inputLine);
}
}
}
return $this->_linesNext;
}
/**
* Converts an attribute and value to LDIF string representation.
*
* It honors correct encoding of values according to RFC 2849. Line
* wrapping will occur at the configured maximum but only if the value is
* greater than 40 chars.
*
* @param string $attr_name Name of the attribute.
* @param string $attr_value Value of the attribute.
*
* @return string LDIF string for that attribute and value.
*/
protected function _convertAttribute($attr_name, $attr_value)
{
// Handle empty attribute or process.
if (!strlen($attr_value)) {
return $attr_name.': ';
}
// If converting is needed, do it.
// Either we have some special chars or a matching "raw" regex
if ($this->_isBinary($attr_value) ||
($this->_options['raw'] &&
preg_match($this->_options['raw'], $attr_name))) {
$attr_name .= ':';
$attr_value = base64_encode($attr_value);
}
// Lowercase attribute names if requested.
if ($this->_options['lowercase']) {
$attr_name = Horde_String::lower($attr_name);
}
// Handle line wrapping.
if ($this->_options['wrap'] > 40 &&
strlen($attr_value) > $this->_options['wrap']) {
$attr_value = wordwrap($attr_value, $this->_options['wrap'], PHP_EOL . ' ', true);
}
return $attr_name . ': ' . $attr_value;
}
/**
* Converts an entry's DN to LDIF string representation.
*
* It honors correct encoding of values according to RFC 2849.
*
* @todo I am not sure, if the UTF8 stuff is correctly handled right now
*
* @param string $dn UTF8 encoded DN.
*
* @return string LDIF string for that DN.
*/
protected function _convertDN($dn)
{
// If converting is needed, do it.
return $this->_isBinary($dn)
? 'dn:: ' . base64_encode($dn)
: 'dn: ' . $dn;
}
/**
* Returns whether some data is considered binary and must be
* base64-encoded.
*
* @param string $value Some data.
*
* @return boolean True if the data should be encoded.
*/
protected function _isBinary($value)
{
$binary = false;
// ASCII-chars that are NOT safe for the start and for being inside the
// value. These are the integer values of those chars.
$unsafe_init = array(0, 10, 13, 32, 58, 60);
$unsafe = array(0, 10, 13);
// Test for illegal init char.
$init_ord = ord(substr($value, 0, 1));
if ($init_ord > 127 || in_array($init_ord, $unsafe_init)) {
$binary = true;
}
// Test for illegal content char.
for ($i = 0, $len = strlen($value); $i < $len; $i++) {
$char_ord = ord(substr($value, $i, 1));
if ($char_ord >= 127 || in_array($char_ord, $unsafe)) {
$binary = true;
}
}
// Test for ending space
if (substr($value, -1) == ' ') {
$binary = true;
}
return $binary;
}
/**
* Writes an attribute to the file handle.
*
* @param string $attr_name Name of the attribute.
* @param string|array $attr_values Single attribute value or array with
* attribute values.
*
* @throws Horde_Ldap_Exception
*/
protected function _writeAttribute($attr_name, $attr_values)
{
// Write out attribute content.
if (!is_array($attr_values)) {
$attr_values = array($attr_values);
}
foreach ($attr_values as $attr_val) {
$line = $this->_convertAttribute($attr_name, $attr_val);
$this->_writeLine($line, 'Unable to write attribute ' . $attr_name . ' of entry ' . $this->_entrynum);
}
}
/**
* Writes a DN to the file handle.
*
* @param string $dn DN to write.
*
* @throws Horde_Ldap_Exception
*/
protected function _writeDN($dn)
{
// Prepare DN.
if ($this->_options['encode'] == 'base64') {
$dn = $this->_convertDN($dn);
} elseif ($this->_options['encode'] == 'canonical') {
$dn = Horde_Ldap_Util::canonicalDN($dn, array('casefold' => 'none'));
}
$this->_writeLine($dn, 'Unable to write DN of entry ' . $this->_entrynum);
}
/**
* Finishes an LDIF entry.
*
* @throws Horde_Ldap_Exception
*/
protected function _finishEntry()
{
$this->_writeLine('', 'Unable to close entry ' . $this->_entrynum);
}
/**
* Writes an arbitary line to the file handle.
*
* @param string $line Content to write.
* @param string $error If error occurs, throw this exception message.
*
* @throws Horde_Ldap_Exception
*/
protected function _writeLine($line, $error = 'Unable to write to file handle')
{
$line .= PHP_EOL;
if (is_resource($this->handle()) &&
fwrite($this->handle(), $line, strlen($line)) === false) {
throw new Horde_Ldap_Exception($error);
}
}
}