1<?php
2/**
3 * LDIF capabilities for Horde_Ldap.
4 *
5 * This class provides a means to convert between Horde_Ldap_Entry objects and
6 * LDAP entries represented in LDIF format files. Reading and writing are
7 * supported and manipulating of single entries or lists of entries.
8 *
9 * Usage example:
10 * <code>
11 * // Read and parse an LDIF file into Horde_Ldap_Entry objects
12 * // and print out the DNs. Store the entries for later use.
13 * $entries = array();
14 * $ldif = new Horde_Ldap_Ldif('test.ldif', 'r', $options);
15 * do {
16 *     $entry = $ldif->readEntry();
17 *     $dn    = $entry->dn();
18 *     echo " done building entry: $dn\n";
19 *     $entries[] = $entry;
20 * } while (!$ldif->eof());
21 * $ldif->done();
22 *
23 * // Write those entries to another file
24 * $ldif = new Horde_Ldap_Ldif('test.out.ldif', 'w', $options);
25 * $ldif->writeEntry($entries);
26 * $ldif->done();
27 * </code>
28 *
29 * Copyright 2009 Benedikt Hallinger
30 * Copyright 2010-2017 Horde LLC (http://www.horde.org/)
31 *
32 * @category  Horde
33 * @package   Ldap
34 * @author    Benedikt Hallinger <beni@php.net>
35 * @author    Jan Schneider <jan@horde.org>
36 * @license   http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0
37 * @see       http://www.ietf.org/rfc/rfc2849.txt
38 * @todo      LDAPv3 controls are not implemented yet
39 */
40class Horde_Ldap_Ldif
41{
42    /**
43     * Options.
44     *
45     * @var array
46     */
47    protected $_options = array('encode'    => 'base64',
48                                'change'    => false,
49                                'lowercase' => false,
50                                'sort'      => false,
51                                'version'   => null,
52                                'wrap'      => 78,
53                                'raw'       => '');
54
55    /**
56     * File handle for read/write.
57     *
58     * @var resource
59     */
60    protected $_fh;
61
62    /**
63     * Whether we opened the file handle ourselves.
64     *
65     * @var boolean
66     */
67    protected $_fhOpened = false;
68
69    /**
70     * Line counter for input file handle.
71     *
72     * @var integer
73     */
74    protected $_inputLine = 0;
75
76    /**
77     * Counter for processed entries.
78     *
79     * @var integer
80     */
81    protected $_entrynum = 0;
82
83    /**
84     * Mode we are working in.
85     *
86     * Either 'r', 'a' or 'w'
87     *
88     * @var string
89     */
90    protected $_mode;
91
92    /**
93     * Whether the LDIF version string was already written.
94     *
95     * @var boolean
96     */
97    protected $_versionWritten = false;
98
99    /**
100     * Cache for lines that have built the current entry.
101     *
102     * @var array
103     */
104    protected $_linesCur = array();
105
106    /**
107     * Cache for lines that will build the next entry.
108     *
109     * @var array
110     */
111    protected $_linesNext = array();
112
113    /**
114     * Constructor.
115     *
116     * Opens an LDIF file for reading or writing.
117     *
118     * $options is an associative array and may contain:
119     * - 'encode' (string): Some DN values in LDIF cannot be written verbatim
120     *                      and have to be encoded in some way. Possible
121     *                      values:
122     *                      - 'none':      No encoding.
123     *                      - 'canonical': See {@link
124     *                                     Horde_Ldap_Util::canonicalDN()}.
125     *                      - 'base64':    Use base64 (default).
126     * - 'change' (boolean): Write entry changes to the LDIF file instead of
127     *                       the entries itself. I.e. write LDAP operations
128     *                       acting on the entries to the file instead of the
129     *                       entries contents.  This writes the changes usually
130     *                       carried out by an update() to the LDIF
131     *                       file. Defaults to false.
132     * - 'lowercase' (boolean): Convert attribute names to lowercase when
133     *                          writing. Defaults to false.
134     * - 'sort' (boolean): Sort attribute names when writing entries according
135     *                     to the rule: objectclass first then all other
136     *                     attributes alphabetically sorted by attribute
137     *                     name. Defaults to false.
138     * - 'version' (integer): Set the LDIF version to write to the resulting
139     *                        LDIF file. According to RFC 2849 currently the
140     *                        only legal value for this option is 1. When this
141     *                        option is set Horde_Ldap_Ldif tries to adhere
142     *                        more strictly to the LDIF specification in
143     *                        RFC2489 in a few places. The default is null
144     *                        meaning no version information is written to the
145     *                        LDIF file.
146     * - 'wrap' (integer): Number of columns where output line wrapping shall
147     *                     occur.  Default is 78. Setting it to 40 or lower
148     *                     inhibits wrapping.
149     * - 'raw' (string): Regular expression to denote the names of attributes
150     *                   that are to be considered binary in search results if
151     *                   writing entries.  Example: 'raw' =>
152     *                   '/(?i:^jpegPhoto|;binary)/i'
153     *
154     * @param string|ressource $file    Filename or file handle.
155     * @param string           $mode    Mode to open the file, either 'r', 'w'
156     *                                  or 'a'.
157     * @param array            $options Options like described above.
158     *
159     * @throws Horde_Ldap_Exception
160     */
161    public function __construct($file, $mode = 'r', $options = array())
162    {
163        // Parse options.
164        foreach ($options as $option => $value) {
165            if (!array_key_exists($option, $this->_options)) {
166                throw new Horde_Ldap_Exception('Option ' . $option . ' not known');
167            }
168            $this->_options[$option] = Horde_String::lower($value);
169        }
170
171        // Set version.
172        $this->version($this->_options['version']);
173
174        // Setup file mode.
175        if (!preg_match('/^[rwa]$/', $mode)) {
176            throw new Horde_Ldap_Exception('File mode ' . $mode . ' not supported');
177        }
178        $this->_mode = $mode;
179
180        // Setup file handle.
181        if (is_resource($file)) {
182            // TODO: checks on mode possible?
183            $this->_fh = $file;
184            return;
185        }
186
187        switch ($mode) {
188        case 'r':
189            if (!file_exists($file)) {
190                throw new Horde_Ldap_Exception('Unable to open ' . $file . ' for reading: file not found');
191            }
192            if (!is_readable($file)) {
193                throw new Horde_Ldap_Exception('Unable to open ' . $file . ' for reading: permission denied');
194            }
195            break;
196
197        case 'w':
198        case 'a':
199            if (file_exists($file)) {
200                if (!is_writable($file)) {
201                    throw new Horde_Ldap_Exception('Unable to open ' . $file . ' for writing: permission denied');
202                }
203            } else {
204                if (!@touch($file)) {
205                    throw new Horde_Ldap_Exception('Unable to create ' . $file . ' for writing: permission denied');
206                }
207            }
208            break;
209        }
210
211        $this->_fh = @fopen($file, $this->_mode);
212        if (!$this->_fh) {
213            throw new Horde_Ldap_Exception('Could not open file ' . $file);
214        }
215
216        $this->_fhOpened = true;
217    }
218
219    /**
220     * Reads one entry from the file and return it as a Horde_Ldap_Entry
221     * object.
222     *
223     * @return Horde_Ldap_Entry
224     * @throws Horde_Ldap_Exception
225     */
226    public function readEntry()
227    {
228        // Read fresh lines, set them as current lines and create the entry.
229        $attrs = $this->nextLines(true);
230        if (count($attrs)) {
231            $this->_linesCur = $attrs;
232        }
233        return $this->currentEntry();
234    }
235
236    /**
237     * Returns true when the end of the file is reached.
238     *
239     * @return boolean
240     */
241    public function eof()
242    {
243        return feof($this->_fh);
244    }
245
246    /**
247     * Writes the entry or entries to the LDIF file.
248     *
249     * If you want to build an LDIF file containing several entries AND you
250     * want to call writeEntry() several times, you must open the file handle
251     * in append mode ('a'), otherwise you will always get the last entry only.
252     *
253     * @todo Implement operations on whole entries (adding a whole entry).
254     *
255     * @param Horde_Ldap_Entry|array $entries Entry or array of entries.
256     *
257     * @throws Horde_Ldap_Exception
258     */
259    public function writeEntry($entries)
260    {
261        if (!is_array($entries)) {
262            $entries = array($entries);
263        }
264
265        foreach ($entries as $entry) {
266            $this->_entrynum++;
267            if (!($entry instanceof Horde_Ldap_Entry)) {
268                throw new Horde_Ldap_Exception('Entry ' . $this->_entrynum . ' is not an Horde_Ldap_Entry object');
269            }
270
271            if ($this->_options['change']) {
272                $this->_changeEntry($entry);
273            } else {
274                $this->_writeEntry($entry);
275            }
276        }
277    }
278
279    /**
280     * Writes an LDIF file that describes an entry change.
281     *
282     * @param Horde_Ldap_Entry $entry
283     *
284     * @throws Horde_Ldap_Exception
285     */
286    protected function _changeEntry($entry)
287    {
288        // Fetch change information from entry.
289        $entry_attrs_changes = $entry->getChanges();
290        $num_of_changes = count($entry_attrs_changes['add'])
291                        + count($entry_attrs_changes['replace'])
292                        + count($entry_attrs_changes['delete']);
293
294        $is_changed = $num_of_changes > 0 || $entry->willBeDeleted() || $entry->willBeMoved();
295
296        // Write version if not done yet, also write DN of entry.
297        if ($is_changed) {
298            if (!$this->_versionWritten) {
299                $this->writeVersion();
300            }
301            $this->_writeDN($entry->currentDN());
302        }
303
304        // Process changes.
305        // TODO: consider DN add!
306        if ($entry->willBeDeleted()) {
307            $this->_writeLine('changetype: delete');
308        } elseif ($entry->willBeMoved()) {
309            $this->_writeLine('changetype: modrdn');
310            $olddn     = Horde_Ldap_Util::explodeDN($entry->currentDN(), array('casefold' => 'none'));
311            array_shift($olddn);
312            $oldparent = implode(',', $olddn);
313            $newdn     = Horde_Ldap_Util::explodeDN($entry->dn(), array('casefold' => 'none'));
314            $rdn       = array_shift($newdn);
315            $parent    = implode(',', $newdn);
316            $this->_writeLine('newrdn: ' . $rdn);
317            $this->_writeLine('deleteoldrdn: 1');
318            if ($parent !== $oldparent) {
319                $this->_writeLine('newsuperior: ' . $parent);
320            }
321            // TODO: What if the entry has attribute changes as well?
322            //       I think we should check for that and make a dummy
323            //       entry with the changes that is written to the LDIF file.
324        } elseif ($num_of_changes > 0) {
325            // Write attribute change data.
326            $this->_writeLine('changetype: modify');
327            foreach ($entry_attrs_changes as $changetype => $entry_attrs) {
328                foreach ($entry_attrs as $attr_name => $attr_values) {
329                    $this->_writeLine("$changetype: $attr_name");
330                    if ($attr_values !== null) {
331                        $this->_writeAttribute($attr_name, $attr_values, $changetype);
332                    }
333                    $this->_writeLine('-');
334                }
335            }
336        }
337
338        // Finish this entry's data if we had changes.
339        if ($is_changed) {
340            $this->_finishEntry();
341        }
342    }
343
344    /**
345     * Writes an LDIF file that describes an entry.
346     *
347     * @param Horde_Ldap_Entry $entry
348     *
349     * @throws Horde_Ldap_Exception
350     */
351    protected function _writeEntry($entry)
352    {
353        // Fetch attributes for further processing.
354        $entry_attrs = $entry->getValues();
355
356        // Sort and put objectclass attributes to first position.
357        if ($this->_options['sort']) {
358            ksort($entry_attrs);
359            if (isset($entry_attrs['objectclass'])) {
360                $oc = $entry_attrs['objectclass'];
361                unset($entry_attrs['objectclass']);
362                $entry_attrs = array_merge(array('objectclass' => $oc), $entry_attrs);
363            }
364        }
365
366        // Write data.
367        if (!$this->_versionWritten) {
368            $this->writeVersion();
369        }
370        $this->_writeDN($entry->dn());
371        foreach ($entry_attrs as $attr_name => $attr_values) {
372            $this->_writeAttribute($attr_name, $attr_values);
373        }
374        $this->_finishEntry();
375    }
376
377    /**
378     * Writes the version to LDIF.
379     *
380     * If the object's version is defined, this method allows to explicitely
381     * write the version before an entry is written.
382     *
383     * If not called explicitely, it gets called automatically when writing the
384     * first entry.
385     *
386     * @throws Horde_Ldap_Exception
387     */
388    public function writeVersion()
389    {
390        if (!is_null($this->version())) {
391            $this->_writeLine('version: ' . $this->version(), 'Unable to write version');
392        }
393        $this->_versionWritten = true;
394    }
395
396    /**
397     * Returns or sets the LDIF version.
398     *
399     * If called with an argument it sets the LDIF version. According to RFC
400     * 2849 currently the only legal value for the version is 1.
401     *
402     * @param integer $version LDIF version to set.
403     *
404     * @return integer The current or new version.
405     * @throws Horde_Ldap_Exception
406     */
407    public function version($version = null)
408    {
409        if ($version !== null) {
410            if ($version != 1) {
411                throw new Horde_Ldap_Exception('Illegal LDIF version set');
412            }
413            $this->_options['version'] = $version;
414        }
415        return $this->_options['version'];
416    }
417
418    /**
419     * Returns the file handle the Horde_Ldap_Ldif object reads from or writes
420     * to.
421     *
422     * You can, for example, use this to fetch the content of the LDIF file
423     * manually.
424     *
425     * @return resource
426     * @throws Horde_Ldap_Exception
427     */
428    public function handle()
429    {
430        if (!is_resource($this->_fh)) {
431            throw new Horde_Ldap_Exception('Invalid file resource');
432        }
433        return $this->_fh;
434    }
435
436    /**
437     * Cleans up.
438     *
439     * This method signals that the LDIF object is no longer needed. You can
440     * use this to free up some memory and close the file handle. The file
441     * handle is only closed, if it was opened from Horde_Ldap_Ldif.
442     *
443     * @throws Horde_Ldap_Exception
444     */
445    public function done()
446    {
447        // Close file handle if we opened it.
448        if ($this->_fhOpened) {
449            fclose($this->handle());
450        }
451
452        // Free variables.
453        foreach (array_keys(get_object_vars($this)) as $name) {
454            unset($this->$name);
455        }
456    }
457
458    /**
459     * Returns the current Horde_Ldap_Entry object.
460     *
461     * @return Horde_Ldap_Entry
462     * @throws Horde_Ldap_Exception
463     */
464    public function currentEntry()
465    {
466        return $this->parseLines($this->currentLines());
467    }
468
469    /**
470     * Parse LDIF lines of one entry into an Horde_Ldap_Entry object.
471     *
472     * @todo what about file inclusions and urls?
473     *       "jpegphoto:< file:///usr/local/directory/photos/fiona.jpg"
474     *
475     * @param array $lines LDIF lines for one entry.
476     *
477     * @return Horde_Ldap_Entry Horde_Ldap_Entry object for those lines.
478     * @throws Horde_Ldap_Exception
479     */
480    public function parseLines($lines)
481    {
482        // Parse lines into an array of attributes and build the entry.
483        $attributes = array();
484        $dn = false;
485        foreach ($lines as $line) {
486            if (!preg_match('/^(\w+)(:|::|:<)\s(.+)$/', $line, $matches)) {
487                // Line not in "attr: value" format -> ignore.  Maybe we should
488                // rise an error here, but this should be covered by
489                // nextLines() already. A problem arises, if users try to feed
490                // data of several entries to this method - the resulting entry
491                // will get wrong attributes. However, this is already
492                // mentioned in the method documentation above.
493                continue;
494            }
495
496            $attr  = $matches[1];
497            $delim = $matches[2];
498            $data  = $matches[3];
499
500            switch ($delim) {
501            case ':':
502                // Normal data.
503                $attributes[$attr][] = $data;
504                break;
505            case '::':
506                // Base64 data.
507                $attributes[$attr][] = base64_decode($data);
508                break;
509            case ':<':
510                // File inclusion
511                // TODO: Is this the job of the LDAP-client or the server?
512                throw new Horde_Ldap_Exception('File inclusions are currently not supported');
513            default:
514                throw new Horde_Ldap_Exception('Parsing error: invalid syntax at parsing entry line: ' . $line);
515            }
516
517            if (Horde_String::lower($attr) == 'dn') {
518                // DN line detected. Save possibly decoded DN.
519                $dn = $attributes[$attr][0];
520                // Remove wrongly added "dn: " attribute.
521                unset($attributes[$attr]);
522            }
523        }
524
525        if (!$dn) {
526            throw new Horde_Ldap_Exception('Parsing error: unable to detect DN for entry');
527        }
528
529        return Horde_Ldap_Entry::createFresh($dn, $attributes);
530    }
531
532    /**
533     * Returns the lines that generated the current Horde_Ldap_Entry object.
534     *
535     * Returns an empty array if no lines have been read so far.
536     *
537     * @return array Array of lines.
538     */
539    public function currentLines()
540    {
541        return $this->_linesCur;
542    }
543
544    /**
545     * Returns the lines that will generate the next Horde_Ldap_Entry object.
546     *
547     * If you set $force to true you can iterate over the lines that build up
548     * entries manually. Otherwise, iterating is done using {@link
549     * readEntry()}. $force will move the file pointer forward, thus returning
550     * the next entry lines.
551     *
552     * Wrapped lines will be unwrapped. Comments are stripped.
553     *
554     * @param boolean $force Set this to true if you want to iterate over the
555     *                       lines manually
556     *
557     * @return array
558     * @throws Horde_Ldap_Exception
559     */
560    public function nextLines($force = false)
561    {
562        // If we already have those lines, just return them, otherwise read.
563        if (count($this->_linesNext) == 0 || $force) {
564            // Empty in case something was left (if used $force).
565            $this->_linesNext = array();
566            $entry_done       = false;
567            $fh               = $this->handle();
568            // Are we in an comment? For wrapping purposes.
569            $commentmode      = false;
570            // How many lines with data we have read?
571            $datalines_read   = 0;
572
573            while (!$entry_done && !$this->eof()) {
574                $this->_inputLine++;
575                // Read line. Remove line endings, we want only data; this is
576                // okay since ending spaces should be encoded.
577                $data = rtrim(fgets($fh));
578                if ($data === false) {
579                    // Error only, if EOF not reached after fgets() call.
580                    if (!$this->eof()) {
581                        throw new Horde_Ldap_Exception('Error reading from file at input line ' . $this->_inputLine);
582                    }
583                    break;
584                }
585
586                if (count($this->_linesNext) > 0 && preg_match('/^$/', $data)) {
587                    // Entry is finished if we have an empty line after we had
588                    // data.
589                    $entry_done = true;
590
591                    // Look ahead if the next EOF is nearby. Comments and empty
592                    // lines at the file end may cause problems otherwise.
593                    $current_pos = ftell($fh);
594                    $data        = fgets($fh);
595                    while (!feof($fh)) {
596                        if (preg_match('/^\s*$/', $data) ||
597                            preg_match('/^#/', $data)) {
598                            // Only empty lines or comments, continue to seek.
599                            // TODO: Known bug: Wrappings for comments are okay
600                            //       but are treaten as error, since we do not
601                            //       honor comment mode here.  This should be a
602                            //       very theoretically case, however I am
603                            //       willing to fix this if really necessary.
604                            $this->_inputLine++;
605                            $current_pos = ftell($fh);
606                            $data        = fgets($fh);
607                        } else {
608                            // Data found if non emtpy line and not a comment!!
609                            // Rewind to position prior last read and stop
610                            // lookahead.
611                            fseek($fh, $current_pos);
612                            break;
613                        }
614                    }
615                    // Now we have either the file pointer at the beginning of
616                    // a new data position or at the end of file causing feof()
617                    // to return true.
618                    continue;
619                }
620
621                // Build lines.
622                if (preg_match('/^version:\s(.+)$/', $data, $match)) {
623                    // Version statement, set version.
624                    $this->version($match[1]);
625                } elseif (preg_match('/^\w+::?\s.+$/', $data)) {
626                    // Normal attribute: add line.
627                    $commentmode        = false;
628                    $this->_linesNext[] = trim($data);
629                    $datalines_read++;
630                } elseif (preg_match('/^\s(.+)$/', $data, $matches)) {
631                    // Wrapped data: unwrap if not in comment mode.
632                    if (!$commentmode) {
633                        if ($datalines_read == 0) {
634                            // First line of entry: wrapped data is illegal.
635                            throw new Horde_Ldap_Exception('Illegal wrapping at input line ' . $this->_inputLine);
636                        }
637                        $this->_linesNext[] = array_pop($this->_linesNext) . trim($matches[1]);
638                        $datalines_read++;
639                    }
640                } elseif (preg_match('/^#/', $data)) {
641                    // LDIF comments.
642                    $commentmode = true;
643                } elseif (preg_match('/^\s*$/', $data)) {
644                    // Empty line but we had no data for this entry, so just
645                    // ignore this line.
646                    $commentmode = false;
647                } else {
648                    throw new Horde_Ldap_Exception('Invalid syntax at input line ' . $this->_inputLine);
649                }
650            }
651        }
652
653        return $this->_linesNext;
654    }
655
656    /**
657     * Converts an attribute and value to LDIF string representation.
658     *
659     * It honors correct encoding of values according to RFC 2849. Line
660     * wrapping will occur at the configured maximum but only if the value is
661     * greater than 40 chars.
662     *
663     * @param string $attr_name  Name of the attribute.
664     * @param string $attr_value Value of the attribute.
665     *
666     * @return string LDIF string for that attribute and value.
667     */
668    protected function _convertAttribute($attr_name, $attr_value)
669    {
670        // Handle empty attribute or process.
671        if (!strlen($attr_value)) {
672            return $attr_name.':  ';
673        }
674
675        // If converting is needed, do it.
676        // Either we have some special chars or a matching "raw" regex
677        if ($this->_isBinary($attr_value) ||
678            ($this->_options['raw'] &&
679             preg_match($this->_options['raw'], $attr_name))) {
680            $attr_name .= ':';
681            $attr_value = base64_encode($attr_value);
682        }
683
684        // Lowercase attribute names if requested.
685        if ($this->_options['lowercase']) {
686            $attr_name = Horde_String::lower($attr_name);
687        }
688
689        // Handle line wrapping.
690        if ($this->_options['wrap'] > 40 &&
691            strlen($attr_value) > $this->_options['wrap']) {
692            $attr_value = wordwrap($attr_value, $this->_options['wrap'], PHP_EOL . ' ', true);
693        }
694
695        return $attr_name . ': ' . $attr_value;
696    }
697
698    /**
699     * Converts an entry's DN to LDIF string representation.
700     *
701     * It honors correct encoding of values according to RFC 2849.
702     *
703     * @todo I am not sure, if the UTF8 stuff is correctly handled right now
704     *
705     * @param string $dn UTF8 encoded DN.
706     *
707     * @return string LDIF string for that DN.
708     */
709    protected function _convertDN($dn)
710    {
711        // If converting is needed, do it.
712        return $this->_isBinary($dn)
713            ? 'dn:: ' . base64_encode($dn)
714            : 'dn: ' . $dn;
715    }
716
717    /**
718     * Returns whether some data is considered binary and must be
719     * base64-encoded.
720     *
721     * @param string $value  Some data.
722     *
723     * @return boolean  True if the data should be encoded.
724     */
725    protected function _isBinary($value)
726    {
727        $binary = false;
728
729        // ASCII-chars that are NOT safe for the start and for being inside the
730        // value. These are the integer values of those chars.
731        $unsafe_init = array(0, 10, 13, 32, 58, 60);
732        $unsafe      = array(0, 10, 13);
733
734        // Test for illegal init char.
735        $init_ord = ord(substr($value, 0, 1));
736        if ($init_ord > 127 || in_array($init_ord, $unsafe_init)) {
737            $binary = true;
738        }
739
740        // Test for illegal content char.
741        for ($i = 0, $len = strlen($value); $i < $len; $i++) {
742            $char_ord = ord(substr($value, $i, 1));
743            if ($char_ord >= 127 || in_array($char_ord, $unsafe)) {
744                $binary = true;
745            }
746        }
747
748        // Test for ending space
749        if (substr($value, -1) == ' ') {
750            $binary = true;
751        }
752
753        return $binary;
754    }
755
756    /**
757     * Writes an attribute to the file handle.
758     *
759     * @param string       $attr_name   Name of the attribute.
760     * @param string|array $attr_values Single attribute value or array with
761     *                                  attribute values.
762     *
763     * @throws Horde_Ldap_Exception
764     */
765    protected function _writeAttribute($attr_name, $attr_values)
766    {
767        // Write out attribute content.
768        if (!is_array($attr_values)) {
769            $attr_values = array($attr_values);
770        }
771        foreach ($attr_values as $attr_val) {
772            $line = $this->_convertAttribute($attr_name, $attr_val);
773            $this->_writeLine($line, 'Unable to write attribute ' . $attr_name . ' of entry ' . $this->_entrynum);
774        }
775    }
776
777    /**
778     * Writes a DN to the file handle.
779     *
780     * @param string $dn DN to write.
781     *
782     * @throws Horde_Ldap_Exception
783     */
784    protected function _writeDN($dn)
785    {
786        // Prepare DN.
787        if ($this->_options['encode'] == 'base64') {
788            $dn = $this->_convertDN($dn);
789        } elseif ($this->_options['encode'] == 'canonical') {
790            $dn = Horde_Ldap_Util::canonicalDN($dn, array('casefold' => 'none'));
791        }
792        $this->_writeLine($dn, 'Unable to write DN of entry ' . $this->_entrynum);
793    }
794
795    /**
796     * Finishes an LDIF entry.
797     *
798     * @throws Horde_Ldap_Exception
799     */
800    protected function _finishEntry()
801    {
802        $this->_writeLine('', 'Unable to close entry ' . $this->_entrynum);
803    }
804
805    /**
806     * Writes an arbitary line to the file handle.
807     *
808     * @param string $line  Content to write.
809     * @param string $error If error occurs, throw this exception message.
810     *
811     * @throws Horde_Ldap_Exception
812     */
813    protected function _writeLine($line, $error = 'Unable to write to file handle')
814    {
815        $line .= PHP_EOL;
816        if (is_resource($this->handle()) &&
817            fwrite($this->handle(), $line, strlen($line)) === false) {
818            throw new Horde_Ldap_Exception($error);
819        }
820    }
821}
822