- Offline CLI `upload-migrate OLD_CONFIG NEW_CONFIG --component HOST
[--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).
This commit is contained in:
1 parent
c260d7bab8
commit
b30fc524d8
41 files changed
+5936
-152
No files matched your search
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in real XEP-0363 HTTPS large-file regression (default: 10 GiB).
|
||||
|
||||
Example: python3 tests/upload_large_network.py --binary /tmp/rust-xmpp
|
||||
Writes the actual complete payload through TLS to the server's local file
|
||||
stream storage, then reads every byte back through verified TLS. No source
|
||||
file, sparse allocation, external server, or body-sized Python buffer is used.
|
||||
Not part of the default local check because it needs >10 GiB free disk.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from _wire_client import Client, BIN
|
||||
|
||||
NS = 'urn:xmpp:http:upload:0'
|
||||
CHUNK = bytes(range(256)) * 4096 # one deterministic 1 MiB buffer
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def proc_status_for_child(pid):
|
||||
# /proc can expose host PIDs while Popen.pid is in a nested PID namespace.
|
||||
parent = str(Path('/proc/self').resolve().name)
|
||||
for entry in Path('/proc').iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
fields = dict(line.split(':', 1) for line in (entry/'status').read_text().splitlines() if ':' in line)
|
||||
namespace_pids = fields.get('NSpid', fields.get('Pid', '')).split()
|
||||
if fields.get('PPid', '').strip() == parent and namespace_pids[-1:] == [str(pid)]:
|
||||
return entry/'status'
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def rss(status_path):
|
||||
if status_path is None:
|
||||
return 0
|
||||
try:
|
||||
for line in status_path.read_text().splitlines():
|
||||
if line.startswith('VmRSS:'):
|
||||
return int(line.split()[1]) * 1024
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def progress(phase, count, size, start, last):
|
||||
now = time.monotonic()
|
||||
if now - last >= 5 or count == size:
|
||||
print(f'{phase}: {count}/{size} bytes ({count / max(now-start, .001) / 2**20:.1f} MiB/s)', flush=True)
|
||||
return now
|
||||
return last
|
||||
|
||||
|
||||
def run(args, report):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-large-', dir=args.temp_dir) as tmp:
|
||||
root = Path(tmp)
|
||||
report['free_disk_before_bytes'] = shutil.disk_usage(root).free
|
||||
assert report['free_disk_before_bytes'] >= args.size + 256 * 2**20, 'Insufficient free disk for real upload'
|
||||
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)
|
||||
port, https = free_port(), free_port()
|
||||
while https == port:
|
||||
https = free_port()
|
||||
cert = {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}
|
||||
config = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'],
|
||||
'c2s_ports': [port], 'http_ports': [], 'https_ports': [https], 's2s_ports': [],
|
||||
'modules_enabled': ['tls', 'saslauth', 'disco', 'ping'], 'ssl': cert},
|
||||
'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'ssl': cert}},
|
||||
'upload.localhost': {'component': 'http_file_share', 'options': {
|
||||
'http_host': 'localhost', 'http_file_share_backend': 'local', 'http_file_share_base_url': f'https://localhost:{https}/file_share',
|
||||
'http_file_share_size_limit': args.size,
|
||||
'http_file_share_daily_quota': args.size,
|
||||
'http_file_share_global_quota': args.size,
|
||||
'http_file_share_expires_after': 'never'}}}}
|
||||
config_path = root/'config.json'
|
||||
config_path.write_text(json.dumps(config))
|
||||
subprocess.run([str(args.binary), 'create-user', str(config_path), 'alice', '--password-stdin'],
|
||||
input='large-file-secret\n', text=True, check=True, capture_output=True)
|
||||
with (root/'server.log').open('w+') as log:
|
||||
server = subprocess.Popen([str(args.binary), 'serve', str(config_path), '--experimental'], stdout=log, stderr=log)
|
||||
stop = threading.Event()
|
||||
report['max_rss_bytes'] = {'server': 0, 'client': 0}
|
||||
server_status = proc_status_for_child(server.pid)
|
||||
report['rss_proc_status_paths'] = {'server': str(server_status), 'client': '/proc/self/status'}
|
||||
def sample():
|
||||
while not stop.is_set():
|
||||
for role, status_path in [('server', server_status), ('client', Path('/proc/self/status'))]:
|
||||
report['max_rss_bytes'][role] = max(report['max_rss_bytes'][role], rss(status_path))
|
||||
stop.wait(.05)
|
||||
monitor = threading.Thread(target=sample, daemon=True)
|
||||
monitor.start()
|
||||
client = None
|
||||
try:
|
||||
assert server_status is not None, 'cannot resolve actual server /proc status for RSS sampling'
|
||||
for _ in range(200):
|
||||
assert server.poll() is None, 'server exited during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', port), .1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('server startup timed out')
|
||||
client = Client(port, root/'cert.pem', 'alice', 'large-file-secret')
|
||||
client.send(f"<iq type='get' to='upload.localhost' id='large-slot'><request xmlns='{NS}' filename='large.bin' size='{args.size}' content-type='application/octet-stream'/></iq>")
|
||||
slot = client.iq('large-slot').find(f'{{{NS}}}slot')
|
||||
assert slot is not None
|
||||
put = slot.find(f'{{{NS}}}put')
|
||||
get = slot.find(f'{{{NS}}}get')
|
||||
put_url, get_url = urlsplit(put.get('url')), urlsplit(get.get('url'))
|
||||
assert put_url.scheme == get_url.scheme == 'https'
|
||||
assert put_url.hostname == get_url.hostname == 'localhost'
|
||||
headers = {h.get('name'): h.text for h in put.findall(f'{{{NS}}}header')}
|
||||
context = ssl.create_default_context(cafile=str(root/'cert.pem'))
|
||||
def connect(url):
|
||||
return http.client.HTTPSConnection(url.hostname, url.port, context=context, timeout=120)
|
||||
def path(url):
|
||||
return url.path + ('?' + url.query if url.query else '')
|
||||
start = last = time.monotonic()
|
||||
digest = hashlib.sha256()
|
||||
sent = 0
|
||||
conn = connect(put_url)
|
||||
try:
|
||||
conn.putrequest('PUT', path(put_url))
|
||||
for name, value in {**headers, 'Content-Length': str(args.size), 'Content-Type': 'application/octet-stream'}.items():
|
||||
conn.putheader(name, value)
|
||||
conn.endheaders()
|
||||
while sent < args.size:
|
||||
piece = memoryview(CHUNK)[:min(len(CHUNK), args.size - sent)]
|
||||
conn.send(piece)
|
||||
digest.update(piece)
|
||||
sent += len(piece)
|
||||
last = progress('PUT', sent, args.size, start, last)
|
||||
response = conn.getresponse()
|
||||
assert response.status == 201, (response.status, response.read(4096))
|
||||
response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
report.update(put_bytes=sent, put_seconds=time.monotonic()-start, sha256=digest.hexdigest())
|
||||
# A real complete on-disk file, including allocated blocks, is required.
|
||||
stored = list((root/'data').glob('*/http_file_share/*.bin'))
|
||||
assert len(stored) == 1, stored
|
||||
stat = stored[0].stat()
|
||||
assert stat.st_size == args.size
|
||||
assert stat.st_blocks * 512 >= args.size, 'file unexpectedly sparse'
|
||||
report['stored_bytes'] = stat.st_size
|
||||
report['allocated_bytes'] = stat.st_blocks * 512
|
||||
report['free_disk_after_put_bytes'] = shutil.disk_usage(root).free
|
||||
conn = connect(get_url)
|
||||
try:
|
||||
conn.request('HEAD', path(get_url))
|
||||
response = conn.getresponse()
|
||||
assert response.status == 200 and int(response.getheader('Content-Length')) == args.size
|
||||
assert response.read() == b''
|
||||
finally:
|
||||
conn.close()
|
||||
report['head_status'] = 200
|
||||
start = last = time.monotonic()
|
||||
received, reads = 0, 0
|
||||
downloaded = hashlib.sha256()
|
||||
conn = connect(get_url)
|
||||
try:
|
||||
conn.request('GET', path(get_url))
|
||||
response = conn.getresponse()
|
||||
assert response.status == 200 and int(response.getheader('Content-Length')) == args.size
|
||||
while True:
|
||||
piece = response.read(len(CHUNK))
|
||||
reads += 1
|
||||
if not piece:
|
||||
break
|
||||
downloaded.update(piece)
|
||||
received += len(piece)
|
||||
last = progress('GET', received, args.size, start, last)
|
||||
assert received == args.size and downloaded.digest() == digest.digest()
|
||||
finally:
|
||||
conn.close()
|
||||
report.update(get_bytes=received, get_read_calls=reads, get_seconds=time.monotonic()-start,
|
||||
get_sha256=downloaded.hexdigest())
|
||||
# Exercise a range crossing 4 GiB when the selected size permits it.
|
||||
offset = min(2**32 - 7, args.size - 32)
|
||||
conn = connect(get_url)
|
||||
try:
|
||||
conn.request('GET', path(get_url), headers={'Range': f'bytes={offset}-{offset+31}'})
|
||||
response = conn.getresponse()
|
||||
assert response.status == 206
|
||||
assert response.getheader('Content-Range') == f'bytes {offset}-{offset+31}/{args.size}'
|
||||
assert response.read(33) == bytes((offset+i) % 256 for i in range(32))
|
||||
finally:
|
||||
conn.close()
|
||||
report.update(range_status=206, range_offset=offset)
|
||||
conn = connect(put_url)
|
||||
try:
|
||||
# No second large body: single-use check must reject at headers.
|
||||
conn.putrequest('PUT', path(put_url))
|
||||
for name, value in {**headers, 'Content-Length': str(args.size)}.items():
|
||||
conn.putheader(name, value)
|
||||
conn.endheaders()
|
||||
response = conn.getresponse()
|
||||
assert response.status == 409, response.status
|
||||
response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
report['reuse_status'] = 409
|
||||
client.send(f"<iq type='get' to='upload.localhost' id='over-quota'><request xmlns='{NS}' filename='extra.bin' size='1'/></iq>")
|
||||
rejected = client.iq('over-quota', error=True)
|
||||
assert rejected.find('.//{urn:ietf:params:xml:ns:xmpp-stanzas}not-acceptable') is not None, ET.tostring(rejected)
|
||||
report['over_quota_condition'] = 'not-acceptable'
|
||||
assert 0 < report['max_rss_bytes']['client'] < 256 * 2**20, report['max_rss_bytes']
|
||||
assert 0 < report['max_rss_bytes']['server'] < 512 * 2**20, report['max_rss_bytes']
|
||||
report['status'] = 'passed'
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(log.read()[-20000:], flush=True)
|
||||
raise
|
||||
finally:
|
||||
if client:
|
||||
client.close()
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait()
|
||||
stop.set()
|
||||
monitor.join(timeout=2)
|
||||
report['temporary_files_cleaned'] = not root.exists()
|
||||
report['free_disk_after_cleanup_bytes'] = shutil.disk_usage(args.temp_dir or tempfile.gettempdir()).free
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', type=Path, default=BIN)
|
||||
parser.add_argument('--size', type=int, default=10 * 2**30, help='actual network bytes; default 10 GiB')
|
||||
parser.add_argument('--report', type=Path, default=Path('/tmp/xmpp078-large.json'))
|
||||
parser.add_argument('--temp-dir', type=Path)
|
||||
args = parser.parse_args()
|
||||
assert args.size >= 32
|
||||
args.binary = args.binary.resolve()
|
||||
report = {'status': 'failed', 'requested_bytes': args.size, 'python_chunk_bytes': len(CHUNK),
|
||||
'transport': 'verified TLS HTTPS', 'storage': 'local streaming file', 'rss_sample_interval_seconds': .05}
|
||||
started = time.monotonic()
|
||||
try:
|
||||
run(args, report)
|
||||
except BaseException as error:
|
||||
report['error'] = repr(error)
|
||||
raise
|
||||
finally:
|
||||
report['total_seconds'] = time.monotonic() - started
|
||||
args.report.write_text(json.dumps(report, indent=2) + '\n')
|
||||
print(json.dumps(report, indent=2), flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline upload-migrate regression using the independent signed S3 fixture.
|
||||
|
||||
Usage: python3 tests/upload_migration_network.py target/release/rust-xmpp
|
||||
Exercises real XMPP slots and HTTP bodies; no cloud service or large disk is needed.
|
||||
The fixture validates SigV4 independently (see upload_s3_network.py).
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from _wire_client import Client
|
||||
from upload_s3_network import (ACCESS, PART, REGION, SECRET, TOKEN, Store,
|
||||
free_port, request, slot, start, stop)
|
||||
|
||||
HOST = 'upload.localhost'
|
||||
|
||||
|
||||
class MigrationStore(Store):
|
||||
"""Fail a chosen completion after earlier objects have really been copied."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.completions = 0
|
||||
self.fail_completion = None
|
||||
|
||||
def verify(self, handler, payload):
|
||||
super().verify(handler, payload)
|
||||
query = parse_qs(urlsplit(handler.path).query, keep_blank_values=True)
|
||||
if handler.command == 'POST' and 'uploadId' in query:
|
||||
self.completions += 1
|
||||
if self.completions == self.fail_completion:
|
||||
self.fail = 'complete-error'
|
||||
|
||||
|
||||
def sha(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def checked(command, success=True):
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=90)
|
||||
output = result.stdout + result.stderr
|
||||
assert (result.returncode == 0) == success, (command, result.returncode, output)
|
||||
assert all(value not in output for value in (ACCESS, SECRET, TOKEN)), 'credentials leaked by CLI'
|
||||
return output
|
||||
|
||||
|
||||
def migrate(binary, old, new, execute=False, success=True):
|
||||
command = [binary, 'upload-migrate', str(old), str(new), '--component', HOST]
|
||||
if execute:
|
||||
command.append('--execute')
|
||||
return checked(command, success)
|
||||
|
||||
|
||||
def source_files(directory):
|
||||
return {str(path.relative_to(directory)): sha(path.read_bytes())
|
||||
for path in directory.rglob('*.bin')}
|
||||
|
||||
|
||||
def run(binary):
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-upload-migration-') as directory, \
|
||||
MigrationStore() as first, MigrationStore() as second:
|
||||
root = Path(directory)
|
||||
stores = (first, second)
|
||||
workers = [threading.Thread(target=store.serve_forever, daemon=True) for store in stores]
|
||||
for worker in workers:
|
||||
worker.start()
|
||||
checked(['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'])
|
||||
c2s, http_port = free_port(), free_port()
|
||||
while c2s == http_port:
|
||||
http_port = free_port()
|
||||
cert = {'key': str(root / 'key.pem'), 'certificate': str(root / 'cert.pem')}
|
||||
options = {'http_host': '127.0.0.1', 'http_file_share_backend': 'local',
|
||||
'http_file_share_base_url': f'http://127.0.0.1:{http_port}/files',
|
||||
'http_paths': {'file_share': '/files'},
|
||||
'http_file_share_size_limit': PART + 123,
|
||||
'http_file_share_daily_quota': PART * 8,
|
||||
'http_file_share_global_quota': PART * 8,
|
||||
'http_file_share_expires_after': False}
|
||||
base = {'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': cert}},
|
||||
HOST: {'component': 'http_file_share', 'options': options}}}
|
||||
configs = []
|
||||
for name, store in [('local', None), ('s3-first', first), ('s3-second', second)]:
|
||||
config = copy.deepcopy(base)
|
||||
if store:
|
||||
component = config['hosts'][HOST]['options']
|
||||
component['http_file_share_backend'] = 's3'
|
||||
component['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 +'}
|
||||
path = root / f'{name}.json'
|
||||
path.write_text(json.dumps(config))
|
||||
configs.append(path)
|
||||
local, remote_a, remote_b = configs
|
||||
result = subprocess.run([binary, 'create-user', str(local), 'alice', '--password-stdin'],
|
||||
input='secret\n', text=True, capture_output=True, timeout=30)
|
||||
assert result.returncode == 0, result.stderr
|
||||
component_path = root / 'data' / 'upload%2elocalhost'
|
||||
identity_path = component_path / 'rust_upload_backend.json'
|
||||
metadata_path = component_path / 'rust_uploads.json'
|
||||
archive_path = component_path / 'uploads.list'
|
||||
process = client = None
|
||||
uploads = []
|
||||
with (root / 'server.log').open('w+') as log:
|
||||
try:
|
||||
process = start(binary, local, c2s, log)
|
||||
client = Client(c2s, root / 'cert.pem', 'alice', 'secret')
|
||||
for index, payload in enumerate((b'A' * PART + b'B' * 123, b'migration\x00hash\xff', b'')):
|
||||
url, headers = slot(client, HOST, len(payload), f'migrate-{index}')
|
||||
assert request(url, 'PUT', payload, headers)[0] == 201
|
||||
status, head, body = request(url, 'HEAD')
|
||||
assert status == 200 and not body
|
||||
head = {key.lower(): value for key, value in head.items()}
|
||||
uploads.append((url, headers, payload, head['etag']))
|
||||
# Both dry-run and execute need the same exclusive data lock.
|
||||
migrate(binary, local, remote_a, success=False)
|
||||
migrate(binary, local, remote_a, execute=True, success=False)
|
||||
assert not first.events, 'live-server lock denial reached S3'
|
||||
client.close(); client = None
|
||||
stop(process); process = None
|
||||
original_files = source_files(component_path / 'http_file_share')
|
||||
assert len(original_files) == len(uploads)
|
||||
original_records = json.loads(metadata_path.read_text())
|
||||
original_archive = archive_path.read_bytes()
|
||||
|
||||
def verify_serving(config, preserve_metadata=True):
|
||||
nonlocal process
|
||||
process = start(binary, config, c2s, log)
|
||||
for url, headers, payload, etag in uploads:
|
||||
status, _, data = request(url)
|
||||
assert status == 200 and sha(data) == sha(payload), 'original URL body/hash changed'
|
||||
status, head, body = request(url, 'HEAD')
|
||||
head = {key.lower(): value for key, value in head.items()}
|
||||
assert status == 200 and body == b''
|
||||
assert int(head['content-length']) == len(payload) and head['etag'] == etag
|
||||
assert request(url, headers={'If-None-Match': etag})[0] == 304
|
||||
assert request(url, 'PUT', payload, headers)[0] == 409, 'original JWT became reusable'
|
||||
stop(process); process = None
|
||||
if preserve_metadata:
|
||||
records = json.loads(metadata_path.read_text())
|
||||
stable = lambda rows: {key: {field: value for field, value in record.items() if field != 'xml'}
|
||||
for key, record in rows.items()}
|
||||
assert stable(records) == stable(original_records), 'slot/JWT metadata changed'
|
||||
|
||||
# Invalid destination configuration must not mutate source state.
|
||||
invalid = json.loads(remote_a.read_text())
|
||||
del invalid['hosts'][HOST]['options']['http_file_share_s3']['bucket']
|
||||
bad = root / 'invalid.json'
|
||||
bad.write_text(json.dumps(invalid))
|
||||
identity = identity_path.read_bytes()
|
||||
migrate(binary, local, bad, execute=True, success=False)
|
||||
assert identity_path.read_bytes() == identity and not first.objects
|
||||
assert source_files(component_path / 'http_file_share') == original_files
|
||||
|
||||
# Default is a plan only: no writes, no identity/metadata cutover.
|
||||
migrate(binary, local, remote_a)
|
||||
assert not first.objects and not first.uploads
|
||||
assert not any(stage in ('init', 'part', 'complete', 'abort', 'delete')
|
||||
for stage, _, _ in first.events), 'dry-run wrote to destination'
|
||||
assert identity_path.read_bytes() == identity
|
||||
assert json.loads(metadata_path.read_text()) == original_records
|
||||
|
||||
# One object is durable before a later copy fails. Neither the
|
||||
# failed command nor a server restart may cut over early.
|
||||
first.fail_completion = first.completions + 2
|
||||
migrate(binary, local, remote_a, execute=True, success=False)
|
||||
assert first.objects, 'failure injection did not leave a resumable copied object'
|
||||
assert identity_path.read_bytes() == identity, 'incomplete migration changed active backend'
|
||||
assert json.loads(metadata_path.read_text()) == original_records
|
||||
assert source_files(component_path / 'http_file_share') == original_files
|
||||
# A pending journal fences both configurations until resume.
|
||||
for blocked in (local, remote_a):
|
||||
rejected = subprocess.Popen([binary, 'serve', str(blocked), '--experimental'],
|
||||
stdout=log, stderr=log)
|
||||
try:
|
||||
assert rejected.wait(timeout=15) != 0, 'pending migration started serving'
|
||||
finally:
|
||||
if rejected.poll() is None:
|
||||
rejected.kill(); rejected.wait(timeout=10)
|
||||
first.fail_completion = None
|
||||
completed_before_resume = first.completions
|
||||
migration_cache, migration_archive = metadata_path.read_bytes(), archive_path.read_bytes()
|
||||
migrate(binary, local, remote_a, execute=True)
|
||||
assert metadata_path.read_bytes() == migration_cache and archive_path.read_bytes() == migration_archive
|
||||
assert first.completions - completed_before_resume < len(uploads), 'resume recopied verified objects'
|
||||
assert source_files(component_path / 'http_file_share') == original_files
|
||||
verify_serving(remote_a)
|
||||
assert sorted(sha(data) for data in first.objects.values()) == sorted(sha(row[2]) for row in uploads)
|
||||
|
||||
# S3 -> S3 and S3 -> local preserve remote source objects, even
|
||||
# though final local files already exist from the first backend.
|
||||
first_objects = dict(first.objects)
|
||||
for old, new, destination in ((remote_a, remote_b, second), (remote_b, local, None)):
|
||||
second_objects = dict(second.objects)
|
||||
before = identity_path.read_bytes()
|
||||
destination_objects = dict(destination.objects) if destination else None
|
||||
migrate(binary, old, new)
|
||||
assert identity_path.read_bytes() == before
|
||||
if destination:
|
||||
assert destination.objects == destination_objects
|
||||
migration_cache, migration_archive = metadata_path.read_bytes(), archive_path.read_bytes()
|
||||
migrate(binary, old, new, execute=True)
|
||||
assert metadata_path.read_bytes() == migration_cache and archive_path.read_bytes() == migration_archive
|
||||
verify_serving(new)
|
||||
assert first.objects == first_objects, 'S3 source objects were deleted/changed'
|
||||
if destination is None:
|
||||
assert second.objects == second_objects, 'second S3 source objects were deleted/changed'
|
||||
assert sorted(sha(data) for data in second.objects.values()) == sorted(sha(row[2]) for row in uploads)
|
||||
assert source_files(component_path / 'http_file_share') == original_files
|
||||
# A native final local file is authoritative even when its Rust
|
||||
# cache completion flag is false. Unfinished multipart intent
|
||||
# still rejects the operation without modifying the backend.
|
||||
native = copy.deepcopy(original_records)
|
||||
native_id = next(iter(native))
|
||||
native[native_id]['multipart_intent'] = True
|
||||
metadata_path.write_text(json.dumps(native))
|
||||
before = identity_path.read_bytes()
|
||||
migrate(binary, local, remote_a, execute=True, success=False)
|
||||
assert identity_path.read_bytes() == before
|
||||
native[native_id] = copy.deepcopy(original_records[native_id])
|
||||
native[native_id]['complete'] = False
|
||||
metadata_path.write_text(json.dumps(native))
|
||||
native_bytes = metadata_path.read_bytes()
|
||||
native_archive = archive_path.read_bytes()
|
||||
migrate(binary, local, remote_a, execute=True)
|
||||
assert metadata_path.read_bytes() == native_bytes, 'migration rewrote native completion cache'
|
||||
assert archive_path.read_bytes() == native_archive
|
||||
verify_serving(remote_a, preserve_metadata=False)
|
||||
assert all(not store.errors for store in stores), [store.errors for store in stores]
|
||||
print('PASS upload migration: local -> S3 -> S3 -> local, hashes, original URLs/JWT/HEAD, lock, dry-run, fail-closed resume')
|
||||
except BaseException:
|
||||
log.flush(); log.seek(0)
|
||||
output = log.read()
|
||||
for credential in (ACCESS, SECRET, TOKEN):
|
||||
output = output.replace(credential, '[REDACTED]')
|
||||
print('SERVER LOG:\n' + output)
|
||||
print('FIXTURE ERRORS:', [store.errors for store in stores])
|
||||
raise
|
||||
finally:
|
||||
if client is not None:
|
||||
client.close()
|
||||
if process is not None:
|
||||
stop(process)
|
||||
for store in stores:
|
||||
store.shutdown()
|
||||
for worker in workers:
|
||||
worker.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run(str(Path(sys.argv[1] if len(sys.argv) > 1 else 'target/release/rust-xmpp').resolve()))
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in genuine MinIO integration; no S3 fixture is used.
|
||||
|
||||
MINIO_BINARY=/path/to/minio python3 tests/upload_minio_network.py --binary target/release/rust-xmpp
|
||||
Default sends an actual 10 GiB HTTPS upload through Rust into isolated MinIO,
|
||||
then verifies every byte through HTTPS, multipart ETag, ranges, restart and crash
|
||||
cleanup. Requires openssl, a genuine MinIO executable, and size + 512 MiB free.
|
||||
--size permits a small smoke check; only sizes >5 GiB qualify the large-object test.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import quote, urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from _wire_client import Client
|
||||
from upload_large_network import CHUNK, free_port, proc_status_for_child, rss, progress
|
||||
from upload_s3_network import slot, start, stop, wait_for
|
||||
|
||||
ACCESS = 'isolated-minio-test-access'
|
||||
SECRET = 'isolated-minio-test-secret-not-production'
|
||||
BUCKET = 'xmpp-integration'
|
||||
PART = 8 * 2**20
|
||||
|
||||
|
||||
def signed(port, method, path, query='', body=b''):
|
||||
"""Small independent SigV4 administrative client, never stores object bodies."""
|
||||
date = time.strftime('%Y%m%dT%H%M%SZ', time.gmtime())
|
||||
scope = date[:8] + '/us-east-1/s3/aws4_request'
|
||||
payload = hashlib.sha256(body).hexdigest()
|
||||
headers = {'host': f'127.0.0.1:{port}', 'x-amz-content-sha256': payload, 'x-amz-date': date}
|
||||
names = ';'.join(sorted(headers))
|
||||
canonical = '\n'.join((method, path, query, ''.join(f'{k}:{headers[k]}\n' for k in sorted(headers)), names, payload))
|
||||
key = ('AWS4' + SECRET).encode()
|
||||
for value in (date[:8], 'us-east-1', 's3', 'aws4_request'):
|
||||
key = hmac.new(key, value.encode(), hashlib.sha256).digest()
|
||||
value = '\n'.join(('AWS4-HMAC-SHA256', date, scope, hashlib.sha256(canonical.encode()).hexdigest()))
|
||||
signature = hmac.new(key, value.encode(), hashlib.sha256).hexdigest()
|
||||
headers['Authorization'] = f'AWS4-HMAC-SHA256 Credential={ACCESS}/{scope}, SignedHeaders={names}, Signature={signature}'
|
||||
conn = http.client.HTTPConnection('127.0.0.1', port, timeout=30)
|
||||
try:
|
||||
conn.request(method, path + ('?' + query if query else ''), body, headers)
|
||||
response = conn.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def uploads(port):
|
||||
status, _, body = signed(port, 'GET', '/' + BUCKET, 'uploads=')
|
||||
assert status == 200, (status, body[:1024])
|
||||
return [n for n in ET.fromstring(body).iter() if n.tag.endswith('}Upload')]
|
||||
|
||||
|
||||
def has_uploaded_part(port):
|
||||
for upload in uploads(port):
|
||||
values = {node.tag.rsplit('}', 1)[-1]: node.text for node in upload}
|
||||
path = '/' + BUCKET + '/' + quote(values['Key'], safe='/')
|
||||
status, _, body = signed(port, 'GET', path, 'uploadId=' + quote(values['UploadId'], safe='-_.~'))
|
||||
if status == 200 and any(node.tag.endswith('}Part') for node in ET.fromstring(body).iter()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run(args, report):
|
||||
try:
|
||||
version = subprocess.check_output([str(args.minio), '--version'], text=True, stderr=subprocess.STDOUT)
|
||||
except subprocess.CalledProcessError as error:
|
||||
raise RuntimeError('MinIO preflight failed before any upload: ' + error.output[-4000:]) from error
|
||||
assert 'version ' in version.lower() and 'MinIO, Inc.' in version, 'supplied executable does not identify as MinIO'
|
||||
binary_hash = hashlib.sha256()
|
||||
with args.minio.open('rb') as stream:
|
||||
for piece in iter(lambda: stream.read(2**20), b''): binary_hash.update(piece)
|
||||
rust_hash = hashlib.sha256()
|
||||
with args.binary.open('rb') as stream:
|
||||
for piece in iter(lambda: stream.read(2**20), b''): rust_hash.update(piece)
|
||||
rust_version = subprocess.check_output([str(args.binary), '--version'], text=True).strip()
|
||||
report.update(minio_version=version.strip(), minio_binary_sha256=binary_hash.hexdigest(), binary=str(args.binary), binary_version=rust_version, binary_sha256=rust_hash.hexdigest(), size=args.size, qualifies_over_5gib=args.size > 5 * 2**30)
|
||||
if args.minio_compat_patch:
|
||||
patch = args.minio_compat_patch.read_bytes()
|
||||
report['minio_test_only_compatibility_patch'] = {'sha256': hashlib.sha256(patch).hexdigest(), 'diff': patch.decode(), 'limitation': 'Modified upstream MinIO: EPERM interface enumeration uses loopback; all S3/auth/storage code remains upstream.'}
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-minio-', dir=args.temp_dir) as directory:
|
||||
root = Path(directory)
|
||||
assert shutil.disk_usage(root).free > args.size + 512 * 2**20, 'insufficient free disk'
|
||||
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, https, minio_port, console = [free_port() for _ in range(4)]
|
||||
cert = {'key': str(root/'key.pem'), 'certificate': str(root/'cert.pem')}
|
||||
options = {'http_host': 'localhost', 'http_file_share_backend': 's3', 'http_file_share_s3': {'endpoint': f'http://127.0.0.1:{minio_port}', 'bucket': BUCKET, 'region': 'us-east-1', 'access_key': ACCESS, 'secret_key': SECRET, 'key_prefix': 'integration'}, 'http_file_share_base_url': f'https://localhost:{https}/file_share', 'http_file_share_size_limit': args.size, 'http_file_share_daily_quota': args.size * 3, 'http_file_share_global_quota': args.size * 3, 'http_file_share_expires_after': False}
|
||||
config = {'global': {'data_path': str(root/'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [c2s], 's2s_ports': [], 'http_ports': [], 'https_ports': [https], 'ssl': cert, 'modules_enabled': ['tls', 'saslauth', 'ping', 'disco']}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed', 'ssl': cert}}, 'upload.localhost': {'component': 'http_file_share', 'options': options}}}
|
||||
config_path = root/'config.json'
|
||||
config_path.write_text(json.dumps(config))
|
||||
subprocess.run([str(args.binary), 'create-user', str(config_path), 'alice', '--password-stdin'], input='integration-secret\n', text=True, check=True, capture_output=True)
|
||||
minio = server = client = None
|
||||
done = threading.Event()
|
||||
report['max_rss_bytes'] = {'server': 0, 'client': 0, 'minio': 0}
|
||||
paths = {'client': Path('/proc/self/status')}
|
||||
def sample():
|
||||
while not done.wait(.05):
|
||||
for role, path in list(paths.items()):
|
||||
report['max_rss_bytes'][role] = max(report['max_rss_bytes'][role], rss(path))
|
||||
monitor = threading.Thread(target=sample, daemon=True)
|
||||
context = ssl.create_default_context(cafile=str(root/'cert.pem'))
|
||||
def request(url, method='GET', headers=None):
|
||||
parsed = urlsplit(url)
|
||||
connection = http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=120)
|
||||
connection.request(method, parsed.path, headers=headers or {})
|
||||
return connection, connection.getresponse()
|
||||
def check_range(url, offset):
|
||||
conn, response = request(url, headers={'Range': f'bytes={offset}-{offset+31}'})
|
||||
try:
|
||||
assert response.status == 206
|
||||
assert response.getheader('Content-Range') == f'bytes {offset}-{offset+31}/{args.size}'
|
||||
assert response.read(33) == bytes((offset+i) % 256 for i in range(32))
|
||||
finally:
|
||||
conn.close()
|
||||
with (root/'minio.log').open('w+') as minio_log, (root/'server.log').open('w+') as log:
|
||||
try:
|
||||
env = dict(os.environ, MINIO_ROOT_USER=ACCESS, MINIO_ROOT_PASSWORD=SECRET, MINIO_BROWSER='off', GOMAXPROCS='4', GOMEMLIMIT='1GiB')
|
||||
minio = subprocess.Popen([str(args.minio), 'server', str(root/'minio-data'), '--address', f'127.0.0.1:{minio_port}', '--console-address', f'127.0.0.1:{console}'], env=env, stdout=minio_log, stderr=minio_log)
|
||||
paths['minio'] = proc_status_for_child(minio.pid)
|
||||
for _ in range(200):
|
||||
assert minio.poll() is None, 'MinIO exited at startup'
|
||||
try:
|
||||
status, _, _ = signed(minio_port, 'PUT', '/' + BUCKET)
|
||||
if status in (200, 409): break
|
||||
except OSError: pass
|
||||
time.sleep(.05)
|
||||
else: raise AssertionError('MinIO startup timeout')
|
||||
server = start(str(args.binary), config_path, c2s, log)
|
||||
paths['server'] = proc_status_for_child(server.pid)
|
||||
assert all(paths.values()), 'cannot resolve actual child process RSS paths'
|
||||
monitor.start()
|
||||
client = Client(c2s, root/'cert.pem', 'alice', 'integration-secret')
|
||||
url, headers = slot(client, 'upload.localhost', args.size, 'real-minio')
|
||||
parsed = urlsplit(url)
|
||||
assert parsed.scheme == 'https' and parsed.hostname == 'localhost'
|
||||
conn = http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=120)
|
||||
digest = hashlib.sha256(); sent = 0; began = last = time.monotonic()
|
||||
try:
|
||||
conn.putrequest('PUT', parsed.path)
|
||||
for name, value in {**headers, 'Content-Length': str(args.size)}.items(): conn.putheader(name, value)
|
||||
conn.endheaders()
|
||||
while sent < args.size:
|
||||
piece = memoryview(CHUNK)[:min(len(CHUNK), args.size-sent)]
|
||||
conn.send(piece); digest.update(piece); sent += len(piece)
|
||||
last = progress('MINIO PUT', sent, args.size, began, last)
|
||||
response = conn.getresponse()
|
||||
assert response.status == 201, (response.status, response.read(4096))
|
||||
response.read()
|
||||
finally: conn.close()
|
||||
report.update(put_bytes=sent, put_seconds=time.monotonic()-began, sha256=digest.hexdigest())
|
||||
# MinIO's own HEAD proves the object is multipart, independent of Rust metadata.
|
||||
object_id = parsed.path.split('/')[-2]
|
||||
status, remote, body = signed(minio_port, 'HEAD', '/' + BUCKET + '/integration/' + object_id)
|
||||
assert status == 200, (status, body)
|
||||
remote = {k.lower(): v for k, v in remote.items()}
|
||||
assert int(remote['content-length']) == args.size
|
||||
etag = remote['etag'].strip('"')
|
||||
assert '-' in etag and int(etag.rsplit('-', 1)[1]) == (args.size + PART - 1)//PART, etag
|
||||
allocated = sum(path.stat().st_blocks * 512 for path in (root/'minio-data').rglob('*') if path.is_file())
|
||||
assert allocated >= args.size, 'MinIO payload storage unexpectedly sparse'
|
||||
report.update(minio_etag=etag, multipart_parts=int(etag.rsplit('-', 1)[1]), minio_allocated_bytes=allocated, free_disk_after_put_bytes=shutil.disk_usage(root).free)
|
||||
conn, response = request(url, 'HEAD')
|
||||
assert response.status == 200 and int(response.getheader('Content-Length')) == args.size
|
||||
response.read(); conn.close()
|
||||
received = 0; downloaded = hashlib.sha256(); began = last = time.monotonic()
|
||||
conn, response = request(url)
|
||||
try:
|
||||
assert response.status == 200
|
||||
while True:
|
||||
piece = response.read(len(CHUNK))
|
||||
if not piece: break
|
||||
downloaded.update(piece); received += len(piece)
|
||||
last = progress('MINIO GET', received, args.size, began, last)
|
||||
finally: conn.close()
|
||||
assert received == args.size and downloaded.digest() == digest.digest()
|
||||
report.update(get_bytes=received, get_sha256=downloaded.hexdigest(), get_seconds=time.monotonic()-began)
|
||||
offsets = sorted(set([0, min(2**32-7, args.size-32), min(5*2**30-7, args.size-32), args.size-32]))
|
||||
for offset in offsets: check_range(url, offset)
|
||||
report['range_offsets'] = offsets
|
||||
client.close(); client = None
|
||||
stop(server); server = None
|
||||
server = start(str(args.binary), config_path, c2s, log); paths['server'] = proc_status_for_child(server.pid)
|
||||
check_range(url, args.size-32)
|
||||
report['restart_range_ok'] = True
|
||||
replay = http.client.HTTPSConnection(parsed.hostname, parsed.port, context=context, timeout=30)
|
||||
try:
|
||||
replay.putrequest('PUT', parsed.path)
|
||||
for name, value in {**headers, 'Content-Length': str(args.size)}.items(): replay.putheader(name, value)
|
||||
replay.endheaders()
|
||||
response = replay.getresponse()
|
||||
assert response.status == 409, 'single-use upload state did not survive restart'
|
||||
response.read()
|
||||
finally: replay.close()
|
||||
report['restart_reuse_status'] = 409
|
||||
client = Client(c2s, root/'cert.pem', 'alice', 'integration-secret')
|
||||
crash_size = max(PART*2, min(args.size, PART*3))
|
||||
# The configured maximum must also permit the crash probe in small smoke mode.
|
||||
crash_size = min(crash_size, args.size)
|
||||
assert crash_size > PART, 'size must exceed 8 MiB for crash multipart test'
|
||||
crash_url, crash_headers = slot(client, 'upload.localhost', crash_size, 'crash-minio')
|
||||
crash = urlsplit(crash_url)
|
||||
conn = http.client.HTTPSConnection(crash.hostname, crash.port, context=context, timeout=30)
|
||||
conn.putrequest('PUT', crash.path)
|
||||
for name, value in {**crash_headers, 'Content-Length': str(crash_size)}.items(): conn.putheader(name, value)
|
||||
conn.endheaders()
|
||||
for _ in range(PART//len(CHUNK)): conn.send(CHUNK)
|
||||
wait_for(lambda: has_uploaded_part(minio_port), 'MinIO never stored crash multipart part')
|
||||
crash_id = crash.path.split('/')[-2]
|
||||
metadata = root/'data'/'upload%2elocalhost'/'rust_uploads.json'
|
||||
wait_for(lambda: json.loads(metadata.read_text())[crash_id].get('upload_id'), 'multipart id not durable')
|
||||
server.kill(); server.wait(timeout=10); server = None
|
||||
conn.close(); client.close(); client = None
|
||||
assert uploads(minio_port), 'no real abandoned multipart at crash'
|
||||
server = start(str(args.binary), config_path, c2s, log); paths['server'] = proc_status_for_child(server.pid)
|
||||
wait_for(lambda: not uploads(minio_port), 'startup did not abort genuine MinIO multipart')
|
||||
check_range(url, args.size-32)
|
||||
report['crash_multipart_cleanup_ok'] = True
|
||||
assert 0 < report['max_rss_bytes']['server'] < args.server_rss_mib * 2**20, report['max_rss_bytes']
|
||||
assert 0 < report['max_rss_bytes']['client'] < 256 * 2**20, report['max_rss_bytes']
|
||||
assert 0 < report['max_rss_bytes']['minio'] < args.minio_rss_mib * 2**20, report['max_rss_bytes']
|
||||
report['ok'] = True
|
||||
except BaseException:
|
||||
for name, stream in [('RUST', log), ('MINIO', minio_log)]:
|
||||
stream.flush(); stream.seek(0)
|
||||
output = stream.read().replace(SECRET, '[REDACTED]').replace(ACCESS, '[REDACTED]')
|
||||
print(name + ' LOG:\n' + output[-12000:])
|
||||
raise
|
||||
finally:
|
||||
done.set()
|
||||
if monitor.is_alive(): monitor.join(timeout=2)
|
||||
if client is not None: client.close()
|
||||
if server is not None: stop(server)
|
||||
if minio is not None:
|
||||
minio.terminate()
|
||||
try: minio.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired: minio.kill(); minio.wait()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', type=Path, default=Path('target/release/rust-xmpp'))
|
||||
parser.add_argument('--minio', type=Path, default=os.environ.get('MINIO_BINARY'))
|
||||
parser.add_argument('--minio-compat-patch', type=Path, help='record any test-only MinIO environment adapter diff in the report')
|
||||
parser.add_argument('--size', type=int, default=10 * 2**30)
|
||||
parser.add_argument('--temp-dir', type=Path)
|
||||
parser.add_argument('--server-rss-mib', type=int, default=512)
|
||||
parser.add_argument('--minio-rss-mib', type=int, default=2048)
|
||||
parser.add_argument('--report', type=Path)
|
||||
args = parser.parse_args()
|
||||
parser.error('--minio or MINIO_BINARY must name a genuine MinIO executable') if args.minio is None else None
|
||||
args.binary, args.minio = args.binary.resolve(), args.minio.resolve()
|
||||
assert args.size > PART and args.size >= 32
|
||||
report = {'ok': False}
|
||||
try: run(args, report)
|
||||
except Exception as error:
|
||||
report['error'] = str(error)
|
||||
raise
|
||||
finally:
|
||||
result = json.dumps(report, indent=2)
|
||||
print(result, flush=True)
|
||||
if args.report: args.report.write_text(result + '\n')
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real TLS XMPP/HTTP multi-component upload isolation; no external services."""
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import copy
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
from _wire_client import Client, BIN
|
||||
|
||||
UPLOAD = 'urn:xmpp:http:upload:0'
|
||||
DISCO = 'http://jabber.org/protocol/disco#info'
|
||||
|
||||
|
||||
def free_port():
|
||||
with socket.socket() as probe:
|
||||
probe.bind(('127.0.0.1', 0))
|
||||
return probe.getsockname()[1]
|
||||
|
||||
|
||||
def request(port, host, path, method='GET', body=None, authorization=None, extra_headers=None):
|
||||
connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5)
|
||||
headers = {'Host': host, 'Connection': 'close'}
|
||||
if authorization:
|
||||
headers['Authorization'] = authorization
|
||||
headers.update(extra_headers or {})
|
||||
try:
|
||||
connection.request(method, path, body, headers)
|
||||
response = connection.getresponse()
|
||||
return response.status, response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def running(binary, config, root):
|
||||
path = root / 'config.json'
|
||||
path.write_text(json.dumps(config))
|
||||
with (root / 'server.log').open('w+') as log:
|
||||
process = subprocess.Popen([binary, 'serve', str(path), '--experimental'], stdout=log, stderr=log)
|
||||
try:
|
||||
for _ in range(100):
|
||||
assert process.poll() is None, 'server exited during startup'
|
||||
try:
|
||||
with socket.create_connection(('127.0.0.1', config['global']['http_ports'][0]), .1):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(.05)
|
||||
else:
|
||||
raise AssertionError('HTTP listener startup timed out')
|
||||
yield
|
||||
except BaseException:
|
||||
log.flush()
|
||||
log.seek(0)
|
||||
print(log.read())
|
||||
raise
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait()
|
||||
|
||||
|
||||
def slot(client, component, identifier, size=5, error=False):
|
||||
client.send(f"<iq type='get' id='{identifier}' to='{component}'><request xmlns='{UPLOAD}' filename='hello.txt' size='{size}' content-type='text/plain'/></iq>")
|
||||
result = client.iq(identifier, error=error)
|
||||
if error:
|
||||
return result
|
||||
put = result.find(f'{{{UPLOAD}}}slot/{{{UPLOAD}}}put')
|
||||
return urlsplit(put.get('url')), put.find(f'{{{UPLOAD}}}header').text
|
||||
|
||||
|
||||
def exercise(binary, root, shared):
|
||||
root.mkdir()
|
||||
cert, key = root / 'cert.pem', root / 'key.pem'
|
||||
subprocess.run(['openssl', 'req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost', '-keyout', str(key), '-out', str(cert)], check=True, capture_output=True)
|
||||
port, c2s = free_port(), free_port()
|
||||
hosts = ['files.example.test', 'files.example.test'] if shared else ['a.example.test', 'b.example.test']
|
||||
mounts = ['/files/a', '/files/b'] if shared else ['/files', '/files']
|
||||
components = ['upload-a.localhost', 'upload-b.localhost']
|
||||
config = {'global': {'data_path': str(root / 'data'), 'interfaces': ['127.0.0.1'], 'c2s_ports': [c2s], 'http_ports': [port], 'https_ports': [], 's2s_ports': [], 'modules_enabled': ['tls', 'saslauth', 'disco', 'websocket', 'bosh', 'http_altconnect'], 'consider_bosh_secure': True, 'consider_websocket_secure': True, 'ssl': {'certificate': str(cert), 'key': str(key)}}, 'hosts': {'localhost': {'options': {'authentication': 'internal_hashed'}}}}
|
||||
for index, component in enumerate(components):
|
||||
config['hosts'][component] = {'component': 'http_file_share', 'options': {'http_host': hosts[index], 'http_paths': {'file_share': mounts[index]}, 'http_file_share_size_limit': 8 * (index + 1), 'http_file_share_daily_quota': 5, 'http_file_share_global_quota': 5, 'http_file_share_secret': f'component-secret-{index}'}}
|
||||
path = root / 'config.json'
|
||||
path.write_text(json.dumps(config))
|
||||
subprocess.run([binary, 'create-user', str(path), 'alice', '--password-stdin'], input='secret\n', text=True, check=True, capture_output=True)
|
||||
slots = []
|
||||
with running(binary, config, root):
|
||||
client = Client(c2s, cert, 'alice', 'secret')
|
||||
try:
|
||||
client.send(f"<iq type='get' id='items' to='localhost'><query xmlns='http://jabber.org/protocol/disco#items'/></iq>")
|
||||
items = {node.get('jid') for node in client.iq('items').iter()}
|
||||
assert set(components) <= items, items
|
||||
for index, component in enumerate(components):
|
||||
client.send(f"<iq type='get' id='info-{index}' to='{component}'><query xmlns='{DISCO}'/></iq>")
|
||||
info = client.iq(f'info-{index}')
|
||||
assert any(node.get('var') == UPLOAD for node in info.iter())
|
||||
fields = {node.get('var'): node.find('{jabber:x:data}value').text for node in info.iter('{jabber:x:data}field')}
|
||||
assert fields['max-file-size'] == str(8 * (index + 1)), fields
|
||||
url, token = slot(client, component, f'slot-{index}')
|
||||
assert url.hostname == hosts[index] and url.path.startswith(mounts[index] + '/'), url
|
||||
slots.append((url, token))
|
||||
slot(client, component, f'quota-{index}', size=1, error=True)
|
||||
# A JWT issued by component A must not authorize B's valid slot.
|
||||
assert request(port, hosts[1], slots[1][0].path, 'PUT', b'hello', slots[0][1])[0] == 403
|
||||
for index, (url, token) in enumerate(slots):
|
||||
assert request(port, hosts[index], url.path, 'PUT', b'hello', token)[0] == 201
|
||||
assert request(port, hosts[index].upper() + f':{port}', url.path) == (200, b'hello')
|
||||
assert request(port, hosts[index], url.path, 'PUT', b'hello', token)[0] == 409
|
||||
assert request(port, 'unknown.example.test', url.path)[0] == 404
|
||||
other = 1 - index
|
||||
wrong_path = mounts[other] + url.path[len(mounts[index]):]
|
||||
assert request(port, hosts[other], wrong_path)[0] == 404
|
||||
# Transport and discovery middleware continue working around uploads.
|
||||
assert request(port, 'localhost', '/.well-known/host-meta')[0] == 200
|
||||
assert request(port, 'localhost', '/xmpp-websocket', extra_headers={'Connection': 'Upgrade', 'Upgrade': 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==', 'Sec-WebSocket-Protocol': 'xmpp'})[0] == 101
|
||||
assert request(port, 'localhost', '/http-bind', 'POST', b"<body xmlns='http://jabber.org/protocol/httpbind' to='localhost' rid='1' wait='1' hold='1' ver='1.6'/>")[0] == 200
|
||||
finally:
|
||||
client.close()
|
||||
with running(binary, config, root):
|
||||
for index, (url, _) in enumerate(slots):
|
||||
assert request(port, hosts[index], url.path) == (200, b'hello')
|
||||
log_text = (root / "server.log").read_text()
|
||||
assert '"route"="upload"' in log_text, "custom upload route missing from HTTP diagnostics"
|
||||
assert all(token not in log_text for _, token in slots), "upload credentials leaked to logs"
|
||||
if shared:
|
||||
for second_path in (mounts[0], mounts[0] + '/nested', '/'):
|
||||
ambiguous = copy.deepcopy(config)
|
||||
ambiguous['hosts'][components[1]]['options']['http_paths']['file_share'] = second_path
|
||||
path.write_text(json.dumps(ambiguous))
|
||||
result = subprocess.run([binary, 'serve', str(path), '--experimental'], capture_output=True, text=True, timeout=8)
|
||||
assert result.returncode != 0 and 'ambiguous HTTP upload components' in result.stderr, result.stderr
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--binary', default=str(BIN))
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='xmpp-upload-multi-') as temporary:
|
||||
for shared in (False, True):
|
||||
exercise(str(Path(args.binary).resolve()), Path(temporary) / str(shared), shared)
|
||||
print('PASS: multiple upload hosts/shared-host paths, discovery, quotas, JWT isolation, persistence, transport composition and conflict validation')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/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()))
|
||||
Reference in new issue
Block a user