, records: int, outcomes: array} * status: `absent`, `unparseable`, or `parsed`. */ public static function attemptOutcomes(string $raw): array { if ($raw === '') { return array( 'status' => 'absent', 'ids' => array(), 'records' => 0, 'outcomes' => array(), ); } $boundedRaw = substr($raw, 0, self::MAX_DRAINED_BUFFER_INPUT_BYTES); $decoded = json_decode($boundedRaw, true); if (!is_array($decoded) || !self::isJsonArrayDocument($boundedRaw)) { return array( 'status' => 'unparseable', 'ids' => array(), 'records' => 0, 'outcomes' => array(), ); } $ids = array(); $outcomes = array(); foreach ($decoded as $record) { if (!is_array($record) || !isset($record['id']) || !is_scalar($record['id'])) { continue; } $id = (string)$record['id']; // The wire contract's own request-id shape. An id that cannot be a // server request id cannot be reconciled against one, and letting // arbitrary browser text into the manifest would put an unbounded // string in a bounded record. if (preg_match('/^[a-zA-Z0-9]{1,64}$/', $id) !== 1) { continue; } $ids[$id] = true; $outcome = isset($record['outcome']) && is_scalar($record['outcome']) ? (string)$record['outcome'] : ''; $healthy = in_array($outcome, self::HEALTHY_OUTCOMES, true); if (!array_key_exists($id, $outcomes) || !$healthy) { $outcomes[$id] = $healthy; } } return array( 'status' => 'parsed', 'ids' => array_slice(array_keys($ids), 0, self::MAX_ATTEMPT_IDS_REPORTED), 'records' => count($decoded), 'outcomes' => $outcomes, ); } /** * Fit the browser's drained attempt buffer inside a byte budget WITHOUT * destroying it. * * The buffer can exceed what the support payload will carry: the browser * store holds up to 16 records / 48 KB. Cutting the serialized array at a * byte offset -- which is what both ends used to do -- leaves invalid * JSON, so an overflowing buffer arrived as "unparseable" and EVERY * attempt was lost rather than the least interesting one. That is the * same defect the journal excerpt had, on the one channel that can * describe attempts the server never saw at all. * * So whole records are dropped, not bytes, and the ones kept are chosen: * attempts that did not succeed first (oldest first, because the first * failure is the one without retry effects), then the rest newest first. * * A buffer whose records carry bounded body excerpts (support report 521) * has those excerpts redacted here, before the JSON can be embedded in * the support payload; see * ABJ_404_Solution_ClientTransportReport::redactReportBodyExcerpt(). * * @param string $raw The raw POSTed buffer. * @param int $budgetBytes Ceiling for the returned JSON. * @return array{json: string, parsed: bool, kept: int, dropped: int, raw_length: int, error: string} */ public static function bound(string $raw, int $budgetBytes): array { $rawLength = strlen($raw); $unparseable = array( 'json' => '', 'parsed' => false, 'kept' => 0, 'dropped' => 0, 'raw_length' => $rawLength, 'error' => '', ); if ($raw === '') { return $unparseable; } $boundedRaw = substr($raw, 0, self::MAX_DRAINED_BUFFER_INPUT_BYTES); $decoded = json_decode($boundedRaw, true); if (!is_array($decoded) || !self::isJsonArrayDocument($boundedRaw)) { $unparseable['error'] = is_array($decoded) ? 'expected a JSON array of attempt records' : json_last_error_msg(); return $unparseable; } if (!class_exists('ABJ_404_Solution_ClientTransportReport') || !in_array('normalizeRecord', get_class_methods('ABJ_404_Solution_ClientTransportReport'), true)) { // The old composer echoes raw input on parse failure. This input // did parse; describe omitted records without forwarding any of them. $marker = '{"client_evidence_status":"unavailable","reason":"normalizer_updating"}'; return array( 'json' => strlen($marker) <= $budgetBytes ? $marker : '[]', 'parsed' => true, 'kept' => 0, 'dropped' => count($decoded), 'raw_length' => $rawLength, 'error' => 'client record normalization unavailable', ); } $records = array(); $changed = false; foreach ($decoded as $record) { $normalized = is_array($record) ? ABJ_404_Solution_ClientTransportReport::normalizeRecord($record) : $record; $changed = $changed || $normalized !== $record; $records[] = $normalized; } if (!$changed && $rawLength <= self::MAX_DRAINED_BUFFER_INPUT_BYTES && $rawLength <= $budgetBytes) { return array( 'json' => $raw, 'parsed' => true, 'kept' => count($records), 'dropped' => 0, 'raw_length' => $rawLength, 'error' => '', ); } $kept = self::keepWithinBudget($records, $budgetBytes); $json = json_encode($kept, JSON_UNESCAPED_SLASHES); if (!is_string($json) || strlen($json) > $budgetBytes) { $unparseable['error'] = 'buffer could not be reduced to the support budget'; return $unparseable; } return array( 'json' => $json, 'parsed' => true, 'kept' => count($kept), 'dropped' => count($records) - count($kept), 'raw_length' => $rawLength, 'error' => '', ); } /** * Whether decoded JSON came from the buffer's required top-level array. * * Associative decoding turns both JSON objects and arrays into PHP arrays, * so the decoded type alone cannot enforce the wire contract. Inspecting * the first non-whitespace byte keeps a valid object from being reported as * a successfully parsed empty attempt list. */ private static function isJsonArrayDocument(string $raw): bool { return substr(ltrim($raw), 0, 1) === '['; } /** * The records that fit, in their original order, failures first. * * @param array $records * @return array */ private static function keepWithinBudget(array $records, int $budgetBytes): array { $failed = array(); $healthy = array(); foreach ($records as $position => $record) { $outcome = is_array($record) && isset($record['outcome']) && is_scalar($record['outcome']) ? (string)$record['outcome'] : ''; if (in_array($outcome, self::HEALTHY_OUTCOMES, true)) { $healthy[] = $position; } else { $failed[] = $position; } } $order = array_merge($failed, array_reverse($healthy)); // Two brackets and the commas between the records; charged up front so // the encoded result cannot creep past the budget on the last record. $used = 2; $keepPositions = array(); foreach ($order as $position) { $encoded = json_encode($records[$position], JSON_UNESCAPED_SLASHES); if (!is_string($encoded)) { continue; } $cost = strlen($encoded) + ($keepPositions === array() ? 0 : 1); if ($used + $cost > $budgetBytes) { continue; } $used += $cost; $keepPositions[] = $position; } sort($keepPositions); $kept = array(); foreach ($keepPositions as $position) { $kept[] = $records[$position]; } return $kept; } }