sektavip-week9-training-pagescripts/validate_static.py
Blamevalidate_static.py108 lines
#!/usr/bin/env python3 """Validate a static bundle without network access; emit a reproducible byte manifest.""" import argparse, hashlib, json, os, re, sys from html.parser import HTMLParser from pathlib import Path from urllib.parse import unquote, urlsplit ALLOWED_SUFFIXES={'.html','.htm','.css','.js','.mjs','.json','.txt','.svg','.png','.jpg','.jpeg','.gif','.webp','.avif','.ico','.woff','.woff2','.ttf','.otf','.pdf','.mp3','.mp4','.webm','.ogg','.wav','.vtt','.md','.mov','.m4v'} MAX_FILES=2000; MAX_FILE_BYTES=100*1024*1024; MAX_TOTAL_BYTES=500*1024*1024 class Refusal(Exception): pass def require(ok, message): if not ok: raise Refusal(message) def css_refs(text): text = re.sub(r'/\*.*?\*/', '', text, flags=re.S) text = re.sub(r'\\([0-9a-fA-F]{1,6})(?:[ \t\r\n\f])?|\\([^\r\n\f])', lambda m: chr(int(m[1], 16) or 0xfffd) if m[1] and int(m[1], 16) <= 0x10ffff else (m[2] or '\ufffd'), text) return re.findall(r'url\(\s*[\"\']?([^\"\')\s]+)', text, re.I) + re.findall(r'@import\s+[\"\']([^\"\']+)', text, re.I) class References(HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) self.refs, self.style, self.module = [], False, False def handle_starttag(self, tag, attrs): a = dict(attrs) require(tag != 'base', 'HTML base changes resource resolution') for key in ('src', 'poster'): if a.get(key): self.refs.append(a[key]) if tag in ('link', 'image', 'use'): for key in ('href', 'xlink:href'): if a.get(key): self.refs.append(a[key]) if tag == 'object' and a.get('data'): self.refs.append(a['data']) if a.get('srcdoc'): nested = References() nested.feed(a['srcdoc']) self.refs.extend(nested.refs) if a.get('srcset'): require('data:' not in a['srcset'], 'data srcset unsupported') self.refs.extend(x.strip().split()[0] for x in a['srcset'].split(',') if x.strip()) if a.get('style'): self.refs.extend(css_refs(a['style'])) if tag == 'script' and a.get('type', '').lower() == 'module': self.module = True if tag == 'style': self.style = True handle_startendtag = handle_starttag def handle_endtag(self, tag): if tag == 'script': self.module = False if tag == 'style': self.style = False def handle_data(self, data): if self.module: self.refs.extend(re.findall(r'(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)[\"\']([^\"\']+)', data)) if self.style: self.refs.extend(css_refs(data)) def validate(root): root=root.resolve(); errors=[]; files=[]; total=0 for p in sorted(root.rglob('*')): if p.is_symlink(): errors.append(f'symlink: {p.relative_to(root)}'); continue if not p.is_file(): continue rel=p.relative_to(root).as_posix(); data=p.read_bytes() if any(x.startswith('.') for x in Path(rel).parts): errors.append(f'private/dot path is not publishable: {rel}') if p.suffix.lower() not in ALLOWED_SUFFIXES: errors.append(f'unexpected file type: {rel}') if len(data)>MAX_FILE_BYTES: errors.append(f'file exceeds {MAX_FILE_BYTES} bytes: {rel}') total+=len(data) if p.suffix.lower()=='.png' and not data.startswith(b'\x89PNG\r\n\x1a\n'): errors.append(f'not PNG: {rel}') if p.suffix.lower() in ('.js','.css','.png','.jpg','.jpeg','.webp','.pdf') and data.lstrip().lower().startswith((b'<!doctype html',b'<html')): errors.append(f'HTML masquerading as asset: {rel}') refs=[] if p.suffix.lower() in ('.html','.htm','.svg'): parser=References(); txt=data.decode('utf-8'); parser.feed(txt); refs=parser.refs elif p.suffix.lower()=='.css': refs=css_refs(data.decode('utf-8')) elif p.suffix.lower() in ('.js','.mjs'): refs=re.findall(r'(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)[\"\']([^\"\']+)',data.decode('utf-8')) for ref in refs: u=urlsplit(ref.strip()) if u.scheme or u.netloc or not u.path: continue # Root-relative routes belong to the serving origin, not this bundle. if u.path.startswith('/'): errors.append(f'root-relative resource needs explicit mapping: {rel}: {ref}'); continue target=(p.parent/unquote(u.path)).resolve() if not target.is_relative_to(root): errors.append(f'path escapes bundle: {rel}: {ref}') elif not target.is_file(): errors.append(f'missing resource: {rel}: {ref}') files.append({'path':rel,'bytes':len(data),'sha256':hashlib.sha256(data).hexdigest()}) if len(files)>MAX_FILES: errors.append(f'file count exceeds {MAX_FILES}') if total>MAX_TOTAL_BYTES: errors.append(f'bundle exceeds {MAX_TOTAL_BYTES} bytes') if not any(x['path'].endswith(('.html','.htm')) for x in files): errors.append('no HTML entry') return files, sorted(set(errors)) if __name__=='__main__': p=argparse.ArgumentParser(); p.add_argument('root',type=Path); p.add_argument('--manifest',type=Path); a=p.parse_args() files, errors=validate(a.root) if errors: print('\n'.join(errors),file=sys.stderr); sys.exit(1) if a.manifest: a.manifest.write_text(json.dumps({'schema':1,'sourceCommit':os.environ.get('CI_COMMIT_SHA'),'files':files},ensure_ascii=False,indent=2)+'\n') print(f'Validated {len(files)} files')