#!/usr/bin/env python3
"""memsafe_prescan.py — deterministic memory-safety pre-scan for
C / C++ / Objective-C source.

ARION service artifact. Scans C-family source files for the call
shapes that produce buffer overflows, format-string injection, stack
leaks, allocator misuse, and the classic banned-function set
(CWE-120/121/122/134/170/252/415/467/562/758/785/789), checkable
offline, line-wise, without executing code:

  * unbounded line/string reads: gets() — HIGH, always
  * scanf-family %s / %[ writes without a field width — HIGH; a
    non-literal scanf format string — MEDIUM
  * strcpy/strcat/stpcpy-family copies with no length argument —
    HIGH into a declared fixed-size array, MEDIUM elsewhere
  * sprintf/vsprintf-family writes with no bound — HIGH into a
    declared fixed-size array, MEDIUM elsewhere
  * literal overrun: memcpy/memmove/memset/strncpy/strncat/fgets/
    snprintf/read/recv into an array declared smaller than the
    literal size argument — HIGH
  * format-string injection: printf/fprintf/vprintf/dprintf/syslog/
    asprintf family with a non-literal format argument — HIGH when
    the format identifier is input-shaped (buf/input/user/argv/
    msg/data/name/...), MEDIUM otherwise
  * variable-size copies into fixed arrays: memcpy/memmove/bcopy
    with a non-literal size that is not sizeof(dest)-shaped — MEDIUM
  * strncpy/strncat that can leave the destination unterminated —
    MEDIUM; strncpy bound taken from strlen(src) — HIGH (bounded in
    name only)
  * returning the address of a local (non-static, non-const) array —
    HIGH (caller gets a dangling stack pointer)
  * allocator result dereferenced with no NULL check in the
    lookahead window — MEDIUM
  * p = realloc(p, n) overwrite-before-check idiom — LOW (leaks the
    old block on OOM)
  * double free: free(x) twice with no reassignment between — MEDIUM
  * mktemp/tmpnam/tempnam predictable temp names — MEDIUM
  * alloca() with a non-literal or large size — MEDIUM
  * malloc/calloc with a product of two non-literals — LOW
    (overflow-into-alloc)
  * low-tier banned calls: strtok (not reentrant), strncat without
    the terminator write it still needs — LOW

Declaration tracking: `char name[N]` (plus wchar_t/BYTE/uint8_t/
char-family typedefs) builds a per-file name->size map so literal
overruns and fixed-array severity tiers are size-aware. static and
const arrays are tracked but never produce RET-STACK or unterminated
findings.

Suppression: comments and string contents are scrubbed before shape
analysis (string literals remain visible to format-string checks);
macro definitions and function declarations/definitions for the
dangerous names themselves are skipped; member calls (obj.strcpy,
p->strcpy) are skipped; safe variants (gets_s, scanf_s width forms,
mkstemp, snprintf-correct, strncpy + explicit terminator write) stay
silent.

Honest scope: PRE-SCAN, not an audit. Line- and window-based; no
dataflow. It cannot tell whether a variable-size copy is clamped
elsewhere — a FLAG means "deserves human review"; a clean report is
not proof of memory safety.

Usage:
  memsafe_prescan.py <file-or-dir> [--json]
  memsafe_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.
"""
import json
import os
import re
import sys
from collections import deque

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}

SKIP_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv",
             "venv", "dist", "build", ".tox", ".mypy_cache", "target",
             ".idea", ".next", "coverage", "docs", "Documentation"}

C_EXT = {".c", ".h", ".cc", ".cpp", ".cxx", ".c++", ".hpp", ".hh",
         ".hxx", ".m", ".mm", ".ino"}

# ---------------------------------------------------------------------
# comment / string scrubbing
# ---------------------------------------------------------------------

def strip_comments(lines):
    """Remove // and /* */ comments, preserving line numbers."""
    out = []
    in_block = False
    for line in lines:
        res = []
        i = 0
        while i < len(line):
            if in_block:
                j = line.find("*/", i)
                if j == -1:
                    i = len(line)
                else:
                    in_block = False
                    i = j + 2
            elif line.startswith("//", i):
                break
            elif line.startswith("/*", i):
                in_block = True
                i += 2
            else:
                res.append(line[i])
                i += 1
        out.append("".join(res))
    return out


STRING_RE = re.compile(r'"(?:[^"\\\n]|\\.)*"|\'(?:[^\'\\\n]|\\.)*\'')


def scrub_strings(line):
    return STRING_RE.sub('""', line)


def string_literals(line):
    return [m.group(0)[1:-1] for m in STRING_RE.finditer(line)]


# ---------------------------------------------------------------------
# declaration model — fixed-size char arrays
# ---------------------------------------------------------------------

ARRAY_DECL_RE = re.compile(
    r"\b(?:char|wchar_t|char16_t|char32_t|char8_t|TCHAR|BYTE|byte|"
    r"uint8_t|int8_t|unsigned\s+char|signed\s+char|UCHAR|u_char|gchar|"
    r"xmlChar)\s+(\w+)\s*\[\s*(\d+)\s*\]")
STATIC_OR_CONST_RE = re.compile(r"\b(static|const)\b")


STRUCT_OPEN_RE = re.compile(
    r"\b(struct|union|class|enum)\s+\w*[^{;]*\{")


