vb6decompiler 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vb6decompiler/procs.py ADDED
@@ -0,0 +1,444 @@
1
+ """The VB6 procedure table, read from the executable itself.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A native VB6 executable carries its own metadata: a ``VB5!`` header points at a
6
+ ``ProjectInfo``, which points at an ``ObjectTable``, which lists every form,
7
+ module and class with a pointer to its method table — and each method table entry
8
+ is the entry address of compiled code. No symbols, no PDB, no decompiler needed.
9
+
10
+ Measured on a 111 MB target: **834 objects / 34,415 methods** read from the header
11
+ chain in 0.4 s, with zero decompiler involvement. A generic recursive-descent
12
+ engine on the same file found 6,875 functions (20 % of the 34,159 entries a
13
+ decompiler listing gave) and stopped at its instruction cap; the engine said so
14
+ itself.
15
+
16
+ **A native build does not expose method entry addresses here.** In the same target
17
+ the ``ObjectInfo`` block resolves, but its documented ``lpMethods`` slot holds no
18
+ table of code addresses (measured: every candidate pointer in the block pointed at
19
+ code bytes, not at an array of entry points, and none of the 9 known entries of a
20
+ test form appeared anywhere in the block). Object and method *counts* are evidence;
21
+ the addresses are not, so :class:`Method` keeps ``address=None`` instead of
22
+ inventing one. Entry addresses come from a decompiler listing's ``'VA`` comments —
23
+ see :func:`merge_listing_names` — and are marked as such.
24
+
25
+ Chain (offsets are the documented VB6 layout used by vb6rs and Alex Ionescu's
26
+ notes; each was verified by resolving a field twice and comparing):
27
+
28
+ VBHeader ("VB5!" — note: VB5 and VB6 share the magic)
29
+ +0x1C lpProjectData -> ProjectInfo (dwVersion 0x1F4)
30
+ ProjectInfo+0x04 -> ObjectTable
31
+ ObjectTable+0x2A total_objects (u16)
32
+ ObjectTable+0x2C compiled_objects(u16)
33
+ ObjectTable+0x30 lpObjectArray -> 0x30-byte records
34
+ ObjectTable+0x40 project name VA
35
+ ObjectTable+0x50 identifier (GUID-ish, hex)
36
+ record+0x00 lpObjectInfo -> ObjectInfo (+0x20 wMethodCount, +0x24 lpMethods,
37
+ +0x28 constants count)
38
+ record+0x1C name VA, +0x20 method_count, +0x24 lpMethodNames, +0x28 fObjectType
39
+
40
+ Addresses inside these structures are VA when ``>= ImageBase``, RVA otherwise, and
41
+ occasionally a raw file offset. :func:`_Resolver.read` tries all three in that
42
+ order; a field that resolves under two interpretations is a bug in the caller, not
43
+ in the file.
44
+
45
+ What is *not* recoverable: method names for modules (VB6 stores them for classes
46
+ and forms only, and only as the designer's names), and the concept of "event
47
+ handler" — a form method is just a method whose name happens to look like
48
+ ``Command1_Click``. Names from a decompiler's listing can be merged in with
49
+ :func:`merge_listing_names`; they are hints, not evidence.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import os
55
+ import re
56
+ import struct
57
+ from dataclasses import dataclass, field
58
+
59
+ #: fObjectType values measured in one real native build (834 objects). The value is
60
+ #: not a small enum: the same build reported 0x18001 for modules, 0x18083 for forms,
61
+ #: 0x118003 for a class and 0x1018083 for the 24 embedded DataReport objects, so a
62
+ #: bit-mask guess would mislabel them. Unknown values are printed as hex, never
63
+ #: mapped to the nearest known type.
64
+ OBJECT_TYPES = {
65
+ 0x18001: "module",
66
+ 0x18083: "form",
67
+ 0x118003: "class",
68
+ 0x1018083: "datareport",
69
+ }
70
+
71
+ #: A VB6 procedure starts with ``push ebp; mov ebp, esp`` (55 8B EC). The bytes
72
+ #: after that vary (``83 EC``, ``6A``, ``E8``). Used to prove that a table entry
73
+ #: really is a procedure entry and not a stub or a data pointer.
74
+ PROLOGUE = b"\x55\x8b\xec"
75
+
76
+ #: Header of a procedure in a decompiler listing, e.g.
77
+ #: ``Private Sub Command1_Click() '36FB300`` — the trailing hex is the VA and is
78
+ #: the only reliable identity (names repeat within a form: one target had 267
79
+ #: bodies in a form all labelled ``FisButton_Click``).
80
+ LISTING_HEADER = re.compile(
81
+ r"^\s*(?:Private|Public|Friend|Static)?\s*"
82
+ r"(?:Sub|Function|Property\s+(?:Get|Let|Set))\s+([A-Za-z_]\w*)?\s*\(?.*?'\s*([0-9A-Fa-f]{6,8})\s*$"
83
+ )
84
+
85
+
86
+ @dataclass
87
+ class Method:
88
+ index: int
89
+ address: int | None # verified entry VA (prologue present); None otherwise
90
+ file_offset: int | None
91
+ in_code: bool
92
+ name: str | None = None
93
+ raw_pointer: int | None = None # what the table actually held, for diagnostics
94
+
95
+
96
+ @dataclass
97
+ class VBObject:
98
+ index: int
99
+ name: str
100
+ object_type: str
101
+ raw_type: int
102
+ method_count: int
103
+ methods: list[Method] = field(default_factory=list)
104
+
105
+ @property
106
+ def method_addresses(self) -> list[int]:
107
+ return [m.address for m in self.methods if m.address is not None]
108
+
109
+
110
+ class VBError(Exception):
111
+ """The file has no readable VB6 header."""
112
+
113
+
114
+ class _Resolver:
115
+ """Pointer field -> file bytes, trying VA, then RVA, then raw offset."""
116
+
117
+ def __init__(self, data: bytes, image_base: int, sections: list[dict]):
118
+ self.data = data
119
+ self.base = image_base
120
+ self.sections = sections
121
+
122
+ def rva_to_off(self, rva: int) -> int | None:
123
+ for s in self.sections:
124
+ span = max(s["vsize"], s["rawsize"])
125
+ if s["rva"] <= rva < s["rva"] + span:
126
+ delta = rva - s["rva"]
127
+ if delta < s["rawsize"]:
128
+ return s["rawptr"] + delta
129
+ return None
130
+ return None
131
+
132
+ def va_to_off(self, va: int) -> int | None:
133
+ if va >= self.base:
134
+ return self.rva_to_off(va - self.base)
135
+ return None
136
+
137
+ def read(self, ptr: int, size: int) -> tuple[bytes | None, int | None]:
138
+ for cand in (self.va_to_off(ptr), self.rva_to_off(ptr)):
139
+ if cand is not None and 0 <= cand and cand + size <= len(self.data):
140
+ return self.data[cand : cand + size], cand
141
+ if 0 <= ptr and ptr + size <= len(self.data):
142
+ return self.data[ptr : ptr + size], ptr
143
+ return None, None
144
+
145
+ def cstr(self, ptr: int, limit: int = 256) -> str | None:
146
+ raw, _ = self.read(ptr, limit)
147
+ if raw is None:
148
+ return None
149
+ cut = raw.find(b"\0")
150
+ if cut < 0:
151
+ return None
152
+ return decode_cp1254(raw[:cut])
153
+
154
+
155
+ def decode_cp1254(raw: bytes) -> str:
156
+ """VB6 strings are CP1254 (Turkish). Never latin-1: it silently mangles İ/ş/ğ."""
157
+ try:
158
+ return raw.decode("cp1254")
159
+ except UnicodeDecodeError:
160
+ return raw.decode("latin1", "replace")
161
+
162
+
163
+ def _sections(data: bytes) -> tuple[list[dict], int]:
164
+ pe_off = struct.unpack_from("<I", data, 0x3C)[0]
165
+ coff = pe_off + 4
166
+ machine = struct.unpack_from("<H", data, coff)[0]
167
+ nsec = struct.unpack_from("<H", data, coff + 2)[0]
168
+ optsize = struct.unpack_from("<H", data, coff + 16)[0]
169
+ opt = coff + 20
170
+ is32 = machine == 0x14C
171
+ image_base = (
172
+ struct.unpack_from("<I", data, opt + 28)[0]
173
+ if is32
174
+ else struct.unpack_from("<Q", data, opt + 24)[0]
175
+ )
176
+ out = []
177
+ so = opt + optsize
178
+ for i in range(nsec):
179
+ b = so + i * 40
180
+ name = data[b : b + 8].rstrip(b"\0").decode("latin1", "replace")
181
+ vsize, va, rawsize, rawptr = struct.unpack_from("<IIII", data, b + 8)
182
+ out.append({"name": name, "rva": va, "vsize": vsize, "rawptr": rawptr, "rawsize": rawsize})
183
+ return out, image_base
184
+
185
+
186
+ def _vb_header(data: bytes) -> dict | None:
187
+ off = data.find(b"VB5!")
188
+ if off < 0 or off + 104 > len(data):
189
+ return None
190
+ keys = [
191
+ "magic", "runtime_build", "lang_dll", "seclang_dll", "runtime_rev",
192
+ "lcid", "seclcid", "lpSubMain", "lpProjectData", "fMdlIntCtls",
193
+ "fMdlIntCtls2", "thread_flags", "thread_count", "form_count",
194
+ "external_count", "thunk_count", "lpGuiTable", "lpExternalTable",
195
+ "lpComRegisterData", "desc_selfrel", "exe_selfrel", "help_selfrel", "name_selfrel",
196
+ ]
197
+ vals = struct.unpack_from("<4sH14s14sH" + "I" * 8 + "HH" + "I" * 8, data, off)
198
+ hdr = dict(zip(keys, vals))
199
+ hdr["offset"] = off
200
+ return hdr
201
+
202
+
203
+ @dataclass
204
+ class VBProject:
205
+ path: str
206
+ image_base: int
207
+ project_name: str | None
208
+ project_description: str | None
209
+ runtime_build: int
210
+ runtime_rev: int
211
+ form_count: int
212
+ object_count: int
213
+ compiled_count: int
214
+ identifier: str
215
+ objects: list[VBObject]
216
+
217
+ # -- derived facts ------------------------------------------------------
218
+
219
+ @property
220
+ def methods(self) -> list[tuple[VBObject, Method]]:
221
+ return [(o, m) for o in self.objects for m in o.methods]
222
+
223
+ def procedure_addresses(self) -> list[int]:
224
+ """Unique method entry addresses *if the build exposed any* (often none)."""
225
+ return sorted({a for o in self.objects for a in o.method_addresses})
226
+
227
+ def methods_by_address(self) -> list[tuple[VBObject, "Method"]]:
228
+ """Only the (object, method) pairs whose address a prologue verified."""
229
+ return [(o, m) for o in self.objects for m in o.methods if m.address is not None]
230
+
231
+ def object_of(self, va: int) -> VBObject | None:
232
+ for o in self.objects:
233
+ if va in o.method_addresses:
234
+ return o
235
+ return None
236
+
237
+ def stats(self) -> dict:
238
+ addrs = self.procedure_addresses()
239
+ return {
240
+ "name": self.project_name,
241
+ "objects": len(self.objects),
242
+ "declared_objects": self.object_count,
243
+ "methods": sum(o.method_count for o in self.objects),
244
+ "method_entries_read": sum(len(o.methods) for o in self.objects),
245
+ "unique_entries": len(addrs),
246
+ "methods_without_address": sum(
247
+ 1 for o in self.objects for m in o.methods if m.address is None
248
+ ),
249
+ }
250
+
251
+
252
+ def read(path: str) -> VBProject:
253
+ """Decode the VB6 header chain of one executable or DLL."""
254
+ with open(path, "rb") as fh:
255
+ data = fh.read()
256
+ if data[:2] != b"MZ":
257
+ raise VBError(f"{os.path.basename(path)}: not an MZ image (pass design text to 'vb6d frm', compiled binaries here)")
258
+ try:
259
+ sections, image_base = _sections(data)
260
+ except struct.error as exc:
261
+ # A text file or a stub with junk at 0x3C computes offsets past the buffer;
262
+ # say what the input is, not what unpack_from wanted (same trio as blob.py).
263
+ raise VBError(f"{os.path.basename(path)}: PE header did not parse (truncated, or not a compiled binary)") from exc
264
+ hdr = _vb_header(data)
265
+ if hdr is None:
266
+ raise VBError(f"{os.path.basename(path)}: no 'VB5!' header (not a VB5/VB6 build)")
267
+ res = _Resolver(data, image_base, sections)
268
+
269
+ def selfrel(rel: int, limit: int = 400) -> str | None:
270
+ if not rel:
271
+ return None
272
+ o = hdr["offset"] + rel # bSZ* fields are relative to the VBHeader itself
273
+ if not (0 <= o < len(data)):
274
+ return None
275
+ cut = data.find(b"\0", o, o + limit)
276
+ return decode_cp1254(data[o:cut]) if cut > 0 else None
277
+
278
+ raw, _ = res.read(hdr["lpProjectData"], 0x23C)
279
+ if raw is None:
280
+ raise VBError(f"{os.path.basename(path)}: ProjectInfo pointer did not resolve")
281
+ lp_object_table = struct.unpack_from("<I", raw, 4)[0]
282
+
283
+ raw, _ = res.read(lp_object_table, 0x54)
284
+ if raw is None:
285
+ raise VBError(f"{os.path.basename(path)}: ObjectTable pointer did not resolve")
286
+ total_objects = struct.unpack_from("<H", raw, 0x2A)[0]
287
+ compiled = struct.unpack_from("<H", raw, 0x2C)[0]
288
+ lp_object_array = struct.unpack_from("<I", raw, 0x30)[0]
289
+ name_va = struct.unpack_from("<I", raw, 0x40)[0]
290
+ identifier = struct.unpack_from("<I", raw, 0x50)[0]
291
+
292
+ raw, _ = res.read(lp_object_array, max(1, total_objects) * 0x30)
293
+ if raw is None:
294
+ raise VBError(f"{os.path.basename(path)}: object array did not resolve")
295
+
296
+ objects: list[VBObject] = []
297
+ for i in range(total_objects):
298
+ rec = raw[i * 0x30 : (i + 1) * 0x30]
299
+ if len(rec) < 0x30:
300
+ break
301
+ (lp_objinfo, _reserved, _lp_pub, _lp_stat, _lp_modpub, _lp_modstat,
302
+ name_ptr, method_count, method_names_va, _o_stat, f_type, _null) = struct.unpack(
303
+ "<12I", rec
304
+ )
305
+ obj = VBObject(
306
+ index=i,
307
+ name=res.cstr(name_ptr) or f"<object {i}>",
308
+ object_type=OBJECT_TYPES.get(f_type, hex(f_type)),
309
+ raw_type=f_type,
310
+ method_count=method_count,
311
+ )
312
+
313
+ info, _ = res.read(lp_objinfo, 0x38)
314
+ method_ptrs: list[int] = []
315
+ if info:
316
+ lp_methods = struct.unpack_from("<I", info, 0x24)[0]
317
+ w_methods = struct.unpack_from("<H", info, 0x20)[0]
318
+ n = method_count or w_methods
319
+ m_raw, _ = res.read(lp_methods, max(1, n) * 4)
320
+ if m_raw:
321
+ for j in range(n):
322
+ method_ptrs.append(struct.unpack_from("<I", m_raw, j * 4)[0])
323
+
324
+ names: list[str | None] = []
325
+ if method_names_va:
326
+ nm, _ = res.read(method_names_va, max(1, method_count) * 4)
327
+ if nm:
328
+ for j in range(method_count):
329
+ names.append(res.cstr(struct.unpack_from("<I", nm, j * 4)[0], 128))
330
+
331
+ for j, ptr in enumerate(method_ptrs):
332
+ off = res.va_to_off(ptr) if ptr else None
333
+ if off is None and ptr:
334
+ off = res.rva_to_off(ptr)
335
+ # A pointer only counts as a method entry if the bytes there are a VB6
336
+ # prologue. In the reference native build the slot holds small integers
337
+ # (0x4, 0x7), which are not addresses at all: accepting them produced
338
+ # 208 "methods" of which 0 were code.
339
+ is_code = bool(
340
+ off is not None
341
+ and 0 <= off < len(data) - 3
342
+ and data[off : off + 3] == PROLOGUE
343
+ )
344
+ obj.methods.append(
345
+ Method(
346
+ index=j,
347
+ address=ptr if is_code else None,
348
+ file_offset=off,
349
+ in_code=is_code,
350
+ name=names[j] if j < len(names) else None,
351
+ raw_pointer=ptr,
352
+ )
353
+ )
354
+ objects.append(obj)
355
+
356
+ return VBProject(
357
+ path=path,
358
+ image_base=image_base,
359
+ project_name=res.cstr(name_va),
360
+ project_description=selfrel(hdr["desc_selfrel"]),
361
+ runtime_build=hdr["runtime_build"],
362
+ runtime_rev=hdr["runtime_rev"],
363
+ form_count=hdr["form_count"],
364
+ object_count=total_objects,
365
+ compiled_count=compiled,
366
+ identifier=f"{identifier:08X}",
367
+ objects=objects,
368
+ )
369
+
370
+
371
+ # ---------------------------------------------------------------------------
372
+ # verification helpers
373
+ # ---------------------------------------------------------------------------
374
+
375
+
376
+ def prologue_report(path: str, project: VBProject | None = None, sample: int | None = None) -> dict:
377
+ """How many table entries really start with the VB6 prologue.
378
+
379
+ A table entry that is *not* a prologue is usually a wrapper, a thunk or a
380
+ designer-only method with no code — count it separately, do not call it "code
381
+ we missed".
382
+ """
383
+ project = project or read(path)
384
+ with open(path, "rb") as fh:
385
+ data = fh.read()
386
+ addrs = project.procedure_addresses()
387
+ if sample:
388
+ addrs = addrs[:sample]
389
+ ok, bad = 0, []
390
+ for va in addrs:
391
+ off = va - project.image_base
392
+ if 0 <= off < len(data) - 3 and data[off : off + 3] == PROLOGUE:
393
+ ok += 1
394
+ else:
395
+ bad.append(va)
396
+ return {
397
+ "checked": len(addrs),
398
+ "prologue": ok,
399
+ "not_prologue": len(bad),
400
+ "examples_not_prologue": [hex(a) for a in bad[:10]],
401
+ }
402
+
403
+
404
+ def body_bounds(addresses: list[int]) -> dict[int, int]:
405
+ """Procedure body end = next procedure start in address order.
406
+
407
+ This is the honest bound for a *contiguous* method table. It is wrong for any
408
+ code the table does not describe (shared helpers, generated thunks), which is
409
+ why callers must not treat the result as a parsed body: the bytes between two
410
+ entries can belong to a helper that has no table entry.
411
+ """
412
+ ordered = sorted(set(addresses))
413
+ return {va: (ordered[i + 1] if i + 1 < len(ordered) else None) for i, va in enumerate(ordered)}
414
+
415
+
416
+ def parse_listing(path: str) -> tuple[str, dict[int, str]]:
417
+ """Names from a decompiler listing: ``{'obj':..., va: name}``.
418
+
419
+ Only the trailing VA is trusted. One measured decompiler gave 267 bodies in a
420
+ single form the same name; keying by name merges them, keying by VA does not.
421
+ """
422
+ obj = os.path.basename(path).rsplit(".", 1)[0]
423
+ names: dict[int, str] = {}
424
+ with open(path, encoding="cp1254", errors="replace") as fh:
425
+ for line in fh:
426
+ m = LISTING_HEADER.match(line)
427
+ if m:
428
+ names.setdefault(int(m.group(2), 16), m.group(1) or f"proc_{m.group(2).lower()}")
429
+ return obj, names
430
+
431
+
432
+ def merge_listing_names(listing_dir: str) -> dict[int, list[str]]:
433
+ """VA -> every name any listing gave it (conflicts are kept, not resolved)."""
434
+ out: dict[int, list[str]] = {}
435
+ for root, _dirs, files in os.walk(listing_dir):
436
+ for name in files:
437
+ if not name.lower().endswith((".frm", ".bas", ".cls")):
438
+ continue
439
+ _obj, names = parse_listing(os.path.join(root, name))
440
+ for va, n in names.items():
441
+ bucket = out.setdefault(va, [])
442
+ if n not in bucket:
443
+ bucket.append(n)
444
+ return out
@@ -0,0 +1,227 @@
1
+ """Joining literal fragments back into whole SQL statements — and saying where it stops.
2
+
3
+ Why this exists
4
+ ---------------
5
+ Native VB6 does not keep SQL in one piece. The compiler stores each literal
6
+ separately and the program concatenates them at run time with ``__vbaStrCat``:
7
+ on the reference target 8,597 calls, 262,162 literals, of which only 1,298 were
8
+ whole statements. Everything else is a fragment like ``"SELECT ... FROM tOrder
9
+ WHERE HOrderDep = '"`` followed by a value the user typed.
10
+
11
+ So "which table does this screen write" cannot be answered by reading literals: it
12
+ has to be answered by *stitching* neighbours and then being honest about what
13
+ closed. Measured on 1,273 write-candidate procedures:
14
+
15
+ * 108 statements closed completely (26 exact, 35 with a shape warning, 47 by
16
+ splitting two statements that were glued together),
17
+ * 699 stayed open inside a quote, 119 ended on a dangling connector, 112 carried
18
+ escaped human text,
19
+ * 134 of 247 writing procedures produced no candidate at all.
20
+
21
+ This module reproduces that classification. It never fills a gap with a guess: a
22
+ fragment that needs a run-time value is reported as ``RUNTIME_GAP``, which is the
23
+ honest answer and tells you a live trace is required.
24
+
25
+ Two measured traps are encoded in the gates:
26
+
27
+ 1. **``lit.addr`` is the address of the text, not of the instruction.** Sorting
28
+ fragments by it approximates program order; it is not proof of it. Callers that
29
+ need order must supply it (see the ``order`` field) and say where it came from.
30
+ 2. **A verb inside quotes is not a verb.** ``WHERE X = 'UPDATE t SET ...'`` is one
31
+ statement assigning a string, not two. :func:`statements` counts verbs outside
32
+ quotes only, which is what stopped the count from doubling.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from dataclasses import dataclass, field
39
+
40
+ from .literals import Literal
41
+
42
+ #: How many following fragments to try when closing a statement.
43
+ MAX_MORE = 6
44
+
45
+ _SQL_HINT = re.compile(
46
+ r"\b(select|from|where|and|or|order|group|values|set|insert|update|delete|union|like|in)\b",
47
+ re.I,
48
+ )
49
+ _TARGET = re.compile(r"\b(from|into|update)[ \t]+([A-Za-z_][A-Za-z0-9_$]*)", re.I)
50
+
51
+ #: Words that follow `from`/`into`/`update` in real text without being a table. The
52
+ #: reference corpus produced 217 "tables" named ``where`` before this list existed.
53
+ _NOT_A_TABLE = {
54
+ "where", "set", "select", "values", "value", "table", "and", "or", "on",
55
+ "join", "as", "the", "this", "it", "a", "an", "null", "not", "into", "from",
56
+ }
57
+ _ARITH = re.compile(r"^[()\s]*[A-Za-z_][A-Za-z0-9_().\s]*(<>|>=|<=|=|<|>|\+|-|\*|,)")
58
+ _NUMERIC = re.compile(r"^[\s()\d.,'<>+=-]+$")
59
+
60
+ #: "Escaped human text": a message glued onto SQL. A closed empty quote followed by
61
+ #: a letter, a date glued to a parenthesis, or a sentence with !/? in it.
62
+ ESCAPE = re.compile(r"''[^\s'()=,<>+\-*0-9]|\)[0-9]{2}\.[0-9]{2}|[!?]")
63
+ #: A statement ending on an operator or connector is a fragment, not a statement.
64
+ #: A trailing quote is deliberately NOT in this set: a balanced statement legitimately
65
+ #: ends with one (``... = 'E1'``), and :func:`balanced` already catches the open case.
66
+ _DANGLING = re.compile(r"(and|or|,|<|>|=|\+|-|\blike\b|\bin\b|\()\s*$", re.I)
67
+
68
+
69
+ def normalise(text: str) -> str:
70
+ return re.sub(r"\s+", " ", (text or "")).strip().lower()
71
+
72
+
73
+ def key(text: str) -> str:
74
+ """Comparison key: quotes and spacing removed, case folded."""
75
+ return re.sub(r"['\"]", "", normalise(text)).strip()
76
+
77
+
78
+ def statements(text: str) -> int:
79
+ """Verbs outside quotes. A verb inside a string value is not a statement.
80
+
81
+ The verb is matched after a non-letter, not after a word boundary: a VB6 program
82
+ concatenates data straight onto SQL, so the text can read ``…02SELECT ContactKey…``
83
+ and ``\\b`` sees no boundary between ``2`` and ``S``. With ``\\b`` that string
84
+ counted as one statement instead of two and a two-statement join was reported
85
+ CLOSED.
86
+ """
87
+ in_quote, buf = False, []
88
+ for ch in text:
89
+ if ch == "'":
90
+ in_quote = not in_quote
91
+ buf.append(" ")
92
+ elif not in_quote:
93
+ buf.append(ch)
94
+ flat = "".join(buf)
95
+ return len(
96
+ re.findall(r"(?:^|[^A-Za-z_])(select|insert|update|delete)\b", flat, re.I)
97
+ )
98
+
99
+
100
+ def starts_statement(text: str) -> bool:
101
+ """Does this candidate begin where a statement can begin?
102
+
103
+ Without this gate, a chain starting in the middle of an expression closes
104
+ perfectly well — measured output included ``AltStockCode' AND AltStockType = '…'``
105
+ reported as CLOSED. A fragment before the verb belongs to the previous statement.
106
+ """
107
+ return bool(re.match(r"\s*(select|insert|update|delete)\b", text, re.I))
108
+
109
+
110
+ def balanced(text: str) -> bool:
111
+ return text.count("'") % 2 == 0 and text.count("(") == text.count(")")
112
+
113
+
114
+ def is_sql_fragment(text: str) -> bool:
115
+ """Is this fragment SQL/arithmetic rather than a message that got concatenated?"""
116
+ if not text or not text.strip():
117
+ return True
118
+ if _SQL_HINT.search(text):
119
+ return True
120
+ stripped = text.strip()
121
+ return bool(_NUMERIC.match(stripped) or _ARITH.match(stripped))
122
+
123
+
124
+ @dataclass
125
+ class Candidate:
126
+ text: str
127
+ parts: int
128
+ status: str # CLOSED / OPEN_QUOTE / ESCAPE / DANGLING / MULTI / STUCK
129
+ reason: str
130
+ tables: list[str] = field(default_factory=list)
131
+
132
+ @property
133
+ def closed(self) -> bool:
134
+ return self.status == "CLOSED"
135
+
136
+ def as_row(self) -> dict:
137
+ return {
138
+ "status": self.status,
139
+ "parts": self.parts,
140
+ "reason": self.reason,
141
+ "tables": ";".join(self.tables),
142
+ "sql": self.text,
143
+ }
144
+
145
+
146
+ def chains(literals: list[Literal]) -> dict[int, list[Literal]]:
147
+ """Group literals by procedure, preserving the order the source gave them."""
148
+ out: dict[int, list[Literal]] = {}
149
+ for lit in literals:
150
+ if lit.proc_va is None:
151
+ continue
152
+ out.setdefault(lit.proc_va, []).append(lit)
153
+ return out
154
+
155
+
156
+ def stitch(chain: list[Literal], start: int, max_more: int = MAX_MORE) -> list[Candidate]:
157
+ """Every candidate beginning at ``chain[start]``, longest first, classified.
158
+
159
+ A chain may only *start* at a fragment that begins a statement. Starting
160
+ everywhere produced thousands of balanced mid-expression joins that passed every
161
+ other gate; a statement boundary in VB6 is where the literal itself opens with a
162
+ verb.
163
+ """
164
+ if not starts_statement(chain[start].text):
165
+ return []
166
+ out: list[Candidate] = []
167
+ for n in range(1, max_more + 1):
168
+ parts = chain[start : start + n]
169
+ if len(parts) < n:
170
+ break
171
+ text = "".join(p.text for p in parts)
172
+ if not _SQL_HINT.search(text):
173
+ continue
174
+ out.append(_classify(text, parts))
175
+ return out
176
+
177
+
178
+ def _classify(text: str, parts: list[Literal]) -> Candidate:
179
+ stmts = statements(text)
180
+ tables = sorted(
181
+ {m.group(2) for m in _TARGET.finditer(text) if m.group(2).lower() not in _NOT_A_TABLE}
182
+ )
183
+ if not starts_statement(text):
184
+ return Candidate(text, len(parts), "NOT_START", "begins mid-statement", tables)
185
+ if ESCAPE.search(text):
186
+ return Candidate(text, len(parts), "ESCAPE", "human text glued onto SQL", tables)
187
+ if not balanced(text):
188
+ open_q = text.count("'") % 2 == 1
189
+ return Candidate(
190
+ text,
191
+ len(parts),
192
+ "OPEN_QUOTE" if open_q else "OPEN_PAREN",
193
+ "run-time value or missing tail" if open_q else "unbalanced parentheses",
194
+ tables,
195
+ )
196
+ if stmts > 1:
197
+ return Candidate(text, len(parts), "MULTI", f"{stmts} statements glued together", tables)
198
+ if _DANGLING.search(text.strip()):
199
+ return Candidate(text, len(parts), "DANGLING", "ends on a connector", tables)
200
+ if not any(is_sql_fragment(p.text) for p in parts[1:]):
201
+ return Candidate(text, len(parts), "STUCK", "continuation is not SQL", tables)
202
+ return Candidate(text, len(parts), "CLOSED", "balanced, single statement", tables)
203
+
204
+
205
+ def stitch_all(literals: list[Literal], max_more: int = MAX_MORE) -> list[Candidate]:
206
+ """Best closed candidate per procedure, plus the count of open fragments."""
207
+ out: list[Candidate] = []
208
+ for _proc, chain in chains(literals).items():
209
+ best: Candidate | None = None
210
+ for i in range(len(chain)):
211
+ for cand in stitch(chain, i, max_more=max_more):
212
+ if cand.closed and (best is None or len(cand.text) > len(best.text)):
213
+ best = cand
214
+ if best is not None:
215
+ out.append(best)
216
+ return out
217
+
218
+
219
+ def summarise(candidates: list[Candidate]) -> dict:
220
+ counts: dict[str, int] = {}
221
+ for c in candidates:
222
+ counts[c.status] = counts.get(c.status, 0) + 1
223
+ return {
224
+ "candidates": len(candidates),
225
+ "closed": counts.get("CLOSED", 0),
226
+ "by_status": counts,
227
+ }