fix(storage): ASCII-encode S3 object metadata — s3-only upload 500 on Hebrew filenames (INV-STG2)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 7s
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 7s
תיקון: כל העלאת קובץ עם שם עברי נכשלה ב-500 תחת backend s3-only. השורש:
`ingest._stage_file` מצרף את שם-הקובץ המקורי כ-S3 object metadata
(`metadata={"filename": src.name}`), ו-`S3Backend.put_bytes` העביר אותו כמו-שהוא
ל-`put_object`. botocore אוכף ASCII-only על S3 metadata → ParamValidationError →
500. שם עברי כמו "יומון 5167 - 11.6.26.pdf" שבר כל upload. נחשף ב-cutover ל-s3-only
(2026-06-11): קליטת היומונים (וגם כל מסמך/פסיקה עם שם עברי) הפסיקה לעבוד; היומון
האחרון שנקלט (5165, 9.6) היה לפני ה-cutover.
התיקון (נרמול-במקור, G1; בשכבת-האחסון היחידה, INV-STG2):
- `_ascii_metadata` מקודד ערכי-metadata לא-ASCII ב-percent-encoding (lossless,
שחזור עם urllib.parse.unquote); ASCII רגיל עובר ללא שינוי (קריאוּת).
- `S3Backend.put_bytes` מחיל אותו על כל ערכי ה-Metadata.
בדיקות: test_ascii_metadata_encodes_hebrew (helper) +
test_s3_put_bytes_sends_ascii_metadata (משחזר את מסלול-הכשל מול fake put_object).
16 עוברות בקובץ.
Invariants: מקיים G1 (נרמול-במקור, לא תיקון-בקריאה), INV-STG2 (שם-קובץ עברי
כ-metadata ולא ככ-key), G2 (אין מסלול-אחסון מקביל — תיקון ה-choke-point היחיד).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,22 @@ def normalize_key(key: str | Path) -> str:
|
||||
return posix.as_posix().lstrip("/")
|
||||
|
||||
|
||||
def _ascii_metadata(value) -> str:
|
||||
"""Coerce an S3 user-metadata value to ASCII.
|
||||
|
||||
S3/MinIO object metadata must be ASCII (botocore raises ParamValidationError
|
||||
otherwise). The only non-ASCII value we attach is the original Hebrew
|
||||
filename (``ingest._stage_file`` → ``metadata={"filename": ...}``), so a
|
||||
Hebrew name like ``"יומון 5167 - 11.6.26.pdf"`` would 500 every s3-only
|
||||
upload. Percent-encode non-ASCII losslessly (recover with
|
||||
``urllib.parse.unquote``) while leaving plain-ASCII values readable."""
|
||||
s = str(value)
|
||||
if s.isascii():
|
||||
return s
|
||||
from urllib.parse import quote
|
||||
return quote(s)
|
||||
|
||||
|
||||
class StorageBackend:
|
||||
"""Abstract backend. All methods are async except the cheap path helpers."""
|
||||
|
||||
@@ -247,7 +263,7 @@ class S3Backend(StorageBackend):
|
||||
if content_type:
|
||||
extra["ContentType"] = content_type
|
||||
if metadata:
|
||||
extra["Metadata"] = {kk: str(vv) for kk, vv in metadata.items()}
|
||||
extra["Metadata"] = {kk: _ascii_metadata(vv) for kk, vv in metadata.items()}
|
||||
async with self._client() as s3:
|
||||
await s3.put_object(Bucket=_bucket_name(bucket), Key=k, Body=data, **extra)
|
||||
return f"s3://{_bucket_name(bucket)}/{k}"
|
||||
|
||||
Reference in New Issue
Block a user