| @@ -6,13 +6,54 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | namespace WCPOS\WooCommercePOS\Sync; |
| 9 | 9 | |
| 10 | +use WCPOS\WooCommercePOS\Logger; | |
| 11 | + | |
| 10 | 12 | /** |
| 11 | 13 | * Normalizes structured meta values before sync documents are hashed or emitted. |
| 12 | 14 | */ |
| 13 | 15 | final class Meta_Normalizer { |
| 14 | 16 | /** |
| 17 | + * Upper bound on array elements and object properties in ONE meta value, counted | |
| 18 | + * across every nesting level. The POS's own structured meta (`_woocommerce_pos_data`, | |
| 19 | + * attribute maps, line-item meta) is a few hundred nodes; anything past this is another | |
| 20 | + * plugin's bulk data and is not served: one store carried a 16.7-million-element meta | |
| 21 | + * value, and the JSON round trip below killed every request that touched the record | |
| 22 | + * (Sentry WOOCOMMERCE-POS-2KQ, ~22 fatals an hour). | |
| 23 | + */ | |
| 24 | + public const OVERSIZED_META_NODE_LIMIT = 20000; | |
| 25 | + | |
| 26 | + /** | |
| 27 | + * Upper bound on string bytes in ONE meta value, summed across nesting and keys (8 MiB): | |
| 28 | + * the same cut-off for values that are few entries but enormous strings. | |
| 29 | + * | |
| 30 | + * Deliberately well clear of legitimate use. `Test_Catalog_Proxy_Meta_Scaling` pins a | |
| 31 | + * 1 MiB meta value as something that must round-trip, so the cut-off sits eight times | |
| 32 | + * above the largest size this repo asserts is normal, and still three orders of | |
| 33 | + * magnitude below the gigabyte that took a store's requests down. | |
| 34 | + */ | |
| 35 | + public const OVERSIZED_META_BYTE_LIMIT = 8388608; | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * Meta keys already reported as oversized in this request (one warning per key). | |
| 39 | + * | |
| 40 | + * @var array<string, true> | |
| 41 | + */ | |
| 42 | + private static array $oversized_logged = array(); | |
| 43 | + | |
| 44 | + /** | |
| 45 | + * Clear the per-request set of already-reported keys. | |
| 46 | + * | |
| 47 | + * The dedupe is request-scoped in production, where the process ends with the | |
| 48 | + * response. Long-lived processes and the test suite share one process across many | |
| 49 | + * requests, so they reset at the boundary like the other request-scoped collectors. | |
| 50 | + */ | |
| 51 | + public static function reset_request_state(): void { | |
| 52 | + self::$oversized_logged = array(); | |
| 53 | + } | |
| 54 | + | |
| 55 | + /** | |
| 15 | 56 | * Register the shared pre-stamping normalization seams. |
| 16 | 57 | */ |
| 17 | 58 | public static function register_hooks(): void { |
| 18 | 59 | add_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'normalize' ), 5 ); |
| @@ -90,17 +131,27 @@ | ||
| 90 | 131 | * |
| 91 | 132 | * @return array |
| 92 | 133 | */ |
| 93 | 134 | private static function normalize_meta_data( array $meta_data ): array { |
| 135 | + $was_list = array_keys( $meta_data ) === array_keys( array_values( $meta_data ) ); | |
| 136 | + $dropped = false; | |
| 94 | 137 | foreach ( $meta_data as $index => $entry ) { |
| 95 | 138 | // Top-level entity meta reaches the filters as live WC_Meta_Data objects |
| 96 | 139 | // (they only become arrays at JSON-encode time); convert a copy to the |
| 97 | 140 | // exact shape it would serialize to, and only swap it in when normalization |
| 98 | 141 | // actually happens — untouched entries keep their original form so |
| 99 | - // revision hashes of scalar-only records are unchanged. | |
| 142 | + // revision hashes of scalar-only records are unchanged. The budget check | |
| 143 | + // runs on the live value BEFORE the round trip: encoding an oversized value | |
| 144 | + // is the allocation that took the whole request down. | |
| 100 | 145 | $is_meta_object = $entry instanceof \WC_Meta_Data; |
| 101 | 146 | if ( $is_meta_object ) { |
| 102 | - $entry = json_decode( wp_json_encode( $entry ), true ); | |
| 147 | + $data = $entry->get_data(); | |
| 148 | + if ( self::exceeds_value_budget( $data['value'] ?? null ) ) { | |
| 149 | + self::drop_oversized( $meta_data, $index, $data ); | |
| 150 | + $dropped = true; | |
| 151 | + continue; | |
| 152 | + } | |
| 153 | + $entry = json_decode( wp_json_encode( $data ), true ); | |
| 103 | 154 | } |
| 104 | 155 | |
| 105 | 156 | if ( ! is_array( $entry ) ) { |
| 106 | 157 | continue; |
| @@ -105,8 +156,14 @@ | ||
| 105 | 156 | if ( ! is_array( $entry ) ) { |
| 106 | 157 | continue; |
| 107 | 158 | } |
| 108 | 159 | |
| 160 | + if ( ! $is_meta_object && self::exceeds_value_budget( $entry['value'] ?? null ) ) { | |
| 161 | + self::drop_oversized( $meta_data, $index, $entry ); | |
| 162 | + $dropped = true; | |
| 163 | + continue; | |
| 164 | + } | |
| 165 | + | |
| 109 | 166 | $entry_changed = false; |
| 110 | 167 | if ( isset( $entry['value'] ) && is_string( $entry['value'] ) ) { |
| 111 | 168 | $raw = $entry['value']; |
| 112 | 169 | $trimmed = trim( $raw ); |
| @@ -130,9 +187,157 @@ | ||
| 130 | 187 | $meta_data[ $index ] = $entry; |
| 131 | 188 | } |
| 132 | 189 | } |
| 133 | 190 | |
| 191 | + if ( $dropped && $was_list ) { | |
| 192 | + // A list with a hole JSON-encodes as an object; the wire expects a list. | |
| 193 | + $meta_data = array_values( $meta_data ); | |
| 194 | + } | |
| 195 | + | |
| 134 | 196 | return $meta_data; |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 200 | + * Remove an oversized entry from the payload and say so once per key per request. | |
| 201 | + * The value itself is never logged. | |
| 202 | + * | |
| 203 | + * @param array $meta_data Serialized REST meta entries (by reference). | |
| 204 | + * @param int|string $index Index of the entry to drop. | |
| 205 | + * @param array $entry Array form of the entry (`id`, `key`, `value`). | |
| 206 | + */ | |
| 207 | + private static function drop_oversized( array &$meta_data, $index, array $entry ): void { | |
| 208 | + unset( $meta_data[ $index ] ); | |
| 209 | + self::note_oversized_meta( | |
| 210 | + isset( $entry['key'] ) ? (string) $entry['key'] : '', | |
| 211 | + (int) ( $entry['id'] ?? 0 ) | |
| 212 | + ); | |
| 213 | + } | |
| 214 | + | |
| 215 | + /** | |
| 216 | + * Record that an oversized meta entry was withheld, once per key per request. | |
| 217 | + * The value is never logged. Shared with the v1 lane, which drops the same | |
| 218 | + * entries at its own serializer rather than through this class. | |
| 219 | + * | |
| 220 | + * @param string $key Meta key that was withheld. | |
| 221 | + * @param int $meta_id Meta row id, when known. | |
| 222 | + */ | |
| 223 | + public static function note_oversized_meta( string $key, int $meta_id = 0 ): void { | |
| 224 | + if ( isset( self::$oversized_logged[ $key ] ) ) { | |
| 225 | + return; | |
| 226 | + } | |
| 227 | + self::$oversized_logged[ $key ] = true; | |
| 228 | + Logger::warning( | |
| 229 | + sprintf( | |
| 230 | + 'WCPOS sync: dropped oversized meta "%s" (meta id %d) from the POS payload; the stored value exceeds %d nodes or %d bytes and cannot be served to the POS.', | |
| 231 | + $key, | |
| 232 | + $meta_id, | |
| 233 | + self::OVERSIZED_META_NODE_LIMIT, | |
| 234 | + self::OVERSIZED_META_BYTE_LIMIT | |
| 235 | + ) | |
| 236 | + ); | |
| 237 | + } | |
| 238 | + | |
| 239 | + /** | |
| 240 | + * Whether a meta value is too large to serve. Walks iteratively and stops the moment a | |
| 241 | + * limit is crossed, so the cost is bounded by the limits, not by the value: a | |
| 242 | + * 16-million-element value costs the same as a 20,001-element one. | |
| 243 | + * | |
| 244 | + * EVERY object is expanded, not just stdClass. WordPress unserializes stored meta, so a | |
| 245 | + * value can come back as an instance of some other plugin's class, and json_encode | |
| 246 | + * serializes its public properties just the same — a custom object wrapping the | |
| 247 | + * multi-million-element array would otherwise walk straight past this check into the | |
| 248 | + * encode that killed the request. `get_object_vars()` is called from outside the value's | |
| 249 | + * class, so it sees exactly the public properties the encoder will. Traversal is by | |
| 250 | + * refcount, never a deep copy. | |
| 251 | + * | |
| 252 | + * Keys count toward both budgets: a value can be a few entries under enormous keys. | |
| 253 | + * | |
| 254 | + * A self-referencing object graph terminates on the node limit and reports oversized, | |
| 255 | + * which is correct — json_encode cannot represent one either. | |
| 256 | + * | |
| 257 | + * @param mixed $value Meta value as stored. | |
| 258 | + * | |
| 259 | + * @return bool | |
| 260 | + */ | |
| 261 | + public static function exceeds_value_budget( $value ): bool { | |
| 262 | + $unencodable = false; | |
| 263 | + $value = self::as_encoded( $value, $unencodable ); | |
| 264 | + if ( $unencodable ) { | |
| 265 | + return true; | |
| 266 | + } | |
| 267 | + if ( is_string( $value ) ) { | |
| 268 | + return \strlen( $value ) > self::OVERSIZED_META_BYTE_LIMIT; | |
| 269 | + } | |
| 270 | + if ( ! is_array( $value ) && ! \is_object( $value ) ) { | |
| 271 | + return false; | |
| 272 | + } | |
| 273 | + | |
| 274 | + $nodes = 0; | |
| 275 | + $bytes = 0; | |
| 276 | + $stack = array( is_array( $value ) ? $value : get_object_vars( $value ) ); | |
| 277 | + while ( array() !== $stack ) { | |
| 278 | + $current = array_pop( $stack ); | |
| 279 | + foreach ( $current as $child_key => $child ) { | |
| 280 | + ++$nodes; | |
| 281 | + if ( is_string( $child_key ) ) { | |
| 282 | + $bytes += \strlen( $child_key ); | |
| 283 | + } | |
| 284 | + $child = self::as_encoded( $child, $unencodable ); | |
| 285 | + if ( $unencodable ) { | |
| 286 | + return true; | |
| 287 | + } | |
| 288 | + if ( is_string( $child ) ) { | |
| 289 | + $bytes += \strlen( $child ); | |
| 290 | + } elseif ( is_array( $child ) ) { | |
| 291 | + $stack[] = $child; | |
| 292 | + } elseif ( \is_object( $child ) ) { | |
| 293 | + $stack[] = get_object_vars( $child ); | |
| 294 | + } | |
| 295 | + if ( $nodes > self::OVERSIZED_META_NODE_LIMIT || $bytes > self::OVERSIZED_META_BYTE_LIMIT ) { | |
| 296 | + return true; | |
| 297 | + } | |
| 298 | + } | |
| 299 | + } | |
| 300 | + | |
| 301 | + return false; | |
| 302 | + } | |
| 303 | + | |
| 304 | + /** | |
| 305 | + * What `json_encode()` will actually serialize for a value. | |
| 306 | + * | |
| 307 | + * A `JsonSerializable` object is encoded from `jsonSerialize()`, NOT from its public | |
| 308 | + * properties, so an object can expose nothing and still return a multi-million-element | |
| 309 | + * array to the encoder. Budgeting `get_object_vars()` alone would wave exactly that | |
| 310 | + * through. The encoder is going to call this method moments later anyway, so calling it | |
| 311 | + * here adds no execution that was not already going to happen. | |
| 312 | + * | |
| 313 | + * A chain deeper than a handful of levels, or one that throws, is reported as | |
| 314 | + * unencodable and the entry is withheld: `json_encode()` would fail on it too, and | |
| 315 | + * failing there is the fatal this guard exists to prevent. | |
| 316 | + * | |
| 317 | + * @param mixed $value Value to resolve. | |
| 318 | + * @param bool $unencodable Set to true when the value cannot be resolved safely. | |
| 319 | + * | |
| 320 | + * @return mixed | |
| 321 | + */ | |
| 322 | + private static function as_encoded( $value, bool &$unencodable ) { | |
| 323 | + $depth = 0; | |
| 324 | + while ( $value instanceof \JsonSerializable ) { | |
| 325 | + if ( ++$depth > 8 ) { | |
| 326 | + $unencodable = true; | |
| 327 | + | |
| 328 | + return null; | |
| 329 | + } | |
| 330 | + try { | |
| 331 | + $value = $value->jsonSerialize(); | |
| 332 | + } catch ( \Throwable $error ) { | |
| 333 | + $unencodable = true; | |
| 334 | + | |
| 335 | + return null; | |
| 336 | + } | |
| 337 | + } | |
| 338 | + | |
| 339 | + return $value; | |
| 135 | 340 | } |
| 136 | 341 | |
| 137 | 342 | /** |
| 138 | 343 | * Convert JSON objects to arrays unless doing so would change their wire shape. |