Files
AnyPS5/tools/guest_pointer_audit.py
T
LeandroLP23andClaude Opus 5.5 6f4a93d0e4 fix(tools): keep source lines when the guest pointer audit blanks comments and literals
Main's progress.code now drops blank lines, so the audit reported
lines from a shorter text (Export.cpp:145 for an access on line 172).
The audit blanks comments and literals itself, keeping every line,
including spliced ones.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 22:08:27 +01:00

186 lines
7.2 KiB
Python

import argparse
import json
import re
from collections import Counter
from pathlib import Path
import progress
ROOT = Path(__file__).resolve().parent.parent
PRX = ROOT / "core" / "libs" / "prx"
SAFE = {"GuestCopyIn_nid_no_patch": (0, 1), "GuestCopyOut_nid_no_patch": (0, 1), "CopyGuestString_nid_no_patch": (0,),
"CopyGuestPath_nid_no_patch": (0,), "CopyGuestEnvironment_nid_no_patch": (0,)}
UNEVALUATED = ("sizeof", "alignof", "decltype", "noexcept")
RAW_CALLS = {
"memcpy": (0, 1), "memmove": (0, 1), "memset": (0,), "memcmp": (0, 1),
"strcpy": (0, 1), "strncpy": (0, 1), "strlen": (0,), "strnlen": (0,), "strcmp": (0, 1), "strncmp": (0, 1),
"snprintf": (0, 2), "sprintf": (0, 1), "copy": (0, 1, 2), "copy_n": (0, 2), "fill": (0, 1), "fill_n": (0,),
}
PARAMETER = re.compile(r"([A-Za-z_][\w:<>,\s]*?(?:[*&]+\s*(?:const\b\s*)?)+)\s*(\w+)\s*$")
RAW_CALL = re.compile(rf"\b(?:std::|::)?({'|'.join(RAW_CALLS)})\s*\(")
QUIET_CALL = re.compile(rf"\b({'|'.join((*SAFE, *UNEVALUATED))})\s*\(")
CAST = re.compile(r"^(?:\(\s*[\w\s:*<>]+\)(?=\s*\S)|(?:reinterpret|static|const)_cast\s*<[^>]*>|\()\s*")
def split_top_level(text, angles=True):
opening, closing_brackets = ("(<[{", ")>]}") if angles else ("([{", ")]}")
parts, depth, start = [], 0, 0
for index, character in enumerate(text):
if character in opening:
depth += 1
elif character in closing_brackets:
depth -= 1
elif character == "," and depth == 0:
parts.append((start, text[start:index]))
start = index + 1
parts.append((start, text[start:]))
return parts
def closing(text, open_paren):
depth = 0
for index in range(open_paren, len(text)):
if text[index] == "(":
depth += 1
elif text[index] == ")":
depth -= 1
if depth == 0:
return index
return len(text) - 1
def pointer_parameters(signature):
names = []
for _, part in split_top_level(signature):
part = part.split("=")[0].strip()
if "(" in part or "&" in part:
continue
match = PARAMETER.match(part)
if match and "*" in match.group(1):
names.append(match.group(2))
return names
def quiet(body):
blanked = list(body)
for match in QUIET_CALL.finditer(body):
open_paren = match.end() - 1
end = closing(body, open_paren)
spans = [(open_paren + 1, end)]
if match.group(1) in SAFE:
arguments = split_top_level(body[open_paren + 1:end], angles=False)
spans = [(open_paren + 1 + offset, open_paren + 1 + offset + len(text))
for position, (offset, text) in enumerate(arguments) if position in SAFE[match.group(1)]]
for start, stop in spans:
for index in range(start, stop):
if blanked[index] != "\n":
blanked[index] = " "
return "".join(blanked)
def names_parameter(argument, name):
argument = argument.strip()
while True:
stripped = CAST.sub("", argument, count=1)
if stripped == argument:
break
argument = stripped
return re.match(rf"{re.escape(name)}\b", argument) is not None
def accesses(body, name):
word = re.escape(name)
found = []
for kind, pattern in (("member", rf"\b{word}\s*->"), ("index", rf"\b{word}\s*\["),
("dereference", rf"(?:^|[=(,;{{}}!&|?:+\-]|\breturn)\s*\*\s*{word}\b")):
found += [(match.end() - 1, kind) for match in re.finditer(pattern, body)]
for match in RAW_CALL.finditer(body):
open_paren = match.end() - 1
arguments = split_top_level(body[open_paren + 1:closing(body, open_paren)], angles=False)
for position in RAW_CALLS[match.group(1)]:
if position < len(arguments) and names_parameter(arguments[position][1], name):
found.append((open_paren + 1 + arguments[position][0], "library call"))
return sorted(found)
def splice_lines(text):
lines = []
pending = ""
joined = 0
for line in text.replace("\r\n", "\n").split("\n"):
if line.endswith("\\"):
pending += line[:-1]
joined += 1
continue
lines.append(pending + line + "\n" * joined)
pending = ""
joined = 0
if joined:
lines.append(pending + "\n" * joined)
return "\n".join(lines)
def keep_lines(match):
if match.group("code") is not None:
return match.group()
if match.group("comment") is None:
return "LITERAL" + "\n" * match.group().count("\n")
return re.sub(r"[^\n]", " ", match.group())
def source_code(text):
return progress.CPP_TOKEN.sub(keep_lines, splice_lines(text))
def audit_text(text):
results = []
for match in progress.DEFINITION.finditer(text):
open_paren = text.index("(", match.start(1))
close_paren = text.rindex(")", 0, match.end())
body_start = match.end() - 1
body = quiet(text[body_start:progress.body_end(text, body_start) + 1])
for parameter in pointer_parameters(text[open_paren + 1:close_paren]):
hits = accesses(body, parameter)
if hits:
line = text.count("\n", 0, body_start + hits[0][0]) + 1
results.append({"function": match.group(1), "parameter": parameter, "line": line, "kinds": sorted({kind for _, kind in hits})})
return results
def audit(libraries):
report = []
for library in libraries:
for source in sorted(library.rglob("*.cpp")):
if "tests" in source.relative_to(library).parts:
continue
text = source_code(source.read_text(encoding="utf-8", errors="ignore"))
for entry in audit_text(text):
report.append({"library": library.name, "file": source.relative_to(ROOT).as_posix(), **entry})
return report
def main(argv=None):
parser = argparse.ArgumentParser(description="List exported functions that access a guest pointer parameter directly instead of through GuestCopyIn, GuestCopyOut or the CopyGuest* helpers. A report, not a gate: it always exits 0 and does not follow a pointer passed to another function.")
parser.add_argument("libraries", nargs="*", help="library directories under core/libs/prx (default: all)")
parser.add_argument("--json", action="store_true", help="print the findings as JSON")
args = parser.parse_args(argv)
names = args.libraries or sorted(path.name for path in PRX.iterdir() if path.is_dir())
missing = [name for name in names if not (PRX / name).is_dir()]
if missing:
parser.error(f"not a library under core/libs/prx: {', '.join(missing)}")
report = audit([PRX / name for name in names])
if args.json:
print(json.dumps(report, indent=2))
return 0
for entry in report:
print(f"{entry['file']}:{entry['line']}: {entry['function']}: parameter {entry['parameter']} ({', '.join(entry['kinds'])})")
counts = Counter(entry["library"] for entry in report)
print(f"{len(report)} direct guest pointer accesses in {len({(e['file'], e['function']) for e in report})} exported functions")
for library, count in counts.most_common():
print(f" {count:5} {library}")
return 0
if __name__ == "__main__":
raise SystemExit(main())