#!/usr/bin/env python3
"""Compare HEAD and GET on a disposable loopback HTTP server."""
import argparse
import http.client
import json
import platform
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
EXPECTED = b'RELEASE-EXPECTED-v2'
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def respond(self, head):
if self.path == '/ok':
status, body = 200, b'
RELEASE-EXPECTED-v2
'
elif self.path == '/wrong-content':
status, body = 200, b'RELEASE-OLD-v1
'
elif self.path == '/method-mismatch':
# Deliberately model an application that handles methods differently.
status, body = (200, b'ok') if head else (503, b'unavailable')
else:
status, body = 404, b'not found'
self.send_response(status)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
if not head:
self.wfile.write(body)
def do_HEAD(self):
self.respond(True)
def do_GET(self):
self.respond(False)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--output', required=True)
args = parser.parse_args()
output = Path(args.output)
if output.exists():
raise SystemExit('Existing evidence will not be overwritten.')
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, kwargs={'poll_interval': 0.05}, daemon=True)
thread.start()
observations = []
try:
for path in ['/ok', '/wrong-content', '/method-mismatch', '/missing']:
pair = {'path': path}
for method in ['HEAD', 'GET']:
connection = http.client.HTTPConnection('127.0.0.1', server.server_port, timeout=3)
try:
connection.request(method, path)
response = connection.getresponse()
body = response.read()
pair[method.lower()] = {'status': response.status, 'body_bytes': len(body), 'content_length': response.getheader('Content-Length'), 'expected_marker': EXPECTED in body, 'body': body.decode('utf-8')}
finally:
connection.close()
observations.append(pair)
assert all(row['head']['body_bytes'] == 0 for row in observations)
assert observations[0]['get']['expected_marker']
assert observations[1]['head']['status'] == 200 and not observations[1]['get']['expected_marker']
assert observations[2]['head']['status'] == 200 and observations[2]['get']['status'] == 503
assert observations[3]['get']['status'] == 404
finally:
server.shutdown()
server.server_close()
thread.join(timeout=3)
labels = ['정상 본문', '다른 릴리스 본문', '의도적으로 다른 메서드 처리', '없는 경로']
rows = [[label, item['head']['status'], item['head']['body_bytes'], item['get']['status'], item['get']['body_bytes'], '있음' if item['get']['expected_marker'] else '없음'] for label, item in zip(labels, observations)]
record = {'schema_version': 1, 'observed_at': datetime.now(timezone.utc).isoformat(), 'environment': {'os': platform.system(), 'python': platform.python_version(), 'server': 'Python ThreadingHTTPServer', 'binding': '127.0.0.1:자동 배정 포트'}, 'method': '임시 루프백 서버의 네 경로에 HEAD와 GET을 각각 요청하고 상태·본문 바이트 수·예상 릴리스 문자열을 비교했다. 잘못된 본문과 메서드별 분기는 실험을 위해 의도적으로 구현했다.', 'limits': '합성 서버에서의 기능 비교이며 운영 장애·CDN·TLS·부하·성능 측정이 아니다. 실제 서비스가 HEAD와 GET을 다르게 구현했다는 증거가 아니다.', 'observations': observations, 'tables': {'requests': {'headers': ['경로 조건', 'HEAD 상태', 'HEAD 본문 바이트', 'GET 상태', 'GET 본문 바이트', 'GET 예상 문자열'], 'rows': rows}}}
output.parent.mkdir(parents=True, exist_ok=True)
with output.open('x', encoding='utf-8') as stream:
json.dump(record, stream, ensure_ascii=False, indent=2)
stream.write('\n')
print(json.dumps({'output': str(output), 'cases': len(rows), 'checks': 'passed'}, ensure_ascii=False))
if __name__ == '__main__':
main()