Skip to content

Commit a854453

Browse files
authored
Update extstore s3 driver key percent encoding to be consistent with other SDKs and S3's safe character guidelines. (#1637)
1 parent 9312b9d commit a854453

3 files changed

Lines changed: 126 additions & 14 deletions

File tree

temporalio/contrib/aws/s3driver/README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,23 @@ driver = S3StorageDriver(client=MyS3Client(), bucket="my-temporal-payloads")
5858

5959
### Key structure
6060

61-
Payloads are stored under content-addressable keys derived from a SHA-256 hash of the serialized payload bytes, segmented by namespace and workflow/activity identifiers when serialization context is available, e.g.:
61+
Payloads are stored under content-addressable keys derived from a SHA-256 hash of the serialized payload bytes, segmented by namespace and workflow/activity identifiers when serialization context is available.
6262

63-
v0/ns/my-namespace/wfi/my-workflow-id/d/sha256/<hash>
63+
Workflow key:
64+
65+
v0/ns/{namespace}/wt/{workflow-type}/wi/{workflow-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest}
66+
67+
Activity key:
68+
69+
v0/ns/{namespace}/at/{activity-type}/ai/{activity-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest}
70+
71+
Fallback key (used when no namespace, workflow, or activity information is available):
72+
73+
v0/d/{hash-algorithm}/{hex-digest}
74+
75+
- Missing values (including a missing run ID) are encoded as the literal `null`.
76+
- `hex-digest` is the lower-case SHA-256 hex digest (64 characters).
77+
- Dynamic path segments are percent-encoded: any byte outside S3's [safe character set](https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html) (`0-9`, `a-z`, `A-Z`, and `! - _ . * ' ( )`) is escaped as `%XX` over its UTF-8 bytes.
6478

6579
### Notes
6680

temporalio/contrib/aws/s3driver/_driver.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import asyncio
1010
import hashlib
11-
import urllib.parse
11+
import string
1212
from collections.abc import Callable, Coroutine, Sequence
1313
from typing import Any, TypeVar
1414

@@ -25,6 +25,26 @@
2525

2626
_T = TypeVar("_T")
2727

28+
# S3's safe character set for object key names. See
29+
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
30+
_S3_SAFE_CHARS = frozenset(string.ascii_letters + string.digits + "!-_.*'()")
31+
32+
33+
def _percent_encode(val: str | None) -> str | None:
34+
"""Percent-encode a path segment per the S3 key spec.
35+
36+
Encodes every byte outside :data:`_S3_SAFE_CHARS` as ``%XX`` with
37+
upper-case hex digits, operating on the UTF-8 bytes so non-ASCII
38+
characters are escaped byte by byte. Returns ``None`` for empty or
39+
missing values so callers can substitute ``"null"``.
40+
"""
41+
if not val:
42+
return None
43+
return "".join(
44+
chr(byte) if chr(byte) in _S3_SAFE_CHARS else f"%{byte:02X}"
45+
for byte in val.encode("utf-8")
46+
)
47+
2848

2949
def _format_client_context(client: S3StorageDriverClient) -> str:
3050
"""Format the client's ``describe()`` output as ", k=v, k=v" for error
@@ -127,24 +147,20 @@ async def store(
127147
(e.g. proto binary). The returned list is the same length as
128148
``payloads``.
129149
"""
130-
131-
def _quote(val: str | None) -> str | None:
132-
return urllib.parse.quote(val, safe="") if val else None
133-
134150
# Build context segments from the target identity.
135151
context_segments = ""
136152
target = context.target
137-
namespace = _quote(target.namespace) if target is not None else None
153+
namespace = _percent_encode(target.namespace) if target is not None else None
138154
namespace_segment = f"/ns/{namespace}" if namespace else ""
139155
if isinstance(target, StorageDriverWorkflowInfo):
140-
wf_type = _quote(target.type) or "null"
141-
wf_id = _quote(target.id) or "null"
142-
wf_run_id = _quote(target.run_id) or "null"
156+
wf_type = _percent_encode(target.type) or "null"
157+
wf_id = _percent_encode(target.id) or "null"
158+
wf_run_id = _percent_encode(target.run_id) or "null"
143159
context_segments = f"/wt/{wf_type}/wi/{wf_id}/ri/{wf_run_id}"
144160
elif isinstance(target, StorageDriverActivityInfo):
145-
act_type = _quote(target.type) or "null"
146-
act_id = _quote(target.id) or "null"
147-
act_run_id = _quote(target.run_id) or "null"
161+
act_type = _percent_encode(target.type) or "null"
162+
act_id = _percent_encode(target.id) or "null"
163+
act_run_id = _percent_encode(target.run_id) or "null"
148164
context_segments = f"/at/{act_type}/ai/{act_id}/ri/{act_run_id}"
149165

150166
async def _upload(payload: Payload) -> StorageDriverClaim:

tests/contrib/aws/s3driver/test_s3driver.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,88 @@ async def test_key_urlencodes_namespace(
336336
== f"v0/ns/my%2Fns%231/wt/null/wi/wf1/ri/null/d/sha256/{expected_hash}"
337337
)
338338

339+
async def test_key_preserves_s3_safe_special_chars(
340+
self, driver_client: S3StorageDriverClient
341+
) -> None:
342+
"""S3's safe special characters are left unescaped per the key spec."""
343+
driver = S3StorageDriver(client=driver_client, bucket=BUCKET)
344+
payload = make_payload()
345+
# Every special character S3 lists as safe: ! - _ . * ' ( )
346+
ctx = make_workflow_context(namespace="ns1", workflow_id="!-_.*'()")
347+
[claim] = await driver.store(ctx, [payload])
348+
expected_hash = hashlib.sha256(payload.SerializeToString()).hexdigest()
349+
assert (
350+
claim.claim_data["key"]
351+
== f"v0/ns/ns1/wt/null/wi/!-_.*'()/ri/null/d/sha256/{expected_hash}"
352+
)
353+
354+
async def test_key_escapes_tilde(
355+
self, driver_client: S3StorageDriverClient
356+
) -> None:
357+
"""Tilde is not in S3's safe set and must be percent-encoded (%7E)."""
358+
driver = S3StorageDriver(client=driver_client, bucket=BUCKET)
359+
payload = make_payload()
360+
ctx = make_workflow_context(namespace="ns1", workflow_id="wf~1")
361+
[claim] = await driver.store(ctx, [payload])
362+
expected_hash = hashlib.sha256(payload.SerializeToString()).hexdigest()
363+
assert (
364+
claim.claim_data["key"]
365+
== f"v0/ns/ns1/wt/null/wi/wf%7E1/ri/null/d/sha256/{expected_hash}"
366+
)
367+
368+
async def test_key_escapes_non_ascii_as_utf8_bytes(
369+
self, driver_client: S3StorageDriverClient
370+
) -> None:
371+
"""Non-ASCII characters are percent-encoded byte by byte from UTF-8."""
372+
driver = S3StorageDriver(client=driver_client, bucket=BUCKET)
373+
payload = make_payload()
374+
# "é" is U+00E9, which is 0xC3 0xA9 in UTF-8.
375+
ctx = make_workflow_context(namespace="ns1", workflow_id="café")
376+
[claim] = await driver.store(ctx, [payload])
377+
expected_hash = hashlib.sha256(payload.SerializeToString()).hexdigest()
378+
assert (
379+
claim.claim_data["key"]
380+
== f"v0/ns/ns1/wt/null/wi/caf%C3%A9/ri/null/d/sha256/{expected_hash}"
381+
)
382+
383+
async def test_key_matches_spec_workflow_example(
384+
self, driver_client: S3StorageDriverClient
385+
) -> None:
386+
"""Reproduces the workflow key example from the S3 key spec."""
387+
driver = S3StorageDriver(client=driver_client, bucket=BUCKET)
388+
payload = make_payload()
389+
ctx = make_workflow_context(
390+
namespace="payments prod",
391+
workflow_type="ChargeWorkflow",
392+
workflow_id="order+123=abc",
393+
run_id="3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31",
394+
)
395+
[claim] = await driver.store(ctx, [payload])
396+
expected_hash = hashlib.sha256(payload.SerializeToString()).hexdigest()
397+
assert claim.claim_data["key"] == (
398+
"v0/ns/payments%20prod/wt/ChargeWorkflow/wi/order%2B123%3Dabc"
399+
f"/ri/3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31/d/sha256/{expected_hash}"
400+
)
401+
402+
async def test_key_matches_spec_activity_example(
403+
self, driver_client: S3StorageDriverClient
404+
) -> None:
405+
"""Reproduces the activity key example from the S3 key spec."""
406+
driver = S3StorageDriver(client=driver_client, bucket=BUCKET)
407+
payload = make_payload()
408+
ctx = make_activity_context(
409+
namespace="payments prod",
410+
activity_type="Capture/Charge",
411+
activity_id="activity id+42",
412+
run_id="9e1d1fd9-2f8a-4c40-93e2-731f31b9268b",
413+
)
414+
[claim] = await driver.store(ctx, [payload])
415+
expected_hash = hashlib.sha256(payload.SerializeToString()).hexdigest()
416+
assert claim.claim_data["key"] == (
417+
"v0/ns/payments%20prod/at/Capture%2FCharge/ai/activity%20id%2B42"
418+
f"/ri/9e1d1fd9-2f8a-4c40-93e2-731f31b9268b/d/sha256/{expected_hash}"
419+
)
420+
339421
async def test_key_urlencoded_roundtrip(
340422
self, driver_client: S3StorageDriverClient
341423
) -> None:

0 commit comments

Comments
 (0)