116 lines
8.6 KiB
Python
116 lines
8.6 KiB
Python
#!/usr/bin/python3
|
|
"""Bounded static and loopback HTTP checks for the FrameBridge website."""
|
|
from pathlib import Path
|
|
from html.parser import HTMLParser
|
|
from urllib.parse import urlsplit,unquote
|
|
from urllib.request import urlopen
|
|
from http.server import ThreadingHTTPServer,SimpleHTTPRequestHandler
|
|
import json,hashlib,threading,tempfile,shutil,subprocess,html,os
|
|
BASE=Path(__file__).resolve().parent
|
|
DIST=BASE/'dist'
|
|
REPORT=BASE/'qa/WEBSITE-STATUS.json'
|
|
REPORT.parent.mkdir(mode=0o700,exist_ok=True)
|
|
class Inspect(HTMLParser):
|
|
def __init__(self):
|
|
super().__init__();self.links=[];self.assets=[];self.ids=set();self.lang=None;self.main=False;self.scripts=[];self.code=[];self.in_code=False;self.current=''
|
|
def handle_starttag(self,tag,attrs):
|
|
a=dict(attrs)
|
|
if tag=='html':self.lang=a.get('lang')
|
|
if 'id' in a:self.ids.add(a['id'])
|
|
if tag=='main' and a.get('id')=='content':self.main=True
|
|
if tag=='a' and 'href'in a:self.links.append(a['href'])
|
|
if tag in ('script','source','img') and 'src'in a:self.assets.append(a['src'])
|
|
if tag=='video' and 'poster'in a:self.assets.append(a['poster'])
|
|
if tag=='link' and a.get('rel') in ('stylesheet','icon'):self.assets.append(a['href'])
|
|
if tag=='script':self.scripts.append(a)
|
|
if tag=='code':self.in_code=True;self.current=''
|
|
def handle_data(self,data):
|
|
if self.in_code:self.current+=data
|
|
def handle_endtag(self,tag):
|
|
if tag=='code':self.code.append(self.current);self.in_code=False
|
|
def resolve(path):
|
|
p=DIST/unquote(path.lstrip('/'))
|
|
return p/'index.html' if path.endswith('/') or p.is_dir() else p
|
|
def main():
|
|
os.umask(0o077);pages=sorted(DIST.rglob('*.html'));assert pages
|
|
failures=[];pending=[];infra=set();http_paths=set();external=set()
|
|
for p in pages:
|
|
raw=p.read_text();q=Inspect();q.feed(raw);rel='/'+str(p.relative_to(DIST));http_paths.add(rel)
|
|
if q.lang!='en' or not q.main:failures.append(str(p.relative_to(DIST))+': missing English or main landmark')
|
|
if 'gerabh' in raw or '/home/gerabh' in raw:failures.append(str(p.relative_to(DIST))+': personal host path')
|
|
if "'unsafe-inline'" in raw:failures.append(str(p.relative_to(DIST))+': unsafe inline CSP')
|
|
for value in q.links+q.assets:
|
|
u=urlsplit(value)
|
|
if u.scheme or u.netloc:
|
|
external.add(value);continue
|
|
if not u.path:
|
|
if u.fragment and u.fragment not in q.ids:failures.append(str(p.relative_to(DIST))+': missing anchor '+u.fragment)
|
|
continue
|
|
if any(u.path.startswith(prefix) for prefix in ['/git/','/debian/','/downloads/']):
|
|
infra.add(u.path);continue
|
|
if not u.path.startswith('/'):failures.append('Non-root relative URL '+value);continue
|
|
target=resolve(u.path)
|
|
if not target.exists():
|
|
if u.path=='/assets/framebridge-preview.mp4':pending.append('Preview video asset pending')
|
|
else:failures.append('Missing local target '+value+' referenced by '+str(p.relative_to(DIST)))
|
|
elif u.path in q.assets:http_paths.add(u.path)
|
|
if u.fragment and target.is_file() and target.suffix=='.html':
|
|
other=Inspect();other.feed(target.read_text())
|
|
if u.fragment not in other.ids:failures.append('Missing target fragment '+value)
|
|
for item in q.scripts:
|
|
if item.get('src')!='/assets/site.js':failures.append('Unexpected script source')
|
|
css=(DIST/'assets/site.css').read_text()
|
|
assert css.count('{')==css.count('}'),'CSS brace mismatch'
|
|
class Quiet(SimpleHTTPRequestHandler):
|
|
def __init__(self,*a,**k):super().__init__(*a,directory=str(DIST),**k)
|
|
def log_message(self,*a):pass
|
|
server=ThreadingHTTPServer(('127.0.0.1',0),Quiet);thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
|
http_count=0
|
|
try:
|
|
for path in sorted(http_paths):
|
|
with urlopen('http://127.0.0.1:'+str(server.server_port)+path,timeout=3) as response:
|
|
if response.status!=200:failures.append('HTTP status '+str(response.status)+' for '+path)
|
|
response.read();http_count+=1
|
|
finally:server.shutdown();server.server_close();thread.join(timeout=2)
|
|
# Exercise the guarded final-metadata branch using clearly synthetic values
|
|
# in a disposable directory. No signature or installation command executes.
|
|
meta=json.loads((BASE/'release.json').read_text())
|
|
with tempfile.TemporaryDirectory(prefix='framebridge-web-qa-') as td:
|
|
temp=Path(td);web=temp/'web';web.mkdir()
|
|
shutil.copyfile(BASE/'build_site.py',web/'build_site.py')
|
|
shutil.copytree(BASE/'src',web/'src')
|
|
(web/'assets').mkdir()
|
|
for p in (BASE/'assets').iterdir():
|
|
if p.is_file() and p.suffix!='.mp4':shutil.copyfile(p,web/'assets'/p.name)
|
|
fixture=dict(meta,state='ready',sha256='a'*64,signing_fingerprint='A'*40,size_bytes=1,source_archive_url='/downloads/SYNTHETIC-SOURCE.tar.gz')
|
|
(web/'release.json').write_text(json.dumps(fixture))
|
|
result=subprocess.run(['/usr/bin/python3','-I','-S',str(web/'build_site.py')],capture_output=True,text=True,timeout=10)
|
|
if result.returncode:failures.append('Synthetic-ready rendering failed: '+result.stderr[-600:])
|
|
else:
|
|
q=Inspect();q.feed((web/'dist/download/index.html').read_text())
|
|
blocks=[x for x in q.code if '\n' in x]
|
|
if len(blocks)!=3:failures.append('Expected manual verification, APT setup and after-install command blocks')
|
|
for text in blocks:
|
|
result=subprocess.run(['/bin/bash','-n'],input=text,text=True,capture_output=True,timeout=3)
|
|
if result.returncode:failures.append('Rendered install block shell syntax: '+result.stderr.strip())
|
|
manual=next((x for x in blocks if 'gpgv --keyring' in x),'')
|
|
if not manual or 'sha256sum --check' not in manual or 'expected="' not in manual:failures.append('Manual flow missing required verification')
|
|
apt=next((x for x in blocks if 'sources.list.d' in x),'')
|
|
if 'signed-by=' not in apt or 'apt-key' in apt:failures.append('APT trust not scoped with Signed-By')
|
|
status={'host':'GERAMEG','website_source':str(BASE),'web_root':str(DIST),'language':'English','buildless':True,'html_pages_checked':len(pages),'loopback_http_paths_checked':http_count,'static_failures':failures,'pending_assets':sorted(set(pending)),'infra_routes_expected':sorted(infra),'external_links':sorted(external),'no_external_runtime_dependencies':True,'copy_controls_progressive':True,'ready_branch_rendered_with_synthetic_fixture':True,'install_blocks_syntax_checked_not_executed':True,'release_state':meta.get('state'),'package_sha256_present':bool(meta.get('sha256')),'signing_fingerprint_present':bool(meta.get('signing_fingerprint')),'video_present':(DIST/'assets/framebridge-preview.mp4').is_file(),'visual_browser_QA_performed':False,'visual_browser_QA_limit':'No browser or user desktop was operated by this subtask. Root may inspect the staged site via the authorized GERAMEG surface.','production_network_or_services_changed':False,'site_ready_for_private_staging':not failures,'public_release_ready':not failures and not pending and meta.get('state')=='ready','homepage_sha256':hashlib.sha256((DIST/'index.html').read_bytes()).hexdigest()}
|
|
visual=BASE/'qa/VISUAL-REVIEW.json'
|
|
if visual.exists():
|
|
status['visual_browser_QA_performed']=True
|
|
status['visual_browser_QA_limit']=json.loads(visual.read_text())['limitations']
|
|
status['visual_browser_QA_report']=str(visual)
|
|
status.update(support_page='/support/',support_funding_channel_configured=bool(meta.get('support_payment_url')),support_payment_form_present='<form' in (DIST/'support/index.html').read_text(),license_identifier=meta.get('license'),dependency_source_archives=len(meta.get('dependency_sources',[])))
|
|
video=DIST/'assets/framebridge-preview.mp4'
|
|
if video.exists():
|
|
status['video_sha256']=hashlib.sha256(video.read_bytes()).hexdigest()
|
|
guide=json.loads((DIST/'assets/command-guide.json').read_text())
|
|
status.update(shared_guide_version=guide['version'],shared_guide_topics=len(guide['topics']),shared_guide_sha256=guide['guide_sha256'])
|
|
REPORT.write_text(json.dumps(status,indent=2)+'\n')
|
|
print(json.dumps({'pages':len(pages),'http_paths':http_count,'failures':failures,'pending_assets':status['pending_assets'],'private_staging_ready':status['site_ready_for_private_staging'],'public_ready':status['public_release_ready'],'report':str(REPORT)},indent=2))
|
|
return 1 if failures else 0
|
|
if __name__=='__main__':raise SystemExit(main())
|