Skip to content

Commit 2d1543e

Browse files
committed
fix: address code quality issues across the project
- Add path traversal protection (safe_path utility) for RPA and APK extraction - Fix rpa.py: rename read_util→read_until, add EOF check, handle bytes filenames, rename dir→output_dir parameter - Fix stmts.py: mutable default args, variable shadowing, unused variable, return type annotation, error message, unpickler error handling - Fix save.py: rstrip→removesuffix bug, remove redundant base64 imports - Fix apk.py: tarfile path traversal protection - Fix cli.py: verbose sets root logger, add sys import, nargs='+' for decompile, remove inline __import__ - Fix decompile.py: bare raise instead of raise e - Fix translate.py: mutable default arg, add datetime import - Fix safe_pickle.py: correct safe_loads docstring - Add ecdsa optional dependency in pyproject.toml - Export all public API from __init__.py
1 parent d0f391e commit 2d1543e

11 files changed

Lines changed: 104 additions & 47 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = []
1919

2020
[project.optional-dependencies]
2121
release = ["twine"]
22+
sign = ["ecdsa"]
2223

2324
[project.urls]
2425
"Homepage" = "https://github.com/cnfatal/rpycdec"

src/rpycdec/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .decompile import decompile, decompile_file
22
from .cli import main
33
from .rpa import extract_rpa
4-
from .save import extract_save, restore_save
4+
from .save import extract_save, restore_save, dump_save_info, generate_new_key
5+
from .translate import extract_translations

