"""Session 37. Read-only JPEG structure and bounded steganography audit.
Usage: python jpeg_file_audit.py IMAGE_DIR TOOLS_ROOT OUTPUT_DIR
Requires Pillow, OutGuess 0.4, Steghide 0.5.1, ExifTool and jpeginfo.
TOOLS_ROOT is the extracted /usr tree's parent. No source JPEG is rewritten.
Only no-key/default OutGuess (without ECC) and empty-passphrase Steghide
are scanned. Successful process exit or random extracted bytes are not
proof of a payload. Outputs are never executed.
"""
import collections, hashlib, io, json, os, random, re, subprocess, sys
from pathlib import Path
from PIL import Image

def sha(b): return hashlib.sha256(b).hexdigest()
def u16(b): return int.from_bytes(b, 'big')

def parse_jpeg(b, start=0):
    """Walk length-delimited headers and entropy scans, not a blind EOI search."""
    assert b[start:start+2] == b'\xff\xd8', 'missing SOI'
    pos=start+2; segments=[]; scans=[]
    while pos<len(b):
        offset=pos
        assert b[pos]==255, ('invalid marker',pos)
        while pos<len(b) and b[pos]==255: pos+=1
        assert pos<len(b), 'truncated marker'
        code=b[pos];pos+=1
        if code==217:
            return {'start':start,'end':pos,'length':pos-start,'segments':segments,'scans':scans}
        assert code not in [0,216,*range(208,216)], ('unexpected standalone marker',code,pos)
        if code==1: continue
        length=u16(b[pos:pos+2]); assert length>=2 and pos+length<=len(b), 'invalid segment length'
        payload=b[pos+2:pos+length];pos+=length
        seg={'marker':f'{code:02X}','offset':offset,'payload_bytes':len(payload),'sha256':sha(payload)}
        if code==192 or code==194:
            seg['frame']={'precision':payload[0],'height':u16(payload[1:3]),'width':u16(payload[3:5]),'components':payload[5], 'component_spec_hex':payload[6:].hex()}
        if code==224 and payload.startswith(b'JFIF\0'):
            seg['jfif']={'version':list(payload[5:7]),'density_unit':payload[7],'x_density':u16(payload[8:10]),'y_density':u16(payload[10:12]),'thumbnail_width':payload[12],'thumbnail_height':payload[13]}
        if code==254: seg['comment_latin1']=payload.decode('latin1')
        if 224<=code<=239: seg['prefix_hex']=payload[:32].hex()
        segments.append(seg)
        if code==218:
            scan_start=pos; restarts=0
            while True:
                p=b.find(b'\xff',pos);assert p>=0,'missing end of scan'
                q=p+1
                while q<len(b) and b[q]==255:q+=1
                assert q<len(b),'truncated scan'
                if b[q]==0: pos=q+1;continue
                if 208<=b[q]<=215: restarts+=1;pos=q+1;continue
                pos=p;break
            scans.append({'offset':scan_start,'length':pos-scan_start,'restart_markers':restarts})
    raise ValueError('missing EOI')

def command(exe,args,env):
    p=subprocess.run([str(exe),*map(str,args)],capture_output=True,timeout=90,env=env)
    return {'returncode':p.returncode,'stdout':p.stdout.decode('utf8','replace'),'stderr':p.stderr.decode('utf8','replace')}

def output_info(p):
    if not p.exists():return {'exists':False}
    b=p.read_bytes();return {'exists':True,'bytes':len(b),'sha256':sha(b),'ascii_fraction':sum(x in (9,10,13) or 32<=x<127 for x in b)/max(1,len(b)), 'pgp_signed_message':b.startswith(b'-----BEGIN PGP SIGNED MESSAGE-----'), 'prefix_hex':b[:32].hex()}