def member_regions(lines):
    """Return the set of line numbers inside struct/union/class/enum
    bodies — declarations there are members, not function locals."""
    regions = set()
    depth = 0
    member_depths = []
    for i, raw in enumerate(lines, 1):
        code = scrub_strings(raw)
        depth_before = depth
        opens = code.count("{")
        closes = code.count("}")
        if STRUCT_OPEN_RE.search(code):
            member_depths.append(depth_before + opens)
        depth += opens - closes
        while member_depths and depth < member_depths[-1]:
            member_depths.pop()
        if member_depths:
            regions.add(i)
    return regions


def build_array_map(lines):
    """name -> list of (lineno, size, static_or_const, indented)."""
    members = member_regions(lines)
    amap = {}
    for i, line in enumerate(lines, 1):
        code = scrub_strings(line)
        for m in ARRAY_DECL_RE.finditer(code):
            name, size = m.group(1), int(m.group(2))
            prefix = code[:m.start()]
            e = amap.setdefault(name, [])
            e.append({
                "line": i,
                "size": size,
                "sc": bool(STATIC_OR_CONST_RE.search(prefix)),
                "local": bool(line[:1].isspace()) and i not in members,
            })
    return amap


def array_decl(amap, name, at_line):
    """Nearest preceding declaration of `name`, or None."""
    best = None
    for e in amap.get(name, []):
        if e["line"] <= at_line:
            best = e
    return best


def base_name(expr):
    """'dst', 'obj.dst', 'p->dst', 'dst[i]', '&dst' -> 'dst'."""
    e = expr.strip().lstrip("&").strip()
    e = re.sub(r"\[.*\]$", "", e).strip()
    if "->" in e:
        e = e.split("->")[-1].strip()
    elif "." in e:
        e = e.split(".")[-1].strip()
    m = re.match(r"^(\w+)$", e)
    return m.group(1) if m else None


# ---------------------------------------------------------------------
# call-shape helpers
# ---------------------------------------------------------------------

def split_args(s):
    args, depth, cur = [], 0, []
    for ch in s:
        if ch in "([":
            depth += 1
        elif ch in ")]":
            depth -= 1
        if ch == "," and depth == 0:
            args.append("".join(cur))
            cur = []
        else:
            cur.append(ch)
    if cur:
        args.append("".join(cur))
    return [a.strip() for a in args]


def lit_spans(line):
    return [(m.start(), m.end()) for m in STRING_RE.finditer(line)]


def in_lit(spans, pos):
    return any(a <= pos < b for a, b in spans)


def find_call(raw, fname, spans):
    """Return inner arg string of first `fname(` outside a literal."""
    for m in re.finditer(r"(?<![\w>.\-])" + re.escape(fname) + r"\s*\("
                         + r"|\bstd::" + re.escape(fname) + r"\s*\(",
                         raw):
        if in_lit(spans, m.start()):
            continue
        i = raw.index("(", m.start())
        depth, j = 0, i
        while j < len(raw):
            if raw[j] == "(":
                depth += 1
            elif raw[j] == ")":
                depth -= 1
                if depth == 0:
                    return raw[i + 1:j]
            j += 1
        return raw[i + 1:]
    return None


DECL_PREFIX_RE = re.compile(
    r"^\s*(?:extern\b|static\b|inline\b|typedef\b|virtual\b|"
    r"(?!return\b|if\b|while\b|for\b|switch\b|case\b|sizeof\b|"
    r"delete\b|else\b|do\b)[\w:\*]+)\s+[\w:\*&\s]*$")


def is_declaration_context(code, fname, prev=""):
    """True if `fname` appears as a declaration/definition/macro."""
    s = code.strip()
    if s.startswith("#define") or s.startswith("#"):
        return True
    m = re.search(r"(?<![\w>.\-])" + re.escape(fname) + r"\s*\(", s)
    if not m:
        return True
    prefix = s[:m.start()].strip()
    if not prefix:
        # type on the previous line: `char *\ngets(char *s)` — a
        # continuation line inside a declaration, not a call.
        p = prev.strip()
        if p and re.search(r"[\w\*&,]$", p) and not \
                re.search(r"[;{}():]$|=\s*$", p):
            return True
        return False
    # `return strcpy(` / `x = strcpy(` / `if (strcpy(` keep analysis
    if re.search(r"(=|return|if|while|for|&&|\|\||!|,|\(|case)\s*$",
                 prefix):
        return False
    # `char *strcpy(` / `extern int scanf(` — a type before the name
    if re.match(r"^[\w:\*&\s]+$", prefix):
        return True
    return False


QUAL_WORD_RE = re.compile(
    r"\b(const|volatile|register|restrict|struct|enum|union|"
    r"unsigned|signed|long|short|typename)\b")
BUILTIN_TYPE_RE = re.compile(
    r"^(char|int|float|double|void|bool|short|long|FILE|size_t|"
    r"ssize_t|ptrdiff_t|intptr_t|uintptr_t|wchar_t|char8_t|"
    r"char16_t|char32_t|va_list|\w*int\d+_t|\w+_t|__\w+)$")
ARG_SEP_RE = re.compile(
    r"^\s*([\w:]+)[\s\*&][\w:\s\*&\[\]]*$")


def arg_is_decl(a):
    """`const char *s` / `size_t n` / `MachineInstr *mi` — but NOT
    `a * b` (multiplication) or a bare identifier."""
    a = a.strip().rstrip("...")
    if not a or a == "void":
        return False
    if QUAL_WORD_RE.search(a):
        return True
    m = ARG_SEP_RE.match(a)
    if not m:
        return False
    head = m.group(1)
    return bool(BUILTIN_TYPE_RE.match(head) or
                re.match(r"^[A-Z]", head))


