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/cli.py ADDED
@@ -0,0 +1,292 @@
1
+ """Command line entry point: one subcommand per stage, JSON or CSV on stdout.
2
+
3
+ Every subcommand prints what it measured and what it could not do. Exit code is 0
4
+ when the run completed, whatever it found — a missing thing is a result, not a
5
+ failure. An unusable input (not a PE, no ``VB5!`` header, missing file) exits 2
6
+ with one line on stderr; ``VB6D_DEBUG=1`` keeps the traceback for bug reports.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import csv
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+
18
+ def _emit(rows, as_json: bool, fields: list[str] | None = None) -> None:
19
+ if as_json:
20
+ print(json.dumps(rows, ensure_ascii=False, indent=2, default=str))
21
+ return
22
+ if isinstance(rows, dict):
23
+ for k, v in rows.items():
24
+ print(f"{k}\t{v}")
25
+ return
26
+ if not rows:
27
+ return
28
+ if isinstance(rows[0], dict):
29
+ cols = fields or list(rows[0].keys())
30
+ w = csv.DictWriter(sys.stdout, fieldnames=cols, extrasaction="ignore")
31
+ w.writeheader()
32
+ for r in rows:
33
+ w.writerow(r)
34
+ else:
35
+ for r in rows:
36
+ print(r)
37
+
38
+
39
+ def cmd_pe(args) -> int:
40
+ from . import pe
41
+
42
+ rows = pe.scan_directory(args.path, recursive=args.recursive)
43
+ _emit(rows, args.json)
44
+ if not args.json:
45
+ vb6 = [r for r in rows if r.get("vb6")]
46
+ errs = [r for r in rows if r.get("error")]
47
+ print(f"# {len(rows)} images, {len(vb6)} import a VB6 runtime, {len(errs)} unreadable", file=sys.stderr)
48
+ return 0
49
+
50
+
51
+ def cmd_procs(args) -> int:
52
+ from . import procs
53
+
54
+ project = procs.read(args.exe)
55
+ stats = project.stats()
56
+ if args.names:
57
+ names = procs.merge_listing_names(args.names)
58
+ stats["names_from_listing"] = len(names)
59
+ if args.validate:
60
+ stats["prologue"] = procs.prologue_report(args.exe, project)
61
+ if args.json:
62
+ _emit(
63
+ {
64
+ "stats": stats,
65
+ "objects": [
66
+ {
67
+ "index": o.index,
68
+ "name": o.name,
69
+ "type": o.object_type,
70
+ "methods": o.method_count,
71
+ }
72
+ for o in project.objects
73
+ ],
74
+ },
75
+ True,
76
+ )
77
+ else:
78
+ for k, v in stats.items():
79
+ print(f"{k}\t{v}")
80
+ print(f"\n# {len(project.objects)} objects")
81
+ for o in project.objects[: args.limit]:
82
+ print(f" [{o.index:4d}] {o.name:<24} {o.object_type:<12} methods={o.method_count}")
83
+ return 0
84
+
85
+
86
+ def cmd_literals(args) -> int:
87
+ from . import literals
88
+
89
+ src = args.source
90
+ if os.path.isdir(src):
91
+ lits = literals.scan_listings(src)
92
+ else:
93
+ lits = literals.scan_binary(src, min_chars=args.min_chars)
94
+ if args.sql_only:
95
+ lits = [l for l in lits if l.is_sql]
96
+ _emit([l.as_row() for l in lits], False)
97
+ print(f"# {len(lits)} literals", file=sys.stderr)
98
+ return 0
99
+
100
+
101
+ def cmd_stitch(args) -> int:
102
+ from . import literals, sqlstitch
103
+
104
+ src = args.source
105
+ lits = literals.scan_listings(src) if os.path.isdir(src) else literals.scan_binary(src)
106
+ cands = sqlstitch.stitch_all(lits, max_more=args.max_more)
107
+ rows = [c.as_row() for c in cands]
108
+ _emit(rows, args.json)
109
+ if not args.json:
110
+ print(f"# {sqlstitch.summarise(cands)}", file=sys.stderr)
111
+ return 0
112
+
113
+
114
+ def cmd_frm(args) -> int:
115
+ from . import frm
116
+
117
+ res = frm.scan_directory(args.path, recursive=args.recursive)
118
+ if args.json:
119
+ _emit(
120
+ {
121
+ "files_seen": res.files_seen,
122
+ "forms": [
123
+ {
124
+ "name": f.name,
125
+ "type": f.type,
126
+ "controls": len(f.controls),
127
+ "client_size_twips": f.client_size,
128
+ "warnings": len(f.warnings),
129
+ }
130
+ for f in res.forms
131
+ ],
132
+ "no_design_block": res.no_design,
133
+ "failures": res.failures,
134
+ },
135
+ True,
136
+ )
137
+ return 0
138
+ for f in res.forms:
139
+ size = f.client_size
140
+ print(f"{f.name} {size if size else 'unknown'} controls={len(f.controls)}")
141
+ print(
142
+ f"# {res.files_seen} files: {len(res.forms)} parsed, "
143
+ f"{len(res.no_design)} without a design block, {len(res.failures)} failed",
144
+ file=sys.stderr,
145
+ )
146
+ return 0
147
+
148
+
149
+ def cmd_blob(args) -> int:
150
+ from dataclasses import asdict
151
+
152
+ from . import blob
153
+
154
+ if args.validate:
155
+ report = blob.validate(args.exe, frm_dir=args.frm_dir)
156
+ payload = asdict(report)
157
+ payload["field_diffs"] = dict(report.field_diffs)
158
+ payload["control_rate"] = round(report.control_rate, 2)
159
+ payload["form_rate"] = round(report.form_rate, 2)
160
+ _emit(payload, True)
161
+ print(report.summary(), file=sys.stderr)
162
+ return 0
163
+ forms = blob.extract_forms(args.exe)
164
+ _emit(
165
+ [
166
+ {
167
+ "name": f.name,
168
+ "controls": len(f.controls),
169
+ "width": f.width,
170
+ "height": f.height,
171
+ }
172
+ for f in forms
173
+ ],
174
+ args.json,
175
+ )
176
+ print(f"# {len(forms)} forms from form-data blobs", file=sys.stderr)
177
+ return 0
178
+
179
+
180
+ def cmd_datareport(args) -> int:
181
+ from . import datareport
182
+
183
+ files = datareport.iter_files([args.path]) if os.path.isdir(args.path) else [args.path]
184
+ designs = datareport.read_paths(files)
185
+ if args.json:
186
+ _emit({"summary": datareport.summary(designs), "designs": [d.as_dict() for d in designs]}, True)
187
+ return 0
188
+ for d in designs:
189
+ print(f"{os.path.basename(d.path)} {d.name} records={len(d.records)} sections={len(d.sections)} controls={len(d.controls)}")
190
+ for k, v in datareport.summary(designs).items():
191
+ print(f"# {k} {v}", file=sys.stderr)
192
+ return 0
193
+
194
+
195
+ def cmd_mask(args) -> int:
196
+ from . import mask
197
+
198
+ # A gate aimed at a typo'd or missing path found nothing because it looked at
199
+ # nothing; exiting 0 there would make every CI run of this gate meaningless.
200
+ os.stat(args.path)
201
+ result = mask.mask_tree(args.path, dry_run=args.dry_run)
202
+ changed = {k: v for k, v in result.items() if v}
203
+ print(f"# {len(result)} files scanned, {len(changed)} changed, {sum(changed.values())} lines")
204
+ audit = mask.audit(args.path)
205
+ print(f"# audit: files={audit.files} lines={audit.lines} findings={audit.findings} clean={audit.clean}")
206
+ # --dry-run skips the *write*, not the check: a gate that exits 0 in dry-run can
207
+ # never fail in CI, which is how a real leak would sail through.
208
+ return 0 if audit.clean else 1
209
+
210
+
211
+ def build_parser() -> argparse.ArgumentParser:
212
+ p = argparse.ArgumentParser(
213
+ prog="vb6d",
214
+ description="Evidence-based recovery of native-compiled VB6 programs.",
215
+ )
216
+ sub = p.add_subparsers(dest="command", required=True)
217
+
218
+ q = sub.add_parser("pe", help="PE surface of a file or install directory")
219
+ q.add_argument("path")
220
+ q.add_argument("--recursive", action="store_true")
221
+ q.add_argument("--json", action="store_true")
222
+ q.set_defaults(func=cmd_pe)
223
+
224
+ q = sub.add_parser("procs", help="VB6 object table: objects, method counts, addresses")
225
+ q.add_argument("exe")
226
+ q.add_argument("--names", metavar="LISTING_DIR", help="attach names from a decompiler listing")
227
+ q.add_argument("--validate", action="store_true", help="check that entries are real prologues")
228
+ q.add_argument("--limit", type=int, default=40)
229
+ q.add_argument("--json", action="store_true")
230
+ q.set_defaults(func=cmd_procs)
231
+
232
+ q = sub.add_parser("literals", help="BSTR/UTF-16 and ASCII literals, with addresses")
233
+ q.add_argument("source", help="an .exe/.dll, or a directory of decompiler listings")
234
+ q.add_argument("--min-chars", type=int, default=4)
235
+ q.add_argument("--sql-only", action="store_true")
236
+ q.set_defaults(func=cmd_literals)
237
+
238
+ q = sub.add_parser("stitch", help="join SQL fragments and classify what did not close")
239
+ q.add_argument("source")
240
+ q.add_argument("--max-more", type=int, default=6)
241
+ q.add_argument("--json", action="store_true")
242
+ q.set_defaults(func=cmd_stitch)
243
+
244
+ q = sub.add_parser("frm", help="parse .frm design blocks")
245
+ q.add_argument("path")
246
+ q.add_argument("--recursive", action="store_true")
247
+ q.add_argument("--json", action="store_true")
248
+ q.set_defaults(func=cmd_frm)
249
+
250
+ q = sub.add_parser("blob", help="form geometry from the executable's form-data blobs")
251
+ q.add_argument("exe")
252
+ q.add_argument("--validate", action="store_true")
253
+ q.add_argument("--frm-dir", help="ground-truth .frm directory for --validate")
254
+ q.add_argument("--json", action="store_true")
255
+ q.set_defaults(func=cmd_blob)
256
+
257
+ q = sub.add_parser("datareport", help="DataReport layouts from report DLLs")
258
+ q.add_argument("path", help="a DLL, or the install directory holding rpt*.dll")
259
+ q.add_argument("--json", action="store_true")
260
+ q.set_defaults(func=cmd_datareport)
261
+
262
+ q = sub.add_parser("mask", help="mask credentials in generated artefacts and audit the result")
263
+ q.add_argument("path")
264
+ q.add_argument("--dry-run", action="store_true")
265
+ q.set_defaults(func=cmd_mask)
266
+
267
+ return p
268
+
269
+
270
+ def main(argv: list[str] | None = None) -> int:
271
+ args = build_parser().parse_args(argv)
272
+ # A stranger pipes output to a file on a cp1252 console; literals recovered
273
+ # from UTF-16 are not encodable there. Cosmetic only: never changes exit codes.
274
+ for stream in (sys.stdout, sys.stderr):
275
+ try:
276
+ stream.reconfigure(encoding="utf-8", errors="backslashreplace")
277
+ except (AttributeError, OSError):
278
+ pass
279
+ try:
280
+ return args.func(args)
281
+ except Exception as exc:
282
+ # Named errors (VBError, PEError, FrmError) already carry their path and
283
+ # cause; one line keeps a stranger's first crash readable, not a wall of
284
+ # traceback. VB6D_DEBUG restores the full traceback for bug reports.
285
+ if os.environ.get("VB6D_DEBUG"):
286
+ raise
287
+ print(f"error: {exc}", file=sys.stderr)
288
+ return 2
289
+
290
+
291
+ if __name__ == "__main__":
292
+ raise SystemExit(main())