def main():
    src,tools,out=map(lambda s:Path(s).resolve(),sys.argv[1:]);out.mkdir(parents=True,exist_ok=True)
    env=os.environ.copy();env['LD_LIBRARY_PATH']=str(tools/'usr/lib/x86_64-linux-gnu');env['PERL5LIB']=str(tools/'usr/share/perl5');env['LC_ALL']='C'
    bins={x:tools/'usr/bin'/x for x in ['outguess','steghide','exiftool','jpeginfo']}
    versions={x:{'sha256':sha(p.read_bytes()),'version':command(p,{'outguess':['-h'],'steghide':['--version'],'exiftool':['-ver'],'jpeginfo':['--version']}[x],env)} for x,p in bins.items()}
    # One deterministic cover, independent known binary message, exact recovery.
    rng=random.Random(3301037);cover=out/'control_cover.jpg'
    Image.frombytes('RGB',(512,512),rng.randbytes(512*512*3)).save(cover,quality=92)
    payload=b'Cicada research Session 37: constructed control, not puzzle content.\n'+bytes(range(32))
    plain=out/'control_payload.bin';plain.write_bytes(payload);controls=[]
    for tool in ['outguess','steghide']:
        for label,key in [('default',None),('keyed','session37-control')]:
            steg=out/f'control_{tool}_{label}.jpg';recovered=out/f'control_{tool}_{label}.bin'
            if recovered.exists():recovered.unlink()
            if tool=='outguess':
                opts=[] if key is None else ['-k',key]
                embed=command(bins[tool],[*opts,'-d',plain,cover,steg],env)
                extract=command(bins[tool],[*opts,'-r',steg,recovered],env)
            else:
                k=key or ''
                embed=command(bins[tool],['embed','-cf',cover,'-ef',plain,'-sf',steg,'-p',k,'-f'],env)
                extract=command(bins[tool],['extract','-sf',steg,'-xf',recovered,'-p',k,'-f'],env)
            exact=recovered.exists() and recovered.read_bytes()==payload
            controls.append({'tool':tool,'mode':label,'exact_recovery':exact,'payload_sha256':sha(payload),'embed':embed,'extract':extract})
            assert exact and embed['returncode']==extract['returncode']==0, controls[-1]
    negatives=[]
    for tool in ['outguess','steghide']:
        dst=out/f'negative_{tool}.bin'
        if dst.exists():dst.unlink()
        args=['-r',cover,dst] if tool=='outguess' else ['extract','-sf',cover,'-xf',dst,'-p','','-f']
        run=command(bins[tool],args,env)
        negatives.append({'tool':tool,**run,'output':output_info(dst),'equals_control_payload':dst.exists() and dst.read_bytes()==payload})
        assert not negatives[-1]['equals_control_payload']
    # Structural controls: fake EOI inside a comment, a real trailer, and multiple scans.
    cb=cover.read_bytes();com=b'control\xff\xd9inside-comment';marker=b'\xff\xfe'+(len(com)+2).to_bytes(2,'big')+com
    sentinel=cb[:2]+marker+cb[2:]+b'TRAILER-CONTROL'
    s=parse_jpeg(sentinel);assert sentinel[s['end']:]==b'TRAILER-CONTROL'
    prog=io.BytesIO();Image.open(cover).save(prog,format='JPEG',progressive=True)
    ps=parse_jpeg(prog.getvalue());assert ps['end']==len(prog.getvalue()) and len(ps['scans'])>1
    joined=cb+cb;js=parse_jpeg(joined);js2=parse_jpeg(joined,js['end']);assert js2['end']==len(joined)
    print('Four steganography recovery controls and three parser controls passed.',flush=True)
    files=sorted(src.glob('[0-9][0-9].jpg'));assert len(files)==75
    manifest=json.loads((src/'jpeg_source_manifest.json').read_text());expected={r['file']:r['sha256'] for r in manifest['files']}
    rows=[];all_hashes={sha(p.read_bytes()):p.name for p in files}
    for p in files:
        b=p.read_bytes();assert sha(b)==expected[p.name]
        parsed=parse_jpeg(b);segments=parsed['segments'];tail=b[parsed['end']:]
        image=Image.open(io.BytesIO(b));image.load()
        row={'file':p.name,'part':'LP1' if int(p.stem)<17 else 'LP2','lp2_page':int(p.stem)-17 if int(p.stem)>=17 else None,'file_bytes':len(b),'sha256':sha(b),'jpeg':parsed,'pixel_dimensions':list(image.size),'pixel_mode':image.mode,'tail_bytes':len(tail),'tail_sha256':sha(tail) if tail else None,'comments':[x['comment_latin1'] for x in segments if 'comment_latin1' in x], 'exif_bytes':len(image.info.get('exif',b'')),'icc_bytes':len(image.info.get('icc_profile',b'')),'icc_sha256':sha(image.info['icc_profile']) if 'icc_profile' in image.info else None}
        row['quantization_signature']=sha(b''.join(b[x['offset']:x['offset']+4+x['payload_bytes']] for x in segments if x['marker']=='DB'))
        row['huffman_signature']=sha(b''.join(b[x['offset']:x['offset']+4+x['payload_bytes']] for x in segments if x['marker']=='C4'))
        row['marker_sequence']=[x['marker'] for x in segments]
        if tail:
            pos=parsed['end'];pieces=[]
            while b[pos:pos+2]==b'\xff\xd8':
                nxt=parse_jpeg(b,pos);piece=b[pos:nxt['end']];pieces.append({'offset':pos,'bytes':len(piece),'sha256':sha(piece),'exact_standalone_match':all_hashes.get(sha(piece))});pos=nxt['end']
            row['appended_jpegs']=pieces;row['unparsed_tail_bytes_after_appended_jpegs']=len(b)-pos
            n=parsed['end'];row['ends_with_reversed_first_jpeg']=len(b)>=2*n and b[-n:]==b[:n][::-1]
            if row['ends_with_reversed_first_jpeg']:
                middle=b[n:-n];row['middle_bytes']=len(middle)
                try:row['reversed_hex_middle_text']=bytes.fromhex(middle.strip().decode()[::-1]).decode()
                except (ValueError,UnicodeError):pass
        for tool in ['outguess','steghide']:
            dst=out/f'{p.stem}_{tool}.bin'
            if dst.exists():dst.unlink()
            args=['-r',p,dst] if tool=='outguess' else ['extract','-sf',p,'-xf',dst,'-p','','-f']
            row[tool]={'mode':'no-key/no-ECC' if tool=='outguess' else 'empty passphrase',**command(bins[tool],args,env),'output':output_info(dst)}
        row['jpeginfo']=command(bins['jpeginfo'],['-c',p],env)
        rows.append(row);print(p.name,'tail',len(tail),'outguess',row['outguess']['returncode'],row['outguess']['output'].get('bytes'), 'PGP',row['outguess']['output'].get('pgp_signed_message'), 'steghide',row['steghide']['returncode'],flush=True)
    # ExifTool supplies an independent metadata read, without system file attributes.
    metadata=command(bins['exiftool'],['-json','-G1','-a','-EXIF:all','-XMP:all','-IPTC:all','-Comment','-ICC_Profile:all','-JFIF:all','-ThumbnailImage','-PreviewImage',*files],env)
    (out/'jpeg_metadata_results.json').write_text(metadata['stdout']);assert metadata['returncode']==0
    results={'scope':{'files':75,'LP1':17,'LP2':58,'source_revision':manifest['repository_revision'],'outguess':'default/no key, no ECC','steghide':'empty passphrase; no dictionary search'},'versions':versions,'controls':controls,'negative_controls':negatives,'parser_controls':{'comment_contains_false_EOI':True,'appended_payload':True,'progressive_scan_count':len(ps['scans']),'concatenated_jpegs':True},'files':rows}
    (out/'jpeg_file_results.json').write_text(json.dumps(results,indent=2))
    for p in files:assert sha(p.read_bytes())==expected[p.name]
    print('Source hashes unchanged; all results saved.',flush=True)

if __name__=='__main__': main()
