initial commit
Rust verification / test (push) Successful in 4m10s

This commit is contained in:
wt committed 2026-10-28 15:59:54 +07:00
commit cf25e28c28
156 files changed
+38669

No files matched your search

+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Compare two isolated 2FAuth instances without emitting response bodies/secrets.
No redirects or retries. Cookie sessions require explicit opt-in. See compatibility/README.md.
"""
import argparse
import json
import copy
import http.cookiejar
import re
import os
from pathlib import Path
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_CASES = [
{'id': 'health', 'method': 'GET', 'path': '/up'},
{'id': 'manifest', 'method': 'GET', 'path': '/manifest.json'},
{'id': 'deprecated-name', 'method': 'GET', 'path': '/api/v1/user/name'},
{'id': 'unknown-api', 'method': 'GET', 'path': '/api/v1/__compat_missing__'},
{'id': 'cors-preflight', 'method': 'OPTIONS', 'path': '/api/v1/twofaccounts', 'expected_status': 204,
'headers': {'Origin': 'https://example.invalid', 'Access-Control-Request-Method': 'POST', 'Access-Control-Request-Headers': 'Authorization,Content-Type'},
'compare_headers': ['Access-Control-Allow-Origin', 'Access-Control-Allow-Methods', 'Access-Control-Allow-Headers', 'Access-Control-Allow-Credentials'],
'expected_headers': {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': None}},
{'id': 'cors-actual', 'method': 'GET', 'path': '/api/v1/twofaccounts', 'expected_status': 401,
'headers': {'Origin': 'https://example.invalid'},
'compare_headers': ['Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials'],
'expected_headers': {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': None}},
{'id': 'cors-without-origin', 'method': 'GET', 'path': '/api/v1/twofaccounts', 'expected_status': 401,
'compare_headers': ['Access-Control-Allow-Origin', 'Access-Control-Allow-Credentials'],
'expected_headers': {'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': None}},
]
SAFE_CASES = {(c.get('method', 'GET'), c['path']) for c in DEFAULT_CASES}
MAX_BYTES = 4 * 1024 * 1024
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs): return None
def base_url(value):
parsed = urllib.parse.urlsplit(value)
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError('base must be HTTP(S), without credentials, query or fragment')
return value.rstrip('/')
def fetch(base, case, side, timeout, opener=None, cookies=None):
headers = {'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest'}
headers.update(case.get('headers', {}))
# Values never appear in output. Side-specific credentials refer to env names.
env_headers = case.get('headers_env', {}).get(side, {})
for header, name in env_headers.items():
if name not in os.environ: return {'error': 'missing_environment_variable'}
headers[header] = os.environ[name]
for header, cookie_name in case.get('headers_from_cookies', {}).items():
matches = [c for c in (cookies or []) if c.name == cookie_name]
if len(matches) != 1: return {'error': 'missing_or_ambiguous_cookie'}
headers[header] = urllib.parse.unquote(matches[0].value)
data = None
if sum(name in case for name in ('json','form','raw')) > 1:
raise ValueError('a request must choose one body encoding')
if 'json' in case:
data = json.dumps(case['json']).encode()
headers['Content-Type'] = 'application/json'
elif 'form' in case:
data = urllib.parse.urlencode(case['form'], doseq=True).encode()
headers['Content-Type'] = 'application/x-www-form-urlencoded'
elif 'raw' in case:
data = case['raw'].encode()
req = urllib.request.Request(base + case['path'], data=data, headers=headers, method=case.get('method', 'GET'))
try:
try: response = (opener or urllib.request.build_opener(NoRedirect())).open(req, timeout=timeout)
except urllib.error.HTTPError as exc: response = exc
with response:
body = response.read(MAX_BYTES + 1)
if len(body) > MAX_BYTES: return {'error': 'response_too_large'}
result = {'status': response.code, 'content_type': response.headers.get('Content-Type', '').split(';')[0].lower(), 'body': body, 'headers': response.headers}
return result
except Exception as exc:
# Exception text can contain URLs or credentials, so only emit class name.
return {'error': type(exc).__name__}
def remove_pointer(value, pointer):
if not pointer.startswith('/'): raise ValueError('ignored JSON pointers must start with /')
parts = [s.replace('~1', '/').replace('~0', '~') for s in pointer[1:].split('/')]
cursor = value
for part in parts[:-1]:
try: cursor = cursor[int(part)] if isinstance(cursor, list) else cursor[part]
except (KeyError, IndexError, ValueError, TypeError): return
if isinstance(cursor, dict): cursor.pop(parts[-1], None)
elif isinstance(cursor, list):
try: cursor[int(parts[-1])] = None
except (IndexError, ValueError): pass
def comparable(response, base, case):
body = response['body'].decode('utf-8', errors='replace')
if response['content_type'] == 'application/json':
try:
body = json.loads(body)
def normalize(value):
if isinstance(value, str): return value.replace(base, '<BASE>')
if isinstance(value, dict): return {key: normalize(item) for key, item in value.items()}
if isinstance(value, list): return [normalize(item) for item in value]
return value
body = normalize(body)
for pointer in case.get('ignore_json_pointers', []): remove_pointer(body, pointer)
except json.JSONDecodeError: body = body.replace(base, '<BASE>')
else:
body = body.replace(base, '<BASE>')
return body
def resolve(value, variables):
"""Resolve explicit {{capture}} references, keeping typed whole-value JSON."""
if isinstance(value, dict): return {k: resolve(v, variables) for k, v in value.items()}
if isinstance(value, list): return [resolve(v, variables) for v in value]
if not isinstance(value, str): return value
match = re.fullmatch(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}', value)
if match: return variables[match[1]]
return re.sub(r'\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}', lambda m: str(variables[m[1]]), value)
def read_pointer(value, pointer):
if pointer and not pointer.startswith('/'): raise ValueError('invalid JSON pointer')
for token in pointer[1:].split('/') if pointer else []:
token = token.replace('~1', '/').replace('~0', '~')
value = value[int(token)] if isinstance(value, list) else value[token]
return value
def is_text_token(response):
return response.get('status') == 200 and response.get('content_type') in ('text/plain', 'text/html') and re.fullmatch(rb'[A-Za-z0-9_-]{32,256}', response.get('body', b'')) is not None
def assert_json_response(response, case):
try:
payload = json.loads(response['body'])
for pointer, expected in case.get('expected_json', {}).items():
if read_pointer(payload, pointer) != expected: return False
for pointer in case.get('absent_json_pointers', []):
try: read_pointer(payload, pointer)
except (KeyError, IndexError, TypeError, ValueError): continue
return False
return True
except (KeyError, IndexError, TypeError, ValueError):
return False
def capture_values(response, captures, variables):
if not captures: return
payload = json.loads(response['body'])
for name, pointer in captures.items():
if not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name): raise ValueError('invalid capture name')
if pointer and not pointer.startswith('/'): raise ValueError('invalid capture pointer')
value = payload
for token in pointer[1:].split('/') if pointer else []:
token = token.replace('~1', '/').replace('~0', '~')
value = value[int(token)] if isinstance(value, list) else value[token]
variables[name] = value
def validate_path(path):
if not isinstance(path, str) or not path.startswith('/') or path.startswith('//') or any(c in path for c in '#\r\n'):
raise ValueError('case paths must be absolute local paths without fragments or newlines')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--reference', required=True, type=base_url)
parser.add_argument('--candidate', required=True, type=base_url)
parser.add_argument('--cases', type=Path)
parser.add_argument('--allow-side-effects', action='store_true')
parser.add_argument('--timeout', type=float, default=10)
parser.add_argument('--sessions', action='store_true', help='retain separate in-memory cookie jars; requires --allow-side-effects')
args = parser.parse_args()
cases = json.loads(args.cases.read_text()) if args.cases else DEFAULT_CASES
if args.reference == args.candidate: parser.error('reference and candidate must be separate instances')
if args.sessions and not args.allow_side_effects: parser.error('--sessions requires --allow-side-effects')
cookies = {side: http.cookiejar.CookieJar() for side in ('reference', 'candidate')}
openers = {side: urllib.request.build_opener(NoRedirect(), urllib.request.HTTPCookieProcessor(cookies[side])) for side in cookies}
variables = {side: {} for side in cookies}
failed = False
for index, case in enumerate(cases):
path = case.get('path', '')
try: validate_path(path)
except ValueError as error: parser.error(str(error))
risky = (case.get('method', 'GET'), path) not in SAFE_CASES or bool(case.get('headers_env')) or bool(case.get('headers_from_cookies')) or args.sessions or (bool(case.get('headers')) and case not in DEFAULT_CASES)
# User case labels can contain credentials too. Output stable numeric IDs only.
if (risky or case.get('side_effects', False)) and not args.allow_side_effects:
print(json.dumps({'case': index, 'result': 'skipped_requires_allow_side_effects'})); continue
responses = []
for side, base in (('reference', args.reference), ('candidate', args.candidate)):
try:
resolved = resolve(copy.deepcopy(case), variables[side])
validate_path(resolved['path'])
response = fetch(base, resolved, side, args.timeout,
openers[side] if args.sessions else None,
cookies[side] if args.sessions else None)
if 'error' not in response:
capture_values(response, case.get('capture_json', {}), variables[side])
except (KeyError, ValueError, TypeError, IndexError):
response = {'error': 'invalid_or_missing_capture'}
responses.append(response)
left, right = responses
errors = [r.get('error') for r in (left, right)]
if any(errors):
failed = True
print(json.dumps({'case': index, 'result': 'error', 'errors': errors})); continue
checks = {
'status': left['status'] == right['status'],
'content_type': left['content_type'] == right['content_type'],
'body': comparable(left, args.reference, case) == comparable(right, args.candidate, case),
}
if case.get('expect_text_token'):
checks['text_token_shape'] = all(is_text_token(response) for response in (left, right))
if case.get('expected_json') or case.get('absent_json_pointers'):
checks['json_assertions'] = all(assert_json_response(response, case) for response in (left, right))
if 'expected_status' in case:
checks['expected_status'] = left['status'] == right['status'] == case['expected_status']
if 'expected_headers' in case:
checks['expected_headers'] = all(response['headers'].get(header) == expected for response in (left, right) for header, expected in case['expected_headers'].items())
for header in case.get('compare_headers', []):
# Headers are compared in memory; neither names nor values are printed.
checks['selected_headers'] = checks.get('selected_headers', True) and left['headers'].get(header) == right['headers'].get(header)
equal = all(checks.values()); failed |= not equal
print(json.dumps({'case': index, 'result': 'equal' if equal else 'different', 'reference_status': left['status'], 'candidate_status': right['status'], 'checks': checks}))
return int(failed)
if __name__ == '__main__': raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Extract source evidence, not a substitute for Laravel route:list / migrated schema."""
import argparse, hashlib, json, pathlib, re, subprocess
def quoted(s): return re.findall(r"'([^']*)'",s)
def middleware(s):
m=re.search(r"(?:'middleware'\s*=>|middleware\()\s*(\[[^]]*\]|'[^']*')",s)
return quoted(m[1]) if m else []
def routes(root,rel,prefix,base):
lines=(root/rel).read_text().splitlines(); stack=[]; result=[]; i=0
while i<len(lines):
s=lines[i].strip(); line=i+1
if s.startswith('//'): i+=1; continue
if s.startswith('Route::group(') or (s.startswith('Route::middleware(') and '->group(' in s):
stack.append(middleware(s)); i+=1; continue
if s=='});' and stack: stack.pop(); i+=1; continue
if s.startswith('Route::withoutMiddleware('):
while '->get(' not in s: i+=1; s+=' '+lines[i].strip()
# /up's excluded middleware are retained separately below.
if re.match(r'Route::(?:get|post|put|patch|delete|apiResource|withoutMiddleware)',s):
while ';' not in s: i+=1; s+=' '+lines[i].strip()
m=re.search(r"(?:Route::|->)(get|post|put|patch|delete|apiResource)\('([^']*)'",s)
if not m: raise ValueError(f'unparsed {rel}:{line}')
verb,path=m.groups(); inherited=base+sum(stack,[]); local=middleware(s) if verb!='get' or 'withoutMiddleware' not in s else []
handler=re.search(r'\[([A-Za-z]+)::class,\s*\'([^\']+)\'\]',s)
controller=re.search(r"apiResource\('[^']+',\s*(\w+)::class",s)
name=re.search(r"->name\('([^']+)'\)",s)
constraints=dict(re.findall(r"->where\('([^']+)',\s*'([^']+)'\)",s))
variants=[(verb.upper(),path,handler[1]+'@'+handler[2] if handler else 'closure',name[1] if name else None)]
if verb=='apiResource':
singular={'twofaccounts':'twofaccount','groups':'group','users':'user'}[path]
variants=[(v,path+suffix,controller[1]+'@'+action,path+'.'+action) for v,suffix,action in [('GET','','index'),('POST','','store'),('GET','/{'+singular+'}','show'),('PUT','/{'+singular+'}','update'),('PATCH','/{'+singular+'}','update'),('DELETE','/{'+singular+'}','destroy')] if not (path=='users' and action=='update')]
for method,p,h,n in variants:
entry=dict(method=method,path=prefix+'/'+p.lstrip('/'),handler=h,name=n,middleware_declared=inherited+local,constraints=constraints,source=f'{rel}:{line}')
if p=='/up': entry['excluded_middleware']=['StartSession','PreventRequestForgery','SubstituteBindings','SetLanguage','CustomCreateFreshApiToken']
if method=='GET': entry['implicit_HEAD']=True
result.append(entry)
i+=1
return result
def main():
p=argparse.ArgumentParser(); p.add_argument('--upstream',type=pathlib.Path,default=pathlib.Path('upstream')); p.add_argument('--out',type=pathlib.Path,default=pathlib.Path('rust-server/compatibility')); a=p.parse_args(); root=a.upstream; a.out.mkdir(parents=True,exist_ok=True)
if (root/'.git').exists():
commit=subprocess.check_output(['git','-C',str(root),'rev-parse','HEAD'],text=True).strip()
else:
commit=(root/'UPSTREAM_COMMIT').read_text().strip()
if not re.fullmatch(r'[0-9a-f]{40}',commit): raise ValueError('invalid pinned commit metadata')
kernel=(root/'app/Http/Kernel.php').read_text()
def raw_block(name):
m=re.search(r'protected \$'+name+r'\s*=\s*\[(.*?)\n \];',kernel,re.S)
return m[1].strip() if m else None
rt=routes(root,'routes/web.php','',['web'])+routes(root,'routes/api/v1.php','/api/v1',['api.v1'])
payload={'upstream_commit':commit,'kind':'static application declarations; not runtime package routes','notes':['GET routes implicitly support HEAD. Ordering is source ordering.','Middleware declarations require alias/group expansion, deduplication and Laravel priority sorting.','Package-discovered routes and OPTIONS/CORS behavior require runtime route:list.','settingName has global constraint [a-zA-Z]+ from RouteServiceProvider.'], 'middleware_source':'app/Http/Kernel.php','middleware_global_php':raw_block('middleware'),'middleware_groups_php':raw_block('middlewareGroups'),'middleware_aliases_php':raw_block('middlewareAliases'),'middleware_priority_php':raw_block('middlewarePriority'),'routes':rt}
write=lambda name,data:(a.out/name).write_text(json.dumps(data,indent=2)+'\n')
write('routes.json',payload)
migrations=[]
for f in sorted((root/'database/migrations').glob('*.php')):
src=f.read_text(); up=re.split(r'public function down\(',src)[0]
migrations.append({'file':str(f.relative_to(root)),'sha256':hashlib.sha256(src.encode()).hexdigest(),'schema_operations':re.findall(r"Schema::(create|table|rename|dropIfExists)\(\s*'([^']+)'",up),'up_source':up,'note':'Raw up source includes exact definitions and transformations. Helpers defined after down may need the full source; no SQL schema is inferred.'})
write('database.json',{'upstream_commit':commit,'kind':'ordered migration evidence; not a materialized SQL schema','drivers':['sqlite','mysql','pgsql','sqlsrv'],'migrations':migrations})
env={}
for f in sorted((root/'config').glob('*.php')):
for n,line in enumerate(f.read_text().splitlines(),1):
if line.lstrip().startswith('//'): continue
for key in re.findall(r"env\('([^']+)'",line): env.setdefault(key,[]).append(f'{f.relative_to(root)}:{n}')
write('environment.json',{'upstream_commit':commit,'kind':'configuration env names and source references only; no deployment values','variables':env,'dynamic_names':['USERPREF_DEFAULT__<PREFERENCE>','USERPREF_LOCKED__<PREFERENCE>']})
print(json.dumps({'commit':commit,'application_routes':len(rt),'migrations':len(migrations),'env_names':len(env)}))
if __name__=='__main__': main()
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Exercise Passport wire compatibility against a disposable copy of a PHP DB.
Requires a bootable pinned PHP reference app (including vendor dependencies),
its matching APP_KEY/RSA keys, and a freshly built Rust executable. Never changes
its source database. Cookies, keys and bearer values are never printed.
"""
import argparse
import http.cookies
import json
import os
import pathlib
import sqlite3
import subprocess
import tempfile
import time
import urllib.error
import urllib.request
FIXTURE = r'''<?php
require getenv('INTEROP_UPSTREAM').'/vendor/autoload.php';
$app=require getenv('INTEROP_UPSTREAM').'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$clients=app(Laravel\Passport\ClientRepository::class);
try {$clients->personalAccessClient('users');} catch (RuntimeException $e) {$clients->createPersonalAccessGrantClient('2FAuth','users');}
$user=App\Models\User::findOrFail((int)getenv('INTEROP_USER_ID'));
// A synthetic fresh login also refreshes the application's inactivity clock.
$user->last_seen_at=now(); $user->save();
$token=$user->createToken('php-generated-interop', []);
$e=app('encrypter'); $csrf='php-interop-csrf-value-00001';
$jwt=app(Laravel\Passport\ApiTokenCookieFactory::class)->make($user->id,$csrf)->getValue();
$cookie=$e->encryptString(Illuminate\Cookie\CookieValuePrefix::create('laravel_token',$e->getKey()).$jwt);
$xsrf=$e->encryptString(Illuminate\Cookie\CookieValuePrefix::create('XSRF-TOKEN',$e->getKey()).$csrf);
file_put_contents(getenv('INTEROP_FIXTURE'),json_encode(['token'=>$token->accessToken,'id'=>$token->accessTokenId,'cookie'=>$cookie,'xsrf'=>$xsrf]));
'''
VALIDATE = r'''<?php
require getenv('INTEROP_UPSTREAM').'/vendor/autoload.php';
$app=require getenv('INTEROP_UPSTREAM').'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$token=json_decode(file_get_contents(getenv('INTEROP_ISSUED')),true)['accessToken'];
$request=Symfony\Component\HttpFoundation\Request::create('/api/v1/twofaccounts','GET',[],[],[],['HTTP_AUTHORIZATION'=>'Bearer '.$token]);
$psr=(new Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory)->createRequest($request);
$validated=app(League\OAuth2\Server\ResourceServer::class)->validateAuthenticatedRequest($psr);
echo json_encode(['php_validated_rust_bearer'=>(string)$validated->getAttribute('oauth_user_id')===getenv('INTEROP_USER_ID')]);
'''
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--php', required=True, type=pathlib.Path)
parser.add_argument('--upstream', required=True, type=pathlib.Path)
parser.add_argument('--reference-app', required=True, type=pathlib.Path)
parser.add_argument('--reference-db', required=True, type=pathlib.Path)
parser.add_argument('--candidate-binary', required=True, type=pathlib.Path)
parser.add_argument('--user-id', default='1')
parser.add_argument('--port', default=18081, type=int)
args = parser.parse_args()
if not os.environ.get('APP_KEY'):
parser.error('APP_KEY must be set to the reference app key')
with tempfile.TemporaryDirectory(prefix='twofauth-passport-interop-') as directory:
work = pathlib.Path(directory)
database = work / 'copy.sqlite'
with sqlite3.connect(args.reference_db) as source, sqlite3.connect(database) as target:
source.backup(target)
target.executescript('CREATE TABLE IF NOT EXISTS rust_sessions (token_hash TEXT PRIMARY KEY,user_id INTEGER,csrf_hash TEXT NOT NULL,expires_at INTEGER NOT NULL);CREATE TABLE IF NOT EXISTS rust_login_attempts (key TEXT PRIMARY KEY,window INTEGER NOT NULL,attempts INTEGER NOT NULL);')
fixture_path, issued_path = work / 'fixture.json', work / 'issued.json'
fixture_php, validate_php = work / 'fixture.php', work / 'validate.php'
fixture_php.write_text(FIXTURE)
validate_php.write_text(VALIDATE)
php_env = dict(os.environ, APP_BASE_PATH=str(args.reference_app.resolve()), APP_ENV='local', DB_CONNECTION='sqlite', DB_DATABASE=str(database), INTEROP_UPSTREAM=str(args.upstream.resolve()), INTEROP_USER_ID=args.user_id, INTEROP_FIXTURE=str(fixture_path), INTEROP_ISSUED=str(issued_path))
php_env.pop('DATABASE_URL', None)
subprocess.run([str(args.php.resolve()), str(fixture_php)], env=php_env, check=True, capture_output=True)
fixture = json.loads(fixture_path.read_text())
rust_env = dict(os.environ, DATABASE_URL='sqlite://' + str(database), PASSPORT_KEY_PATH=str(args.reference_app.resolve() / 'storage'), RUST_COOKIE_SECURE='false')
with (work / 'server.log').open('w') as log:
process = subprocess.Popen([str(args.candidate_binary.resolve()), 'serve', '--listen', f'127.0.0.1:{args.port}'], env=rust_env, stdout=log, stderr=subprocess.STDOUT)
base = f'http://127.0.0.1:{args.port}'
def request(path='/api/v1/twofaccounts', headers=None, method='GET', body=None, cookies=None):
headers = dict(headers or {})
if cookies is not None:
headers['Cookie'] = '; '.join(f'{k}={v}' for k, v in cookies.items())
data = None if body is None else json.dumps(body).encode()
if data is not None:
headers['Content-Type'] = 'application/json'
req = urllib.request.Request(base + path, headers=headers, method=method, data=data)
try:
response = urllib.request.urlopen(req, timeout=10)
except urllib.error.HTTPError as error:
response = error
if cookies is not None:
for header in response.headers.get_all('Set-Cookie', []):
parsed = http.cookies.SimpleCookie()
parsed.load(header)
for name, item in parsed.items():
if item['max-age'] == '0' or not item.value:
cookies.pop(name, None)
else:
cookies[name] = item.value
return response.status, response.read()
try:
for _ in range(100):
try:
request('/up')
break
except OSError:
if process.poll() is not None:
raise RuntimeError('candidate exited before readiness')
time.sleep(.05)
results = {
'php_bearer': request(headers={'Authorization': 'Bearer ' + fixture['token']})[0],
'php_cookie': request(headers={'Cookie': 'laravel_token=' + fixture['cookie'], 'X-XSRF-TOKEN': fixture['xsrf']})[0],
'cookie_missing_csrf': request(headers={'Cookie': 'laravel_token=' + fixture['cookie']})[0],
'tampered_bearer': request(headers={'Authorization': 'Bearer ' + fixture['token'][:-8] + 'AAAAAAAA'})[0],
}
cookies = {'laravel_token': fixture['cookie'], 'XSRF-TOKEN': fixture['xsrf']}
results['php_browser_bootstrap'] = request('/', cookies=cookies)[0]
results['legacy_cookie_cleared'] = 'laravel_token' not in cookies
results['upgraded_browser_api'] = request(cookies=cookies, headers={'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN', '')})[0]
results['upgraded_browser_logout'] = request('/user/logout', cookies=cookies, headers={'X-XSRF-TOKEN': cookies.get('XSRF-TOKEN', '')})[0]
results['browser_api_after_logout'] = request(cookies=cookies)[0]
status, issued = request('/oauth/personal-access-tokens', headers={'Authorization': 'Bearer ' + fixture['token']}, method='POST', body={'name': 'rust-generated-interop'})
if status != 200:
raise AssertionError(f'Rust PAT issuance returned {status}')
issued_path.write_bytes(issued)
validated = subprocess.run([str(args.php.resolve()), str(validate_php)], env=php_env, capture_output=True, text=True, check=True)
results.update(json.loads(validated.stdout))
with sqlite3.connect(database) as connection:
connection.execute('UPDATE oauth_access_tokens SET revoked=1 WHERE id=?', (fixture['id'],))
results['revoked_php_bearer'] = request(headers={'Authorization': 'Bearer ' + fixture['token']})[0]
expected = {'php_bearer': 200, 'php_cookie': 200, 'cookie_missing_csrf': 401, 'tampered_bearer': 401, 'php_browser_bootstrap': 200, 'legacy_cookie_cleared': True, 'upgraded_browser_api': 200, 'upgraded_browser_logout': 200, 'browser_api_after_logout': 401, 'php_validated_rust_bearer': True, 'revoked_php_bearer': 401}
print(json.dumps(results, indent=2))
if results != expected:
raise AssertionError('Passport interoperability expectations failed')
finally:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
if __name__ == '__main__':
main()
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Run HTTP comparisons using fresh, disposable PHP and Rust fixture databases.
Requires PHP 8.4+ with required extensions and Composer-installed upstream/vendor.
No production configuration is copied. No response bodies or cookies are printed.
"""
import argparse
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import shutil
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
def command(argv, env, cwd):
result = subprocess.run([str(x) for x in argv], env=env, cwd=cwd, capture_output=True)
if result.returncode:
raise RuntimeError('fixture_command_failed')
return result.stdout.decode(errors='replace')
def port():
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
return sock.getsockname()[1]
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--php', required=True, type=Path)
parser.add_argument('--source', type=Path, default=ROOT.parent / 'upstream')
parser.add_argument('--candidate-binary', type=Path, default=ROOT / 'target/release/twofauth-rs')
parser.add_argument('--output', type=Path)
parser.add_argument('--candidate-db', choices=('native', 'laravel-copy'), default='native', help='native init or SQLite backup of real migrated PHP fixture')
parser.add_argument('--timeout', type=float, default=30)
parser.add_argument('--semantic-checks', action='store_true', help='also verify health timing, CSRF login and rendered QR content; QR needs Node sharp')
parser.add_argument('--passport-interop', action='store_true', help='also exercise PHP-issued and Rust-issued Passport tokens against the disposable migrated database')
parser.add_argument('--extra-cases', type=Path, action='append', default=[], help='additional stateful JSON case files to compare using the disposable fixtures')
args = parser.parse_args()
php, source, binary = (p.resolve() for p in (args.php, args.source, args.candidate_binary))
if not (source / 'vendor/autoload.php').is_file():
parser.error('install the pinned upstream Composer dependencies first')
processes = []
try:
with tempfile.TemporaryDirectory(prefix='twofauth-reference-') as directory:
work = Path(directory)
candidate_source = str(binary)
shutil.copy2(binary, work / 'candidate-binary')
binary = work / 'candidate-binary'
app = work / 'reference'
shutil.copytree(source, app, ignore=shutil.ignore_patterns('vendor', 'node_modules', '.env*', 'cache', 'logs', 'sessions', 'testing'))
(app / 'vendor').symlink_to(source / 'vendor', target_is_directory=True)
for subdir in ('bootstrap/cache', 'storage/logs', 'storage/framework/cache/data', 'storage/framework/sessions', 'storage/framework/views'):
(app / subdir).mkdir(parents=True, exist_ok=True)
reference_port, candidate_port = port(), port()
while candidate_port == reference_port: candidate_port = port()
reference_base = f'http://127.0.0.1:{reference_port}'
candidate_base = f'http://127.0.0.1:{candidate_port}'
# Start with an explicit environment so deployment secrets/config cannot leak into fixtures.
runtime = {name: os.environ[name] for name in ('PATH', 'PHPRC', 'PHP_INI_SCAN_DIR', 'LD_LIBRARY_PATH', 'SYSTEMROOT') if name in os.environ}
reference_env = dict(runtime, APP_BASE_PATH=str(app), APP_ENV='local', APP_NAME='2FAuth', APP_KEY='base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', APP_URL=reference_base, DB_CONNECTION='sqlite', DB_DATABASE=str(work / 'reference.sqlite'), SESSION_DRIVER='file', SESSION_SECURE_COOKIE='false', CACHE_STORE='file', MAIL_MAILER='array', THROTTLE_API='1000')
candidate_env = dict(runtime, APP_URL=candidate_base, APP_KEY=reference_env['APP_KEY'], THROTTLE_API='1000', DATABASE_URL='sqlite://' + str(work / 'candidate.sqlite'), RUST_COOKIE_SECURE='false', RUST_PUBLIC_DIR=str(ROOT / 'public'))
php_version = command([php, '-r', 'echo PHP_VERSION;'], reference_env, app)
command([php, 'artisan', 'migrate', '--force'], reference_env, app)
command([php, 'artisan', 'passport:keys', '--force'], reference_env, app)
fixture = work / 'fixture.php'
fixture.write_text('''<?php
require getenv('APP_BASE_PATH').'/vendor/autoload.php';
$app = require getenv('APP_BASE_PATH').'/bootstrap/app.php';
$app->make(Illuminate\\Contracts\\Console\\Kernel::class)->bootstrap();
foreach ([['Compat','compat@example.invalid',false],['Admin','admin@example.invalid',true],['Recipient','recipient@example.invalid',false]] as [$name,$email,$admin]) {
$user = new App\\Models\\User;
$user->name = $name;
$user->email = $email;
$user->password = 'Synthetic-compat-password-4620';
$user->is_admin = $admin;
$user->save();
}
''')
command([php, fixture], reference_env, app)
if args.candidate_db == 'laravel-copy':
with sqlite3.connect(work / 'reference.sqlite') as reference_db, sqlite3.connect(work / 'candidate.sqlite') as candidate_db:
reference_db.backup(candidate_db)
candidate_env['APP_KEY'] = reference_env['APP_KEY']
command([binary, 'prepare-sqlite'], candidate_env, work)
else:
command([binary, 'init'], candidate_env, work)
password = work / 'password'
password.write_text('Synthetic-compat-password-4620')
password.chmod(0o600)
for name, email in (('Compat', 'compat@example.invalid'), ('Admin', 'admin@example.invalid'), ('Recipient', 'recipient@example.invalid')):
command([binary, 'create-user', '--name', name, '--email', email, '--password-file', password], candidate_env, work)
with sqlite3.connect(work / 'candidate.sqlite') as database:
database.execute("UPDATE users SET is_admin=1 WHERE email='admin@example.invalid'")
password.unlink()
commands = [([php, '-S', f'127.0.0.1:{reference_port}', '-t', app / 'public', source / 'vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php'], reference_env, app / 'public'), ([binary, 'serve', '--listen', f'127.0.0.1:{candidate_port}'], candidate_env, work)]
for argv, env, cwd in commands:
processes.append(subprocess.Popen([str(x) for x in argv], env=env, cwd=cwd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL))
for base, process in zip((reference_base, candidate_base), processes):
for _ in range(100):
if process.poll() is not None: raise RuntimeError('server_start_failed')
try:
with urllib.request.urlopen(base + '/up', timeout=1): break
except Exception: time.sleep(.05)
else: raise RuntimeError('server_start_timeout')
record = {'candidate_source': candidate_source, 'candidate_database_mode': args.candidate_db, 'reference_php': php_version, 'candidate_sha256': hashlib.sha256(binary.read_bytes()).hexdigest(), 'database': 'fresh disposable SQLite; upstream migrations applied', 'fixture_config': {'THROTTLE_API': 1000, 'note': 'bulk fixture sequence; does not establish default throttle equivalence'}, 'composer_lock_sha256': hashlib.sha256((source / 'composer.lock').read_bytes()).hexdigest(), 'harness_sha256': hashlib.sha256((ROOT / 'tools/differential_http.py').read_bytes()).hexdigest(), 'results': {}}
suites = [('default', None)] + [(name, ROOT / f'compatibility/cases.{name}.json') for name in ('stateful', 'extended', 'admin-sharing')]
def compare_suite(name, cases_path):
argv = [sys.executable, str(ROOT / 'tools/differential_http.py'), '--reference', reference_base, '--candidate', candidate_base, '--timeout', str(args.timeout)]
if cases_path is not None: argv += ['--cases', str(cases_path), '--sessions', '--allow-side-effects']
result = subprocess.run(argv, capture_output=True, text=True)
if result.returncode not in (0, 1): raise RuntimeError('comparison_failed')
record['results'][name] = {'exit_code': result.returncode, 'cases': [json.loads(line) for line in result.stdout.splitlines()]}
if cases_path is not None: record['results'][name]['cases_sha256'] = hashlib.sha256(cases_path.read_bytes()).hexdigest()
for name, cases_path in suites:
compare_suite(name, cases_path)
if args.output: args.output.write_text(json.dumps(record, indent=2) + '\n')
if args.semantic_checks:
spec = importlib.util.spec_from_file_location('semantic_http', ROOT / 'tools/semantic_http.py')
semantic = importlib.util.module_from_spec(spec)
spec.loader.exec_module(semantic)
record['semantic_results'] = semantic.check(reference_base, candidate_base, php, source, work, reference_env, args.timeout)
checks = record['semantic_results']
record['semantic_pass'] = all(checks['health_render_timing'].values()) and all(checks['csrf_token_flow'].values()) and checks['qr_payload'].get('result') == 'decoded' and checks['qr_payload'].get('decoded_uri_equal') and checks['qr_payload'].get('matches_fixture')
# OAuth uses Laravel's unnamed web throttle, shared with password login.
# Run login/QR semantics before the extra token-error burst consumes it.
record['extra_cases_order'] = 'after core suites and supplemental semantic checks'
for index, path in enumerate(args.extra_cases):
compare_suite('extra-' + str(index), path.resolve())
if args.passport_interop:
interop_env = dict(reference_env, RUST_PUBLIC_DIR=str(ROOT / 'public'))
interop = subprocess.run([sys.executable, str(ROOT / 'tools/passport_interop.py'), '--php', str(php), '--upstream', str(source), '--reference-app', str(app), '--reference-db', str(work / 'reference.sqlite'), '--candidate-binary', str(binary), '--port', str(port())], env=interop_env, capture_output=True, text=True)
record['passport_interop'] = {'exit_code': interop.returncode}
if interop.returncode == 0:
record['passport_interop']['checks'] = json.loads(interop.stdout)
# Stop the servers before their temporary application files are removed.
for process in processes:
process.terminate()
process.wait(timeout=10)
serialized = json.dumps(record, indent=2) + '\n'
if args.output: args.output.write_text(serialized)
print(serialized, end='')
return int(any(result['exit_code'] for result in record['results'].values()) or (args.semantic_checks and not record.get('semantic_pass', False)) or record.get('passport_interop', {}).get('exit_code', 0))
except Exception as error:
print(json.dumps({'error': type(error).__name__}), file=sys.stderr)
return 2
finally:
for process in processes:
if process.poll() is None:
process.kill()
process.wait()
if __name__ == '__main__': raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
"""Supplemental semantic checks for disposable fixture servers; never print bodies."""
import base64
import http.cookiejar
import importlib.util
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import urllib.parse
import urllib.request
def check(reference, candidate, php, source, work, php_env, timeout):
spec = importlib.util.spec_from_file_location('compat_http', Path(__file__).with_name('differential_http.py'))
harness = importlib.util.module_from_spec(spec)
spec.loader.exec_module(harness)
sides = {}
for side, base in (('reference', reference), ('candidate', candidate)):
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(harness.NoRedirect(), urllib.request.HTTPCookieProcessor(jar))
def fetch(case): return harness.fetch(base, case, side, timeout, opener, jar)
health = fetch({'path': '/up'})
csrf = fetch({'path': '/refresh-csrf'})
headers = {'X-XSRF-TOKEN': 'XSRF-TOKEN'}
login = fetch({'path': '/user/login', 'method': 'POST', 'headers_from_cookies': headers, 'json': {'email': 'compat@example.invalid', 'password': 'Synthetic-compat-password-4620'}})
created = fetch({'path': '/api/v1/twofaccounts', 'method': 'POST', 'headers_from_cookies': headers, 'json': {'service': 'compat-semantic', 'account': 'fixture@example.invalid', 'otp_type': 'hotp', 'secret': 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 'digits': 6, 'algorithm': 'sha1', 'counter': 0}})
qr = {'error': 'account_not_created'}
if created.get('status') == 201:
account_id = json.loads(created['body'])['id']
qr = fetch({'path': f'/api/v1/twofaccounts/{account_id}/qrcode', 'headers_from_cookies': headers})
sides[side] = {'base': base, 'health': health, 'csrf': csrf, 'login': login, 'qr': qr}
pattern = r'Response successfully rendered in [0-9]+ms\.'
health_bodies = [sides[side]['health'].get('body', b'').decode(errors='replace').replace(sides[side]['base'], '<BASE>') for side in sides]
health = {'content_types_match': all(sides[side]['health'].get('content_type') == 'text/html' for side in sides), 'timing_field_present_once': all(len(re.findall(pattern, body)) == 1 for body in health_bodies), 'equal_except_render_ms': re.sub(pattern, 'Response successfully rendered in <MS>ms.', health_bodies[0]) == re.sub(pattern, 'Response successfully rendered in <MS>ms.', health_bodies[1]), 'statuses_ok': all(sides[side]['health'].get('status') == 200 for side in sides)}
csrf = {'text_token_shapes_valid': all(harness.is_text_token(sides[side]['csrf']) for side in sides), 'subsequent_login_succeeded': all(sides[side]['login'].get('status') == 200 for side in sides)}
qr = {'result': 'error', 'decoded_uri_equal': False, 'matches_fixture': False}
try:
node = os.environ.get('CODEX_PRIMARY_RUNTIME_NODE') or shutil.which('node')
if not node: raise RuntimeError('missing_svg_renderer')
decoded = []
for side in sides:
response = sides[side]['qr']
if response.get('status') != 200 or response.get('content_type') != 'application/json': raise RuntimeError('qr_request_failed')
image = json.loads(response['body'])['qrcode']
prefix = 'data:image/svg+xml;base64,'
if not image.startswith(prefix): raise RuntimeError('unexpected_image_format')
svg = work / (side + '-qr.svg')
png = work / (side + '-qr.png')
svg.write_bytes(base64.b64decode(image[len(prefix):], validate=True))
render = subprocess.run([node, '-e', "require('sharp')(process.argv[1],{density:300}).resize(1024,1024).flatten({background:'#fff'}).png().toFile(process.argv[2]).catch(()=>process.exit(1))", str(svg), str(png)], capture_output=True, timeout=30)
if render.returncode: raise RuntimeError('svg_render_failed')
code = "require $argv[1]; $r=new Zxing\\QrReader($argv[2]); $t=$r->text(); if (!is_string($t)||$t==='') exit(1); echo $t;"
result = subprocess.run([str(php), '-r', code, str(source / 'vendor/autoload.php'), str(png)], env=php_env, capture_output=True, timeout=30)
if result.returncode: raise RuntimeError('qr_decode_failed')
uri = result.stdout.decode()
parsed = urllib.parse.urlsplit(uri)
values = dict(urllib.parse.parse_qsl(parsed.query))
normalized = {'type': parsed.netloc.lower(), 'label': urllib.parse.unquote(parsed.path.lstrip('/')), 'secret': values.get('secret'), 'issuer': values.get('issuer'), 'algorithm': values.get('algorithm', 'SHA1').lower(), 'digits': int(values.get('digits', 6)), 'counter': int(values.get('counter', 0))}
if parsed.scheme.lower() != 'otpauth': raise RuntimeError('unexpected_qr_payload')
decoded.append(normalized)
expected = {'type': 'hotp', 'label': 'compat-semantic:fixture@example.invalid', 'secret': 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 'issuer': 'compat-semantic', 'algorithm': 'sha1', 'digits': 6, 'counter': 0}
qr = {'result': 'decoded', 'decoded_uri_equal': decoded[0] == decoded[1], 'matches_fixture': all(value == expected for value in decoded)}
except Exception as error:
qr['error'] = type(error).__name__
if isinstance(error, RuntimeError): qr['reason'] = str(error)
return {'health_render_timing': health, 'csrf_token_flow': csrf, 'qr_payload': qr}