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\Server\Paging;
13
14use FreeDSx\Ldap\Control\Control;
15use FreeDSx\Ldap\Control\ControlBag;
16use FreeDSx\Ldap\Entry\Attribute;
17use FreeDSx\Ldap\Protocol\LdapEncoder;
18
19/**
20 * This determines "equality" of one paging request with another.
21 *
22 * Per RFC 2696:
23 *
24 * When the client wants to retrieve more entries for the result set, it MUST
25 * send to the server a searchRequest with all values identical to the
26 * initial request with the exception of the messageID, the cookie, and
27 * optionally a modified pageSize. The cookie MUST be the octet string
28 * on the last searchResultDone response returned by the server.
29 *
30 * @author Chad Sikorra <Chad.Sikorra@gmail.com>
31 */
32class PagingRequestComparator
33{
34    /**
35     * @var LdapEncoder
36     */
37    private $encoder;
38
39    public function __construct(LdapEncoder $encoder = null)
40    {
41        $this->encoder = $encoder ?? new LdapEncoder();
42    }
43
44    /**
45     * Compares the old paging request with the new request to determine if it is valid.
46     *
47     * @param PagingRequest $oldPagingRequest The previous paging request.
48     * @param PagingRequest $newPagingRequest The paging request that was received.
49     * @return bool
50     */
51    public function compare(
52        PagingRequest $oldPagingRequest,
53        PagingRequest $newPagingRequest
54    ): bool {
55        if ($oldPagingRequest->getNextCookie() !== $newPagingRequest->getCookie()) {
56            return false;
57        }
58
59        $oldSearch = $oldPagingRequest->getSearchRequest();
60        $newSearch = $newPagingRequest->getSearchRequest();
61
62        return $newPagingRequest->isCritical() === $oldPagingRequest->isCritical()
63            && $oldSearch->getAttributesOnly() === $newSearch->getAttributesOnly()
64            && $oldSearch->getDereferenceAliases() === $newSearch->getDereferenceAliases()
65            && $oldSearch->getScope() === $newSearch->getScope()
66            && $oldSearch->getTimeLimit() === $newSearch->getTimeLimit()
67            && $oldSearch->getSizeLimit() === $newSearch->getSizeLimit()
68            && (string)$oldSearch->getBaseDn() === (string)$newSearch->getBaseDn()
69            && $oldSearch->getFilter()->toString() === $newSearch->getFilter()->toString()
70            && $this->attributesMatch($oldSearch->getAttributes(), $newSearch->getAttributes())
71            && $this->controlsMatch($newPagingRequest->controls(), $oldPagingRequest->controls());
72    }
73
74    /**
75     * @param Attribute[] $oldAttrs
76     * @param Attribute[] $newAttrs
77     * @return bool
78     */
79    private function attributesMatch(
80        array $oldAttrs,
81        array $newAttrs
82    ): bool {
83        if (count($oldAttrs) !== count($newAttrs)) {
84            return false;
85        }
86
87        // This works by removing each attribute from their respective arrays as a match is found.
88        // By the end, each array should be empty. We check that below.
89        foreach ($oldAttrs as $iN => $oldAttr) {
90            foreach ($newAttrs as $iO => $newAttr) {
91                if ($newAttr->equals($oldAttr)) {
92                    unset($newAttrs[$iO]);
93                    unset($oldAttrs[$iN]);
94                    continue 2;
95                }
96            }
97        }
98
99        return empty($newAttrs)
100            && empty($oldAttrs);
101    }
102
103    /**
104     * This is a somewhat crude way to determine that two sets of controls are "equal". It does the following:
105     *
106     *   1. Sorts the controls based on their type.
107     *   2. Encodes the controls to their string value.
108     *   3. Compares the collections encoded value to see if they match.
109     *
110     * If the encoded values of the two collections are the same, then they are considered "equal".
111     *
112     * @param ControlBag $newControls
113     * @param ControlBag $oldControls
114     * @return bool
115     */
116    private function controlsMatch(
117        ControlBag $newControls,
118        ControlBag $oldControls
119    ): bool {
120        if ($oldControls->count() !== $newControls->count()) {
121            return false;
122        }
123
124        // Short circuit this...nothing to check. Only a paging control.
125        if (empty($oldControls) && empty($newControls)) {
126            return true;
127        }
128
129        $oldControls = $oldControls->toArray();
130        $newControls = $newControls->toArray();
131
132        // Sort both arrays, so they are ordered the same, then encode to a string and compare.
133        usort($oldControls, function (Control $a, Control $b) {
134            return $a->getTypeOid() <=> $b->getTypeOid();
135        });
136        usort($newControls, function (Control $a, Control $b) {
137            return $a->getTypeOid() <=> $b->getTypeOid();
138        });
139
140        $oldEncoded = array_map(function (Control $control) {
141            return $this->encoder->encode($control->toAsn1());
142        }, $oldControls);
143        $newEncoded = array_map(function (Control $control) {
144            return $this->encoder->encode($control->toAsn1());
145        }, $newControls);
146
147        return implode('', $oldEncoded) === implode('', $newEncoded);
148    }
149}
150