| 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 |
|