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