"""An in-process S3 bucket for round-trip tests: put, multipart upload,
get, and ListObjectsV2, kept in memory. It doesn't check signatures; the
signing has its own tests against AWS's published vectors. Like Fabled
Archives, it refuses to replace an object that is already there.
with FakeS3() as s3:
target = s3.target()
...
s3.objects # {key: bytes}
"""
from __future__ import annotations
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, unquote, urlsplit
from xml.sax.saxutils import escape
from backend.app.services.backup_s3 import S3Target
BUCKET = "curator"
class FakeS3:
def __init__(self):
self.objects: dict[str, bytes] = {}
self.uploads: dict[str, dict[int, bytes]] = {}
outer = self
class Handler(BaseHTTPRequestHandler):
def _parts(self):
u = urlsplit(self.path)
path = unquote(u.path).lstrip("/")
bucket, _, key = path.partition("/")
query = parse_qs(u.query, keep_blank_values=True) # `?uploads` has no value
return bucket, key, {k: v[0] for k, v in query.items()}
def _reply(self, status, body=b"", headers=None):
self.send_response(status)
for name, value in (headers or {}).items():
self.send_header(name, value)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _body(self):
length = int(self.headers.get("Content-Length") or 0)
return self.rfile.read(length) if length else b""
def do_PUT(self):
_, key, q = self._parts()
body = self._body()
if "uploadId" in q:
outer.uploads[q["uploadId"]][int(q["partNumber"])] = body
return self._reply(200, headers={"ETag": f'"p{q["partNumber"]}"'})
if key in outer.objects:
return self._reply(403, b"AccessDenied")
outer.objects[key] = body
return self._reply(200)
def do_POST(self):
_, key, q = self._parts()
self._body()
if "uploads" in q:
upload_id = f"u{len(outer.uploads) + 1}"
outer.uploads[upload_id] = {}
return self._reply(200, (
""
f"{upload_id}"
"").encode())
parts = outer.uploads.pop(q["uploadId"])
outer.objects[key] = b"".join(parts[n] for n in sorted(parts))
return self._reply(200, b"")
def do_DELETE(self):
_, _, q = self._parts()
outer.uploads.pop(q.get("uploadId", ""), None)
return self._reply(204)
def do_GET(self):
_, key, q = self._parts()
if q.get("list-type") == "2":
prefix = q.get("prefix", "")
items = "".join(
f"{escape(k)}{len(v)}"
"2026-01-01T00:00:00Z"
for k, v in sorted(outer.objects.items()) if k.startswith(prefix)
)
return self._reply(200, f"{items}".encode())
if key not in outer.objects:
return self._reply(404, b"NoSuchKey")
return self._reply(200, outer.objects[key])
def log_message(self, *args):
pass
self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.httpd.shutdown()
self.httpd.server_close()
def target(self) -> S3Target:
host, port = self.httpd.server_address
return S3Target(f"http://{host}:{port}", BUCKET, "AK", "SK")