Skip to content

Commit 2ac70aa

Browse files
authored
Merge pull request #15 from soatok/deterministic-id
Create generic automatic ID generation class
2 parents 42da43c + e7c55d0 commit 2ac70aa

File tree

6 files changed

+192
-0
lines changed

6 files changed

+192
-0
lines changed

composer.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"require": {
1616
"php": "^8",
1717
"paragonie/ionizer": "^1",
18+
"paragonie/sodium_compat": "^1",
1819
"ezyang/htmlpurifier": "^4"
1920
},
2021
"require-dev": {
@@ -31,5 +32,8 @@
3132
"taint": ["psalm --taint-analysis"],
3233
"test": ["phpunit", "psalm", "psalm --taint-analysis"],
3334
"unit-test": ["phpunit"]
35+
},
36+
"suggest": {
37+
"ext-sodium": "ID population is faster."
3438
}
3539
}

src/AutoID.php

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
declare(strict_types=1);
3+
namespace Soatok\Cupcake;
4+
5+
use Soatok\Cupcake\Core\Container;
6+
use SodiumException;
7+
8+
class AutoID
9+
{
10+
private bool $objectHash;
11+
private string $key;
12+
private int $length;
13+
14+
public function __construct(?string $key = null, int $length = 16, bool $objectHash = false)
15+
{
16+
if (is_null($key)) {
17+
$key = sodium_crypto_generichash_keygen();
18+
}
19+
$this->key = $key;
20+
$this->length = $length;
21+
$this->objectHash = $objectHash;
22+
}
23+
24+
/**
25+
* @throws SodiumException
26+
*/
27+
public function autoId(string $unique): string
28+
{
29+
return 'cupcake-' . sodium_bin2hex(
30+
sodium_crypto_generichash(
31+
$unique,
32+
$this->key,
33+
$this->length
34+
)
35+
);
36+
}
37+
38+
public function autoPopulate(Container $container): Container
39+
{
40+
$copy = clone $container;
41+
$obj = $this->objectHash ? spl_object_hash($this) : '';
42+
if (!$copy->idIsPopulated()) {
43+
$copy->setId($this->autoId(
44+
pack('P', 0) . $obj
45+
));
46+
}
47+
return $this->recursivePopulate($copy, $obj);
48+
}
49+
50+
public function setLength(int $length): void
51+
{
52+
$this->length = $length;
53+
}
54+
55+
public function setObjectHash(bool $useObjectHash): void
56+
{
57+
$this->objectHash = $useObjectHash;
58+
}
59+
60+
private function recursivePopulate(
61+
Container $container,
62+
string $prefix = '',
63+
int $depth = 1
64+
): Container {
65+
$depthPacked = pack('P', $depth);
66+
$iterator = 0;
67+
foreach ($container->getIngredients() as $ingredient) {
68+
// Calculate the current position in the structure.
69+
$current = $prefix . pack('P', $iterator);
70+
if (!$ingredient->idIsPopulated()) {
71+
$ingredient->setId(
72+
$this->autoId($depthPacked . $current)
73+
);
74+
}
75+
76+
++$iterator;
77+
if ($ingredient instanceof Container) {
78+
$this->recursivePopulate(
79+
$ingredient,
80+
$current,
81+
$depth + 1
82+
);
83+
}
84+
}
85+
return $container;
86+
}
87+
}

src/Core/Container.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ abstract class Container implements IngredientInterface
2626
protected string $afterEach = '';
2727
protected string $beforeEach = '';
2828

29+
public function __clone()
30+
{
31+
foreach ($this->ingredients as $index => $original) {
32+
$this->ingredients[$index] = clone $original;
33+
}
34+
}
35+
2936
/**
3037
* @param IngredientInterface $ingredient
3138
* @return self

src/Core/IngredientInterface.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ public function customAttributes(): array;
2525
*/
2626
public function getId(): string;
2727

