Skip to content

Commit a09221c

Browse files
[13.x] Adds fingerprinting utility.
1 parent 34c35f6 commit a09221c

File tree

3 files changed

+355
-0
lines changed

3 files changed

+355
-0
lines changed
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
namespace Illuminate\Hashing;
4+
5+
use Illuminate\Contracts\Support\Jsonable;
6+
use Illuminate\Support\LazyCollection;
7+
use Illuminate\Support\Str;
8+
use Stringable;
9+
10+
class Fingerprint implements Stringable
11+
{
12+
/**
13+
* The default algorithm to use to create non-cryptographic fingerprints.
14+
*
15+
* @var string
16+
*/
17+
public static $with = 'xxh3';
18+
19+
/**
20+
* Create a new Fingerprint instance.
21+
*
22+
* @param mixed $value
23+
* @param string $fingerprinter
24+
* @param array $options
25+
* @param string|null $hash
26+
*/
27+
public function __construct(protected $value, protected $fingerprinter, protected $options, protected $hash = null)
28+
{
29+
//
30+
}
31+
32+
/**
33+
* Returns the fingerprintable value.
34+
*
35+
* @return mixed
36+
*/
37+
public function value()
38+
{
39+
return $this->value;
40+
}
41+
42+
/**
43+
* Returns the algorithm used to generate a fingerprint hash.
44+
*
45+
* @return string
46+
*/
47+
public function uses()
48+
{
49+
return $this->fingerprinter;
50+
}
51+
52+
/**
53+
* Returns the fingerprint hash encoded in Base64.
54+
*
55+
* @return string
56+
*/
57+
public function hash()
58+
{
59+
return Str::toBase64($this->raw());
60+
}
61+
62+
/**
63+
* Returns the fingerprint hash as a binary string.
64+
*/
65+
public function raw()
66+
{
67+
return $this->hash ??= $this->generate();
68+
}
69+
70+
/**
71+
* Regenerates a fingerprint hash and returns it encoded in Base64.
72+
*
73+
* @return string
74+
*/
75+
public function rehash()
76+
{
77+
$this->hash = null;
78+
79+
return $this->hash();
80+
}
81+
82+
/**
83+
* Generates a fingerprint hash as a binary string.
84+
*
85+
* @return string
86+
*/
87+
protected function generate()
88+
{
89+
// When the value to be hashed is a simple string, we can just hash it and return the
90+
// result as-is. For other types of objects, we will try to normalize them to avoid
91+
// loading the whole buffer, especially when these have a large memory footprint.
92+
if (is_string($this->value) || $this->value instanceof Stringable) {
93+
return hash($this->fingerprinter, $this->value, true, $this->options);
94+
}
95+
96+
$context = hash_init($this->fingerprinter, 0, '', $this->options);
97+
98+
foreach ($this->normalizeValue() as $value) {
99+
hash_update($context, json_encode($value, JSON_THROW_ON_ERROR));
100+
}
101+
102+
return hash_final($context, true);
103+
}
104+
105+
/**
106+
* Normalize the fingerprintable value into an iterable object for hashing.
107+
*
108+
* @return iterable
109+
*/
110+
protected function normalizeValue()
111+
{
112+
return match (true) {
113+
is_resource($this->value) => new LazyCollection(function () {
114+
rewind($this->value);
115+
116+
while (!feof($this->value)) {
117+
yield fgetc($this->value);
118+
}
119+
}),
120+
is_iterable($this->value) => new LazyCollection(function () {
121+
foreach ($this->value as $value) {
122+
yield $value;
123+
}
124+
}),
125+
is_array($this->value) => $this->value,
126+
default => [$this->value],
127+
};
128+
}
129+
130+
/**
131+
* Determines if this fingerprint hash and the issued hash are the same.
132+
*
133+
* @param string $hash
134+
* @param bool $fromBase64
135+
* @return bool
136+
*/
137+
public function is($hash, $fromBase64 = true)
138+
{
139+
return hash_equals($this->raw(), $fromBase64 || $hash instanceof self ? Str::fromBase64($hash) : $hash);
140+
}
141+
142+
/**
143+
* Determines if this fingerprint hash and the issued hash are different.
144+
*
145+
* @param string $hash
146+
* @param bool $fromBase64
147+
* @return bool
148+
*/
149+
public function isNot($hash, $fromBase64 = true)
150+
{
151+
return !$this->is($hash, $fromBase64);
152+
}
153+
154+
/**
155+
* Returns the string representation of the object.
156+
*
157+
* @return string
158+
*/
159+
public function __toString()
160+
{
161+
return $this->hash();
162+
}
163+
164+
/**
165+
* Create a new Fingerprint instance.
166+
*
167+
* @param mixed $value
168+
* @param string|null $algorithm
169+
* @param array $options
170+
* @return static
171+
*/
172+
public static function of($value, $algorithm = null, $options = [])
173+
{
174+
return new static($value, $algorithm ?? static::$with, $options);
175+
}
176+
}

