1<?php
2/**
3 * This file is part of the FreeDSx LDAP package.
4 *
5 * (c) Chad Sikorra <Chad.Sikorra@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10
11namespace FreeDSx\Ldap\Search\Filter;
12
13use FreeDSx\Asn1\Asn1;
14use FreeDSx\Asn1\Type\AbstractType;
15use FreeDSx\Asn1\Type\IncompleteType;
16use FreeDSx\Asn1\Type\OctetStringType;
17use FreeDSx\Asn1\Type\SequenceType;
18use FreeDSx\Ldap\Entry\Attribute;
19use FreeDSx\Ldap\Exception\ProtocolException;
20use FreeDSx\Ldap\Exception\RuntimeException;
21use FreeDSx\Ldap\Protocol\LdapEncoder;
22
23/**
24 * Represents a substring filter. RFC 4511, 4.5.1.7.2.
25 *
26 * SubstringFilter ::= SEQUENCE {
27 *     type           AttributeDescription,
28 *     substrings     SEQUENCE SIZE (1..MAX) OF substring CHOICE {
29 *         initial [0] AssertionValue,  -- can occur at most once
30 *         any     [1] AssertionValue,
31 *         final   [2] AssertionValue } -- can occur at most once
32 *     }
33 *
34 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
35 */
36class SubstringFilter implements FilterInterface
37{
38    use FilterAttributeTrait;
39
40    protected const CHOICE_TAG = 4;
41
42    /**
43     * @var null|string
44     */
45    protected $startsWith;
46
47    /**
48     * @var null|string
49     */
50    protected $endsWith;
51
52    /**
53     * @var string[]
54     */
55    protected $contains = [];
56
57    /**
58     * @param string $attribute
59     * @param null|string $startsWith
60     * @param null|string $endsWith
61     * @param string[] ...$contains
62     */
63    public function __construct(string $attribute, ?string $startsWith = null, ?string $endsWith = null, string ...$contains)
64    {
65        $this->attribute = $attribute;
66        $this->startsWith = $startsWith;
67        $this->endsWith = $endsWith;
68        $this->contains = $contains;
69    }
70
71    /**
72     * Get the value that it should start with.
73     *
74     * @return null|string
75     */
76    public function getStartsWith(): ?string
77    {
78        return $this->startsWith;
79    }
80
81    /**
82     * Set the value it should start with.
83     *
84     * @param null|string $value
85     * @return $this
86     */
87    public function setStartsWith(?string $value)
88    {
89        $this->startsWith = $value;
90
91        return $this;
92    }
93
94    /**
95     * Get the value it should end with.
96     *
97     * @return null|string
98     */
99    public function getEndsWith(): ?string
100    {
101        return $this->endsWith;
102    }
103
104    /**
105     * Set the value it should end with.
106     *
107     * @param null|string $value
108     * @return $this
109     */
110    public function setEndsWith(?string $value)
111    {
112        $this->endsWith = $value;
113
114        return $this;
115    }
116
117    /**
118     * Get the values it should contain.
119     *
120     * @return string[]
121     */
122    public function getContains(): array
123    {
124        return $this->contains;
125    }
126
127    /**
128     * Set the values it should contain.
129     *
130     * @param string[] ...$values
131     * @return $this
132     */
133    public function setContains(string ...$values)
134    {
135        $this->contains = $values;
136
137        return $this;
138    }
139
140    /**
141     * {@inheritdoc}
142     */
143    public function toAsn1(): AbstractType
144    {
145        if ($this->startsWith === null && $this->endsWith === null && count($this->contains) === 0) {
146            throw new RuntimeException('You must provide a contains, starts with, or ends with value to the substring filter.');
147        }
148        $substrings = Asn1::sequenceOf();
149
150        if ($this->startsWith !== null) {
151            $substrings->addChild(Asn1::context(0, Asn1::octetString($this->startsWith)));
152        }
153
154        foreach ($this->contains as $contain) {
155            $substrings->addChild(Asn1::context(1, Asn1::octetString($contain)));
156        }
157
158        if ($this->endsWith !== null) {
159            $substrings->addChild(Asn1::context(2, Asn1::octetString($this->endsWith)));
160        }
161
162        return Asn1::context(self::CHOICE_TAG, Asn1::sequence(
163            Asn1::octetString($this->attribute),
164            $substrings
165        ));
166    }
167
168    /**
169     * {@inheritdoc}
170     */
171    public function toString(): string
172    {
173        $filter = self::PAREN_LEFT . $this->attribute . self::FILTER_EQUAL;
174
175        $value = '';
176        if (count($this->contains) !== 0) {
177            $value = array_map(function ($value) {
178                return Attribute::escape($value);
179            }, $this->contains);
180            $value = '*' . implode('*', $value) . '*';
181        }
182        if ($this->startsWith !== null) {
183            $startsWith = Attribute::escape($this->startsWith);
184            $value = ($value === '' ? $startsWith . '*' : $startsWith) . $value;
185        }
186        if ($this->endsWith !== null) {
187            $endsWith = Attribute::escape($this->endsWith);
188            $value = $value . ($value === '' ? '*' . $endsWith : $endsWith);
189        }
190
191        return $filter . $value . self::PAREN_RIGHT;
192    }
193
194    /**
195     * {@inheritdoc}
196     */
197    public static function fromAsn1(AbstractType $type)
198    {
199        $encoder = new LdapEncoder();
200        $type = $type instanceof IncompleteType ? $encoder->complete($type, AbstractType::TAG_TYPE_SEQUENCE) : $type;
201        if (!($type instanceof SequenceType && count($type->getChildren()) === 2)) {
202            throw new ProtocolException('The substring type is malformed');
203        }
204
205        $attrType = $type->getChild(0);
206        $substrings = $type->getChild(1);
207        if (!($attrType instanceof OctetStringType && $substrings instanceof SequenceType && count($substrings) > 0)) {
208            throw new ProtocolException('The substring filter is malformed.');
209        }
210        [$startsWith, $endsWith, $contains] = self::parseSubstrings($substrings);
211
212        return new self($attrType->getValue(), $startsWith, $endsWith, ...$contains);
213    }
214
215    /**
216     * @throws ProtocolException
217     */
218    protected static function parseSubstrings(SequenceType $substrings): array
219    {
220        $startsWith = null;
221        $endsWith = null;
222        $contains = [];
223
224        /** @var AbstractType $substring */
225        foreach ($substrings->getChildren() as $substring) {
226            if ($substring->getTagClass() !== AbstractType::TAG_CLASS_CONTEXT_SPECIFIC) {
227                throw new ProtocolException('The substring filter is malformed.');
228            }
229            # Starts With and Ends With can occur only once each. Contains can occur multiple times.
230            if ($substring->getTagNumber() === 0) {
231                if ($startsWith !== null) {
232                    throw new ProtocolException('The substring filter is malformed.');
233                } else {
234                    $startsWith = $substring;
235                }
236            } elseif ($substring->getTagNumber() === 1) {
237                $contains[] = $substring->getValue();
238            } elseif ($substring->getTagNumber() === 2) {
239                if ($endsWith !== null) {
240                    throw new ProtocolException('The substring filter is malformed.');
241                } else {
242                    $endsWith = $substring;
243                }
244            } else {
245                throw new ProtocolException('The substring filter is malformed.');
246            }
247        }
248
249        return [
250            ($startsWith !== null) ? $startsWith->getValue() : null,
251            ($endsWith !== null) ? $endsWith->getValue() : null,
252            $contains
253        ];
254    }
255}
256