def args_are_signature(args):
    """True when call 'args' are actually parameter declarations —
    i.e. the line is a function definition/prototype, not a call."""
    return any(arg_is_decl(a) for a in args)


def member_call(code, fname):
    """obj.fname( / p->fname( / ns::fname( (non-std) -> member."""
    return bool(re.search(
        r"(?:\.|->|(?<!std)::)\s*" + re.escape(fname) + r"\s*\(", code))


LIT_INT_RE = re.compile(r"^(?:\(\s*)?(\d+)(?:\s*\))?$")
SIZEOF_SELF_RE = re.compile(
    r"^sizeof\s*(?:\(\s*(\w+)\s*\)|(\w+))$")


def literal_int(expr):
    m = LIT_INT_RE.match(expr.strip())
    return int(m.group(1)) if m else None


# ---------------------------------------------------------------------
# function vocabularies
# ---------------------------------------------------------------------

UNBOUNDED_COPY = {
    "strcpy", "strcat", "stpcpy", "wcscpy", "wcscat", "lstrcpy",
    "lstrcpyA", "lstrcat", "lstrcatA", "_tcscpy", "_tcscat",
    "_mbscpy", "_mbscat", "strcpyA", "strcatA", "wmemcpy",  # wmemcpy n=count
}
UNBOUNDED_COPY -= {"wmemcpy"}  # wmemcpy takes a count — handled elsewhere

SPRINTF_FAM = {"sprintf", "vsprintf", "stprintf", "wsprintfA",
               "wsprintfW", "swprintf", "vswprintf"}

SCANF_FAM = {"scanf", "fscanf", "sscanf", "wscanf", "fwscanf",
             "swscanf", "vscanf", "vfscanf", "vsscanf"}

# fmt-arg index for printf-family (0-based arg position of the format)
PRINTF_FMT_IDX = {"printf": 0, "vprintf": 0, "fprintf": 1,
                  "vfprintf": 1, "sprintf": 1, "snprintf": 2,
                  "vsnprintf": 2, "dprintf": 1, "vdprintf": 1,
                  "asprintf": 1, "vasprintf": 1, "syslog": 1,
                  "wprintf": 0, "fwprintf": 1, "swprintf2": 2,
                  "err": 1, "warn": 0, "errx": 1, "warnx": 0,
                  "printf_chk": 0}

BOUNDED_SZ_ARG3 = {"memcpy", "memmove", "memset", "strncpy", "strncat",
                   "read", "recv", "bcopy", "CopyMemory",
                   "RtlCopyMemory", "wmemcpy", "wmemmove", "wmemset",
                   "getline", "memccpy"}
# name -> (dest_arg_idx, size_arg_idx)
BOUNDED_POS = {"memcpy": (0, 2), "memmove": (0, 2), "memset": (0, 2),
               "strncpy": (0, 2), "strncat": (0, 2), "read": (1, 2),
               "recv": (1, 2), "bcopy": (1, 2), "CopyMemory": (0, 2),
               "RtlCopyMemory": (0, 2), "wmemcpy": (0, 2),
               "wmemmove": (0, 2), "wmemset": (0, 2),
               "memccpy": (0, 2), "snprintf": (0, 1), "fgets": (0, 1)}

ALLOC_FNS = {"malloc", "calloc", "realloc", "strdup", "strndup",
             "new", "valloc", "memalign", "xmalloc"}

INPUT_VOCAB_RE = re.compile(
    r"(?i)(buf|buff|input|user|argv|msg|req|request|line|data|packet|"
    r"pkt|name|nick|host|path|text|body|query|cmd|str|read|from|peer|"
    r"remote|client|net|sock|in$|_in\b|src)")

GETTEXT_WRAP_RE = re.compile(r"^(_|N_|gettext|ngettext|tr|dgettext)\s*\(")

TEMPFNS = {"mktemp", "tmpnam", "tempnam", "tmpnam_r"}

SAFE_HINT_RE = re.compile(
    r"(?i)(strlcpy|strlcat|snprintf|vsnprintf|mkstemp|gets_s|scanf_s|"
    r"memcpy_s|strcpy_s|strcat_s|sprintf_s|fgets|check_alloc|"
    r"xmalloc|xstrdup|GC_MALLOC)")

BANNED_LOW = {"strtok"}


def fmt_is_literal(arg):
    a = arg.strip()
    return a.startswith('"') or GETTEXT_WRAP_RE.match(a)


def scanf_unbounded(lit):
    """Scan a format literal for width-less %s / %[ conversions."""
    i = 0
    while i < len(lit):
        if lit[i] == "%":
            if i + 1 < len(lit) and lit[i + 1] == "%":
                i += 2
                continue
            j = i + 1
            if j < len(lit) and lit[j] == "*":
                # suppressed conversion — nothing stored
                while j < len(lit) and not lit[j].isalpha() \
                        and lit[j] != "[":
                    j += 1
                i = j + 1 if j < len(lit) else j
                continue
            width = []
            while j < len(lit) and lit[j].isdigit():
                width.append(lit[j])
                j += 1
            if j < len(lit) and lit[j] in "s[" and not width:
                return True
            i = j + 1 if j < len(lit) else j
        else:
            i += 1
    return False


