validate_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')