28+
/**
29+
* @return bool
30+
*/
31+
public function idIsPopulated(): bool;
32+
2833
/**
2934
* Return the HTML to display to the end user.
3035
*

src/Core/StapleTrait.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ public function getId(): string
116116
return $this->id;
117117
}
118118

119+
public function idIsPopulated(): bool
120+
{
121+
return !empty($this->id);
122+
}
123+
119124
/**
120125
* @param string $id
121126
* @return self

tests/AutoIDTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
declare(strict_types=1);
3+
namespace Soatok\Cupcake\Tests;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Soatok\Cupcake\Form;
7+
use Soatok\Cupcake\AutoID;
8+
use Soatok\Cupcake\Ingredients\Div;
9+
use Soatok\Cupcake\Ingredients\Input\Text;
10+
11+
/**
12+
* @covers \Soatok\Cupcake\AutoID
13+
*/
14+
class AutoIDTest extends TestCase
15+
{
16+
public function testEmpty()
17+
{
18+
$form = new Form();
19+
$form->disableAntiCSRF();
20+
$autoId = new AutoID(str_repeat("\0", 32));
21+
22+
// Default behavior:
23+
$this->assertSame(
24+
'<form id="cupcake-b5e166965cf1713adfda5de4b6c52228" method="GET" action=""></form>',
25+
$autoId->autoPopulate($form) . ''
26+
);
27+
28+
// This always mixes with spl_object_hash even with weak keys
29+
$autoId->setObjectHash(true);
30+
31+
$this->assertNotSame(
32+
'<form id="cupcake-b5e166965cf1713adfda5de4b6c52228" method="GET" action=""></form>',
33+
$autoId->autoPopulate($form) . ''
34+
);
35+
36+
$expect = $autoId->autoId(pack('P', 0) . spl_object_hash($autoId));
37+
$this->assertSame(
38+
'<form id="' . $expect . '" method="GET" action=""></form>',
39+
$autoId->autoPopulate($form) . ''
40+
);
41+
}
42+
43+
public function testFormWithZeroKey()
44+
{
45+
$form = $this->getDummyForm();
46+
$autoId = new AutoID(str_repeat("\0", 32));
47+
48+
$this->assertSame(
49+
'<form id="cupcake-b5e166965cf1713adfda5de4b6c52228" method="GET" action=""><input id="cupcake-2d7603abf000d275112cbe001b7ff4f1" type="text" name="foo" /><div id="cupcake-481ad0fd1b490065721c82859ee4e00b"><input id="cupcake-6f90b0f6691dfb0521222b0a9312366f" type="text" name="bar" /></div></form>',
50+
$autoId->autoPopulate($form) . ''
51+
);
52+
53+
$autoId->setObjectHash(true);
54+
$this->assertNotSame(
55+
'<form id="cupcake-b5e166965cf1713adfda5de4b6c52228" method="GET" action=""><input id="cupcake-2d7603abf000d275112cbe001b7ff4f1" type="text" name="foo" /><div id="cupcake-481ad0fd1b490065721c82859ee4e00b"><input id="cupcake-6f90b0f6691dfb0521222b0a9312366f" type="text" name="bar" /></div></form>',
56+
$autoId->autoPopulate($form) . ''
57+
);
58+
59+
$h0 = $autoId->autoId(pack('P', 0) . spl_object_hash($autoId));
60+
$h1 = $autoId->autoId(pack('P', 1) . spl_object_hash($autoId) . pack('P', 0));
61+
$h2 = $autoId->autoId(pack('P', 1) . spl_object_hash($autoId) . pack('P', 1));
62+
$h3 = $autoId->autoId(pack('P', 2) . spl_object_hash($autoId) . pack('P', 1) . pack('P', 0));
63+
64+
$this->assertSame(
65+
'<form id="' . $h0
66+
. '" method="GET" action=""><input id="' . $h1
67+
. '" type="text" name="foo" /><div id="' . $h2
68+
. '"><input id="' . $h3 .
69+
'" type="text" name="bar" /></div></form>',
70+
$autoId->autoPopulate($form) . ''
71+
);
72+
}
73+
74+
private function getDummyForm(): Form
75+
{
76+
$form = new Form();
77+
$form->disableAntiCSRF();
78+
$form->append(new Text('foo'));
79+
$form->append(
80+
(new Div())->append(new Text('bar'))
81+
);
82+
return $form;
83+
}
84+
}

0 commit comments

Comments
 (0)