fix: harden the Divi BlockTree against opaque/invalid writes

Final fix wave for stage 2a, load-bearing for 2b's writers:
- Block::setAttrs()/Container::insertChild()/removeChild() refuse on an opaque
  block (undecodable attrs or a global module) instead of silently corrupting it.
- Container::append() is now @internal (Parser only) and refuses on a void
  block, so a child can no longer be silently dropped from the serialized output.
- Block::create() validates the block name against namespace/block-name.
- Json::decodeObject() returns null (-> opaque) for a top-level object whose
  keys are a plain list ("0","1",...) rather than returning [] and letting the
  Serializer re-encode it as a corrupting JSON array.
- AttrMap::set() rejects empty path segments and keeps an existing stdClass
  value an object when rewrapping it (no silent object->list flip); AttrMap::get()
  is updated in step so it can still read back through such an object.
- Block::copy() deep-clones stdClass attribute values instead of sharing them.
- Json::encode()'s existing JsonException-on-invalid-UTF-8 behaviour is documented.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:12:49 +02:00
co-authored by Claude Opus 5.5
parent f433fef8f7
commit 4137882008
10 changed files with 339 additions and 7 deletions
+17 -2
View File
@@ -15,7 +15,11 @@ use stdClass;
*/
final class Json
{
/** @return array<string, mixed>|null Null when $json is not a JSON object. */
/**
* @return array<string, mixed>|null Null when $json is not a JSON object, or its top level
* is one only nominally (keys "0", "1", … – see the class docblock): re-encoding those
* keys as a PHP array would silently turn the object back into a JSON list.
*/
public static function decodeObject(string $json): ?array
{
try {
@@ -28,6 +32,11 @@ final class Json
return null;
}
$keys = get_object_vars($value);
if ($keys !== [] && array_is_list($keys)) {
return null;
}
$converted = self::fromJson($value);
return is_array($converted) ? $converted : [];
@@ -52,7 +61,13 @@ final class Json
return $value;
}
/** @throws JsonException */
/**
* @throws JsonException When $value cannot be represented as JSON – including a string
* with an invalid UTF-8 byte sequence (JSON_ERROR_UTF8). 2a only ever encodes
* attributes this plugin already decoded from valid block markup, so that never
* happens here; a caller that later encodes user-supplied strings (2b's writers)
* must catch this and map it to a validation error instead of a fatal.
*/
public static function encode(mixed $value): string
{
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).