Skip to content

Commit 797aa6f

Browse files
Add external payload reference storage contract
1 parent 7ba54ec commit 797aa6f

8 files changed

Lines changed: 653 additions & 27 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Contracts;
6+
7+
interface ExternalPayloadStorageDriver
8+
{
9+
/**
10+
* Persist encoded payload bytes and return a stable URI.
11+
*/
12+
public function put(string $data, string $sha256, string $codec): string;
13+
14+
/**
15+
* Fetch previously persisted encoded payload bytes.
16+
*/
17+
public function get(string $uri): string;
18+
19+
/**
20+
* Delete previously persisted payload bytes when retention removes a run.
21+
*/
22+
public function delete(string $uri): void;
23+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Exceptions;
6+
7+
use RuntimeException;
8+
9+
final class ExternalPayloadIntegrityException extends RuntimeException
10+
{
11+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Support;
6+
7+
use InvalidArgumentException;
8+
use Workflow\Serializers\CodecRegistry;
9+
10+
final class ExternalPayloadReference
11+
{
12+
public const SCHEMA = 'durable-workflow.v2.external-payload-reference.v1';
13+
14+
public function __construct(
15+
public readonly string $uri,
16+
public readonly string $sha256,
17+
public readonly int $sizeBytes,
18+
public readonly string $codec,
19+
public readonly string $schema = self::SCHEMA,
20+
) {
21+
if ($this->schema !== self::SCHEMA) {
22+
throw new InvalidArgumentException('Unsupported external payload reference schema.');
23+
}
24+
25+
if ($this->uri === '') {
26+
throw new InvalidArgumentException('External payload reference URI must be a non-empty string.');
27+
}
28+
29+
self::validateSha256($this->sha256);
30+
31+
if ($this->sizeBytes < 0) {
32+
throw new InvalidArgumentException('External payload reference size_bytes must be a non-negative integer.');
33+
}
34+
35+
if ($this->codec === '') {
36+
throw new InvalidArgumentException('External payload reference codec must be a non-empty string.');
37+
}
38+
39+
CodecRegistry::canonicalize($this->codec);
40+
}
41+
42+
/**
43+
* @param array<string, mixed> $data
44+
*/
45+
public static function fromArray(array $data): self
46+
{
47+
$schema = $data['schema'] ?? null;
48+
$uri = $data['uri'] ?? null;
49+
$sha256 = $data['sha256'] ?? null;
50+
$sizeBytes = $data['size_bytes'] ?? null;
51+
$codec = $data['codec'] ?? null;
52+
53+
if ($schema !== self::SCHEMA) {
54+
throw new InvalidArgumentException('Unsupported external payload reference schema.');
55+
}
56+
57+
if (! is_string($uri) || $uri === '') {
58+
throw new InvalidArgumentException('External payload reference URI must be a non-empty string.');
59+
}
60+
61+
if (! is_string($sha256)) {
62+
throw new InvalidArgumentException('External payload reference sha256 must be a hex digest.');
63+
}
64+
65+
if (! is_int($sizeBytes) || $sizeBytes < 0) {
66+
throw new InvalidArgumentException('External payload reference size_bytes must be a non-negative integer.');
67+
}
68+
69+
if (! is_string($codec) || $codec === '') {
70+
throw new InvalidArgumentException('External payload reference codec must be a non-empty string.');
71+
}
72+
73+
return new self(
74+
uri: $uri,
75+
sha256: strtolower($sha256),
76+
sizeBytes: $sizeBytes,
77+
codec: CodecRegistry::canonicalize($codec),
78+
schema: $schema,
79+
);
80+
}
81+
82+
/**
83+
* @return array{schema: string, uri: string, sha256: string, size_bytes: int, codec: string}
84+
*/
85+
public function toArray(): array
86+
{
87+
return [
88+
'schema' => $this->schema,
89+
'uri' => $this->uri,
90+
'sha256' => $this->sha256,
91+
'size_bytes' => $this->sizeBytes,
92+
'codec' => $this->codec,
93+
];
94+
}
95+
96+
private static function validateSha256(string $sha256): void
97+
{
98+
if (! preg_match('/\A[a-f0-9]{64}\z/i', $sha256)) {
99+
throw new InvalidArgumentException('External payload reference sha256 must be a hex digest.');
100+
}
101+
}
102+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Support;
6+
7+
use Workflow\Serializers\CodecRegistry;
8+
use Workflow\V2\Contracts\ExternalPayloadStorageDriver;
9+
use Workflow\V2\Exceptions\ExternalPayloadIntegrityException;
10+
11+
final class ExternalPayloadStorage
12+
{
13+
public static function store(
14+
ExternalPayloadStorageDriver $driver,
15+
string $data,
16+
string $codec
17+
): ExternalPayloadReference {
18+
$codec = CodecRegistry::canonicalize($codec);
19+
$sha256 = hash('sha256', $data);
20+
$uri = $driver->put($data, $sha256, $codec);
21+
22+
return new ExternalPayloadReference(uri: $uri, sha256: $sha256, sizeBytes: strlen($data), codec: $codec);
23+
}
24+
25+
public static function fetch(ExternalPayloadStorageDriver $driver, ExternalPayloadReference $reference): string
26+
{
27+
$data = $driver->get($reference->uri);
28+
29+
if (strlen($data) !== $reference->sizeBytes) {
30+
throw new ExternalPayloadIntegrityException('External payload size does not match its reference.');
31+
}
32+
33+
if (! hash_equals($reference->sha256, hash('sha256', $data))) {
34+
throw new ExternalPayloadIntegrityException('External payload hash does not match its reference.');
35+
}
36+
37+
return $data;
38+
}
39+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Workflow\V2\Support;
6+
7+
use InvalidArgumentException;
8+
use RuntimeException;
9+
use Workflow\V2\Contracts\ExternalPayloadStorageDriver;
10+
11+
final class LocalFilesystemExternalPayloadStorage implements ExternalPayloadStorageDriver
12+
{
13+
private string $root;
14+
15+
public function __construct(string $root)
16+
{
17+
$resolved = realpath($root);
18+
19+
if ($resolved === false) {
20+
if (! mkdir($root, 0775, true) && ! is_dir($root)) {
21+
throw new RuntimeException(sprintf('Unable to create external payload storage root [%s].', $root));
22+
}
23+
24+
$resolved = realpath($root);
25+
}
26+
27+
if ($resolved === false || ! is_dir($resolved)) {
28+
throw new InvalidArgumentException(sprintf(
29+
'External payload storage root [%s] is not a directory.',
30+
$root
31+
));
32+
}
33+
34+
$this->root = rtrim($resolved, DIRECTORY_SEPARATOR);
35+
}
36+
37+
public function put(string $data, string $sha256, string $codec): string
38+
{
39+
$this->validateSha256($sha256);
40+
$codecSegment = $this->safeCodecSegment($codec);
41+
$path = $this->root . DIRECTORY_SEPARATOR . $codecSegment . DIRECTORY_SEPARATOR . substr(
42+
$sha256,
43+
0,
44+
2
45+
) . DIRECTORY_SEPARATOR . $sha256;
46+
$directory = dirname($path);
47+
48+
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
49+
throw new RuntimeException(sprintf('Unable to create external payload directory [%s].', $directory));
50+
}
51+
52+
if (! is_file($path) && file_put_contents($path, $data, LOCK_EX) === false) {
53+
throw new RuntimeException(sprintf('Unable to write external payload [%s].', $path));
54+
}
55+
56+
return self::pathToFileUri($path);
57+
}
58+
59+
public function get(string $uri): string
60+
{
61+
$path = $this->pathFromUri($uri);
62+
if (! is_file($path)) {
63+
throw new RuntimeException(sprintf('Unable to read external payload [%s].', $uri));
64+
}
65+
66+
$data = file_get_contents($path);
67+
68+
if ($data === false) {
69+
throw new RuntimeException(sprintf('Unable to read external payload [%s].', $uri));
70+
}
71+
72+
return $data;
73+
}
74+
75+
public function delete(string $uri): void
76+
{
77+
$path = $this->pathFromUri($uri);
78+
79+
if (is_file($path)) {
80+
unlink($path);
81+
}
82+
}
83+
84+
private function pathFromUri(string $uri): string
85+
{
86+
$parts = parse_url($uri);
87+
88+
if (($parts['scheme'] ?? null) !== 'file') {
89+
throw new InvalidArgumentException('Local external storage can only read file:// URIs.');
90+
}
91+
92+
$host = $parts['host'] ?? '';
93+
if ($host !== '' && $host !== 'localhost') {
94+
throw new InvalidArgumentException('Local external storage can only read file://localhost URIs.');
95+
}
96+
97+
$path = rawurldecode($parts['path'] ?? '');
98+
$resolved = realpath($path);
99+
if ($resolved === false) {
100+
$parent = realpath(dirname($path));
101+
if ($parent !== false && str_starts_with($parent, $this->root . DIRECTORY_SEPARATOR)) {
102+
return $path;
103+
}
104+
}
105+
106+
if ($path === '' || $resolved === false || ! str_starts_with($resolved, $this->root . DIRECTORY_SEPARATOR)) {
107+
throw new InvalidArgumentException('External payload URI is outside the local storage root.');
108+
}
109+
110+
return $resolved;
111+
}
112+
113+
private static function pathToFileUri(string $path): string
114+
{
115+
return 'file://' . implode('/', array_map('rawurlencode', explode(DIRECTORY_SEPARATOR, $path)));
116+
}
117+
118+
private function validateSha256(string $sha256): void
119+
{
120+
if (! preg_match('/\A[a-f0-9]{64}\z/i', $sha256)) {
121+
throw new InvalidArgumentException('sha256 must be a hex digest.');
122+
}
123+
}
124+
125+
private function safeCodecSegment(string $codec): string
126+
{
127+
if (! preg_match('/\A[A-Za-z0-9_.-]+\z/', $codec)) {
128+
throw new InvalidArgumentException('Codec contains characters that are unsafe for local storage paths.');
129+
}
130+
131+
return $codec;
132+
}
133+
}

0 commit comments

Comments
 (0)