/ tests / UtilityTest.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\library as L;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;

/**
 * Used to test the various methods in utility, in particular, those
 * related to posting lists and time.
 *
 * @author Chris Pollett
 */
class UtilityTest extends UnitTest
{
    /**
     * No set up being done for the time being
     */
    public function setUp()
    {
    }
    /**
     * No tear down being done for the time being
     */
    public function tearDown()
    {
    }
    /**
     * Determines if the checkTimeInterval method can correctly determine
     * if a time of day is between the times of day of two timestamps
     */
    public function checkTimeIntervalTestCase()
    {
        $three_oh_five = 1592172350;
        $one_hour = 3600;
        $this->assertEqual(-1, L\checkTimeInterval("14:00", -1, $three_oh_five),
            "(a) no sleep duration (-1) does not contain 3:05pm");
        $this->assertEqual(-1, L\checkTimeInterval("16:00", -1, $three_oh_five),
            "(b) no sleep duration (-1) does not contain 3:05pm");
        $this->assertEqual(-1, L\checkTimeInterval("14:00", $one_hour,
            $three_oh_five), "2pm +1hr does not contain 3:05pm");
        $this->assertEqual(1592175600, L\checkTimeInterval("14:00",
            2 * $one_hour,  $three_oh_five),
            "2pm +2hr interval contains 3:05pm and ends at 4pm");
    }
    /**
     * Checks passwordPolicyViolations against explicit policies rather
     * than the ambient config, so the result does not depend on the
     * machine's profile: a length-only policy accepts an eight character
     * password and flags short, over-long, and forbidden-character ones,
     * and each require-a-class policy flags a password lacking that class
     * and accepts one that has it.
     */
    public function passwordPolicyViolationsTestCase()
    {
        $length_only = ["min_length" => 8, "max_length" => 64,
            "lowercase" => false, "uppercase" => false,
            "digit" => false, "symbol" => false, "forbidden" => "'\""];
        $this->assertTrue(empty(L\passwordPolicyViolations("abcdefgh",
            $length_only)), "eight chars pass a length-only policy");
        $this->assertTrue(in_array("too_short",
            L\passwordPolicyViolations("abc", $length_only)),
            "a short password is flagged too short");
        $this->assertTrue(in_array("too_long",
            L\passwordPolicyViolations(str_repeat("a", 65),
            $length_only)), "an over-long password is flagged too long");
        $this->assertTrue(in_array("forbidden",
            L\passwordPolicyViolations("abcdefg'h", $length_only)),
            "a single quote is a forbidden character");
        $this->assertTrue(in_array("forbidden",
            L\passwordPolicyViolations('abcdefg"h', $length_only)),
            "a double quote is a forbidden character");
        $require_upper = array_merge($length_only,
            ["uppercase" => true]);
        $this->assertTrue(in_array("uppercase",
            L\passwordPolicyViolations("abcdefgh", $require_upper)),
            "requiring uppercase flags an all-lowercase password");
        $this->assertTrue(empty(L\passwordPolicyViolations("Abcdefgh",
            $require_upper)), "a password with uppercase then passes");
        $require_digit = array_merge($length_only, ["digit" => true]);
        $this->assertTrue(in_array("digit",
            L\passwordPolicyViolations("abcdefgh", $require_digit)),
            "requiring a digit flags a letters-only password");
        $require_symbol = array_merge($length_only, ["symbol" => true]);
        $this->assertTrue(in_array("symbol",
            L\passwordPolicyViolations("abcdefgh", $require_symbol)),
            "requiring a symbol flags an alphanumeric password");
        $require_lower = array_merge($length_only,
            ["lowercase" => true]);
        $this->assertTrue(in_array("lowercase",
            L\passwordPolicyViolations("ABCDEFGH", $require_lower)),
            "requiring lowercase flags an all-uppercase password");
    }
    /**
     * Used to check Encoding decoding using unary coding
     */
    public function unaryCodeTestCase()
    {
        $start = 0;
        $current_string = "";
        for($i = 1; $i <= 20; $i++) {
            $current_string = L\appendUnary($i, $current_string, $start);
        }
        for($j = 20; $j >= 1; $j--) {
            $current_string = L\appendUnary($j, $current_string, $start);
        }
        $start = 0;
        for($i = 1; $i <= 20; $i++) {
            $decoded = L\decodeUnary($current_string, $start);
            $this->assertEqual($i, $decoded, "(a) Decode Encode $i");
        }
        for($j = 20; $j >= 1; $j--) {
            $decoded = L\decodeUnary($current_string, $start);
            $this->assertEqual($j, $decoded, "(b) Decode Encode $j");
        }
        $start = 0;
        for($i = 0; $i <= 15; $i++) {
            $decoded = L\decodeUnary("\xFF\xFF", $start);
            $this->assertEqual(1, $decoded, "$i th encoded 1 decodes to 1");
        }
    }
    /**
     * Used to check Encoding decoding using unary coding
     */
    public function encodeDecodeBitsCodeTestCase()
    {
        $to_encodes = [1, 257, 4, 9, 65535, 93];
        $bit_lens = [1, 9, 3, 4, 16, 7];
        $start = 0;
        $encoded = "";
        foreach ($to_encodes as $to_encode) {
            $encoded = L\appendBits($to_encode, $encoded, $start);
        }
        $i = 0;
        $start = 0;
        foreach ($bit_lens as $bit_len) {
            $decode = L\decodeBits($encoded, $start, $bit_len);
            $this->assertEqual($to_encodes[$i], $decode, "Encode ".
                $to_encodes[$i] ." decodes as $decode");
            $i++;
        }
    }
    /**
     * Used to check Encoding decoding gamma codes
     */
    public function encodeDecodeGammaTestCase()
    {
        $to_encodes = [1, 257, 4, 9, 65535, 93];
        $start = 0;
        $encoded = "";
        foreach ($to_encodes as $to_encode) {
            $encoded = L\appendGamma($to_encode, $encoded, $start);
        }
        $start = 0;
        $num_encoded = count($to_encodes);
        $decodes = L\decodeGammaList($encoded, $start, $num_encoded);
        for ($i = 0; $i < $num_encoded; $i++) {
            $this->assertEqual($to_encodes[$i], $decodes[$i], "Encode ".
                "{$to_encodes[$i]} decodes as {$decodes[$i]}");
        }
    }
    /**
     * Check that encoding and decoding integers using the vByte scheme works
     */
    public function encodeDecodeVByteTestCase()
    {
        for ($i = 0; $i < 1000000; $i += 500) {
            $enc = L\vByteEncode($i);
            $start = 0;
            $decode = L\vByteDecode($enc, $start);
            $this->assertEqual($i, $decode,
                "Encoding and decoding $i give $i");
        }
    }
    /**
     * The encode255 / decode255 pair must round trip exactly and, just
     * as importantly, the encoded form must never contain a 0xFF byte.
     * The postings file uses 0xFF as its record separator, so a single
     * stray 0xFF inside an encoded record would split it in the wrong
     * place and hand the index reader a corrupt offset and length, the
     * fault behind the posting-decode errors seen in production. This
     * walks inputs built from the bytes the codec rewrites, plus a
     * string of every byte value, and checks both properties.
     */
    public function encodeDecode255RoundTripTestCase()
    {
        $samples = ["", "\xFE", "\xFF", "\xFE\xFE", "\xFE\xFD",
            "\xFF\xFF", "a\xFFb", "\xFE\xFF\xFE", "plain text",
            "\x00\x01\xFE\xFF\xFD\x80"];
        $all_bytes = "";
        for ($i = 0; $i < 256; $i++) {
            $all_bytes .= chr($i);
        }
        $samples[] = $all_bytes . $all_bytes;
        foreach ($samples as $sample) {
            $encoded = L\encode255($sample);
            $this->assertTrue(strpos($encoded, "\xFF") === false,
                "the encoded form holds no 0xFF separator byte");
            $this->assertEqual($sample, L\decode255($encoded),
                "encode255 then decode255 returns the original bytes");
        }
    }
    /**
     * Used to check Encoding decoding using unary coding
     */
    public function encodeDecodeRiceTestCase()
    {
        $position_list = [90, 101, 570, 581, 737, 950, 1100, 1119, 1127,
            1147, 1175, 1185, 1930, 1969, 2020, 2040, 2068, 2083, 2090, 2102,
            2126, 2170, 2182, 2191, 2217, 2228, 2250, 2260, 2370, 2392, 2403,
            2447, 2456, 2467, 2476, 2486, 2503, 2508, 2610, 2628, 2629, 2641,
            2674, 2693, 2710, 2753, 2761, 2770, 2847, 2885, 2899, 2920, 2934,
            3000, 3019, 3039, 3058, 3070, 3133, 3168, 3227, 3240, 3249, 3266,
            3277, 3296, 3309, 3327, 3348, 3366, 3368, 3375, 3424, 3456, 3458,
            3463, 3478, 3487, 3511, 3513, 3523, 3557, 3614, 3828, 3880, 3896,
            3910, 3999, 4039, 4056, 4165, 4226, 4248, 4269, 4308, 4324, 4338,
            4444, 4484, 4560, 4577, 4597, 4622, 4695, 4710, 4801, 4824, 4859,
            4876, 4981, 5071, 5109, 5131, 5199, 5232, 5270, 5287, 5317, 5330,
            5373, 5409, 5426, 5490, 5500, 5501, 5533, 5544, 5722, 5765, 5799,
            5821, 5854, 5938, 5967, 6004, 6036, 6195, 6262, 6319, 6337, 6345,
            6346, 6391, 6430, 6452, 6460, 6514, 6580, 6736, 6758, 6794, 6820,
            6976];
        $num_positions = count($position_list);
        $average_gap = ($position_list[$num_positions - 1] -
            $position_list[0])/$num_positions;
        $modulus =  max(ceil(log($average_gap + 1, 2)), 2);
        $start = 0;
        $encoded = L\appendRiceSequence($position_list, $modulus, "",
            $start, 0);
        $start = 0;
        $decodes = L\decodeRiceSequence($encoded, $start, $num_positions, 0);
        for ($i = 0; $i < $num_positions; $i++) {
            $this->assertEqual($position_list[$i], $decodes[$i], "Encode ".
                "{$position_list[$i]} decodes as {$decodes[$i]}");
        }
    }
    /**
     * Used to check Encoding decoding using unary coding
     */
    public function encodeDecodePositionListTestCase()
    {
        $position_list = [90, 101, 570, 581, 737, 950, 1100, 1119, 1127,
            1147, 1175, 1185, 1930, 1969, 2020, 2040, 2068, 2083, 2090, 2102,
            2126, 2170, 2182, 2191, 2217, 2228, 2250, 2260, 2370, 2392, 2403,
            2447, 2456, 2467, 2476, 2486, 2503, 2508, 2610, 2628, 2629, 2641,
            2674, 2693, 2710, 2753, 2761, 2770, 2847, 2885, 2899, 2920, 2934,
            3000, 3019, 3039, 3058, 3070, 3133, 3168, 3227, 3240, 3249, 3266,
            3277, 3296, 3309, 3327, 3348, 3366, 3368, 3375, 3424, 3456, 3458,
            3463, 3478, 3487, 3511, 3513, 3523, 3557, 3614, 3828, 3880, 3896,
            3910, 3999, 4039, 4056, 4165, 4226, 4248, 4269, 4308, 4324, 4338,
            4444, 4484, 4560, 4577, 4597, 4622, 4695, 4710, 4801, 4824, 4859,
            4876, 4981, 5071, 5109, 5131, 5199, 5232, 5270, 5287, 5317, 5330,
            5373, 5409, 5426, 5490, 5500, 5501, 5533, 5544, 5722, 5765, 5799,
            5821, 5854, 5938, 5967, 6004, 6036, 6195, 6262, 6319, 6337, 6345,
            6346, 6391, 6430, 6452, 6460, 6514, 6580, 6736, 6758, 6794, 6820,
            6976];
        $num_positions = count($position_list);
        $encoded = L\encodePositionList($position_list);
        $decodes = L\decodePositionList($encoded, $num_positions);
        for ($i = 0; $i < $num_positions; $i++) {
            $this->assertEqual($position_list[$i], $decodes[$i], "Encode ".
                "{$position_list[$i]} decodes as {$decodes[$i]}");
        }
    }
    /**
     * Checks that short position lists, the ones with fewer than three
     * entries that take the plain gamma path rather than the Rice path,
     * survive a round trip. The larger position-list test only exercises
     * the Rice path, so without this a break in the short path would go
     * unnoticed.
     */
    public function encodeDecodeSmallPositionListTestCase()
    {
        $small_lists = [[5], [5, 9], [1, 2], [90, 101]];
        foreach ($small_lists as $position_list) {
            $encoded = L\encodePositionList($position_list);
            $decodes = L\decodePositionList($encoded, count($position_list));
            $this->assertEqual($position_list, $decodes,
                "Small list " . json_encode($position_list) .
                " decodes as " . json_encode($decodes));
        }
    }
    /**
     * Checks that decoding a Rice sequence does not break when asked for
     * more numbers than the input actually holds. Reading past the end of
     * the input used to apply a decrement to a boolean, which PHP warns
     * about; this confirms the real numbers at the front still decode and
     * the call simply returns the count requested without that warning.
     */
    public function decodeRiceSequenceShortInputTestCase()
    {
        $positions = [3, 10, 17, 18];
        $encoded = L\encodePositionList($positions);
        $start_bit_offset = 0;
        $first = L\decodeGammaList($encoded, $start_bit_offset, 1)[0];
        $warned = false;
        set_error_handler(function ($number, $text) use (&$warned) {
            if (strpos($text, "Decrement on type bool") !== false) {
                $warned = true;
            }
            return true;
        });
        $over_count = count($positions) + 8;
        $decodes = L\decodeRiceSequence($encoded, $start_bit_offset,
            $over_count, $first);
        restore_error_handler();
        $this->assertEqual([10, 17, 18], array_slice($decodes, 0, 3),
            "the real positions at the front decode correctly");
        $this->assertEqual($over_count, count($decodes),
            "the decoder returns the number of values requested");
        $this->assertTrue(!$warned,
            "no decrement-on-boolean warning past the end of input");
    }
    /**
     * Used to check Encoding decoding using Modified9 coding
     */
    public function modified9TestCase()
    {
        $encode_list = [151466751, 11746, 11746];
        $encoded = L\encodeModified9($encode_list);
        $offset = 0;
        $decode_list = L\decodeModified9($encoded, $offset);
        $this->assertEqual($encode_list, $decode_list,
            "Encoding and decoding an array with Modified9 gives same result");
    }
    /**
     * Used to check if posting lists can be properly encoded/decoded
     */
    public function packUnpackPostingTestCase()
    {
        $posting_list = [90, 101, 570, 581, 737, 950, 1100, 1119, 1127,
            1147, 1175, 1185, 1930, 1969, 2020, 2040, 2068, 2083, 2090, 2102,
            2126, 2170, 2182, 2191, 2217, 2228, 2250, 2260, 2370, 2392, 2403,
            2447, 2456, 2467, 2476, 2486, 2503, 2508, 2610, 2628, 2629, 2641,
            2674, 2693, 2710, 2753, 2761, 2770, 2847, 2885, 2899, 2920, 2934,
            3000, 3019, 3039, 3058, 3070, 3133, 3168, 3227, 3240, 3249, 3266,
            3277, 3296, 3309, 3327, 3348, 3366, 3368, 3375, 3424, 3456, 3458,
            3463, 3478, 3487, 3511, 3513, 3523, 3557, 3614, 3828, 3880, 3896,
            3910, 3999, 4039, 4056, 4165, 4226, 4248, 4269, 4308, 4324, 4338,
            4444, 4484, 4560, 4577, 4597, 4622, 4695, 4710, 4801, 4824, 4859,
            4876, 4981, 5071, 5109, 5131, 5199, 5232, 5270, 5287, 5317, 5330,
            5373, 5409, 5426, 5490, 5500, 5501, 5533, 5544, 5722, 5765, 5799,
            5821, 5854, 5938, 5967, 6004, 6036, 6195, 6262, 6319, 6337, 6345,
            6346, 6391, 6430, 6452, 6460, 6514, 6580, 6736, 6758, 6794, 6820,
            6976];
        $packed = L\packPosting(10, $posting_list);
        $offset = 0;
        $out_doc_list = L\unpackPosting($packed, $offset, true);
        $this->assertEqual($out_doc_list[0], 10,
            "Doc index from unpack of long packed posting equal");
        $this->assertEqual($out_doc_list[1], $posting_list,
            "Unpack of long packed posting equal");
        $offset = 0;
        $posting_list = [254, 12000, 24000];
        $packed = L\packPosting(33689, $posting_list);
        $out_doc_list = L\unpackPosting($packed, $offset, true);
        $this->assertEqual($out_doc_list[0], 33689,
            "Doc index from unpack of first word has delta[0] case");
        $this->assertEqual($out_doc_list[1], $posting_list,
            "Unpack of delta[0] case");
        $offset = 0;
        $posting_list = [511, 12000, 24000];
        $packed = L\packPosting(33689, $posting_list);
        $out_doc_list = L\unpackPosting($packed, $offset, true);
        $this->assertEqual($out_doc_list[0], 33689,
            "Doc index from unpack of first word has delta[0] case 2");
        $this->assertEqual($out_doc_list[1], $posting_list,
            "Unpack of delta[0] case 2");
        $posting_list = [6000, 12000, 24000];
        $packed = L\packPosting(100000, $posting_list);
        $offset = 0;
        $out_doc_list = L\unpackPosting($packed, $offset, true);
        $this->assertEqual($out_doc_list[0], 100000,
            "Bigger Doc index from unpack of long packed posting equal");
        $this->assertEqual($out_doc_list[1], $posting_list,
            "Bigger Delta unpack of posting equal");
        $posting_list = [1, 4, 7, 174];
        $packed = L\packPosting(0, $posting_list);
        $unpack_int = unpack("N*", $packed);
        $offset = 0;
        $out_doc_list = L\unpackPosting($packed, $offset, true);
        $this->assertEqual($out_doc_list[0], 0,
            "Doc index from unpack of doc index 0 case");
        $this->assertEqual($out_doc_list[1], $posting_list,
            "Unpack of doc index 0 case");
    }
    /**
     * Checks webencode/webdecode to see inverses. Checks base64Hash/
     * unbase64Hash to see inverses
     */
    public function webencodeWebdecodeTestCase()
    {
        $expected = "=+~-@hi ya everyone!!@~+-=";
        $encode_decoded = L\webdecode(L\webencode($expected));
        $this->assertEqual($expected, $encode_decoded,
            "Webencode/Webdecode works correctly");
        $encode_decoded = L\unbase64Hash(L\base64Hash($expected));
        $this->assertEqual($expected, $encode_decoded,
            "base64Hash/unbase64Hash works correctly");
        $expected = "\xFE\xFD\xFF\xFE\xFD";
        $encode_decoded = L\decode255(L\encode255("\xFE\xFD\xFF\xFE\xFD"));
        $this->assertEqual($expected, $encode_decoded,
            "encode255/decode255 works correctly");
    }
    /**
     * Tests crawlAuthHash, the keyed HMAC-SHA256 sibling of
     * crawlHash that the CSRF token machinery uses. Covers
     * determinism, output shape (32 raw / 43 URL-safe base64
     * chars, no padding or unsafe chars), input-sensitivity,
     * key-sensitivity by way of AUTH_KEY mixing through the
     * underlying hash_hmac call, hash_equals acceptance of
     * matching pairs, hash_equals rejection of single-byte
     * tampered pairs, and a roundtrip mirroring how
     * Controller::generateCSRFToken / checkCSRFToken use the
     * primitive (hash($user . $time) plus "*$time" suffix,
     * length-54 token, hash_equals verification).
     */
    public function crawlAuthHashTestCase()
    {
        // Determinism: same input → same output
        $a = L\crawlAuthHash("hello");
        $b = L\crawlAuthHash("hello");
        $this->assertEqual($a, $b,
            "crawlAuthHash is deterministic for the same input");
        // Output length: 32 raw bytes / 43 base64 chars
        $raw = L\crawlAuthHash("hello", true);
        $this->assertEqual(32, strlen($raw),
            "crawlAuthHash raw output is 32 bytes");
        $this->assertEqual(43, strlen($a),
            "crawlAuthHash base64 output is 43 chars");
        // URL-safe base64: no /, +, or = chars
        $this->assertTrue(strpbrk($a, "/+=") === false,
            "crawlAuthHash output is URL-safe (no /, +, or =)");
        // Input-sensitivity: one-byte change flips many bits
        $c = L\crawlAuthHash("hellp");
        $this->assertNotEqual($a, $c,
            "crawlAuthHash differs when the input differs by one byte");
        // hash_equals accepts matching pairs
        $this->assertTrue(hash_equals(
            L\crawlAuthHash("token-input"),
            L\crawlAuthHash("token-input")),
            "hash_equals returns true for two equal crawlAuthHash outputs");
        // hash_equals rejects a single-byte tamper
        $good = L\crawlAuthHash("token-input");
        $bad = substr($good, 0, -1) .
            (($good[-1] === "x") ? "y" : "x");
        $this->assertFalse(hash_equals($good, $bad),
            "hash_equals returns false when one byte differs");
        // CSRF-style roundtrip: token = hash($user.$time) . "*$time"
        $user = "alice";
        $time = (string) time();
        $token = L\crawlAuthHash($user . $time) . "*$time";
        $this->assertEqual(54, strlen($token),
            "CSRF-shaped token is 54 chars (43 hash + 1 star + 10 time)");
        $parts = explode("*", $token);
        $this->assertEqual(2, count($parts),
            "CSRF-shaped token splits into hash and time on '*'");
        $this->assertTrue(hash_equals(
            L\crawlAuthHash($user . $parts[1]), $parts[0]),
            "CSRF-shaped token verifies with hash_equals on the same user");
        // A different user with the same timestamp should not verify
        $this->assertFalse(hash_equals(
            L\crawlAuthHash("bob" . $parts[1]), $parts[0]),
            "CSRF-shaped token does not verify for a different user");
    }
    /**
     * crawlCrypt computes a bcrypt hash with crypt(). It behaves the
     * same whether or not it is called inside a fiber: there is no
     * helper process and no suspend, so a fiber that calls it runs to
     * completion in a single step and returns the very same hash a
     * direct call gives. A low bcrypt cost keeps the test quick.
     */
    public function crawlCryptCooperativeTestCase()
    {
        $salt = '$2y$04$' .
            strtr(base64_encode(random_bytes(16)), '+', '.');
        $inline = L\crawlCrypt("hunter2", $salt);
        $this->assertEqual(crypt("hunter2", $salt), $inline,
            "outside a fiber crawlCrypt is a plain crypt");
        $suspended = false;
        $fiber = new \Fiber(function () use ($salt) {
            return L\crawlCrypt("hunter2", $salt);
        });
        $fiber->start();
        while ($fiber->isSuspended()) {
            $suspended = true;
            $fiber->resume();
        }
        $in_fiber = $fiber->getReturn();
        $this->assertFalse($suspended,
            "in a fiber crawlCrypt runs inline without suspending");
        $this->assertEqual($inline, $in_fiber,
            "in a fiber crawlCrypt produces the same hash as inline");
    }
}
X