# fast line-level pre-filter — a line with none of these tokens can
# never produce a finding
ANY_CAND_RE = re.compile(
    r"\b(gets|scanf|fscanf|sscanf|wscanf|fwscanf|swscanf|vscanf|"
    r"vfscanf|vsscanf|strcpy|strcat|stpcpy|wcscpy|wcscat|lstrcpy|"
    r"lstrcpyA|lstrcat|lstrcatA|_tcscpy|_tcscat|_mbscpy|_mbscat|"
    r"strcpyA|strcatA|sprintf|vsprintf|stprintf|wsprintfA|wsprintfW|"
    r"swprintf|vswprintf|memcpy|memmove|memset|strncpy|strncat|read|"
    r"recv|bcopy|CopyMemory|RtlCopyMemory|wmemcpy|wmemmove|wmemset|"
    r"memccpy|snprintf|fgets|getline|printf|vprintf|fprintf|vfprintf|"
    r"vsnprintf|dprintf|vdprintf|asprintf|vasprintf|syslog|wprintf|"
    r"fwprintf|err|warn|errx|warnx|printf_chk|malloc|calloc|realloc|"
    r"strdup|strndup|valloc|memalign|alloca|free|return|mktemp|tmpnam|"
    r"tempnam|tmpnam_r|strtok)\s*\(|"
    r"\b\w+\s*\[\s*\w*\s*\]\s*=|\breturn\b")


class Finding:
    def __init__(self, code, sev, file, line, msg):
        self.code = code
        self.sev = sev
        self.file = file
        self.line = line
        self.msg = msg

    def as_dict(self):
        return {"code": self.code, "severity": self.sev,
                "file": self.file, "line": self.line, "msg": self.msg}


def is_comment_or_blank(line):
    return not line.strip()