src/rpycdec/apk.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66
"""
77

88
import io
9+
import os
910
import zipfile
1011
import tarfile
1112
import logging
1213
from pathlib import Path
1314
from typing import Optional
1415

16+
from rpycdec.utils import safe_path
17+
1518
logger = logging.getLogger(__name__)
1619

1720

@@ -172,7 +175,17 @@ def extract_apk(
172175
)
173176

174177
with tarfile.open(fileobj=wrapped_stream, mode="r:gz") as tar:
175-
tar.extractall(path=private_output)
178+
# Path traversal protection
179+
for member in tar.getmembers():
180+
try:
181+
safe_path(str(private_output), member.name)
182+
except ValueError:
183+
logger.warning(
184+
"Skipping path traversal attempt in tar: %s",
185+
member.name,
186+
)
187+
continue
188+
tar.extract(member, path=private_output)
176189
logger.info(
177190
f"Extracted {len(tar.getmembers())} files from private.mp3"
178191
)

src/rpycdec/cli.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import argparse
22
import logging
33
import os
4+
import sys
5+
46
from rpycdec.decompile import decompile
57
from rpycdec.rpa import extract_rpa
68
from rpycdec.save import extract_save, restore_save, dump_save_info, generate_new_key
@@ -30,7 +32,7 @@ def extract_rpa_files(srcs: list[str], **kwargs):
3032
for src in srcs:
3133
with open(src, "rb") as f:
3234
output_path = kwargs.get("output") or os.path.dirname(src)
33-
extract_rpa(f, dir=output_path)
35+
extract_rpa(f, output_dir=output_path)
3436

3537

3638
def run_extract_translations(
@@ -84,7 +86,7 @@ def main():
8486
)
8587

8688
decompile_parser = subparsers.add_parser("decompile", help="decompile rpyc file")
87-
decompile_parser.add_argument("src", nargs=1, help="rpyc file or directory")
89+
decompile_parser.add_argument("src", nargs="+", help="rpyc file or directory")
8890
decompile_parser.add_argument(
8991
"--output",
9092
"-o",
@@ -222,7 +224,7 @@ def main():
222224

223225
args = argparser.parse_args()
224226
if args.verbose:
225-
logger.setLevel(logging.DEBUG)
227+
logging.getLogger().setLevel(logging.DEBUG)
226228
if not args.command:
227229
argparser.print_help()
228230
return
@@ -231,6 +233,6 @@ def main():
231233
if args.command in ("decompile", "save", "unrpa") and not os.environ.get(
232234
"RPYCDEC_NO_WARNING"
233235
):
234-
print(SECURITY_WARNING, file=__import__("sys").stderr)
236+
print(SECURITY_WARNING, file=sys.stderr)
235237

236238
args.func(args)

src/rpycdec/decompile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def decompile_file(input_file, output_path=None, **kwargs):
2626
code = util.get_code(stmt)
2727
except Exception as e:
2828
logger.error("decode file %s failed: %s", input_file, e)
29-
raise e
29+
raise
3030
utils.write_file(output_path, code)
3131
logger.info("decompile %s -> %s", input_file, output_path)
3232

src/rpycdec/rpa.py

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
1+
import logging
12
import os
23
import zlib
34
from io import BufferedIOBase
45

56
from rpycdec.safe_pickle import rpa_loads
7+
from rpycdec.utils import safe_path
68

9+
logger = logging.getLogger(__name__)
710

8-
def read_util(data: BufferedIOBase, util: int = 0x00) -> bytes:
11+
12+
def read_until(data: BufferedIOBase, delimiter: int = 0x00) -> bytes:
13+
"""Read bytes from stream until delimiter is found.
14+
15+
Raises ValueError on unexpected EOF.
16+
"""
917
content = bytearray()
1018
while True:
1119
c = data.read(1)
12-
if c[0] == util:
20+
if not c:
21+
raise ValueError("Unexpected EOF while reading stream")
22+
if c[0] == delimiter:
1323
break
1424
content += c
1525
return content
@@ -23,14 +33,13 @@ def start_to_bytes(left: list | None) -> bytes:
2333
return left[0].encode("latin-1")
2434

2535

26-
def extract_rpa(r: BufferedIOBase, dir: str | None = None):
27-
dir = dir or "."
28-
magic = read_util(r, 0x20)
36+
def extract_rpa(r: BufferedIOBase, output_dir: str | None = None):
37+
output_dir = output_dir or "."
38+
magic = read_until(r, 0x20)
2939
if magic != b"RPA-3.0":
30-
print("Not a Ren'Py archive.")
31-
return
32-
index_offset = int(read_util(r, 0x20), 16)
33-
key = int(read_util(r, 0x0A).decode(), 16)
40+
raise ValueError("Not a Ren'Py RPA-3.0 archive.")
41+
index_offset = int(read_until(r, 0x20), 16)
42+
key = int(read_until(r, 0x0A).decode(), 16)
3443

3544
# read index
3645
r.seek(index_offset)
@@ -43,6 +52,10 @@ def extract_rpa(r: BufferedIOBase, dir: str | None = None):
4352
]
4453

4554
for filename, entries in index.items():
55+
# Handle bytes filenames from Python 2 era archives
56+
if isinstance(filename, bytes):
57+
filename = filename.decode("utf-8", errors="surrogateescape")
58+
4659
data = bytearray()
4760
for offset, dlen, start in entries:
4861
r.seek(offset)
@@ -51,11 +64,19 @@ def extract_rpa(r: BufferedIOBase, dir: str | None = None):
5164
if block.startswith(start):
5265
block = block[len(start) :]
5366
else:
54-
print("Warning: %s does not start with %s" % (filename, start))
67+
logger.warning(
68+
"%s does not start with expected prefix %s", filename, start
69+
)
5570
data += block
5671

57-
filename = os.path.join(dir, filename)
58-
os.makedirs(os.path.dirname(filename), exist_ok=True)
59-
with open(filename, "wb") as f:
60-
print("extracting: ", filename)
72+
# Path traversal protection
73+
try:
74+
dest = safe_path(output_dir, filename)
75+
except ValueError:
76+
logger.warning("Skipping path traversal attempt: %s", filename)
77+
continue
78+
79+
os.makedirs(os.path.dirname(dest), exist_ok=True)
80+
with open(dest, "wb") as f:
81+
logger.info("extracting: %s", dest)
6182
f.write(data)

src/rpycdec/safe_pickle.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,10 @@ def find_class(self, module: str, name: str) -> Any:
219219

220220

221221
def safe_loads(data: bytes, **kwargs: Any) -> Any:
222-
"""Safely unpickle data using SafeUnpickler (strict mode — raises on unknown classes)."""
222+
"""Safely unpickle data using SafeUnpickler.
223+
224+
Unknown classes are substituted with DummyClass placeholders.
225+
"""
223226
return SafeUnpickler(io.BytesIO(data), **kwargs).load()
224227

225228

src/rpycdec/save.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,7 @@ def restore_save(
498498
key_file: Path to security_keys.txt for re-signing (optional)
499499
"""
500500
if not output_file:
501-
output_file = extracted_dir.rstrip("/").rstrip(".extracted") + ".restored.save"
501+
output_file = extracted_dir.rstrip("/").removesuffix(".extracted") + ".restored.save"
502502

503503
print(f"Restoring save file from: {extracted_dir}")
504504
print(f"Output file: {output_file}")
@@ -604,8 +604,6 @@ def sign_data(log_data: bytes, key_file: str) -> str:
604604

605605
def encode_line(kind: str, key: bytes, sig: bytes = b"") -> str:
606606
"""Encode a signature line in Ren'Py format."""
607-
import base64
608-
609607
key_b64 = base64.b64encode(key).decode("ascii")
610608
if sig:
611609
sig_b64 = base64.b64encode(sig).decode("ascii")
@@ -614,8 +612,6 @@ def encode_line(kind: str, key: bytes, sig: bytes = b"") -> str:
614612

