PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / core / ExactInteger.php

ExactInteger.php in 404 Solution trunk, at includes/core/ExactInteger.php

104 lines 4.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Read a whole number from an untrusted scalar, or refuse it.
9 *
10 * `is_numeric($v) ? (int)$v : $fallback` is the shape this replaces, and it is
11 * wrong in a specific way: is_numeric() admits '1.9', '0.5', 1.0, '1e1' and
12 * ' 3', and the cast then answers each with a confident WRONG whole number
13 * rather than with "unreadable". Where that number decides an identity -- a
14 * column position, a pair slot, a repetition ordinal -- the caller cannot tell
15 * a real value from a truncated one, and every downstream check passes on the
16 * fabrication.
17 *
18 * Two places in this plugin found that out independently:
19 *
20 * 1. ABJ_404_Solution_ShowIndexRowReader, where '0.5' became the 0 that means
21 * UNIQUE and the comparison it fed answers a difference with destructive
22 * DDL.
23 * 2. ABJ_404_Solution_DetachAbAttempt, where '0.5' and '1.9' truncate into
24 * positions 0 and 1 of the SAME counterbalanced pair and satisfy every
25 * condition the detach A/B decision rule applies -- manufacturing a causal
26 * verdict out of two corrupt journal lines.
27 *
28 * The first fixed it locally and explained it thoroughly; nothing generalised
29 * the rule, so the second was written fresh with the same defect. This class is
30 * the generalisation.
31 *
32 * Integrality is decided from the TEXT, never from the number the text converts
33 * to. Past 2^53 a double has no room left for the fractional part it was
34 * handed, so '9007199254740992.5' arrives already rounded and a floor() check
35 * downstream sees nothing wrong.
36 */
37 final class ABJ_404_Solution_ExactInteger {
38
39 /**
40 * The exact whole number this value spells, or null when it does not spell
41 * one at or above $minimum.
42 *
43 * Deliberately does NOT trim: whitespace means something wrote the field
44 * other than the code that owns it, and only a caller knows whether its
45 * source legitimately pads (a database driver reporting metadata does; a
46 * JSON journal written by this plugin does not). Callers that need it trim
47 * before calling, which keeps the decision visible at the site that can
48 * justify it.
49 *
50 * Booleans are refused rather than cast: (string) renders true as '1' and
51 * false as '', so accepting them would read one as a confident flag and the
52 * other as absent.
53 *
54 * @param mixed $value
55 * @param int $minimum Smallest value the field's documented domain allows.
56 * @return int|null
57 */
58 public static function read($value, int $minimum): ?int {
59 if (!is_scalar($value) || is_bool($value)) {
60 return null;
61 }
62 $text = (string)$value;
63 // Two checks, not one. is_numeric() is the wide gate that makes the
64 // arithmetic below well-defined (and is what lets a static analyser see
65 // that it is); the pattern is the narrow one that rejects every
66 // spelling is_numeric() accepts but this method refuses to truncate.
67 if ($text === '' || !is_numeric($text)) {
68 return null;
69 }
70 if (preg_match('/\A[+-]?[0-9]+\z/', $text) !== 1) {
71 return null;
72 }
73 $number = $text + 0;
74 if (is_float($number)) {
75 // A digit run too long for an int converts to a float instead:
76 // still whole, but possibly infinite and possibly outside the range
77 // an int holds. (int) answers both with a different value.
78 if (!is_finite($number)
79 || $number < (float)PHP_INT_MIN || $number >= (float)PHP_INT_MAX) {
80 return null;
81 }
82 }
83 $integer = (int)$number;
84 return $integer < $minimum ? null : $integer;
85 }
86
87 /**
88 * read(), with a caller-supplied answer for "this value is unreadable".
89 *
90 * For the many call sites whose domain has a sentinel already -- -1 for "no
91 * coordinate", 0 for "not supplied" -- so an unreadable field and an absent
92 * one land on the same value without each site repeating the null check.
93 *
94 * @param mixed $value
95 * @param int $minimum Smallest value the field's documented domain allows.
96 * @param int $whenUnreadable Returned when $value does not spell a whole
97 * number at or above $minimum.
98 */
99 public static function readOr($value, int $minimum, int $whenUnreadable): int {
100 $read = self::read($value, $minimum);
101 return $read === null ? $whenUnreadable : $read;
102 }
103 }
104