def scan_file(fpath, rel, raw_lines, out):
    lines = strip_comments(raw_lines)
    amap = build_array_map(lines)
    frees = {}   # name -> lineno of last free() call
    nulwrites = {}  # name -> lineno of last `name[...] = 0` write
    prev = ""

    for i, raw in enumerate(lines, 1):
        s = raw.strip()
        if not s:
            continue
        code = scrub_strings(raw)
        before = len(out)

        # block boundaries reset the double-free window — frees in
        # sibling functions / if-else branches are different scopes.
        # Runs before the candidate pre-filter: def lines like
        # `void f() {` carry no dangerous vocabulary but still close
        # the window.
        if re.match(r"^\s*(}|else\b|#else|#endif|case\b|default\s*:)",
                    code) or (
                code.rstrip().endswith("{") and
                re.search(r"\)\s*[\w\s:<>]*\{\s*$", code) and
                not re.match(
                    r"^\s*(if|for|while|switch|catch|else|do|try|"
                    r"else\s+if)\b", code)):
            frees = {}

        # cheap reject — nothing interesting on this line
        if not ANY_CAND_RE.search(code):
            prev = code
            continue
        spans = lit_spans(raw)
        called = {m.group(1) for m in
                  re.finditer(r"\b(\w+)\s*\(", code)}
        called |= {m.group(1) for m in
                   re.finditer(r"\bstd::(\w+)\s*\(", code)}

        # track explicit terminator writes & free() calls
        for m in re.finditer(r"\b(\w+)\s*\[\s*\w*\s*\]\s*=\s*'?\\?0'?", code):
            nulwrites[m.group(1)] = i
        fm = re.search(r"\bfree\s*\(\s*(\w+)\s*\)", code)
        if fm:
            nm = fm.group(1)
            if nm in frees and i - frees[nm] <= 60:
                # reassignment between the two frees?
                window = lines[frees[nm]:i - 1]
                reassigned = any(
                    re.search(r"\b" + re.escape(nm) + r"\s*=", w)
                    for w in window)
                if not reassigned:
                    out.append(Finding(
                        "MS-DOUBLE-FREE", "MEDIUM", rel, i,
                        f"second `free({nm})` with no reassignment "
                        "between calls — double-free / "
                        "use-after-free risk"))
            frees[nm] = i

        # ---- unbounded line reads: gets() ---------------------------
        if re.search(r"\bgets\s*\(", code) and not \
                member_call(code, "gets") and \
                not is_declaration_context(code, "gets", prev):
            out.append(Finding(
                "MS-GETS", "HIGH", rel, i,
                "`gets()` has no bound and no safe use — deleted from "
                "C11/C++14; replace with fgets"))

        # ---- scanf-family -------------------------------------------
        for fn in SCANF_FAM & called:
            call = find_call(raw, fn, spans)
            if call is None or member_call(code, fn) or \
                    is_declaration_context(code, fn, prev):
                continue
            args = split_args(call)
            if args_are_signature(args):
                continue
            lit = None
            for a in args:
                lits = string_literals(a)
                if lits:
                    lit = lits[0]
                    break
            if lit is not None:
                if scanf_unbounded(lit):
                    out.append(Finding(
                        "MS-SCANF-NOWIDTH", "HIGH", rel, i,
                        f"`{fn}` %s/%[ conversion without a field "
                        "width — unbounded write into the destination "
                        "buffer; add a width (%31s) or use fgets"))
            else:
                out.append(Finding(
                    "MS-SCANF-NONLIT", "MEDIUM", rel, i,
                    f"`{fn}` with a non-literal format string — "
                    "attacker-controlled conversions write to "
                    "attacker-chosen widths"))
            break

        # ---- unbounded string copies ---------------------------------
        for fn in sorted(UNBOUNDED_COPY & called):
            call = find_call(raw, fn, spans)
            if call is None or member_call(code, fn) or \
                    is_declaration_context(code, fn, prev):
                continue
            args = split_args(call)
            if args_are_signature(args):
                continue
            dst = base_name(args[0]) if args else None
            decl = array_decl(amap, dst, i) if dst else None
            sev = "HIGH" if decl else "MEDIUM"
            where = "a fixed-size array" if decl else \
                "a destination of unknown bound"
            out.append(Finding(
                "MS-STRCPY", sev, rel, i,
                f"`{fn}` copies with no length argument into {where}"
                + (f" ({dst}[{decl['size']}])" if decl else "")
                + " — use strlcpy/strncpy+terminate/snprintf"))
            break

        # ---- sprintf-family ------------------------------------------
        for fn in sorted(SPRINTF_FAM & called):
            call = find_call(raw, fn, spans)
            if call is None or member_call(code, fn) or \
                    is_declaration_context(code, fn, prev):
                continue
            args = split_args(call)
            if args_are_signature(args):
                continue
            dst = base_name(args[0]) if args else None
            decl = array_decl(amap, dst, i) if dst else None
            sev = "HIGH" if decl else "MEDIUM"
            where = "a fixed-size array" if decl else \
                "a destination of unknown bound"
            out.append(Finding(
                "MS-SPRINTF", sev, rel, i,
                f"`{fn}` writes an unbounded formatted string into "
                f"{where}"
                + (f" ({dst}[{decl['size']}])" if decl else "")
                + " — use snprintf"))
            break

        # ---- bounded calls: literal overrun / variable size ----------
        for fn, (dpos, spos) in BOUNDED_POS.items():
            if fn not in called:
                continue
            call = find_call(raw, fn, spans)
            if call is None or member_call(code, fn) or \
                    is_declaration_context(code, fn, prev):
                continue
            args = split_args(call)
            if len(args) <= max(dpos, spos) or \
                    args_are_signature(args):
                continue
            dst = base_name(args[dpos])
            decl = array_decl(amap, dst, i) if dst else None
            sz = args[spos]
            n = literal_int(sz)
            sm = SIZEOF_SELF_RE.match(sz)
            if decl and n is not None and n > decl["size"]:
                out.append(Finding(
                    "MS-COPY-LITOVER", "HIGH", rel, i,
                    f"`{fn}` size {n} exceeds `{dst}[{decl['size']}]` "
                    "— literal overrun"))
            elif fn in {"strncpy", "strncat"} and decl:
                if re.search(r"\bstrlen\s*\(", sz):
                    out.append(Finding(
                        "MS-STRNCPY-NONUL", "HIGH", rel, i,
                        f"`{fn}` bound taken from strlen(src) — "
                        "bounded in name only; same exposure as "
                        "strcpy/strcat"))
                else:
                    bound_ok = n is not None and n < decl["size"]
                    if not bound_ok:
                        # look ahead: dst[...] = 0 within 3 lines?
                        ahead = lines[i:i + 3]
                        term = any(
                            re.search(r"\b" + re.escape(dst) +
                                      r"\s*\[", a) and
                            re.search(r"=\s*'\\?0'?", a)
                            for a in ahead)
                        if not term:
                            out.append(Finding(
                                "MS-STRNCPY-NONUL", "MEDIUM", rel, i,
                                f"`{fn}` into `{dst}[{decl['size']}]` "
                                "can fill the buffer with no NUL "
                                "terminator — add `dst[sizeof(dst)-1]"
                                " = '\\0'`"))
            elif decl and n is None and not (
                    sm and (sm.group(1) or sm.group(2)) == dst):
                # non-literal size into a fixed array
                out.append(Finding(
                    "MS-MEMCPY-VARSZ", "MEDIUM", rel, i,
                    f"`{fn}` into `{dst}[{decl['size']}]` with a "
                    "non-literal size — no visible bound; clamp "
                    "to sizeof(dst)"))
            break

        # ---- printf-family non-literal formats ------------------------
        for fn, fidx in PRINTF_FMT_IDX.items():
            if fn not in called:
                continue
            call = find_call(raw, fn, spans)
            if call is None or member_call(code, fn) or \
                    is_declaration_context(code, fn, prev):
                continue
            args = split_args(call)
            if len(args) <= fidx or args_are_signature(args):
                continue
            fmt = args[fidx]
            if fmt_is_literal(fmt):
                continue
            # fmt could be gettext-wrapped literal; severity keys off
            # the final identifier segment so FloatData::spec reads as
            # MEDIUM, not input-shaped
            fmt_tokens = re.findall(r"\w+", fmt)
            fmt_tok = fmt_tokens[-1] if fmt_tokens else fmt
            sev = "HIGH" if INPUT_VOCAB_RE.search(fmt_tok) else \
                "MEDIUM"
            out.append(Finding(
                "MS-FMT-NONLIT", sev, rel, i,
                f"`{fn}` format argument `{fmt.strip()[:40]}` is not a "
                "string literal — a `%`-bearing value becomes an "
                "arbitrary read/write primitive; pass \"%s\""))
            break

        # ---- return of stack storage ---------------------------------
        rm = re.search(r"\breturn\s+&?\s*(\w+)\s*;", code)
        if rm:
            nm = rm.group(1)
            decl = array_decl(amap, nm, i)
            if decl and decl["local"] and not decl["sc"] and \
                    i - decl["line"] <= 120:
                out.append(Finding(
                    "MS-RET-STACK", "HIGH", rel, i,
                    f"`return {nm}` hands the caller a pointer to a "
                    "function-local array — dangling stack storage "
                    "(CWE-562)"))

        # ---- allocators ------------------------------------------------
        am = re.search(
            r"\b(\w+)\s*=\s*(malloc|calloc|realloc|strdup|strndup|"
            r"valloc|memalign)\s*\(", code)
        if am:
            var, fn = am.group(1), am.group(2)
            if fn == "realloc":
                call = find_call(raw, "realloc", spans)
                first = split_args(call)[0] if call else ""
                if base_name(first) == var:
                    out.append(Finding(
                        "MS-REALLOC-SELF", "LOW", rel, i,
                        f"`{var} = realloc({var}, ...)` leaks the old "
                        "block on failure — assign to a temp first"))
            # lookahead: deref without NULL check
            window = lines[i:i + 5]
            checked = any(
                re.search(r"(if|assert|while)\s*\([^)]*\b" +
                          re.escape(var) + r"\b", w) or
                re.search(r"\b" + re.escape(var) +
                          r"\s*[!=]=\s*NULL", w) or
                re.search(r"NULL\s*[!=]=\s*\b" + re.escape(var), w)
                for w in window) or \
                re.search(r"(if|assert)\s*\([^)]*\b" + re.escape(var),
                          code[am.end():])
            deref = any(
                re.search(re.escape(var) + r"\s*(\[|->)", w) or
                re.search(r"\*\s*" + re.escape(var) + r"\b", w) or
                re.search(r"(strcpy|strncpy|memcpy|memmove|memset|"
                          r"sprintf|strcat)\s*\(\s*"
                          + re.escape(var), w)
                for w in window)
            if deref and not checked:
                out.append(Finding(
                    "MS-ALLOC-UNCHECKED", "MEDIUM", rel, i,
                    f"`{var} = {fn}(...)` is dereferenced with no NULL "
                    "check in sight — NULL deref on OOM"))
        # malloc(n*m) — overflow-into-alloc
        for fn in ("malloc", "calloc", "realloc", "valloc", "alloca"):
            if fn not in called:
                continue
            call = find_call(raw, fn, spans)
            if call is None or member_call(code, fn) or \
                    is_declaration_context(code, fn, prev):
                continue
            arg = split_args(call)[0] if split_args(call) else ""
            if fn == "alloca":
                if literal_int(arg) is None or \
                        (literal_int(arg) or 0) > 8192:
                    out.append(Finding(
                        "MS-ALLOCA-VAR", "MEDIUM", rel, i,
                        "`alloca()` with a non-literal/large size — "
                        "unbounded stack consumption"))
            elif re.match(r"^\w+\s*\*\s*\w+$", arg):
                out.append(Finding(
                    "MS-INT-ALLOC", "LOW", rel, i,
                    f"`{fn}` size is a product of non-literals — "
                    "integer overflow yields an undersized buffer "
                    "(calloc-safe form: calloc(n, m))"))
            break

        # ---- predictable temp names ------------------------------------
        for fn in TEMPFNS & called:
            call = find_call(raw, fn, spans)
            if call is not None and not member_call(code, fn) and \
                    not is_declaration_context(code, fn, prev) and \
                    not args_are_signature(split_args(call)):
                out.append(Finding(
                    "MS-MKTEMP", "MEDIUM", rel, i,
                    f"`{fn}()` returns a predictable name — "
                    "race/ symlink-planting window; use mkstemp/ "
                    "O_TMPFILE"))
                break

        # ---- low-tier banned calls -------------------------------------
        for fn in BANNED_LOW & called:
            call = find_call(raw, fn, spans)
            if call is not None and not member_call(code, fn) and \
                    not is_declaration_context(code, fn, prev) and \
                    not args_are_signature(split_args(call)):
                out.append(Finding(
                    "MS-BANNED-LOW", "LOW", rel, i,
                    f"`{fn}()` is on banned-function lists — not "
                    "reentrant; use strtok_r/strsep"))
                break

        # one finding per code per line is enough signal
        if len(out) > before:
            seen = set()
            deduped = []
            for f in out[before:]:
                if f.code in seen:
                    continue
                seen.add(f.code)
                deduped.append(f)
            out[before:] = deduped

        prev = code