tests/Hashing/FingerprintTest.php

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
<?php
2+
3+
namespace Illuminate\Tests\Hashing;
4+
5+
use ArrayIterator;
6+
use Illuminate\Foundation\Auth\User;
7+
use Illuminate\Hashing\Fingerprint;
8+
use Illuminate\Support\Str;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class FingerprintTest extends TestCase
12+
{
13+
protected function setUp(): void
14+
{
15+
Fingerprint::$with = 'xxh3';
16+
}
17+
18+
public function test_hashes_string(): void
19+
{
20+
$fingerprint = Fingerprint::of('test');
21+
22+
$this->assertSame('nsn3kY19/EA=', $fingerprint->hash());
23+
}
24+
25+
public function test_hashes_stringable(): void
26+
{
27+
$fingerprint = Fingerprint::of(Str::of('test'));
28+
29+
$this->assertSame('nsn3kY19/EA=', $fingerprint->hash());
30+
}
31+
32+
public function test_hashes_array(): void
33+
{
34+
$fingerprint = Fingerprint::of(str_split('test'));
35+
36+
$this->assertSame('TEO9OatCBX8=', $fingerprint->hash());
37+
}
38+
39+
public function test_hashes_resource(): void
40+
{
41+
$resource = fopen(__DIR__ . '/fixtures/fingerprintable.txt', 'r');
42+
43+
$fingerprint = Fingerprint::of($resource);
44+
45+
$this->assertSame('6Eq/UI1GMuc=', $fingerprint->hash());
46+
}
47+
48+
public function test_hashes_iterable(): void
49+
{
50+
$fingerprint = Fingerprint::of(new ArrayIterator(str_split('test')));
51+
52+
$this->assertSame('TEO9OatCBX8=', $fingerprint->hash());
53+
}
54+
55+
public function test_hashes_model(): void
56+
{
57+
$fingerprint = Fingerprint::of((new User())->forceFill(['name' => 'test']));
58+
59+
$this->assertSame('ANVzoYpsoh0=', $fingerprint->hash());
60+
}
61+
62+
public function test_hash_is_cached(): void
63+
{
64+
$changes = (new User())->forceFill(['name' => 'test']);
65+
66+
$fingerprint = Fingerprint::of($changes);
67+
68+
$this->assertSame('ANVzoYpsoh0=', $fingerprint->hash());
69+
70+
$changes->forceFill(['name' => 'other-test']);
71+
72+
$this->assertSame('ANVzoYpsoh0=', $fingerprint->hash());
73+
}
74+
75+
public function test_raw_hash(): void
76+
{
77+
$fingerprint = Fingerprint::of('test');
78+
79+
$this->assertSame(Str::fromBase64($fingerprint->hash()), $fingerprint->raw());
80+
}
81+
82+
public function test_serializes_into_string_as_hash(): void
83+
{
84+
$fingerprint = Fingerprint::of('test');
85+
86+
$this->assertSame('nsn3kY19/EA=', (string) $fingerprint);
87+
}
88+
89+
public function test_rehashes_the_same_value(): void
90+
{
91+
$changes = (new User())->forceFill(['name' => 'test']);
92+
93+
$fingerprint = Fingerprint::of($changes);
94+
95+
$this->assertSame('ANVzoYpsoh0=', $fingerprint->hash());
96+
97+
$changes->forceFill(['name' => 'other-test']);
98+
99+
$this->assertSame('4Ginwb2rBAs=', $fingerprint->rehash());
100+
}
101+
102+
public function test_uses_non_default_hashing_algorithm(): void
103+
{
104+
$fingerprint = Fingerprint::of('test', 'sha256');
105+
106+
$this->assertSame('n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=', $fingerprint->hash());
107+
}
108+
109+
public function test_uses_hashing_options(): void
110+
{
111+
$fingerprint = Fingerprint::of('test', 'xxh3', [
112+
'seed' => 'test'
113+
]);
114+
115+
$this->assertSame('nsn3kY19/EA=', $fingerprint->hash());
116+
}
117+
118+
public function test_changes_default_algorithm(): void
119+
{
120+
Fingerprint::$with = 'sha256';
121+
122+
$fingerprint = Fingerprint::of('test');
123+
124+
$this->assertSame('n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg=', $fingerprint->hash());
125+
}
126+
127+
public function test_compares_an_equal_hash(): void
128+
{
129+
$fingerprint = Fingerprint::of('test');
130+
131+
$equal = $fingerprint->hash();
132+
133+
$this->assertTrue($fingerprint->is($equal));
134+
$this->assertFalse($fingerprint->isNot($equal));
135+
136+
$this->assertTrue($fingerprint->is(Str::fromBase64($equal), false));
137+
$this->assertFalse($fingerprint->isNot(Str::fromBase64($equal), false));
138+
}
139+
140+
public function test_compares_a_different_hash(): void
141+
{
142+
$fingerprint = Fingerprint::of('test');
143+
144+
$different = 'different';
145+
146+
$this->assertFalse($fingerprint->is($different));
147+
$this->assertTrue($fingerprint->isNot($different));
148+
149+
$this->assertFalse($fingerprint->is(Str::fromBase64($different), false));
150+
$this->assertTrue($fingerprint->isNot(Str::fromBase64($different), false));
151+
}
152+
153+
public function test_compares_an_equal_fingerprint_instance(): void
154+
{
155+
$fingerprint = Fingerprint::of('test');
156+
157+
$equal = Fingerprint::of('test');
158+
159+
$this->assertTrue($fingerprint->is($equal));
160+
$this->assertFalse($fingerprint->isNot($equal));
161+
162+
$this->assertTrue($fingerprint->is(Str::fromBase64($equal), false));
163+
$this->assertFalse($fingerprint->isNot(Str::fromBase64($equal), false));
164+
}
165+
166+
public function test_compares_a_different_fingerprint_instance(): void
167+
{
168+
$fingerprint = Fingerprint::of('test');
169+
170+
$different = Fingerprint::of('different');
171+
172+
$this->assertFalse($fingerprint->is($different));
173+
$this->assertTrue($fingerprint->isNot($different));
174+
175+
$this->assertFalse($fingerprint->is(Str::fromBase64($different), false));
176+
$this->assertTrue($fingerprint->isNot(Str::fromBase64($different), false));
177+
}
178+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
test

0 commit comments

Comments
 (0)