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
+37
View File
@@ -4,6 +4,10 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\BlockTree;
use InvalidArgumentException;
use LogicException;
use stdClass;
/**
* One block of the tree. A parsed block keeps its original opener/closer bytes; they are
* dropped only when the block itself changes (setAttrs(), a void block getting children),
@@ -15,6 +19,9 @@ namespace LIT\MCP\Integrations\Divi\BlockTree;
*/
final class Block extends Container
{
/** A valid block name: "namespace/block-name" (WordPress' own block name convention). */
private const NAME_PATTERN = '/^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*$/';
/**
* @param array<string, mixed> $attrs
*/
@@ -42,6 +49,10 @@ final class Block extends Container
*/
public static function create(string $name, array $attrs = [], array $children = []): self
{
if (preg_match(self::NAME_PATTERN, $name) !== 1) {
throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
}
$block = new self($name, $attrs, null, null, false, false);
foreach ($children as $child) {
$block->parts[] = $child;
@@ -77,6 +88,10 @@ final class Block extends Container
/** @param array<string, mixed> $attrs */
public function setAttrs(array $attrs): void
{
if ($this->opaque) {
throw new LogicException('Cannot set attributes of an opaque block.');
}
$this->attrs = $attrs;
$this->rawOpen = null;
}
@@ -125,6 +140,28 @@ final class Block extends Container
$this->parts[$position] = clone $part;
}
}
$this->attrs = self::deepCloneAttrs($this->attrs);
}
/**
* PHP's own array-copy-on-write already deep-copies nested arrays, but stdClass values
* (Json.php keeps empty/list-like JSON objects as stdClass) are objects – clone() alone
* would leave the copy's attrs() sharing them with the original.
*
* @param array<string, mixed> $attrs
* @return array<string, mixed>
*/
private static function deepCloneAttrs(array $attrs): array
{
foreach ($attrs as $key => $value) {
if ($value instanceof stdClass) {
$attrs[$key] = (object) self::deepCloneAttrs((array) $value);
} elseif (is_array($value)) {
$attrs[$key] = self::deepCloneAttrs($value);
}
}
return $attrs;
}
protected function touched(): void