def iter_source_files(target):
    if os.path.isfile(target):
        yield target
        return
    for root, dirs, files in os.walk(target):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for f in sorted(files):
            if os.path.splitext(f)[1].lower() in C_EXT:
                yield os.path.join(root, f)


def scan(target):
    findings = []
    scanned = 0
    for path in iter_source_files(target):
        rel = os.path.relpath(path, target) if os.path.isdir(target) \
            else os.path.basename(path)
        try:
            with open(path, "r", errors="replace") as fh:
                raw = fh.readlines()
        except OSError:
            continue
        scanned += 1
        scan_file(path, rel, raw, findings)
    findings.sort(key=lambda f: (SEV_ORDER.get(f.sev, 9), f.file,
                                 f.line))
    return findings, scanned


def report_md(findings, scanned, target):
    counts = {s: 0 for s in SEV_ORDER}
    for f in findings:
        counts[f.sev] = counts.get(f.sev, 0) + 1
    out = ["# Memory-safety pre-scan (C/C++)",
           "",
           f"- target: `{target}`",
           f"- source files scanned: {scanned}",
           f"- findings: HIGH {counts['HIGH']} · MEDIUM {counts['MEDIUM']}"
           f" · LOW {counts['LOW']} · INFO {counts['INFO']}",
           "",
           "_Deterministic pre-scan — not an audit. Line/window-based; "
           "no dataflow. A clean report is not proof of memory "
           "safety._",
           ""]
    if not findings:
        out.append("No findings.")
        return "\n".join(out)
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            out += ["", f"## {cur}", ""]
        out.append(f"- `{f.file}:{f.line}` **{f.code}** — {f.msg}")
    return "\n".join(out)