615613
def decode_line(line: str) -> Tuple[str, bytes, bytes]:
616614
"""Decode a signature line from Ren'Py format."""
617-
import base64
618-
619615
parts = line.strip().split()
620616
if len(parts) < 2:
621617
return "", b"", b""
@@ -661,8 +657,6 @@ def generate_new_key(key_file: str) -> str:
661657
except ImportError:
662658
raise ImportError("ecdsa library required. Install with: pip install ecdsa")
663659

664-
import base64
665-
666660
sk = ecdsa.SigningKey.generate(curve=ecdsa.NIST256p)
667661
vk = sk.verifying_key
668662

src/rpycdec/stmts.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,38 +41,43 @@ def read_rpyc_data(file: io.BufferedReader, slot):
4141
return zlib.decompress(data)
4242

4343

44-
def load(data: io.BufferedReader, slots: list[int] = [1, 2], **kwargs) -> Node | None:
44+
def load(data: io.BufferedReader, slots: list[int] | None = None, **kwargs) -> list[Node] | None:
45+
"""Load Ren'Py AST from a .rpyc file.
46+
47+
Tries each slot in order. Slot 1 is the original script,
48+
slot 2 is the pre-translated version.
49+
"""
50+
if slots is None:
51+
slots = [1, 2]
4552
# 1 is statements before translation, 2 is after translation.
4653
for slot in slots:
4754
try:
4855
bindata = read_rpyc_data(data, slot)
49-
except Exception as e:
50-
logger.warning(f"Failed to read slot {slot}: {e}")
51-
data.seek(0)
52-
continue
53-
if bindata:
56+
if not bindata:
57+
continue
58+
5459
if kwargs.get("dis", False):
5560
logger.info("Disassembling rpyc file...")
5661
pickletools.dis(bindata)
5762

5863
unpickler = SafeUnpickler(
5964
io.BytesIO(bindata), encoding="utf-8", errors="surrogateescape"
6065
)
61-
data, stmts = unpickler.load()
62-
63-
key = data.get("key", "unlocked") # type: ignore
66+
metadata, stmts = unpickler.load()
6467
return stmts
65-
raise Exception("Unsupported file format or invalid file")
68+
except Exception as e:
69+
logger.warning(f"Failed to read slot {slot}: {e}")
70+
data.seek(0)
71+
continue
72+
raise ValueError("Unsupported file format or invalid file")
6673

6774

68-
def load_file(filename, **kwargs) -> Node | None:
69-
"""
70-
load renpy code from rpyc file and return ast tree.
71-
"""
75+
def load_file(filename, **kwargs) -> list[Node] | None:
76+
"""Load Ren'Py AST from a .rpyc/.rpymc file."""
7277
ext = path.splitext(filename)[1]
7378
if ext in [".rpy", ".rpym"]:
7479
raise NotImplementedError(
75-
"unsupport for pase rpy file or use renpy.parser.parse() in renpy's SDK"
80+
"Parsing .rpy files is not supported. Use renpy.parser.parse() from Ren'Py's SDK."
7681
)
7782
# slot 2 is for pre-translated scripts, slot 1 is for normal scripts
7883
slots = [2, 1] if kwargs.get("pre_translated", False) else [1, 2]

src/rpycdec/translate.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import collections
2+
import datetime
23
import hashlib
34
import logging
45
import os
@@ -201,8 +202,10 @@ def _generate_identifier(self, node: renpy.ast.Node) -> str:
201202
# ============================================================================
202203

203204

204-
def unique_identifier(label: str | None, digest: str, existing_identifiers: set = set()) -> str:
205+
def unique_identifier(label: str | None, digest: str, existing_identifiers: set | None = None) -> str:
205206
"""Generate a unique translation identifier"""
207+
if existing_identifiers is None:
208+
existing_identifiers = set()
206209
if label is None:
207210
base = digest
208211
else:
@@ -291,7 +294,7 @@ def write_dialogue_translations(
291294
os.makedirs(os.path.dirname(tl_path) if os.path.dirname(tl_path) else output_dir, exist_ok=True)
292295

293296
with open(tl_path, "w", encoding="utf-8") as f:
294-
f.write(f"# TODO: Translation updated at {__import__('datetime').datetime.now().isoformat()}\n\n")
297+
f.write(f"# TODO: Translation updated at {datetime.datetime.now().isoformat()}\n\n")
295298

296299
for item in items:
297300
# Write source file location comment
@@ -346,7 +349,7 @@ def write_string_translations(
346349
tl_path = os.path.join(output_dir, "strings.rpy")
347350

348351
with open(tl_path, "w", encoding="utf-8") as f:
349-
f.write(f"# TODO: Translation updated at {__import__('datetime').datetime.now().isoformat()}\n\n")
352+
f.write(f"# TODO: Translation updated at {datetime.datetime.now().isoformat()}\n\n")
350353
f.write(f"translate {language} strings:\n\n")
351354

352355
for item in strings:

0 commit comments

Comments
 (0)