From 5d67acf5db40e6a7404020e03cc677919052b0b5 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 6 Oct 2026 09:16:49 -0700 Subject: [PATCH] test/s3: add S3/IAM error-response compatibility harness --- test/s3/error_compat/Makefile | 100 +++++++++ test/s3/error_compat/s3_error_compat_test.py | 207 +++++++++++++++++++ test/s3/error_compat/test_s3.json | 20 ++ 3 files changed, 327 insertions(+) create mode 100644 test/s3/error_compat/Makefile create mode 100644 test/s3/error_compat/s3_error_compat_test.py create mode 100644 test/s3/error_compat/test_s3.json diff --git a/test/s3/error_compat/Makefile b/test/s3/error_compat/Makefile new file mode 100644 index 0000000000..703031ee83 --- /dev/null +++ b/test/s3/error_compat/Makefile @@ -0,0 +1,100 @@ +# S3/IAM error response compatibility test harness + +.PHONY: all test help build-weed check-deps start-server stop-server test-errors test-with-server clean logs + +WEED_BINARY := ../../../weed/weed_binary +S3_PORT := 8334 +TEST_TIMEOUT := 10m +SERVER_DIR := ./test-volume-data +S3_CONFIG := ./test_s3.json + +.DEFAULT_GOAL := help + +all: test + +test: test-with-server + +help: + @echo "S3/IAM error response compatibility test harness" + @echo "" + @echo "Available targets:" + @echo " build-weed - Build the SeaweedFS binary" + @echo " start-server - Start SeaweedFS mini with S3 and embedded IAM enabled" + @echo " stop-server - Stop the test server" + @echo " test-errors - Run the error compatibility checks" + @echo " test-with-server - Start server, run checks, stop server" + @echo " logs - Show server logs" + @echo " clean - Remove test artifacts" + +build-weed: + @echo "Building SeaweedFS binary..." + @cd ../../../weed && go build -o weed_binary . + @chmod +x $(WEED_BINARY) + +check-deps: build-weed + @command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1) + @command -v python3 >/dev/null 2>&1 || (echo "python3 is required but not installed" && exit 1) + @python3 -c "import botocore" 2>/dev/null || (echo "botocore is required: pip install botocore" && exit 1) + @test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1) + @test -f $(S3_CONFIG) || (echo "S3 config not found at $(S3_CONFIG)" && exit 1) + +start-server: check-deps + @echo "Starting SeaweedFS mini for error compatibility tests..." + @rm -f weed-server.pid + @mkdir -p $(SERVER_DIR) + @if curl -sS -o /dev/null http://127.0.0.1:$(S3_PORT)/ >/dev/null 2>&1; then \ + echo "port $(S3_PORT) is already served by another process; refusing to test against it"; \ + exit 1; \ + fi + @$(WEED_BINARY) mini \ + -dir=$(SERVER_DIR) \ + -s3 \ + -s3.port=$(S3_PORT) \ + -s3.config=$(S3_CONFIG) \ + -s3.iam.readOnly=false \ + -master.peers=none \ + > weed-test.log 2>&1 & echo $$! > weed-server.pid + @for i in $$(seq 1 60); do \ + kill -0 $$(cat weed-server.pid) 2>/dev/null || { \ + echo "SeaweedFS server exited during startup"; \ + echo "=== Server logs ==="; \ + test -f weed-test.log && cat weed-test.log || true; \ + exit 1; \ + }; \ + if curl -sS -o /dev/null http://127.0.0.1:$(S3_PORT)/ >/dev/null 2>&1; then \ + echo "SeaweedFS S3 server is ready on port $(S3_PORT)"; \ + sleep 3; \ + exit 0; \ + fi; \ + sleep 1; \ + done; \ + echo "SeaweedFS S3 server failed to start"; \ + echo "=== Server logs ==="; \ + test -f weed-test.log && cat weed-test.log || true; \ + exit 1 + +stop-server: + @if [ -f weed-server.pid ]; then \ + echo "Stopping SeaweedFS server..."; \ + kill $$(cat weed-server.pid) 2>/dev/null || true; \ + sleep 2; \ + rm -f weed-server.pid; \ + fi + +test-errors: + @echo "Running S3/IAM error compatibility checks..." + python3 s3_error_compat_test.py \ + --endpoint http://127.0.0.1:$(S3_PORT) \ + --access-key admin \ + --secret-key admin + +test-with-server: start-server + @$(MAKE) test-errors; TEST_RESULT=$$?; \ + $(MAKE) stop-server; exit $$TEST_RESULT + +logs: + @test -f weed-test.log && tail -100 weed-test.log || echo "No log file found" + +clean: stop-server + @rm -rf $(SERVER_DIR) weed-test.log weed-server.pid + @echo "Cleaned up test artifacts" diff --git a/test/s3/error_compat/s3_error_compat_test.py b/test/s3/error_compat/s3_error_compat_test.py new file mode 100644 index 0000000000..22737d971b --- /dev/null +++ b/test/s3/error_compat/s3_error_compat_test.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Verify S3/IAM error responses match AWS for common client mistakes. + +Needs only botocore (pip install botocore). Sends raw SigV4 requests so the exact +status and response body are visible, compares each with what AWS returns, then +cleans up everything it created (one bucket, one IAM user). + +Usage: + python3 s3_error_compat_test.py --endpoint http://127.0.0.1:8333 \ + --access-key --secret-key + +The key needs Admin rights, and IAM writes must be enabled (-s3.iam.readOnly=false). +""" +import argparse, atexit, base64, datetime, hashlib, http.client, re, sys, time, urllib.parse, uuid + +from botocore.auth import S3SigV4Auth, SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.credentials import Credentials + +ap = argparse.ArgumentParser() +ap.add_argument('--endpoint', default='http://127.0.0.1:8333') +ap.add_argument('--access-key', required=True) +ap.add_argument('--secret-key', required=True) +ap.add_argument('--region', default='us-east-1') +args = ap.parse_args() + +EP = args.endpoint.rstrip('/') +U = urllib.parse.urlsplit(EP) +ADMIN = Credentials(args.access_key, args.secret_key) +BAD_KEY = Credentials('AKIAREPRONOSUCHKEY00', 'x' * 40) +BAD_SECRET = Credentials(args.access_key, 'y' * 40) +B = 'errorcompat-' + uuid.uuid4().hex[:8] +S3DOC = 'https://docs.aws.amazon.com/AmazonS3/latest/API/' +IAMDOC = 'https://docs.aws.amazon.com/IAM/latest/APIReference/' +rows = [] + + +def send(method, path, body=b'', headers=None, creds=ADMIN, service='s3', mutate=None, sign=True, + keep_sha256_header=False): + headers = dict(headers or {}) + url = EP + path + if sign: + req = AWSRequest(method=method, url=url, data=body, headers=headers) + if service == 's3': + if 'X-Amz-Content-SHA256' not in req.headers: + req.headers['X-Amz-Content-SHA256'] = hashlib.sha256(body).hexdigest() + # S3SigV4Auth always recomputes X-Amz-Content-SHA256 from the body; the + # generic SigV4Auth signs a caller-supplied value instead, which is how + # a mismatched declared hash can be produced on the wire. + signer = SigV4Auth if keep_sha256_header else S3SigV4Auth + signer(creds, 's3', args.region).add_auth(req) + else: + SigV4Auth(creds, service, args.region).add_auth(req) + headers = dict(req.headers.items()) + if mutate: + headers = mutate(headers) + conn = http.client.HTTPConnection(U.hostname, U.port, timeout=30) + conn.request(method, path, body=body, headers=headers) + r = conn.getresponse() + data = r.read().decode('utf-8', 'replace') + conn.close() + return r.status, data + + +def shape(data): + """Root element and of an XML error body.""" + body = re.sub(r'<\?xml[^>]*\?>', '', data).strip() + root = re.match(r'<(\w+)', body) + code = re.search(r'([^<]*)', data) + return (root.group(1) if root else ('-' if not body else 'non-xml')), (code.group(1) if code else '') + + +def iam(params, creds=ADMIN): + p = dict(params, Version='2010-05-08') + return dict(method='POST', path='/', body=urllib.parse.urlencode(p).encode(), creds=creds, service='iam', + headers={'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8'}) + + +def case(group, name, expect, ref, req, check=None): + """expect = (status, root element or None, code or None); check() adds a side-effect note.""" + st, data = send(**req) + root, code = shape(data) + note = check() if check else '' + ok = st == expect[0] and expect[1] in (None, root) and expect[2] in (None, code) + rows.append((group, name, '%s %s %s' % (st, root, code), '%s %s %s' % (expect[0], expect[1] or '', expect[2] or ''), note, ok, ref)) + + +def exists(key): + return lambda: 'object %r now exists' % key if send('HEAD', '/%s/%s' % (B, key))[0] == 200 else '' + + +def S3(method, path, **kw): + return dict(method=method, path=path, **kw) + + +# ---------------- setup ---------------- +st, d = send('PUT', '/' + B) +if st != 200: + sys.exit('cannot create bucket %s: %s %s' % (B, st, d[:200])) +send('PUT', '/%s/obj' % B, body=b'hello') +st, d = send('POST', '/%s/mp?uploads' % B) +upload_id = re.search(r'([^<]+)', d).group(1) +user = 'errorcompat-' + uuid.uuid4().hex[:6] +send(**iam({'Action': 'CreateUser', 'UserName': user})) +st, d = send(**iam({'Action': 'CreateAccessKey', 'UserName': user})) +USER = Credentials(re.search(r'([^<]+)', d).group(1), re.search(r'([^<]+)', d).group(1)) +for _ in range(15): # wait until the new key is accepted + if 'InvalidAccessKeyId' not in send('GET', '/', creds=USER)[1]: + break + time.sleep(1) + +# ---------------- 1. requests that run a different operation ---------------- +for sub in ('logging', 'notification', 'accelerate', 'website', 'replication', + 'analytics&id=a', 'inventory&id=a', 'metrics&id=a', 'intelligent-tiering&id=a'): + case(1, 'PUT ?%s' % sub, (501, 'Error', 'NotImplemented'), S3DOC + 'API_Error.html#:~:text=Code%3A%20NotImplemented', + S3('PUT', '/%s?%s' % (B, sub), body=b'')) +case(1, 'CopyObject, x-amz-copy-source without "/"', (400, 'Error', 'InvalidArgument'), + S3DOC + 'API_CopyObject.html#AmazonS3-CopyObject-request-header-CopySource', + S3('PUT', '/%s/copy-dst' % B, headers={'x-amz-copy-source': 'nobucketonly'}), exists('copy-dst')) +case(1, 'UploadPart partNumber=abc', (400, 'Error', 'InvalidArgument'), + S3DOC + 'API_UploadPart.html#AmazonS3-UploadPart-request-uri-querystring-PartNumber', + S3('PUT', '/%s/mp?partNumber=abc&uploadId=%s' % (B, upload_id), body=b'part-body'), exists('mp')) +case(1, 'PutObject, x-amz-content-sha256 != body', (400, 'Error', 'XAmzContentSHA256Mismatch'), + 'undocumented code; ' + S3DOC + 'API_UploadPart.html#API_UploadPart_RequestSyntax', + S3('PUT', '/%s/sha-mismatch' % B, body=b'abc', + headers={'X-Amz-Content-SHA256': hashlib.sha256(b'zzz').hexdigest()}, keep_sha256_header=True), + exists('sha-mismatch')) +case(1, 'PutObject, x-amz-content-sha256 not hex', (400, 'Error', None), 'undocumented', + S3('PUT', '/%s/sha-nothex' % B, body=b'abc', + headers={'X-Amz-Content-SHA256': 'nothex'}, keep_sha256_header=True), exists('sha-nothex')) + +# ---------------- 2. IAM failures in the S3 envelope ---------------- +case(2, 'IAM ListUsers, unknown access key', (403, 'ErrorResponse', 'InvalidClientTokenId'), + 'checked against iam.amazonaws.com (docs list UnrecognizedClientException); ' + IAMDOC + 'CommonErrors.html#CommonErrors-UnrecognizedClientException', iam({'Action': 'ListUsers'}, creds=BAD_KEY)) +case(2, 'IAM ListUsers, wrong secret', (403, 'ErrorResponse', None), 'undocumented for IAM', + iam({'Action': 'ListUsers'}, creds=BAD_SECRET)) +case(2, 'IAM ListUsers, non-admin user', (403, 'ErrorResponse', 'AccessDenied'), + IAMDOC + 'CommonErrors.html#CommonErrors-AccessDeniedException', iam({'Action': 'ListUsers'}, creds=USER)) +case(2, 'IAM CreateUser, non-admin user', (403, 'ErrorResponse', 'AccessDenied'), + IAMDOC + 'CommonErrors.html#CommonErrors-AccessDeniedException', iam({'Action': 'CreateUser', 'UserName': user + 'x'}, creds=USER)) + +# ---------------- 3. wrong status or code ---------------- +tags = [('Action', 'TagUser'), ('UserName', user)] + [('Tags.member.%d.%s' % (i, k), 'k%d' % i if k == 'Key' else 'v') + for i in range(1, 52) for k in ('Key', 'Value')] +case(3, 'IAM TagUser with 51 tags', (409, 'ErrorResponse', 'LimitExceeded'), IAMDOC + 'API_TagUser.html#API_TagUser_Errors', + iam(dict(tags))) +case(3, 'IAM unknown Action', (404, 'ErrorResponse', 'InvalidAction'), 'checked against iam.amazonaws.com', + iam({'Action': 'NoSuchActionZZ'})) + + +def no_date(h): + return {k: v for k, v in h.items() if k.lower() != 'x-amz-date'} + + +def drop(field): + return lambda h: dict(h, Authorization=re.sub(r',? *%s=[^,]*' % field, '', h['Authorization'])) + + +case(3, 'S3 request without x-amz-date', (403, 'Error', 'AccessDenied'), 'checked against s3.amazonaws.com; ' + S3DOC + 'API_Error.html#:~:text=Code%3A%20AccessDenied', + S3('GET', '/' + B, mutate=no_date)) +case(3, 'Authorization without Signature=', (400, 'Error', 'AuthorizationHeaderMalformed'), + 'checked against s3.amazonaws.com; ' + S3DOC + 'API_Error.html#:~:text=Code%3A%20AuthorizationHeaderMalformed', S3('GET', '/' + B, mutate=drop('Signature'))) +case(3, 'Authorization without Credential=', (400, 'Error', 'InvalidArgument'), + 'checked against s3.amazonaws.com ("Unsupported Authorization Type")', S3('GET', '/' + B, mutate=drop('Credential'))) +for m, exp, chk in (('GET', (400, 'Error', 'InvalidArgument'), 'checked against s3.amazonaws.com ("Invalid version id specified"); '), + ('HEAD', (400, None, None), 'checked against s3.amazonaws.com; '), + ('DELETE', (400, None, None), 'unverified (needs write access); ')): + case(3, '%s ?versionId=bogus, unversioned bucket' % m, exp, + chk + S3DOC + 'API_GetObject.html#AmazonS3-GetObject-request-uri-querystring-VersionId', + S3(m, '/%s/obj?versionId=bogus' % B)) +for pn in ('0', '10001'): + case(3, 'UploadPart partNumber=%s' % pn, (400, 'Error', 'InvalidArgument'), + S3DOC + 'API_UploadPart.html#AmazonS3-UploadPart-request-uri-querystring-PartNumber', + S3('PUT', '/%s/mp?partNumber=%s&uploadId=%s' % (B, pn, upload_id), body=b'x')) +case(3, 'DeleteObjects with 1001 keys', (400, 'Error', 'MalformedXML'), S3DOC + 'API_DeleteObjects.html#API_DeleteObjects_RequestBody', + S3('POST', '/%s?delete' % B, body=('' + ''.join('k%d' % i for i in range(1001)) + '').encode())) +# last: this one deletes the user when it should be refused +case(3, 'IAM DeleteUser while it has an access key', (409, 'ErrorResponse', 'DeleteConflict'), + IAMDOC + 'API_DeleteUser.html#API_DeleteUser_Errors', iam({'Action': 'DeleteUser', 'UserName': user})) + +# ---------------- cleanup (atexit so failed runs still clean up) ---------------- +def cleanup(): + try: + send(**iam({'Action': 'DeleteAccessKey', 'UserName': user, 'AccessKeyId': USER.access_key})) + send(**iam({'Action': 'DeleteUser', 'UserName': user})) + send('DELETE', '/%s/mp?uploadId=%s' % (B, upload_id)) + st, d = send('GET', '/%s?list-type=2' % B) + for k in re.findall(r'([^<]+)', d): + send('DELETE', '/%s/%s' % (B, urllib.parse.quote(k))) + send('DELETE', '/' + B) + except Exception as e: + print('cleanup failed: %s' % e) + + +atexit.register(cleanup) + +# ---------------- report ---------------- +w = max(len(r[1]) for r in rows) +print('%-4s %-*s %-46s %-40s %s' % ('', w, 'case', 'got (status root code)', 'expected (AWS)', 'side effect')) +for g, name, got, exp, note, ok, ref in rows: + print('%-4s %-*s %-46s %-40s %s' % ('ok' if ok else 'DIFF', w, name, got, exp, note)) +print('\nreferences:') +for g, name, got, exp, note, ok, ref in rows: + print(' %-*s %s' % (w, name, ref)) +diff = sum(not r[5] for r in rows) +print('\n%d cases, %d differ from AWS' % (len(rows), diff)) +sys.exit(1 if diff else 0) diff --git a/test/s3/error_compat/test_s3.json b/test/s3/error_compat/test_s3.json new file mode 100644 index 0000000000..1f9b460008 --- /dev/null +++ b/test/s3/error_compat/test_s3.json @@ -0,0 +1,20 @@ +{ + "identities": [ + { + "name": "admin", + "credentials": [ + { + "accessKey": "admin", + "secretKey": "admin" + } + ], + "actions": [ + "Admin", + "Read", + "List", + "Tagging", + "Write" + ] + } + ] +}