#!/usr/bin/env python3 # Standalone synthetic fixture; no user files or external network. import io,zipfile,stat,tempfile,hashlib,unicodedata from pathlib import Path,PurePosixPath,PureWindowsPath def preflight(infos,limit=1024): seen=set();errors=[];total=0 for info in infos: name=info.filename;p=PurePosixPath(name);reason=None mode=info.external_attr>>16 if '\\'in name or p.is_absolute()or PureWindowsPath(name).drive or '..'in p.parts:reason='unsafe path' elif stat.S_ISLNK(mode):reason='symlink' elif not p.parts or any(x in ('','.')for x in name.rstrip('/').split('/')):reason='ambiguous path' key=unicodedata.normalize('NFC',name).casefold() if key in seen:reason='duplicate normalized destination' seen.add(key);total+=info.file_size if info.file_size>limit:reason='member size limit' if info.compress_size and info.file_size/info.compress_size>100:reason='compression ratio limit' if reason:errors.append({'name':name,'reason':reason}) if len(infos)>12:errors.append({'reason':'entry count limit'}) if total>2048:errors.append({'reason':'total size limit'}) return errors def archive(entries): stream=io.BytesIO() with zipfile.ZipFile(stream,'w',compression=zipfile.ZIP_DEFLATED)as z: for item,body in entries:z.writestr(item,body) return stream.getvalue() link=zipfile.ZipInfo('shortcut');link.create_system=3;link.external_attr=(stat.S_IFLNK|0o777)<<16 cases={'parent':[('../outside.txt','bad')],'absolute':[('/outside.txt','bad')],'windows-drive':[('C:/outside.txt','bad')],'backslash':[(r'..\outside.txt','bad')],'symlink':[(link,'../outside.txt')],'case-collision':[('Report.txt','one'),('report.txt','two')],'large-member':[('large.txt','0'*4096)],'safe':[('notes/readme.txt','synthetic note\n'),('data/value.csv','id,value\nZ-31,8\n')]} results=[] with tempfile.TemporaryDirectory(prefix='aigos-zip-')as folder: base=Path(folder) for name,entries in cases.items(): blob=archive(entries);root=base/name;root.mkdir();written=[] with zipfile.ZipFile(io.BytesIO(blob))as z: errors=preflight(z.infolist()) if not errors: for info in z.infolist(): dest=(root/info.filename).resolve();dest.relative_to(root.resolve());dest.parent.mkdir(parents=True,exist_ok=True) with dest.open('xb')as f:f.write(z.read(info)) written.append({'name':info.filename,'sha256':hashlib.sha256(dest.read_bytes()).hexdigest()}) results.append({'case':name,'errors':errors,'written':written,'filesOnDisk':len([p for p in root.rglob('*')if p.is_file()])}) assert not (base/'outside.txt').exists() assert all(r['filesOnDisk']==0 for r in results if r['case']!='safe') and results[-1]['filesOnDisk']==2 result={'cases':results,'unsafeCasesBlockedBeforeWrite':7,'safeFilesWritten':2,'outsideFileCreated':False,'limits':{'maxEntries':12,'maxMemberBytes':1024,'maxTotalBytes':2048,'maxDeclaredCompressionRatio':100},'scope':'Tiny synthetic ZIPs in a new empty temp directory. Reject symlinks/backslashes and casefold/NFC collisions conservatively. Not a hardened multi-user extractor: concurrent filesystem mutation, encrypted archives, stream resource exhaustion and power failure are not tested.'} import sys,json from datetime import datetime,timezone result={'experiment':'zip-path-traversal-preflight','executedAt':datetime.now(timezone.utc).isoformat(),'runtime':sys.version.split()[0],'synthetic':True,**result} print(json.dumps(result,ensure_ascii=False,indent=2))