1<?php
2/**
3 * Load an LDAP Schema and provide information
4 *
5 * This class takes a Subschema entry, parses this information
6 * and makes it available in an array. Most of the code has been
7 * inspired by perl-ldap( http://perl-ldap.sourceforge.net).
8 * You will find portions of their implementation in here.
9 *
10 * Copyright 2009 Jan Wagner, Benedikt Hallinger
11 * Copyright 2010-2017 Horde LLC (http://www.horde.org/)
12 *
13 * @category  Horde
14 * @package   Ldap
15 * @author    Jan Wagner <wagner@netsols.de>
16 * @author    Benedikt Hallinger <beni@php.net>
17 * @author    Jan Schneider <jan@horde.org>
18 * @license   http://www.gnu.org/licenses/lgpl-3.0.html LGPL-3.0
19 */
20class Horde_Ldap_Schema
21{
22    /**
23     * Syntax definitions.
24     *
25     * Please don't forget to add binary attributes to isBinary() below to
26     * support proper value fetching from Horde_Ldap_Entry.
27     */
28    const SYNTAX_BOOLEAN =            '1.3.6.1.4.1.1466.115.121.1.7';
29    const SYNTAX_DIRECTORY_STRING =   '1.3.6.1.4.1.1466.115.121.1.15';
30    const SYNTAX_DISTINGUISHED_NAME = '1.3.6.1.4.1.1466.115.121.1.12';
31    const SYNTAX_INTEGER =            '1.3.6.1.4.1.1466.115.121.1.27';
32    const SYNTAX_JPEG =               '1.3.6.1.4.1.1466.115.121.1.28';
33    const SYNTAX_NUMERIC_STRING =     '1.3.6.1.4.1.1466.115.121.1.36';
34    const SYNTAX_OID =                '1.3.6.1.4.1.1466.115.121.1.38';
35    const SYNTAX_OCTET_STRING =       '1.3.6.1.4.1.1466.115.121.1.40';
36
37    /**
38     * Map of entry types to LDAP attributes of subschema entry.
39     *
40     * @var array
41     */
42    public $types = array(
43        'attribute'        => 'attributeTypes',
44        'ditcontentrule'   => 'dITContentRules',
45        'ditstructurerule' => 'dITStructureRules',
46        'matchingrule'     => 'matchingRules',
47        'matchingruleuse'  => 'matchingRuleUse',
48        'nameform'         => 'nameForms',
49        'objectclass'      => 'objectClasses',
50        'syntax'           => 'ldapSyntaxes' );
51
52    /**
53     * Array of entries belonging to this type
54     *
55     * @var array
56     */
57    protected $_attributeTypes    = array();
58    protected $_matchingRules     = array();
59    protected $_matchingRuleUse   = array();
60    protected $_ldapSyntaxes      = array();
61    protected $_objectClasses     = array();
62    protected $_dITContentRules   = array();
63    protected $_dITStructureRules = array();
64    protected $_nameForms         = array();
65
66
67    /**
68     * Hash of all fetched OIDs.
69     *
70     * @var array
71     */
72    protected $_oids = array();
73
74    /**
75     * Whether the schema is initialized.
76     *
77     * @see parse(), get()
78     * @var boolean
79     */
80    protected $_initialized = false;
81
82    /**
83     * Constructor.
84     *
85     * Fetches the Schema from an LDAP connection.
86     *
87     * @param Horde_Ldap $ldap LDAP connection.
88     * @param string     $dn   Subschema entry DN.
89     *
90     * @throws Horde_Ldap_Exception
91     */
92    public function __construct(Horde_Ldap $ldap, $dn = null)
93    {
94        if (is_null($dn)) {
95            // Get the subschema entry via rootDSE.
96            $dse = $ldap->rootDSE(array('subschemaSubentry'));
97            $base = $dse->getValue('subschemaSubentry', 'single');
98            $dn = $base;
99        }
100
101        // Support for buggy LDAP servers (e.g. Siemens DirX 6.x) that
102        // incorrectly call this entry subSchemaSubentry instead of
103        // subschemaSubentry. Note the correct case/spelling as per RFC 2251.
104        if (is_null($dn)) {
105            // Get the subschema entry via rootDSE.
106            $dse = $ldap->rootDSE(array('subSchemaSubentry'));
107            $base = $dse->getValue('subSchemaSubentry', 'single');
108            $dn = $base;
109        }
110
111        // Final fallback in case there is no subschemaSubentry attribute in
112        // the root DSE (this is a bug for an LDAPv3 server so report this to
113        // your LDAP vendor if you get this far).
114        if (is_null($dn)) {
115            $dn = 'cn=Subschema';
116        }
117
118        // Fetch the subschema entry.
119        $result = $ldap->search($dn, '(objectClass=*)',
120                                array('attributes' => array_values($this->types),
121                                      'scope' => 'base'));
122        $entry = $result->shiftEntry();
123        if (!($entry instanceof Horde_Ldap_Entry)) {
124            throw new Horde_Ldap_Exception('Could not fetch Subschema entry');
125        }
126
127        $this->parse($entry);
128    }
129
130    /**
131     * Returns a hash of entries for the given type.
132     *
133     * Types may be: objectclasses, attributes, ditcontentrules,
134     * ditstructurerules, matchingrules, matchingruleuses, nameforms, syntaxes.
135     *
136     * @param string $type Type to fetch.
137     *
138     * @return array
139     * @throws Horde_Ldap_Exception
140     */
141    public function getAll($type)
142    {
143        $map = array('objectclasses'     => $this->_objectClasses,
144                     'attributes'        => $this->_attributeTypes,
145                     'ditcontentrules'   => $this->_dITContentRules,
146                     'ditstructurerules' => $this->_dITStructureRules,
147                     'matchingrules'     => $this->_matchingRules,
148                     'matchingruleuses'  => $this->_matchingRuleUse,
149                     'nameforms'         => $this->_nameForms,
150                     'syntaxes'          => $this->_ldapSyntaxes);
151
152        $key = Horde_String::lower($type);
153        if (!isset($map[$key])) {
154            throw new Horde_Ldap_Exception("Unknown type $type");
155        }
156
157        return $map[$key];
158    }
159
160    /**
161     * Returns a specific entry.
162     *
163     * @param string $type Type of name.
164     * @param string $name Name or OID to fetch.
165     *
166     * @return mixed
167     * @throws Horde_Ldap_Exception
168     */
169    public function get($type, $name)
170    {
171        if (!$this->_initialized) {
172            return null;
173        }
174
175        $type = Horde_String::lower($type);
176        if (!isset($this->types[$type])) {
177            throw new Horde_Ldap_Exception("No such type $type");
178        }
179
180        $name     = Horde_String::lower($name);
181        $type_var = $this->{'_' . $this->types[$type]};
182
183        if (isset($type_var[$name])) {
184            return $type_var[$name];
185        }
186        if (isset($this->_oids[$name]) &&
187            $this->_oids[$name]['type'] == $type) {
188            return $this->_oids[$name];
189        }
190        throw new Horde_Ldap_Exception("Could not find $type $name");
191    }
192
193
194    /**
195     * Fetches attributes that MAY be present in the given objectclass.
196     *
197     * @param string $oc         Name or OID of objectclass.
198     * @param boolean $checksup  Check all superiour objectclasses too?
199     *
200     * @return array Array with attributes.
201     */
202    public function may($oc, $checksup = false)
203    {
204        try {
205            $attributes = $this->_getAttr($oc, 'may');
206        } catch (Horde_Ldap_Exception $e) {
207            $attributes = array();
208        }
209        if ($checksup) {
210            try {
211                foreach ($this->superclass($oc) as $sup) {
212                    $attributes = array_merge($attributes, $this->may($sup, true));
213                }
214            } catch (Horde_Ldap_Exception $e) {
215            }
216            $attributes = array_values(array_unique($attributes));
217        }
218        return $attributes;
219    }
220
221    /**
222     * Fetches attributes that MUST be present in the given objectclass.
223     *
224     * @param string $oc         Name or OID of objectclass.
225     * @param boolean $checksup  Check all superiour objectclasses too?
226     *
227     * @return array Array with attributes.
228     */
229    public function must($oc, $checksup = false)
230    {
231        try {
232            $attributes = $this->_getAttr($oc, 'must');
233        } catch (Horde_Ldap_Exception $e) {
234            $attributes = array();
235        }
236        if ($checksup) {
237            try {
238                foreach ($this->superclass($oc) as $sup) {
239                    $attributes = array_merge($attributes, $this->must($sup, true));
240                }
241            } catch (Horde_Ldap_Exception $e) {
242            }
243            $attributes = array_values(array_unique($attributes));
244        }
245        return $attributes;
246    }
247
248    /**
249     * Fetches the given attribute from the given objectclass.
250     *
251     * @param string $oc   Name or OID of objectclass.
252     * @param string $attr Name of attribute to fetch.
253     *
254     * @return array The attribute.
255     * @throws Horde_Ldap_Exception
256     */
257    protected function _getAttr($oc, $attr)
258    {
259        $oc = Horde_String::lower($oc);
260        if (isset($this->_objectClasses[$oc]) &&
261            isset($this->_objectClasses[$oc][$attr])) {
262            return $this->_objectClasses[$oc][$attr];
263        }
264        if (isset($this->_oids[$oc]) &&
265            $this->_oids[$oc]['type'] == 'objectclass' &&
266            isset($this->_oids[$oc][$attr])) {
267            return $this->_oids[$oc][$attr];
268        }
269        throw new Horde_Ldap_Exception("Could not find $attr attributes for $oc ");
270    }
271
272    /**
273     * Returns the name(s) of the immediate superclass(es).
274     *
275     * @param string $oc Name or OID of objectclass.
276     *
277     * @return array
278     * @throws Horde_Ldap_Exception
279     */
280    public function superclass($oc)
281    {
282        $o = $this->get('objectclass', $oc);
283        return isset($o['sup']) ? $o['sup'] : array();
284    }
285
286    /**
287     * Parses the schema of the given subschema entry.
288     *
289     * @param Horde_Ldap_Entry $entry Subschema entry.
290     */
291    public function parse($entry)
292    {
293        foreach ($this->types as $type => $attr) {
294            // Initialize map type to entry.
295            $type_var          = '_' . $attr;
296            $this->{$type_var} = array();
297
298            if (!$entry->exists($attr)) {
299                continue;
300            }
301
302            // Get values for this type.
303            $values = $entry->getValue($attr);
304            if (!is_array($values)) {
305                continue;
306            }
307            foreach ($values as $value) {
308                // Get the schema entry.
309                $schema_entry = $this->_parse_entry($value);
310                // Set the type.
311                $schema_entry['type'] = $type;
312                // Save a ref in $_oids.
313                $this->_oids[$schema_entry['oid']] = $schema_entry;
314                // Save refs for all names in type map.
315                $names = $schema_entry['aliases'];
316                $names[] = $schema_entry['name'];
317                foreach ($names as $name) {
318                    $this->{$type_var}[Horde_String::lower($name)] = $schema_entry;
319                }
320            }
321        }
322        $this->_initialized = true;
323    }
324
325    /**
326     * Parses an attribute value into a schema entry.
327     *
328     * @param string $value Attribute value.
329     *
330     * @return array Schema entry array.
331     */
332    protected function _parse_entry($value)
333    {
334        // Tokens that have no value associated.
335        $noValue = array('single-value',
336                         'obsolete',
337                         'collective',
338                         'no-user-modification',
339                         'abstract',
340                         'structural',
341                         'auxiliary');
342
343        // Tokens that can have multiple values.
344        $multiValue = array('must', 'may', 'sup');
345
346        // Get an array of tokens.
347        $tokens = $this->_tokenize($value);
348
349        // Remove surrounding brackets.
350        if ($tokens[0] == '(') {
351            array_shift($tokens);
352        }
353        if ($tokens[count($tokens) - 1] == ')') {
354            array_pop($tokens);
355        }
356
357        // First token is the oid.
358        $schema_entry = array('aliases' => array(),
359                              'oid' => array_shift($tokens));
360
361        // Cycle over the tokens until none are left.
362        while (count($tokens) > 0) {
363            $token = Horde_String::lower(array_shift($tokens));
364            if (in_array($token, $noValue)) {
365                // Single value token.
366                $schema_entry[$token] = 1;
367            } else {
368                // Follow a string or a list if it is multivalued.
369                if (($schema_entry[$token] = array_shift($tokens)) == '(') {
370                    // Create the list of values and cycles through the tokens
371                    // until the end of the list is reached ')'.
372                    $schema_entry[$token] = array();
373                    while ($tmp = array_shift($tokens)) {
374                        if ($tmp == ')') {
375                            break;
376                        }
377                        if ($tmp != '$') {
378                            $schema_entry[$token][] = $tmp;
379                        }
380                    }
381                }
382                // Create an array if the value should be multivalued but was
383                // not.
384                if (in_array($token, $multiValue) &&
385                    !is_array($schema_entry[$token])) {
386                    $schema_entry[$token] = array($schema_entry[$token]);
387                }
388            }
389        }
390
391        // Get max length from syntax.
392        if (isset($schema_entry['syntax'])) {
393            if (preg_match('/{(\d+)}/', $schema_entry['syntax'], $matches)) {
394                $schema_entry['max_length'] = $matches[1];
395            }
396        }
397
398        // Force a name.
399        if (empty($schema_entry['name'])) {
400            $schema_entry['name'] = $schema_entry['oid'];
401        }
402
403        // Make one name the default and put the other ones into aliases.
404        if (is_array($schema_entry['name'])) {
405            $aliases                 = $schema_entry['name'];
406            $schema_entry['name']    = array_shift($aliases);
407            $schema_entry['aliases'] = $aliases;
408        }
409
410        return $schema_entry;
411    }
412
413    /**
414     * Tokenizes the given value into an array of tokens.
415     *
416     * @param string $value String to parse.
417     *
418     * @return array Array of tokens.
419     */
420    protected function _tokenize($value)
421    {
422        /* Match one big pattern where only one of the three subpatterns
423         * matches.  We are interested in the subpatterns that matched. If it
424         * matched its value will be non-empty and so it is a token. Tokens may
425         * be round brackets, a string, or a string enclosed by ''. */
426        preg_match_all("/\s* (?:([()]) | ([^'\s()]+) | '((?:[^']+|'[^\s)])*)') \s*/x", $value, $matches);
427
428        $tokens  = array();
429        // Number of tokens (full pattern match).
430        for ($i = 0, $c = count($matches[0]); $i < $c; $i++) {
431            // Each subpattern.
432            for ($j = 1; $j < 4; $j++) {
433                // Pattern match in this subpattern.
434                if (null != trim($matches[$j][$i])) {
435                    // This is the token.
436                    $tokens[$i] = trim($matches[$j][$i]);
437                }
438            }
439        }
440
441        return $tokens;
442    }
443
444    /**
445     * Returns wether a attribute syntax is binary or not.
446     *
447     * This method is used by Horde_Ldap_Entry to decide which PHP function
448     * needs to be used to fetch the value in the proper format (e.g. binary or
449     * string).
450     *
451     * @param string $attribute The name of the attribute (eg.: 'sn').
452     *
453     * @return boolean  True if the attribute is a binary type.
454     */
455    public function isBinary($attribute)
456    {
457        // All syntax that should be treaten as containing binary values.
458        $syntax_binary = array(self::SYNTAX_OCTET_STRING, self::SYNTAX_JPEG);
459
460        // Check Syntax.
461        try {
462            $attr_s = $this->get('attribute', $attribute);
463        } catch (Horde_Ldap_Exception $e) {
464            // Attribute not found in schema, consider attr not binary.
465            return false;
466        }
467
468        if (isset($attr_s['syntax']) &&
469            in_array($attr_s['syntax'], $syntax_binary)) {
470            // Syntax is defined as binary in schema
471            return true;
472        }
473
474        // Syntax not defined as binary, or not found if attribute is a
475        // subtype, check superior attribute syntaxes.
476        if (isset($attr_s['sup'])) {
477            foreach ($attr_s['sup'] as $superattr) {
478                if ($this->isBinary($superattr)) {
479                    // Stop checking parents since we are binary.
480                    return true;
481                }
482            }
483        }
484
485        return false;
486    }
487}
488