# ---------------------------------------------------------------------
# selftest
# ---------------------------------------------------------------------

SELFTEST = [
    # ---- MS-GETS ----
    ("gets-basic",
     "void f(void) {\n    char buf[64];\n    gets(buf);\n}\n",
     {"MS-GETS:HIGH"}),
    ("gets-decl-ok",
     "extern char *gets(char *s);\n",
     set()),
    ("gets-comment-ok",
     "// gets(buf) is bad\nint x = 1;\n",
     set()),
    # ---- scanf ----
    ("scanf-nowidth",
     "void f(void) {\n    char buf[32];\n"
     "    scanf(\"%s\", buf);\n}\n",
     {"MS-SCANF-NOWIDTH:HIGH"}),
    ("scanf-width-ok",
     "scanf(\"%31s\", buf);\n",
     set()),
    ("scanf-set-nowidth",
     "fscanf(f, \"%[^\\n]\", buf);\n",
     {"MS-SCANF-NOWIDTH:HIGH"}),
    ("scanf-suppressed-ok",
     "sscanf(in, \"%*s %9s\", buf);\n",
     set()),
    ("scanf-nonlit",
     "scanf(fmt, buf);\n",
     {"MS-SCANF-NONLIT:MEDIUM"}),
    # ---- strcpy family ----
    ("strcpy-fixed",
     "void f(char *s) {\n    char dst[16];\n    strcpy(dst, s);\n}\n",
     {"MS-STRCPY:HIGH"}),
    ("strcpy-heap",
     "void f(char *s, char *heap) {\n    strcpy(heap, s);\n}\n",
     {"MS-STRCPY:MEDIUM"}),
    ("strcat-fixed",
     "void f(char *s) {\n    char dst[16] = \"\";\n"
     "    strcat(dst, s);\n}\n",
     {"MS-STRCPY:HIGH"}),
    ("strcpy-member-ok",
     "obj.strcpy(a, b);\np->strcpy(a, b);\n",
     set()),
    ("strcpy-proto-ok",
     "char *strcpy(char *dst, const char *src);\n",
     set()),
    # ---- sprintf ----
    ("sprintf-fixed",
     "void f(void) {\n    char b[32];\n"
     "    sprintf(b, \"%d\", x);\n}\n",
     {"MS-SPRINTF:HIGH"}),
    ("vsprintf-heap",
     "vsprintf(out, fmt, ap);\n",
     {"MS-SPRINTF:MEDIUM"}),
    ("snprintf-ok",
     "snprintf(b, sizeof(b), \"%d\", x);\n",
     set()),
    # ---- literal overrun ----
    ("memcpy-litover",
     "void f(char *s) {\n    char d[8];\n    memcpy(d, s, 64);\n}\n",
     {"MS-COPY-LITOVER:HIGH"}),
    ("memcpy-litok",
     "void f(char *s) {\n    char d[64];\n   memcpy(d, s, 64);\n}\n",
     set()),
    ("fgets-litover",
     "void f(FILE *fp) {\n    char d[16];\n"
     "    fgets(d, 128, fp);\n}\n",
     {"MS-COPY-LITOVER:HIGH"}),
    ("memcpy-varsz",
     "void f(char *s, size_t n) {\n    char d[64];\n"
     "    memcpy(d, s, n);\n}\n",
     {"MS-MEMCPY-VARSZ:MEDIUM"}),
    ("memcpy-sizeof-ok",
     "void f(char *s) {\n    char d[64];\n"
     "    memcpy(d, s, sizeof(d));\n}\n",
     set()),
    ("read-sizeof-noparen-ok",
     "void f(int fd) {\n    char buf[20];\n"
     "    n = read(fd, buf, sizeof buf);\n}\n",
     set()),
    ("memcpy-sizeof-var-ok",
     "void f(char *s, size_t n) {\n    char *p = malloc(n);\n"
     "    memcpy(p, s, n);\n    free(p);\n}\n",
     {"MS-ALLOC-UNCHECKED:MEDIUM"}),
    # ---- format-string ----
    ("printf-buf",
     "void f(char *userbuf) {\n    printf(userbuf);\n}\n",
     {"MS-FMT-NONLIT:HIGH"}),
    ("fprintf-fmt-var",
     "fprintf(fp, fmt);\n",
     {"MS-FMT-NONLIT:MEDIUM"}),
    ("printf-lit-ok",
     "printf(\"%s\", name);\nprintf(\"done\");\n",
     set()),
    ("printf-gettext-ok",
     "printf(_(\"hello %s\"), name);\n",
     set()),
    ("syslog-nonlit",
     "syslog(LOG_NOTICE, buf);\n",
     {"MS-FMT-NONLIT:HIGH"}),
    # ---- strncpy ----
    ("strncpy-nonul",
     "void f(char *s) {\n    char d[16];\n"
     "    strncpy(d, s, sizeof(d));\n}\n",
     {"MS-STRNCPY-NONUL:MEDIUM"}),
    ("strncpy-term-ok",
     "void f(char *s) {\n    char d[16];\n"
     "    strncpy(d, s, sizeof(d));\n    d[sizeof(d)-1] = '\\0';\n}\n",
     set()),
    ("strncpy-strlen-src",
     "void f(char *s) {\n    char d[16];\n"
     "    strncpy(d, s, strlen(s));\n}\n",
     {"MS-STRNCPY-NONUL:HIGH"}),
    ("strncpy-under-ok",
     "void f(char *s) {\n    char d[16];\n"
     "    strncpy(d, s, 8);\n}\n",
     set()),
    # ---- return of stack ----
    ("ret-stack",
     "char *f(void) {\n    char buf[32];\n    buf[0] = 'x';\n"
     "    return buf;\n}\n",
     {"MS-RET-STACK:HIGH"}),
    ("ret-stack-static-ok",
     "char *f(void) {\n    static char buf[32];\n    return buf;\n}\n",
     set()),
    ("ret-stack-global-ok",
     "char g[32];\nchar *f(void) {\n    return g;\n}\n",
     set()),
    # ---- allocators ----
    ("malloc-unchecked",
     "void f(void) {\n    char *p = malloc(16);\n"
     "    strcpy(p, \"x\");\n}\n",
     {"MS-STRCPY:MEDIUM", "MS-ALLOC-UNCHECKED:MEDIUM"}),
    ("malloc-checked-ok",
     "void f(void) {\n    char *p = malloc(16);\n"
     "    if (!p) return;\n    p[0] = 'x';\n}\n",
     set()),
    ("realloc-self",
     "void f(char *p) {\n    p = realloc(p, 128);\n"
     "    if (!p) return;\n}\n",
     {"MS-REALLOC-SELF:LOW"}),
    ("realloc-temp-ok",
     "void f(char *p) {\n    char *q = realloc(p, 128);\n"
     "    if (!q) return;\n    p = q;\n}\n",
     set()),
    ("int-alloc",
     "void f(size_t n, size_t m) {\n    char *p = malloc(n * m);\n"
     "    if (!p) return;\n    p[0] = 0;\n}\n",
     {"MS-INT-ALLOC:LOW"}),
    # ---- double free ----
    ("double-free",
     "void f(char *p) {\n    free(p);\n    log_msg();\n"
     "    free(p);\n}\n",
     {"MS-DOUBLE-FREE:MEDIUM"}),
    ("free-reassign-ok",
     "void f(char *p) {\n    free(p);\n    p = malloc(8);\n"
     "    if (!p) return;\n    p[0]=0;\n    free(p);\n}\n",
     set()),
    ("free-null-ok",
     "void f(char *p) {\n    free(p);\n    p = NULL;\n"
     "    free(p);\n}\n",
     set()),
    # ---- temp names / alloca / banned ----
    ("mktemp",
     "void f(void) {\n    char n[64];\n    mktemp(n);\n}\n",
     {"MS-MKTEMP:MEDIUM"}),
    ("mkstemp-ok",
     "int fd = mkstemp(tmpl);\n",
     set()),
    ("alloca-var",
     "void f(size_t n) {\n    char *b = alloca(n);\n    b[0]=0;\n}\n",
     {"MS-ALLOCA-VAR:MEDIUM"}),
    ("alloca-small-ok",
     "void f(void) {\n    char *b = alloca(64);\n    b[0]=0;\n}\n",
     set()),
    ("strtok-low",
     "void f(char *s) {\n    char *t = strtok(s, \",\");\n"
     "    (void)t;\n}\n",
     {"MS-BANNED-LOW:LOW"}),
    # ---- FP guards ----
    ("define-ok",
     "#define strcpy my_strcpy\n",
     set()),
    ("include-ok",
     "#include <stdio.h> // fgets(d, 128, fp) bad\n",
     set()),
    ("string-content-ok",
     "const char *s = \"strcpy(dst, src); gets(x);\";\n",
     set()),
    ("member-fmt-ok",
     "log.printf(buf);\nthis->printf(buf);\n",
     set()),
]


def selftest():
    import tempfile
    fails = []
    for name, src, expect in SELFTEST:
        with tempfile.NamedTemporaryFile(
                "w", suffix=".c", delete=False) as tf:
            tf.write(src)
            path = tf.name
        try:
            findings, _ = scan(path)
            got = {f"{f.code}:{f.sev}" for f in findings}
            if got != expect:
                fails.append((name, sorted(expect), sorted(got)))
        finally:
            os.unlink(path)
    if fails:
        print(f"SELFTEST FAIL ({len(fails)})")
        for n, e, g in fails:
            print(f"  {n}: expected {e} got {g}")
        return 1
    print(f"SELFTEST PASS ({len(SELFTEST)} vectors)")
    return 0


def main(argv):
    args = [a for a in argv[1:] if a != "--json"]
    as_json = "--json" in argv
    if "--selftest" in argv:
        return selftest()
    if len(args) != 1:
        print(__doc__.split("Usage:")[1].split("Stdlib")[0].strip())
        return 2
    target = args[0]
    findings, scanned = scan(target)
    if as_json:
        print(json.dumps({"target": target, "scanned": scanned,
                          "findings": [f.as_dict() for f in findings]},
                         indent=2))
    else:
        print(report_md(findings, scanned, target))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
