diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index abcbbe4..4de4702 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -35,9 +35,10 @@ use stdClass; * DynamicContentOptions::get_options($post_id, 'edit') – option name => its "fields" (key => * field "type"). Always, whatever the map says: "enable_html" is rejected, loop_* names only * when the map names them, custom_meta_ names and the values of settings whose key ends in - * "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY (ruling S29), and no - * string – before or after cleaning (ruling S28) – may contain a reference or ")$". A map - * of another shape fails in the constructor with InvalidArgumentException. + * "meta_key" (meta_key, select_meta_key, loop_meta_key …) obey META_KEY after Divi's own prefix + * stripping (rulings S29, S30), and no string – before or after cleaning (ruling S28) – may + * contain a reference or ")$". A map of another shape fails in the constructor with + * InvalidArgumentException. */ final class VariableRef { @@ -80,12 +81,23 @@ final class VariableRef private const META_PREFIX = 'custom_meta_'; + /** + * Prefixes Divi strips from a meta key option with str_replace – every occurrence, not just + * the leading one (DynamicContentOptionPostMetaKey.php:232,249,275,286; + * DynamicContentOptionLoopPostMetaKey.php:366-373). Ruling S30. + */ + private const META_KEY_PREFIXES = [ + self::META_PREFIX, + 'loop_post_meta_key_', + 'loop_user_meta_key_', + 'loop_term_meta_key_', + ]; + private const NUMBER = '/^-?\d+(\.\d+)?$/'; /** @var array> */ private array $dynamicContent; - /** @param array>|null $dynamicContent See the class docblock. */ /** * @param array|null $dynamicContent See the class docblock. * @throws InvalidArgumentException When the map does not have that shape. @@ -342,11 +354,26 @@ final class VariableRef return $clean; } - /** "telefon", "custom_meta_telefon" yes; "_edit_lock", "custom_meta__edit_lock", "a b" no. */ + /** + * Checks the meta key Divi derives from $key (ruling S30): a leading known prefix is removed the + * way Divi does it – str_replace of every occurrence – and a prefix still present afterwards is + * rejected (defensive). "telefon", "custom_meta_telefon", "loop_post_meta_key_telefon" yes; + * "_edit_lock", "custom_meta__edit_lock", "custom_meta_custom_meta__edit_lock", + * "loop_post_meta_key__edit_lock", "a b" no. + */ private static function isMetaKey(string $key): bool { - if (str_starts_with($key, self::META_PREFIX)) { - $key = substr($key, strlen(self::META_PREFIX)); + foreach (self::META_KEY_PREFIXES as $prefix) { + if (str_starts_with($key, $prefix)) { + $key = str_replace($prefix, '', $key); + break; + } + } + + foreach (self::META_KEY_PREFIXES as $prefix) { + if (str_contains($key, $prefix)) { + return false; + } } return preg_match(self::META_KEY, $key) === 1; diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 3b0177e..bfedaf0 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -144,6 +144,10 @@ final class VariableRefTest extends TestCase 'opacity', ], 'protected meta key' => [self::content('custom_meta__edit_lock', '{}'), 'custom_meta__edit_lock'], + 'doubled meta prefix' => [ + self::content('custom_meta_custom_meta__edit_lock', '{}'), + 'custom_meta_custom_meta__edit_lock', + ], 'loop content outside a loop' => [self::content('loop_post_title', '{}'), 'loop_post_title'], 'unknown dynamic content' => [self::content('post_meta_key', '{}'), 'post_meta_key'], 'extra top-level key' => ['$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}},"x":1})$', 'x'], @@ -296,6 +300,77 @@ final class VariableRefTest extends TestCase } } + /** @return array */ + public static function derivedMetaKeyBypasses(): array + { + return [ + 'doubled prefix in the name' => [self::content('custom_meta_custom_meta__edit_lock', '{}')], + 'tripled prefix in the name' => [self::content('custom_meta_custom_meta_custom_meta__edit_lock', '{}')], + 'prefix rebuilt by the strip' => [self::content('custom_meta_custom_mcustom_meta_eta_telefon', '{}')], + 'loop prefix in the name' => [self::content('custom_meta_loop_post_meta_key__edit_lock', '{}')], + 'doubled prefix in select_meta_key' => [ + self::content('post_meta_key', '{"select_meta_key":"custom_meta_custom_meta__wp_page_template"}'), + ], + 'doubled prefix in meta_key' => [self::content('post_meta_key', '{"meta_key":"custom_meta_custom_meta__x"}')], + 'loop post prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key__edit_lock"}')], + 'loop user prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key__session_tokens"}')], + 'loop term prefix' => [self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key__secret"}')], + 'doubled loop prefix' => [ + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_loop_post_meta_key__edit_lock"}'), + ], + 'mixed loop and meta prefix' => [ + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_custom_meta__edit_lock"}'), + ], + 'mixed meta and loop prefix' => [ + self::content('post_meta_key', '{"select_meta_key":"custom_meta_loop_term_meta_key__secret"}'), + ], + 'two loop prefixes' => [ + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_loop_post_meta_key_telefon"}'), + ], + ]; + } + + /** + * Ruling S30: Divi derives the meta key with str_replace of every prefix occurrence + * (DynamicContentOptionPostMetaKey.php:232,275; DynamicContentOptionLoopPostMetaKey.php:366-373). + * + * @dataProvider derivedMetaKeyBypasses + */ + public function testMetaKeysAreCheckedAfterDivisDerivation(string $value): void + { + $ref = self::ref([ + 'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'], + 'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'], + ]); + + $this->expectException(InvalidArgumentException::class); + + $ref->canonical($value, ComposeFixture::sanitizer()); + } + + public function testDerivedMetaKeysThatAreFineStayAccepted(): void + { + $ref = self::ref([ + 'post_meta_key' => ['meta_key' => 'text', 'select_meta_key' => 'select'], + 'loop_post_meta_key' => ['select_loop_meta_key' => 'select', 'loop_meta_key' => 'text'], + ]); + $sanitizer = ComposeFixture::sanitizer(); + + foreach ([ + self::content('custom_meta_telefon', '{}'), + self::content('post_meta_key', '{"select_meta_key":"custom_meta_fax"}'), + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_post_meta_key_telefon"}'), + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_user_meta_key_first_name"}'), + self::content('loop_post_meta_key', '{"select_loop_meta_key":"loop_term_meta_key_farbe"}'), + self::content( + 'loop_post_meta_key', + '{"select_loop_meta_key":"loop_post_meta_key_manual_custom_field_value","loop_meta_key":"telefon"}' + ), + ] as $value) { + self::assertSame($value, $ref->canonical($value, $sanitizer)); + } + } + /** @return array}> */ public static function malformedMaps(): array {