Expose object modification evidence

This commit is contained in:
2026-08-03 09:23:15 +02:00
parent b823a22b9b
commit bb84122061
3 changed files with 72 additions and 4 deletions
+5
View File
@@ -28,6 +28,11 @@ module artifacts. It provides bounded read/write/list/stat/delete operations
for local and S3-compatible storage. Modules own their object-key namespace and
business metadata; Core does not interpret module files.
`stat` and `list_objects` return object size plus a UTC `modified_at` value when
the backend can prove it. Reconciliation and retention code may use that value
for conservative grace periods, but must treat a missing timestamp as
ineligible for automatic deletion rather than guessing an age.
Rules for modules:
- Store only opaque object keys in business records, never local absolute
+28 -3
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from heapq import nsmallest
import os
from pathlib import Path
@@ -27,6 +28,7 @@ class StorageObjectMissing(StorageBackendError):
class StorageObjectInfo:
key: str
size_bytes: int
modified_at: datetime | None = None
@dataclass(frozen=True, slots=True)
@@ -165,9 +167,14 @@ class LocalFilesystemStorageBackend:
def stat(self, key: str) -> StorageObjectInfo:
path = self._readable_path(key)
metadata = path.stat()
return StorageObjectInfo(
key=normalize_storage_key(key),
size_bytes=path.stat().st_size,
size_bytes=metadata.st_size,
modified_at=datetime.fromtimestamp(
metadata.st_mtime,
tz=timezone.utc,
),
)
def list_objects(
@@ -188,9 +195,14 @@ class LocalFilesystemStorageBackend:
normalized_after is not None and key <= normalized_after
):
continue
metadata = path.stat()
yield StorageObjectInfo(
key=key,
size_bytes=path.stat().st_size,
size_bytes=metadata.st_size,
modified_at=datetime.fromtimestamp(
metadata.st_mtime,
tz=timezone.utc,
),
)
candidates = nsmallest(
@@ -381,7 +393,11 @@ class S3StorageBackend:
raise StorageBackendError(
"S3 object metadata did not include a valid size"
) from exc
return StorageObjectInfo(key=normalized, size_bytes=size)
return StorageObjectInfo(
key=normalized,
size_bytes=size,
modified_at=_storage_modified_at(response.get("LastModified")),
)
def list_objects(
self,
@@ -407,6 +423,7 @@ class S3StorageBackend:
StorageObjectInfo(
key=str(item["Key"]),
size_bytes=int(item.get("Size") or 0),
modified_at=_storage_modified_at(item.get("LastModified")),
)
for item in response.get("Contents", ())
if isinstance(item, dict) and item.get("Key")
@@ -418,6 +435,14 @@ class S3StorageBackend:
)
def _storage_modified_at(value: object) -> datetime | None:
if not isinstance(value, datetime):
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def configured_storage_backend(settings: object) -> StorageBackend:
"""Build the deployment-wide object store from Core settings.
+39 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
import tempfile
@@ -26,10 +27,12 @@ class _S3Error(RuntimeError):
class _FakeS3Client:
def __init__(self) -> None:
self.objects: dict[str, bytes] = {}
self.modified_at: dict[str, datetime] = {}
self.head_error: Exception | None = None
def put_object(self, *, Key: str, Body: bytes, **_kwargs) -> None:
self.objects[Key] = Body
self.modified_at[Key] = datetime.now(timezone.utc)
def get_object(self, *, Key: str, **_kwargs):
try:
@@ -45,10 +48,40 @@ class _FakeS3Client:
payload = self.objects[Key]
except KeyError as exc:
raise _S3Error("NotFound", 404) from exc
return {"ContentLength": len(payload)}
return {
"ContentLength": len(payload),
"LastModified": self.modified_at[Key],
}
def delete_object(self, *, Key: str, **_kwargs) -> None:
self.objects.pop(Key, None)
self.modified_at.pop(Key, None)
def list_objects_v2(
self,
*,
Prefix: str,
MaxKeys: int,
StartAfter: str | None = None,
**_kwargs,
):
keys = [
key
for key in sorted(self.objects)
if key.startswith(Prefix) and (StartAfter is None or key > StartAfter)
]
selected = keys[:MaxKeys]
return {
"Contents": [
{
"Key": key,
"Size": len(self.objects[key]),
"LastModified": self.modified_at[key],
}
for key in selected
],
"IsTruncated": len(keys) > len(selected),
}
class ObjectStorageTests(unittest.TestCase):
@@ -67,6 +100,7 @@ class ObjectStorageTests(unittest.TestCase):
self.assertEqual(b"a", backend.get_bytes("campaign/a.eml"))
self.assertEqual(1, backend.stat("campaign/a.eml").size_bytes)
self.assertIsNotNone(backend.stat("campaign/a.eml").modified_at)
self.assertEqual(
("campaign/a.eml",), tuple(item.key for item in first.objects)
)
@@ -134,6 +168,10 @@ class ObjectStorageTests(unittest.TestCase):
backend.put_bytes("campaign/message.eml", b"message/rfc822")
self.assertTrue(backend.exists("campaign/message.eml"))
self.assertEqual(b"message/rfc822", backend.get_bytes("campaign/message.eml"))
self.assertIsNotNone(backend.stat("campaign/message.eml").modified_at)
self.assertIsNotNone(
backend.list_objects(prefix="campaign/").objects[0].modified_at
)
self.assertFalse(backend.exists("campaign/missing.eml"))
client.head_error = _S3Error("AccessDenied", 403)