"""Real local archive extraction with the same native worker used on PS5.""" from pathlib import Path import binascii,hashlib,io,json,lzma,os,random,struct,subprocess,tarfile,tempfile,time,zipfile from rar_worker import build_host ROOT=Path(__file__).resolve().parents[1] DATA=bytes(range(256))*1024 TOO_BIG="This archive needs more memory to extract than the console app can use." NO_LAST="Incomplete RAR archive or missing volume." NO_FIRST="Missing first RAR volume." def number7z(v): for n in range(8): if v<1<<(8*n+7-n):return bytes([(0xFF00>>n)&0xFF|v>>(8*n)])+(v&((1<<8*n)-1)).to_bytes(n,"little") return b"\xff"+v.to_bytes(8,"little") def encode_7z_header(plain): """Rewrites a bsdtar 7z with the LZMA-compressed header 7-Zip writes by default.""" offset,size=struct.unpack("=0x80:out+=bytes([v&0x7f|0x80]);v>>=7 return out+bytes([v]) def rar5_block(header,data=b""): size=bytes([len(header)]) return struct.pack("1 else vint(0)) service=b"" if quick_open and i+1==volumes: # A quick-open index can carry a valid check-field checksum for a different password. # It is optional cached metadata, not a file to extract or authenticate. other=hashlib.pbkdf2_hmac("sha256",b"index-password",salt,iterations+32) check=bytes(other[j]^other[j+8]^other[j+16]^other[j+24] for j in range(8)) encryption=vint(1)+vint(0)+vint(1)+bytes([power])+salt+iv+check+hashlib.sha256(check).digest()[:4] extra=vint(len(encryption))+encryption index=aes(bytes(2*128*1024+16),iv) header=vint(3)+vint(3)+vint(len(extra))+vint(len(index))+vint(0)+vint(len(index))+vint(0) header+=vint(0)+vint(0)+vint(2)+b"QO"+extra service=framed(header)+index # A volume may end with filler after its end header; UnRAR opens the next volume regardless. filler=bytes(56) if i+10 for p in (root/"rar-multipart").rglob("*") if p.name!=".ps5-react-extraction") open_to_all(root/"rar-multipart") # A set extracted while it downloads: each later volume appears under its final name by a rename, over # the empty file that reserves the name on the console. def stream(name,blobs,gap,extra=()): names=[root/f"{name}-v{i}" for i in range(len(blobs))] names[0].write_bytes(blobs[0]) process=subprocess.Popen([str(binary),str(root/name),str(1024*1024),*map(str,names),"--stream",*extra],stdout=subprocess.PIPE,text=True) for i,blob in enumerate(blobs[1:],1): if gap is None:break time.sleep(gap);partial=root/f"{name}-v{i}.part";partial.write_bytes(blob) names[i].touch();time.sleep(gap);os.rename(partial,names[i]) return process.communicate(timeout=30)[0].splitlines(),names blobs=[p.read_bytes() for p in rar_parts] result,names=stream("rar-stream",blobs,0.6) assert result[0]=="completed",result tree=lambda d:{str(p.relative_to(d)):p.read_bytes() for p in d.rglob("*") if p.is_file() and not p.name.startswith(".ps5-react-")} assert tree(root/"rar-stream")==tree(root/"rar-multipart"),(sorted(tree(root/"rar-stream")),sorted(tree(root/"rar-multipart"))) # Its receipt names every volume, as a run after the download writes it: a later run recovers it. assert run("rar-stream",names)[0]=="completed" started=time.monotonic();result,_=stream("rar-stream-cancel",blobs,None,["--cancel-after","700"]) assert result[0]=="cancelled" and time.monotonic()-started<5,result assert not (root/"rar-stream-cancel").exists() and not (root/"rar-stream-cancel.extracting").exists() damaged=list(blobs);damaged[1]=damaged[1][:4000]+bytes(len(damaged[1])-4000) result,_=stream("rar-stream-damaged",damaged,0.3) assert result[0]=="failed",result assert not (root/"rar-stream-damaged").exists() and not (root/"rar-stream-damaged.extracting").exists() # Extracting while downloading: the first volume is checked at once, the last one as soon as it is there, # however far decoding is. waiting=[root/f"stream-no-last-v{i}" for i in range(4)];waiting[0].write_bytes(real[0].read_bytes()) process=subprocess.Popen([str(binary),str(root/"stream-no-last"),str(1024*1024),*map(str,waiting),"--stream","--password","fixture-password"],stdout=subprocess.PIPE,text=True) time.sleep(1);partial=root/"stream-no-last.part";partial.write_bytes(real[2].read_bytes());os.rename(partial,waiting[3]) started=time.monotonic();out=process.communicate(timeout=20)[0].splitlines() assert out[0]=="failed" and out[-1]==NO_LAST and time.monotonic()-started<3,out result,_=stream("stream-no-first",[p.read_bytes() for p in real[1:]],None,["--password","fixture-password"]) assert result[0]=="failed" and result[-1]==NO_FIRST,result result,_=stream("zip-stream",[zip.read_bytes()],None) assert result[0]=="failed" and "RAR set" in result[-1],result # A worker that goes silent may still write into its staging: the next extraction stages beside it, # and a later launch removes what it left once the set is extracted. silent=root/"silent-worker";silent.write_text("#!/bin/sh\nexec sleep 60\n");silent.chmod(0o755) real=os.environ["PS5_REACT_RAR_WORKER"];os.environ["PS5_REACT_RAR_WORKER"]=str(silent) try:out=subprocess.check_output([str(binary),str(root/"rar-silent"),str(1024*1024),*map(str,rar_parts),"--cancel-after","100","--again",real],text=True,timeout=60).splitlines() finally:os.environ["PS5_REACT_RAR_WORKER"]=real assert out[0]=="cancelled" and out[3]=="completed",out assert (root/"rar-silent.extracting/.ps5-react-stage").exists() and not (root/"rar-silent.extracting-1").exists() assert tree(root/"rar-silent")==tree(root/"rar-multipart") assert run("rar-silent",rar_parts)[0]=="completed" and not (root/"rar-silent.extracting").exists() # A process interruption leaves owned staging; the next extraction restarts safely. st=zip.stat();identity=f"{zip}|{st.st_dev}|{st.st_ino}|{st.st_size}|{st.st_mtime_ns//10**9}|{st.st_mtime_ns%10**9}\n" staging=root/"interrupted.extracting";staging.mkdir();(staging/"unfinished").write_bytes(b"partial") (staging/".ps5-react-stage").write_text("P5ST001:"+hashlib.sha256(identity.encode()).hexdigest()+"\n") assert run("interrupted",[zip])[0]=="completed" assert (root/"interrupted/game.ffpfsc").read_bytes()==DATA orphan=root/"orphan.extracting";orphan.mkdir();(orphan/"keep").write_bytes(b"unowned") assert run("orphan",[zip])[0]=="failed" assert (orphan/"keep").read_bytes()==b"unowned" # A crash before the ownership record leaves empty staging, which the next extraction takes over. (root/"abandoned.extracting").mkdir() assert run("abandoned",[zip])[0]=="completed" assert run("limit",[zip],100)[0]=="failed" assert not (root/"limit").exists() for name,path in [("traversal","../escape"),("absolute","/escape"),("drive","C:/escape")]: p=root/f"{name}.zip" with zipfile.ZipFile(p,"w") as z:z.writestr(path,b"bad") assert run(name,[p])[0]=="failed" assert not (root/name).exists() p=root/"symlink.tar" with tarfile.open(p,"w") as t: entry=tarfile.TarInfo("link");entry.type=tarfile.SYMTYPE;entry.linkname="../escape";t.addfile(entry) assert run("symlink",[p])[0]=="failed" assert not (root/"symlink").exists() assert run("zip",[zip])[0]=="completed" # Owned output is hash-verified on recovery. assert (root/"zip/game.ffpfsc").read_bytes()==DATA (root/"zip/game.ffpfsc").write_bytes(b"corrupt") assert run("zip",[zip])[0]=="failed" # Inspection names a file by its bytes and refuses a too-large window from a download's first bytes. def inspect(*paths,budget=None): args=[] if budget is None else ["--budget",str(budget)] return subprocess.check_output([str(binary),"inspect",*args,*map(str,paths)],text=True,timeout=20).split("\n")[:2] def partial(name,content,keep,hole=0): p=root/name;p.write_bytes(content[:keep]+bytes(hole));return p big_rar=rar5_with_window(9) assert inspect(partial("big.rar.part",big_rar,len(big_rar)-40,1<<20))==["rar",TOO_BIG] small_rar=rar5_with_window(8) assert inspect(partial("small.rar.part",small_rar,len(small_rar)-40,1<<20))==["rar",""] # The walk reads the first file header whatever its budget, and stops after it once the budget is spent; # within its budget it reaches a window declared thousands of files in. assert inspect(partial("big.rar.part",big_rar,len(big_rar)-40,1<<20),budget=0)==["rar",TOO_BIG] late=rar5_with_window(*[8]*5000,9);late_rar=partial("late.rar.part",late,len(late)-40,1<<20) assert inspect(late_rar,budget=0)==["rar",""] assert inspect(late_rar,budget=60000)==["rar",TOO_BIG] assert inspect(rar_parts[0])==["rar",""] assert inspect(partial("hole.part",big_rar,0,1<<20))==["",""] assert inspect(partial("zip.part",zip.read_bytes(),200))==["zip",""] assert inspect(partial("7z.part",seven.read_bytes(),40))==["7z",""] assert inspect(tar)==["tar",""] gz=root/"fixture.tar.gz";gz.write_bytes(__import__("gzip").compress(tar.read_bytes())) assert inspect(gz)==["tar.gz",""] assert inspect(partial("huge.tar.xz.part",(root/"huge.tar.xz").read_bytes(),4096))==["tar.xz",TOO_BIG] assert inspect(partial("game.pkg.part",b"\x7fCNT"+bytes(64),68))==["pkg",""] assert inspect(partial("game.exfat",b"\xeb\x76\x90EXFAT "+bytes(500),512))==["exfat",""] ufs=bytearray(70000);ufs[65536+1372:65536+1376]=struct.pack("