[--execute]`: dry run по умолчанию, local ↔ S3 и S3 ↔ S3 без удаления source. - Последовательное копирование с проверкой размера и полным SHA-256 readback destination; конфликтующий объект не перезаписывается. Временный диск — максимум один переносимый файл дополнительно к постоянному destination. - Durable migration journal позволяет продолжить прерванную операцию с теми же конфигурациями. Незавершённый или повреждённый journal блокирует startup; backend identity меняется только после проверки всех файлов. - Native `uploads`/`upload_stats`, slots и JWT сохраняются; поддерживаются native локальные файлы без Rust completion sidecar. Миграция требует остановки RustXMPP и Prosody, использующих общий `data_path`. - Multipart intent сохраняется до создания удалённого upload. Recovery находит потерянный UploadId через paginated listing и очищает только точный key принадлежащего slot. При сохранных metadata cleanup не зависит от lifecycle; lifecycle остаётся дополнительной защитой, а не гарантией. - Перед completion сохраняется durable marker. Неопределённый результат completion при отсутствии подтверждённого объекта помещает только этот slot в quarantine: PUT 409, retention не удаляет его, maintenance повторяет сверку. Startup продолжается; сохраняется single-use семантика. - Добавлены network regressions миграции и расширены проверки multipart recovery, потерянных ответов, отмены запросов и конфликтов destination. Настройка, команды миграции, recovery и ограничения отката: [docs/HTTP-UPLOAD.md](docs/HTTP-UPLOAD.md). Команды, результаты и границы подтверждения, включая qualification реального object store и 10 GiB: [docs/VALIDATION-0.7.9.md](docs/VALIDATION-0.7.9.md). Итог: 265 тестов, нагрузка 500 TLS клиентов и native reference — успешно. Реальные 10 GiB через MinIO (описанный test adapter), 1280parts и полный SHA-256 пройдены; подробности — [VALIDATION-0.7.9](docs/VALIDATION-0.7.9.md).
516 lines
30 KiB
Python
516 lines
30 KiB
Python
#!/usr/bin/env python3
|
|
"""Real XMPP/HTTP upload integration against an independent S3 protocol fixture.
|
|
|
|
Usage: python3 tests/upload_s3_network.py target/release/rust-xmpp
|
|
No external service or credentials are used. This validates the S3 HTTP contract
|
|
and independently recomputes SigV4; it is not an AWS or MinIO qualification test.
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import http.client
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import re
|
|
from pathlib import Path
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from urllib.parse import parse_qs, quote, unquote, urlsplit
|
|
import xml.etree.ElementTree as ET
|
|
|
|
from _wire_client import Client
|
|
|
|
NS = 'urn:xmpp:http:upload:0'
|
|
ACCESS = 'fixture-access-not-production'
|
|
SECRET = 'fixture-secret-not-production'
|
|
TOKEN = 'fixture-session-not-production'
|
|
REGION = 'fixture-region-1'
|
|
PART = 8 * 1024 * 1024
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as stream:
|
|
stream.bind(('127.0.0.1', 0))
|
|
return stream.getsockname()[1]
|
|
|
|
|
|
def digest(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
class Store(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(self):
|
|
super().__init__(('127.0.0.1', 0), Handler)
|
|
self.objects = {}
|
|
self.uploads = {}
|
|
self.events = []
|
|
self.errors = []
|
|
self.fail = None
|
|
self.lock = threading.Lock()
|
|
|
|
def verify(self, handler, payload):
|
|
authorization = handler.headers['Authorization']
|
|
algorithm, fields = authorization.split(' ', 1)
|
|
assert algorithm == 'AWS4-HMAC-SHA256', 'wrong signature algorithm'
|
|
fields = dict(field.strip().split('=', 1) for field in fields.split(','))
|
|
credential = fields['Credential'].split('/')
|
|
assert credential[0] == ACCESS and credential[2:] == [REGION, 's3', 'aws4_request'], 'wrong credential scope'
|
|
date = handler.headers['x-amz-date']
|
|
assert credential[1] == date[:8], 'date mismatch'
|
|
assert handler.headers['x-amz-security-token'] == TOKEN, 'session token missing'
|
|
payload_hash = digest(payload)
|
|
assert handler.headers['x-amz-content-sha256'] == payload_hash, 'payload checksum mismatch'
|
|
names = fields['SignedHeaders'].split(';')
|
|
assert names == sorted(names), 'signed headers not sorted'
|
|
assert {'host', 'x-amz-date', 'x-amz-content-sha256', 'x-amz-security-token'} <= set(names), 'unsigned required header'
|
|
headers = ''.join(f'{name}:{" ".join(handler.headers[name].split())}\n' for name in names)
|
|
parsed = urlsplit(handler.path)
|
|
# AWS URI encoding preserves slash separators, but escapes all other
|
|
# non-unreserved bytes. Keys here include spaces and plus signs.
|
|
pairs = []
|
|
for key, values in parse_qs(parsed.query, keep_blank_values=True).items():
|
|
pairs.extend((quote(key, safe='-_.~'), quote(value, safe='-_.~')) for value in values)
|
|
query = '&'.join(f'{key}={value}' for key, value in sorted(pairs))
|
|
canonical = '\n'.join((handler.command, parsed.path, query, headers, ';'.join(names), payload_hash))
|
|
scope = '/'.join(credential[1:])
|
|
string_to_sign = '\n'.join((algorithm, date, scope, digest(canonical.encode())))
|
|
signing_key = ('AWS4' + SECRET).encode()
|
|
for value in (credential[1], REGION, 's3', 'aws4_request'):
|
|
signing_key = hmac.new(signing_key, value.encode(), hashlib.sha256).digest()
|
|
signature = hmac.new(signing_key, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
|
assert hmac.compare_digest(signature, fields['Signature']), 'invalid SigV4 signature'
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = 'HTTP/1.1'
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def reply(self, status, payload=b'', **headers):
|
|
self.send_response(status)
|
|
self.send_header('Content-Length', str(len(payload)))
|
|
for name, value in headers.items():
|
|
self.send_header(name.replace('_', '-'), value)
|
|
self.end_headers()
|
|
if self.command != 'HEAD':
|
|
self.wfile.write(payload)
|
|
|
|
def do_GET(self): self.dispatch()
|
|
def do_HEAD(self): self.dispatch()
|
|
def do_POST(self): self.dispatch()
|
|
def do_PUT(self): self.dispatch()
|
|
def do_DELETE(self): self.dispatch()
|
|
|
|
def dispatch(self):
|
|
try:
|
|
payload = self.rfile.read(int(self.headers.get('Content-Length', '0')))
|
|
self.server.verify(self, payload)
|
|
parsed = urlsplit(self.path)
|
|
query = parse_qs(parsed.query, keep_blank_values=True)
|
|
if self.command == 'GET' and 'uploads' in query:
|
|
assert parsed.path == '/api/fixture-bucket', 'list must address bucket root'
|
|
assert query.get('encoding-type') == ['url'], 'list must encode arbitrary XML keys'
|
|
prefix = query.get('prefix', [''])[0]
|
|
with self.server.lock:
|
|
self.server.events.append(('list', prefix, len(payload)))
|
|
entries = sorted((unquote(upload['key'].removeprefix('/api/fixture-bucket/')), uid)
|
|
for uid, upload in self.server.uploads.items()
|
|
if unquote(upload['key'].removeprefix('/api/fixture-bucket/')).startswith(prefix))
|
|
marker = (query.get('key-marker', [''])[0], query.get('upload-id-marker', [''])[0])
|
|
entries = [entry for entry in entries if entry > marker]
|
|
# Small pages force both marker fields to be exercised.
|
|
page, remaining = entries[:2], entries[2:]
|
|
root = ET.Element('ListMultipartUploadsResult')
|
|
for key, uid in page:
|
|
upload = ET.SubElement(root, 'Upload')
|
|
ET.SubElement(upload, 'Key').text = quote(key, safe='-_.~')
|
|
ET.SubElement(upload, 'UploadId').text = uid
|
|
ET.SubElement(root, 'IsTruncated').text = 'true' if remaining else 'false'
|
|
if remaining:
|
|
ET.SubElement(root, 'NextKeyMarker').text = quote(page[-1][0], safe='-_.~')
|
|
ET.SubElement(root, 'NextUploadIdMarker').text = page[-1][1]
|
|
return self.reply(200, ET.tostring(root))
|
|
assert parsed.path.startswith('/api/fixture-bucket/space%20%2B/'), 'bucket/prefix escaping mismatch'
|
|
|
|
key = parsed.path
|
|
with self.server.lock:
|
|
stage = ('init' if 'uploads' in query else 'part' if self.command == 'PUT'
|
|
else 'complete' if self.command == 'POST' else 'abort' if 'uploadId' in query
|
|
else self.command.lower())
|
|
self.server.events.append((stage, key, len(payload)))
|
|
if stage == 'complete':
|
|
assert self.headers.get('If-None-Match') == '*', 'completion must prevent overwrite'
|
|
if self.server.fail == 'complete-malformed':
|
|
self.server.fail = None
|
|
return self.reply(200, b'<unterminated')
|
|
if stage == 'complete' and self.server.fail == 'complete-error':
|
|
self.server.fail = None
|
|
return self.reply(200, b'<Error><Code>InternalError</Code></Error>')
|
|
if self.server.fail == stage:
|
|
self.server.fail = None
|
|
return self.reply(503, b'<Error><Code>FixtureUnavailable</Code></Error>')
|
|
if stage == 'init':
|
|
uid = f'upload+{len(self.server.events)}/='
|
|
self.server.uploads[uid] = {'key': key, 'parts': {}}
|
|
return self.reply(200, f'<InitiateMultipartUploadResult><UploadId>{uid}</UploadId></InitiateMultipartUploadResult>'.encode())
|
|
if stage == 'part':
|
|
upload = self.server.uploads[query['uploadId'][0]]
|
|
number = int(query['partNumber'][0])
|
|
etag = '"' + hashlib.md5(payload).hexdigest() + '"'
|
|
upload['parts'][number] = (payload, etag)
|
|
return self.reply(200, ETag=etag)
|
|
if stage == 'complete':
|
|
upload = self.server.uploads[query['uploadId'][0]]
|
|
root = ET.fromstring(payload)
|
|
parts = list(root)
|
|
result = []
|
|
for index, node in enumerate(parts, 1):
|
|
number = int(node.findtext('PartNumber'))
|
|
assert number == index, 'noncontiguous multipart completion'
|
|
data, etag = upload['parts'][number]
|
|
assert node.findtext('ETag') == etag, 'ETag mismatch'
|
|
if index < len(parts):
|
|
assert len(data) >= 5 * 1024 * 1024, 'nonfinal S3 part below 5 MiB'
|
|
result.append(data)
|
|
self.server.objects[key] = b''.join(result)
|
|
del self.server.uploads[query['uploadId'][0]]
|
|
if self.server.fail == 'complete-ambiguous':
|
|
self.server.fail = None
|
|
# The remote object exists, but the completion response
|
|
# gives the proxy no trustworthy success confirmation.
|
|
return self.reply(200, b'<Error><Code>InternalError</Code></Error>')
|
|
return self.reply(200, b'<CompleteMultipartUploadResult><ETag>"complete"</ETag></CompleteMultipartUploadResult>')
|
|
if stage == 'abort':
|
|
removed = self.server.uploads.pop(query['uploadId'][0], None)
|
|
return self.reply(204 if removed is not None else 404)
|
|
if self.command == 'DELETE':
|
|
self.server.objects.pop(key, None)
|
|
return self.reply(204)
|
|
data = self.server.objects.get(key)
|
|
if data is None:
|
|
return self.reply(404)
|
|
if self.headers.get('Range'):
|
|
start, end = self.headers['Range'].removeprefix('bytes=').split('-')
|
|
start, end = int(start), int(end)
|
|
return self.reply(206, data[start:end + 1], Content_Range=f'bytes {start}-{end}/{len(data)}')
|
|
return self.reply(200, data, Content_Type='application/octet-stream')
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
# The crash recovery case deliberately kills the HTTP peer.
|
|
return
|
|
except BaseException as error:
|
|
self.server.errors.append(f'{type(error).__name__}: {error}')
|
|
self.reply(500, b'fixture validation failed')
|
|
|
|
|
|
def request(url, method='GET', body=None, headers=None):
|
|
parsed = urlsplit(url)
|
|
connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=20)
|
|
try:
|
|
try:
|
|
connection.request(method, parsed.path, body=body, headers=headers or {})
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
# Authorization, replay, and backend preflight errors may produce
|
|
# a response before the large request body finishes transmitting.
|
|
pass
|
|
response = connection.getresponse()
|
|
return response.status, dict(response.getheaders()), response.read()
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
def slot(client, host, size, ident, error=False):
|
|
client.send(f"<iq type='get' id='{ident}' to='{host}'><request xmlns='{NS}' filename='test +.bin' size='{size}' content-type='application/octet-stream'/></iq>")
|
|
response = client.iq(ident, error=error)
|
|
if error:
|
|
return response
|
|
node = response.find(f'{{{NS}}}slot')
|
|
assert node is not None, ET.tostring(response)
|
|
put = node.find(f'{{{NS}}}put')
|
|
headers = {header.get('name'): header.text for header in put}
|
|
return put.get('url'), headers
|
|
|
|
|
|
def start(binary, config, port, log):
|
|
process = subprocess.Popen([binary, 'serve', str(config), '--experimental'], stdout=log, stderr=log)
|
|
for _ in range(100):
|
|
assert process.poll() is None, 'server exited during startup'
|
|
try:
|
|
with socket.create_connection(('127.0.0.1', port), timeout=.1):
|
|
return process
|
|
except OSError:
|
|
time.sleep(.05)
|
|
raise AssertionError('server startup timeout')
|
|
|
|
|
|
def stop(process):
|
|
process.terminate()
|
|
try:
|
|
assert process.wait(timeout=10) == 0, 'server shutdown failed'
|
|
except subprocess.TimeoutExpired:
|
|
process.kill()
|
|
process.wait()
|
|
raise
|
|
|
|
|
|
def wait_for(predicate, message):
|
|
for _ in range(100):
|
|
if predicate():
|
|
return
|
|
time.sleep(.05)
|
|
raise AssertionError(message)
|
|
|
|
|
|
def run(binary):
|
|
with tempfile.TemporaryDirectory(prefix='xmpp-s3-network-') as directory, Store() as store:
|
|
root = Path(directory)
|
|
worker = threading.Thread(target=store.serve_forever, daemon=True)
|
|
worker.start()
|
|
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
|
'-keyout', str(root / 'key.pem'), '-out', str(root / 'cert.pem'), '-subj',
|
|
'/CN=localhost', '-addext', 'subjectAltName=DNS:localhost'], check=True,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
c2s, http_port = free_port(), free_port()
|
|
size = PART + 123
|
|
options = {'http_host': '127.0.0.1', 'http_file_share_backend': 's3', 'http_file_share_s3': {
|
|
'endpoint': f'http://127.0.0.1:{store.server_address[1]}/api',
|
|
'bucket': 'fixture-bucket', 'region': REGION, 'access_key': ACCESS,
|
|
'secret_key': SECRET, 'session_token': TOKEN, 'key_prefix': 'space +'},
|
|
'http_file_share_base_url': f'http://127.0.0.1:{http_port}/s3',
|
|
'http_paths': {'file_share': '/s3'}, 'http_file_share_size_limit': size,
|
|
'http_file_share_daily_quota': size * 7, 'http_file_share_global_quota': size * 7,
|
|
'http_file_share_expires_after': False}
|
|
config_data = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'],
|
|
'c2s_ports': [c2s], 's2s_ports': [], 'http_ports': [http_port], 'https_ports': [],
|
|
'modules_enabled': ['tls', 'saslauth', 'ping', 'disco']}, 'hosts': {
|
|
'localhost': {'options': {'authentication': 'internal_hashed', 'password_hash': 'SHA-256',
|
|
'ssl': {'key': str(root / 'key.pem'), 'certificate': str(root / 'cert.pem')}}},
|
|
's3.localhost': {'component': 'http_file_share', 'options': options},
|
|
'local.localhost': {'component': 'http_file_share', 'options': {'http_host': '127.0.0.1',
|
|
'http_file_share_base_url': f'http://127.0.0.1:{http_port}/local',
|
|
'http_paths': {'file_share': '/local'}}}}}
|
|
config = root / 'config.json'
|
|
config.write_text(json.dumps(config_data))
|
|
subprocess.run([binary, 'create-user', str(config), 'alice', '--password-stdin'],
|
|
input='secret\n', text=True, check=True, capture_output=True)
|
|
process = None
|
|
client = None
|
|
with (root / 'server.log').open('w+') as log:
|
|
try:
|
|
process = start(binary, config, c2s, log)
|
|
client = Client(c2s, root / 'cert.pem', 'alice', 'secret')
|
|
data = b'A' * PART + b'B' * 123
|
|
url, headers = slot(client, 's3.localhost', size, 'good')
|
|
# Send one part and fence on the fixture before sending the
|
|
# tail: the proxy must make progress without buffering the file.
|
|
parsed = urlsplit(url)
|
|
connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=20)
|
|
connection.putrequest('PUT', parsed.path)
|
|
for name, value in {**headers, 'Content-Length': str(size)}.items():
|
|
connection.putheader(name, value)
|
|
connection.endheaders()
|
|
connection.send(data[:PART])
|
|
wait_for(lambda: any(stage == 'part' for stage, _, _ in store.events), 'upload buffered the entire client body')
|
|
connection.send(data[PART:])
|
|
response = connection.getresponse()
|
|
assert response.status == 201, (response.status, response.read())
|
|
response.read()
|
|
connection.close()
|
|
assert [length for stage, _, length in store.events if stage == 'part'] == [PART, 123]
|
|
assert request(url)[2] == data
|
|
status, head, body = request(url, 'HEAD')
|
|
assert status == 200 and int(head.get('content-length', head.get('Content-Length', -1))) == size and body == b''
|
|
etag = head.get('etag', head.get('ETag'))
|
|
assert etag, 'missing ETag'
|
|
count = len(store.events)
|
|
assert request(url, headers={'If-None-Match': etag})[0] == 304
|
|
assert request(url, headers={'If-None-Match': 'W/' + etag})[0] == 304
|
|
assert len(store.events) == count, 'conditional hit reached S3'
|
|
assert request(url, headers={'Range': 'bytes=1-2', 'If-Range': etag})[2] == b'AA'
|
|
status, _, body = request(url, headers={'Range': 'bytes=1-2', 'If-Range': '"stale"'})
|
|
assert status == 200 and body == data
|
|
status, range_headers, body = request(url, headers={'Range': f'bytes={PART - 2}-{PART + 2}'})
|
|
assert status == 206 and body == b'AABBB'
|
|
assert range_headers.get('content-range', range_headers.get('Content-Range')) == f'bytes {PART - 2}-{PART + 2}/{size}'
|
|
assert request(url, headers={'Range': 'bytes=-5'})[2] == b'BBBBB'
|
|
assert request(url, headers={'Range': f'bytes={PART}-'})[2] == b'B' * 123
|
|
count = len(store.events)
|
|
assert request(url, headers={'Range': f'bytes={size}-'})[0] == 416
|
|
assert len(store.events) == count, 'invalid range reached S3'
|
|
for stage, method in (('get', 'GET'), ('head', 'HEAD')):
|
|
store.fail = stage
|
|
assert request(url, method)[0] >= 500
|
|
assert request(url)[2] == data
|
|
assert request(url, 'PUT', data, headers)[0] == 409, 'slot reused'
|
|
local_url, local_headers = slot(client, 'local.localhost', 4, 'local')
|
|
assert request(local_url, 'PUT', b'here', local_headers)[0] == 201
|
|
assert request(local_url)[2] == b'here'
|
|
empty_url, empty_headers = slot(client, 's3.localhost', 0, 'empty')
|
|
assert request(empty_url, 'PUT', b'', empty_headers)[0] == 201
|
|
assert request(empty_url)[2] == b''
|
|
for stage in ('init', 'part', 'complete', 'complete-error'):
|
|
bad_url, bad_headers = slot(client, 's3.localhost', size, 'fail-' + stage)
|
|
store.fail = stage
|
|
status, _, response = request(bad_url, 'PUT', data, bad_headers)
|
|
assert status >= 500, (stage, status)
|
|
assert SECRET.encode() not in response and TOKEN.encode() not in response
|
|
assert request(bad_url)[0] in (404, 410), 'failed upload became readable'
|
|
wait_for(lambda: not store.uploads, 'failed multipart was not aborted')
|
|
assert request(local_url)[2] == b'here', 'S3 failure broke local backend'
|
|
# Failed slots remain retryable and only commit on success.
|
|
def retry_failed_slot():
|
|
# Abort reconciliation briefly holds the same slot claim.
|
|
retry_status = request(bad_url, 'PUT', data, bad_headers)[0]
|
|
assert retry_status in (201, 409), (stage, retry_status)
|
|
return retry_status == 201
|
|
wait_for(retry_failed_slot, 'failed slot remained unavailable: ' + stage)
|
|
ambiguous_url, ambiguous_headers = slot(client, 's3.localhost', size, 'ambiguous-complete')
|
|
store.fail = 'complete-ambiguous'
|
|
status, _, _ = request(ambiguous_url, 'PUT', data, ambiguous_headers)
|
|
assert status >= 500, 'ambiguous completion reported success'
|
|
wait_for(lambda: request(ambiguous_url)[0] == 200, 'committed object was not reconciled after ambiguous completion')
|
|
assert request(ambiguous_url)[2] == data
|
|
assert request(ambiguous_url, 'PUT', data, ambiguous_headers)[0] == 409, 'ambiguous completion allowed object overwrite'
|
|
assert not store.uploads
|
|
abort_url, abort_headers = slot(client, 's3.localhost', size, 'disconnect')
|
|
parsed = urlsplit(abort_url)
|
|
with socket.create_connection((parsed.hostname, parsed.port), timeout=10) as stream:
|
|
head = f'PUT {parsed.path} HTTP/1.1\r\nHost: {parsed.netloc}\r\nContent-Length: {size}\r\n'
|
|
head += ''.join(f'{key}: {value}\r\n' for key, value in abort_headers.items()) + '\r\n'
|
|
stream.sendall(head.encode() + data[:PART])
|
|
wait_for(lambda: any(stage == 'part' and key.endswith('/' + parsed.path.split('/')[-2]) for stage, key, _ in store.events), 'disconnect fixture did not receive first part')
|
|
wait_for(lambda: any(stage == 'abort' and key.endswith('/' + parsed.path.split('/')[-2]) for stage, key, _ in store.events), 'disconnected multipart was not aborted')
|
|
wait_for(lambda: not store.uploads, 'disconnected multipart leaked')
|
|
slot(client, 's3.localhost', size, 'quota', error=True)
|
|
client.close(); client = None
|
|
stop(process); process = None
|
|
options['http_file_share_daily_quota'] = size * 12
|
|
options['http_file_share_global_quota'] = size * 12
|
|
config.write_text(json.dumps(config_data))
|
|
process = start(binary, config, c2s, log)
|
|
assert request(url)[2] == data, 'S3 metadata did not survive restart'
|
|
assert request(url, 'PUT', data, headers)[0] == 409, 'single-use state lost on restart'
|
|
client = Client(c2s, root / 'cert.pem', 'alice', 'secret')
|
|
crash_url, crash_headers = slot(client, 's3.localhost', size, 'crash')
|
|
parsed = urlsplit(crash_url)
|
|
crash_id = parsed.path.split('/')[-2]
|
|
stream = socket.create_connection((parsed.hostname, parsed.port), timeout=10)
|
|
head = f'PUT {parsed.path} HTTP/1.1\r\nHost: {parsed.netloc}\r\nContent-Length: {size}\r\n'
|
|
head += ''.join(f'{key}: {value}\r\n' for key, value in crash_headers.items()) + '\r\n'
|
|
stream.sendall(head.encode() + data[:PART])
|
|
wait_for(lambda: any(stage == 'part' and key.endswith('/' + crash_id) for stage, key, _ in store.events), 'crash fixture did not receive first part')
|
|
metadata = root / 'data' / 's3%2elocalhost' / 'rust_uploads.json'
|
|
cached = json.loads(metadata.read_text())
|
|
assert cached[crash_id].get('upload_id'), 'multipart identifier was not durable before part upload'
|
|
process.kill(); process.wait(timeout=10); process = None
|
|
stream.close()
|
|
client.close(); client = None
|
|
assert store.uploads, 'crash did not leave a remote multipart to recover'
|
|
# Model the earlier crash window: initiation reached S3, but its
|
|
# UploadId never reached the local journal. Include multiple
|
|
# owned uploads plus a foreign prefix match across list pages.
|
|
cached = json.loads(metadata.read_text())
|
|
assert cached[crash_id].get('multipart_intent'), 'intent not durable before initiation'
|
|
cached[crash_id]['upload_id'] = None
|
|
metadata.write_text(json.dumps(cached))
|
|
owned_key = '/api/fixture-bucket/space%20%2B/' + crash_id
|
|
foreign_uid = 'foreign-prefix-match'
|
|
with store.lock:
|
|
for uid in ('orphan-a', 'orphan-b', 'orphan-c'):
|
|
store.uploads[uid] = {'key': owned_key, 'parts': {}}
|
|
store.uploads[foreign_uid] = {'key': owned_key + '-foreign', 'parts': {}}
|
|
process = start(binary, config, c2s, log)
|
|
wait_for(lambda: set(store.uploads) == {foreign_uid}, 'startup did not abort only exact owned-key multiparts')
|
|
assert not any(stage == 'abort' and key == owned_key + '-foreign' for stage, key, _ in store.events), 'recovery aborted foreign prefix match'
|
|
with store.lock:
|
|
del store.uploads[foreign_uid]
|
|
wait_for(lambda: not json.loads(metadata.read_text())[crash_id].get('upload_id'), 'startup retained abandoned multipart identifier')
|
|
assert request(url)[2] == data
|
|
stop(process); process = None
|
|
# Model a remote completion followed by a crash before the
|
|
# local completion marker was persisted. Age exceeds the stale
|
|
# reservation timeout but remains within retention.
|
|
good_id = urlsplit(url).path.split('/')[-2]
|
|
cached = json.loads(metadata.read_text())
|
|
old = int(time.time()) - 700
|
|
cached[good_id].update(complete=False, upload_id='completed-before-crash', created=old)
|
|
metadata.write_text(json.dumps(cached))
|
|
archive = metadata.parent / 'uploads.list'
|
|
lines = archive.read_text().splitlines(keepends=True)
|
|
changed = False
|
|
for index, line in enumerate(lines):
|
|
if good_id in line:
|
|
line, count = re.subn(r'(\["when"\]=)[0-9.]+', lambda match: match[1] + str(old) + '.0', line)
|
|
assert count == 1, 'failed to age authoritative upload timestamp'
|
|
lines[index] = line
|
|
changed = True
|
|
assert changed
|
|
archive.write_text(''.join(lines))
|
|
archive.with_suffix('.lidx').unlink(missing_ok=True)
|
|
options['http_file_share_expires_after'] = 3600
|
|
config.write_text(json.dumps(config_data))
|
|
process = start(binary, config, c2s, log)
|
|
wait_for(lambda: json.loads(metadata.read_text()).get(good_id, {}).get('complete'), 'startup did not reconcile committed object before stale pruning')
|
|
assert request(url)[2] == data, 'committed object was deleted as an old reservation'
|
|
assert not json.loads(metadata.read_text())[good_id].get('upload_id')
|
|
# Unknown completion + HEAD404 quarantines only its own slot.
|
|
client = Client(c2s, root / 'cert.pem', 'alice', 'secret')
|
|
uncertain_url, uncertain_headers = slot(client, 's3.localhost', 1, 'uncertain')
|
|
uncertain_id = urlsplit(uncertain_url).path.split('/')[-2]
|
|
store.fail = 'complete-malformed'
|
|
assert request(uncertain_url, 'PUT', b'Q', uncertain_headers)[0] >= 500
|
|
assert json.loads(metadata.read_text())[uncertain_id]['completion_attempted']
|
|
client.close(); client = None
|
|
stop(process); process = None
|
|
process = start(binary, config, c2s, log)
|
|
assert request(uncertain_url, 'PUT', b'Q', uncertain_headers)[0] == 409
|
|
retained = json.loads(metadata.read_text())[uncertain_id]
|
|
assert retained['completion_attempted'] and not retained['complete']
|
|
client = Client(c2s, root / 'cert.pem', 'alice', 'secret')
|
|
independent_url, independent_headers = slot(client, 's3.localhost', 1, 'independent')
|
|
assert request(independent_url, 'PUT', b'I', independent_headers)[0] == 201
|
|
# A late commit resolves through the next maintenance pass;
|
|
# requesting another slot also triggers that same pruning pass.
|
|
with store.lock:
|
|
store.objects['/api/fixture-bucket/space%20%2B/' + uncertain_id] = b'Q'
|
|
slot(client, 's3.localhost', 1, 'trigger-reconciliation')
|
|
wait_for(lambda: json.loads(metadata.read_text())[uncertain_id]['complete'], 'late completion was not reconciled')
|
|
assert request(uncertain_url)[2] == b'Q'
|
|
client.close(); client = None
|
|
stop(process); process = None
|
|
# Force expiry without slowing this test with a wall-clock wait.
|
|
options['http_file_share_expires_after'] = 0
|
|
config.write_text(json.dumps(config_data))
|
|
process = start(binary, config, c2s, log)
|
|
assert request(url)[0] in (404, 410)
|
|
wait_for(lambda: not store.objects, 'expired S3 objects were not deleted')
|
|
assert not store.errors, store.errors
|
|
log.flush(); log.seek(0)
|
|
output = log.read()
|
|
assert SECRET not in output and TOKEN not in output and ACCESS not in output, 'credentials leaked to logs'
|
|
print('PASS upload S3 protocol fixture: SigV4, multipart, abort, range, restart, expiry and backend isolation')
|
|
except BaseException:
|
|
log.flush(); log.seek(0)
|
|
output = log.read()
|
|
for credential in (SECRET, TOKEN, ACCESS):
|
|
output = output.replace(credential, '[REDACTED]')
|
|
print('SERVER LOG:\n' + output)
|
|
print('FIXTURE ERRORS:', store.errors)
|
|
raise
|
|
finally:
|
|
if client is not None:
|
|
client.close()
|
|
if process is not None:
|
|
stop(process)
|
|
store.shutdown()
|
|
worker.join(timeout=5)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
run(str(Path(sys.argv[1] if len(sys.argv) > 1 else 'target/release/rust-xmpp').resolve()))
|