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 / diagnostics / DetachAbScope.php

DetachAbScope.php in 404 Solution trunk, at includes/diagnostics/DetachAbScope.php

198 lines 8.2 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 * One immutable detach A/B workload scope: the session, request part, and
9 * payload shape whose attempts are counted together.
10 *
11 * These three arrived at every collaborator as adjacent positional strings.
12 * resolve(), nextAttemptIndex(), assignmentSeed(), transientKey() and the
13 * normalized-scope builder all took (sessionId, part, payloadKey) in that
14 * order, so transposing the last two stayed type-correct and silently moved a
15 * request onto a different counter and a different assignment: the experiment
16 * would still report a well-formed slot, for the wrong workload, and nothing
17 * downstream could tell. Normalizing once here and passing one value gives the
18 * mistake nowhere left to happen.
19 *
20 * Normalization is done at construction rather than at each use. The old code
21 * normalized the part and payload key in resolve(), then handed the RAW triple
22 * to two collaborators that normalized them again; the results agreed only
23 * because both normalizers happen to be idempotent, which is a property nobody
24 * had written down and nothing was checking.
25 */
26 final class ABJ_404_Solution_DetachAbScope {
27
28 /** The table endpoint's finite request-part catalog. */
29 const PARTS = array('all', 'table', 'counts', 'pagination');
30
31 /** The part a request falls back to when it names none, or names one we do not serve. */
32 const DEFAULT_PART = 'all';
33
34 /** Prefix identifying this counter's storage generation. */
35 const TRANSIENT_PREFIX = 'abj404_ab_detach_v2_';
36
37 /** @var string The browser-supplied opaque session id, unhashed. */
38 private $sessionId;
39
40 /** @var string One of self::PARTS. */
41 private $part;
42
43 /** @var string A 40-character lowercase hex payload fingerprint. */
44 private $payloadKey;
45
46 /**
47 * Keyed, not positional. Three adjacent strings here would put the exact
48 * transposition this type exists to end back inside the type itself: both
49 * named constructors below would still compile with two of them swapped,
50 * and the value they produced would be a well-formed scope for the wrong
51 * workload. The same mistake was made and caught once already in
52 * ABJ_404_Solution_RequestedRedirectIds' own private constructor.
53 *
54 * @param array{sessionId: string, part: string, payloadKey: string} $fields
55 */
56 private function __construct(array $fields) {
57 $this->sessionId = $fields['sessionId'];
58 $this->part = $fields['part'];
59 $this->payloadKey = $fields['payloadKey'];
60 }
61
62 /**
63 * Build a scope from the AJAX request context.
64 *
65 * The context already carries these three under distinct keys, and the
66 * caller used to unpack them into three locals purely to pass them
67 * positionally. Reading them here keeps the keys attached the whole way.
68 *
69 * @param mixed $context The request context, normally $GLOBALS['abj404_ajax_context'].
70 */
71 public static function fromAjaxContext($context): self {
72 $fields = is_array($context) ? $context : array();
73 return new self(array(
74 'sessionId' => self::scalarField($fields, 'session_id', ''),
75 'part' => self::normalizePart(
76 self::scalarField($fields, 'part', self::DEFAULT_PART)),
77 'payloadKey' => self::normalizePayloadKey(
78 self::scalarField($fields, 'detach_ab_payload_key', '')),
79 ));
80 }
81
82 /**
83 * Build a scope for one session, optionally narrowed to a part and payload.
84 *
85 * The narrowing arrives keyed rather than as two more positional strings,
86 * so the one remaining bare argument is the only one of its type and cannot
87 * be swapped with anything.
88 *
89 * @param array{part?: string, payload_key?: string} $options
90 */
91 public static function forSession(string $sessionId, array $options = array()): self {
92 return new self(array(
93 'sessionId' => $sessionId,
94 'part' => self::normalizePart(
95 isset($options['part']) ? (string)$options['part'] : self::DEFAULT_PART),
96 'payloadKey' => self::normalizePayloadKey(
97 isset($options['payload_key']) ? (string)$options['payload_key'] : ''),
98 ));
99 }
100
101 /** The raw session id, needed only to derive keys; never journalled. */
102 public function sessionId(): string {
103 return $this->sessionId;
104 }
105
106 /** The hashed session id that journal records join on. */
107 public function sessionKey(): string {
108 return self::sessionKeyFor($this->sessionId);
109 }
110
111 /**
112 * Derive the join key for a browser session without journaling its raw value.
113 *
114 * The checkpoint journal is site-wide while counters are per session, so
115 * records need something to join on that is stable per tab and distinct
116 * between tabs. Empty stays empty because "no session" is evidence, not a
117 * shared real session.
118 *
119 * This is a CORRELATION key, not a secret and not an authenticator: nothing
120 * anywhere grants access on the strength of it, and it is derived rather
121 * than stored only to keep the raw per-tab id out of support payloads. It
122 * is deliberately not treated as a privacy guarantee. The id it hashes
123 * comes from abj404GenerateRequestId() (16 chars of Math.random, adequate
124 * against enumeration) but falls back, on a page that loaded the identity
125 * module without the shared generator, to a purely time-derived value --
126 * Date.now() plus a counter plus performance.now() -- which is low enough
127 * entropy to be searched offline no matter which hash wraps it. Choosing a
128 * stronger digest here would not change that; adding entropy at the mint
129 * would, and that is a client-format change rather than a hashing one.
130 *
131 * The formula lives here, on the type that defines what a scope IS, so the
132 * scope does not have to reach back into the policy class that consumes it.
133 * ABJ_404_Solution_DetachAbExperiment::sessionKey() is the plugin-wide name
134 * the diagnostics classes already call and forwards to this.
135 */
136 public static function sessionKeyFor(string $sessionId): string {
137 return $sessionId === '' ? '' : md5($sessionId);
138 }
139
140 public function part(): string {
141 return $this->part;
142 }
143
144 public function payloadKey(): string {
145 return $this->payloadKey;
146 }
147
148 /**
149 * Whether this scope can carry a counter at all.
150 *
151 * "No session" is evidence rather than an error: a request that arrives
152 * without one is not an anonymous member of a shared sequence, it is a
153 * request the experiment cannot scope, and it is answered inert.
154 */
155 public function isSessionless(): bool {
156 return $this->sessionId === '';
157 }
158
159 /** The canonical scope string that both the counter key and the seed derive from. */
160 public function normalizedScope(): string {
161 return implode('|', array($this->sessionKey(), $this->part, $this->payloadKey));
162 }
163
164 /** The transient holding this scope's attempt counter. */
165 public function transientKey(): string {
166 return self::TRANSIENT_PREFIX . md5($this->normalizedScope());
167 }
168
169 /** The stable seed deciding which mode runs first in this scope. */
170 public function assignmentSeed(): string {
171 return md5($this->normalizedScope());
172 }
173
174 /**
175 * Read one field as a string, accepting only values that have a string form.
176 *
177 * @param array<string, mixed> $fields
178 */
179 private static function scalarField(array $fields, string $name, string $fallback): string {
180 if (!isset($fields[$name]) || !is_scalar($fields[$name])) {
181 return $fallback;
182 }
183 return (string)$fields[$name];
184 }
185
186 /** Constrain a supplied part to the catalog the table endpoint serves. */
187 private static function normalizePart(string $part): string {
188 return in_array($part, self::PARTS, true) ? $part : self::DEFAULT_PART;
189 }
190
191 /** Normalize a supplied payload fingerprint without journaling raw input. */
192 private static function normalizePayloadKey(string $payloadKey): string {
193 return preg_match('/^[a-f0-9]{40}$/', $payloadKey) === 1
194 ? $payloadKey
195 : sha1($payloadKey === '' ? 'legacy-payload' : $payloadKey);
196 }
197 }
198