pyliu 0.1.0__tar.gz

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.
@@ -0,0 +1,5 @@
1
+ include README.md
2
+ include pyproject.toml
3
+ include setup.py
4
+ recursive-include Pyliu *.py
5
+ recursive-include tests *.py
pyliu-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyliu
3
+ Version: 0.1.0
4
+ Summary: A lightweight assembly experimentation framework for Python
5
+ Home-page: https://github.com/aa425-aarohcharne/pyliu
6
+ Author: Aaroh
7
+ Author-email: Aaroh <aaroh.charne@gmail.com>
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: keystone-engine
11
+ Dynamic: author
12
+ Dynamic: home-page
13
+ Dynamic: requires-python
14
+
15
+ # Pyliu
16
+
17
+ Pyliu is a lightweight Python assembly experimentation framework for building, compiling, and executing simple assembly-like programs. It combines a small CPU instruction DSL, a compiler layer, and multiple runtime backends so you can explore low-level execution patterns from Python.
18
+
19
+ ## Features
20
+
21
+ - A simple CPU instruction abstraction layer
22
+ - A compiler pipeline for assembling programs into machine code
23
+ - An in-process JIT-style runtime
24
+ - A bare-metal QEMU runtime for isolated execution
25
+ - An optional CLI for installing QEMU explicitly
26
+
27
+ ## Installation
28
+
29
+ Install from the project directory:
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ Or, if you are using the local virtual environment:
36
+
37
+ ```bash
38
+ .venv\Scripts\python.exe -m pip install -e .
39
+ ```
40
+
41
+ ## Optional QEMU dependency
42
+
43
+ QEMU is treated as an optional dependency for Pyliu.
44
+
45
+ By default, Pyliu does not try to download or install anything automatically. If you want to use the QEMU-backed runtime, install it explicitly:
46
+
47
+ ```bash
48
+ python -m Pyliu install-qemu
49
+ ```
50
+
51
+ Or, if you are using the project virtual environment:
52
+
53
+ ```bash
54
+ .venv\Scripts\python.exe -m Pyliu install-qemu
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ from Pyliu.verification import Program
61
+
62
+ program = Program()
63
+ program.begin()
64
+ program.mov("eax", "1")
65
+ program.ret()
66
+ program.end()
67
+
68
+ print(program.compile())
69
+ ```
70
+
71
+ ## Using the QEMU runtime
72
+
73
+ ```python
74
+ from Pyliu.verification import Program, QEMURuntime
75
+
76
+ program = Program()
77
+ program.begin()
78
+ program.mov("eax", "1")
79
+ program.ret()
80
+ program.end()
81
+
82
+ runtime = QEMURuntime(timeout=5)
83
+ print(runtime.run(program))
84
+ ```
85
+
86
+ ## CLI
87
+
88
+ Pyliu exposes a small CLI for optional QEMU installation:
89
+
90
+ ```bash
91
+ python -m Pyliu --help
92
+ python -m Pyliu install-qemu
93
+ ```
94
+
95
+ ## Project structure
96
+
97
+ ```text
98
+ Pyliu/
99
+ ├── __init__.py
100
+ ├── __main__.py
101
+ ├── verification.py
102
+ └── tests/
103
+ └── test_qemu_optional.py
104
+ ```
105
+
106
+ ## Notes
107
+
108
+ - The QEMU runtime is intended for experimentation and research-oriented workflows.
109
+ - The package is designed to remain safe and non-invasive for PyPI-style packaging by avoiding automatic system modifications during import or normal runtime use.
110
+
111
+ ## License
112
+
113
+ This project is provided as-is for educational and research use.
@@ -0,0 +1,27 @@
1
+ """Pyliu package entry point."""
2
+
3
+ from .verification import (
4
+ CPU,
5
+ Compiler,
6
+ Memory,
7
+ OS,
8
+ Playground,
9
+ Program,
10
+ QEMURuntime,
11
+ Runtime,
12
+ ensure_qemu_installed,
13
+ install_qemu,
14
+ )
15
+
16
+ __all__ = [
17
+ "CPU",
18
+ "Compiler",
19
+ "Memory",
20
+ "OS",
21
+ "Playground",
22
+ "Program",
23
+ "QEMURuntime",
24
+ "Runtime",
25
+ "ensure_qemu_installed",
26
+ "install_qemu",
27
+ ]
@@ -0,0 +1,8 @@
1
+ try:
2
+ from .verification import main
3
+ except ImportError: # pragma: no cover - direct script execution fallback
4
+ from verification import main
5
+
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -0,0 +1,854 @@
1
+ import ctypes
2
+ import os
3
+ import platform
4
+ import shutil
5
+ import struct
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ import urllib.request
10
+ from keystone import Ks, KS_ARCH_X86, KS_MODE_16, KS_MODE_32, KS_MODE_64
11
+
12
+
13
+ def _install_qemu_windows_portable(verbose=True):
14
+ """
15
+ Downloads the official QEMU Windows installer (the same builds
16
+ linked from https://www.qemu.org/download/#windows, hosted at
17
+ qemu.weilnetz.de) and runs it silently (/S) into a user-local
18
+ folder under %LOCALAPPDATA% -- no admin rights, no install
19
+ wizard, no clicking anything.
20
+
21
+ NOTE: this path is implemented carefully but has not been run
22
+ against a real Windows machine -- verify it on yours and report
23
+ back if the installer's silent-mode flags behave differently
24
+ than documented for the version you get.
25
+ """
26
+ import re
27
+
28
+ install_dir = os.path.join(
29
+ os.environ.get("LOCALAPPDATA", tempfile.gettempdir()),
30
+ "asmlib_qemu", "qemu",
31
+ )
32
+ exe_path = os.path.join(install_dir, "qemu-system-x86_64.exe")
33
+ if os.path.exists(exe_path):
34
+ return exe_path
35
+
36
+ def _log(msg):
37
+ if verbose:
38
+ print(f"[ensure_qemu_installed] {msg}")
39
+
40
+ _log("locating the latest official QEMU Windows build...")
41
+ listing_url = "https://qemu.weilnetz.de/w64/"
42
+ try:
43
+ with urllib.request.urlopen(listing_url, timeout=15) as resp:
44
+ html = resp.read().decode(errors="ignore")
45
+ except Exception as e:
46
+ raise RuntimeError(
47
+ f"Could not reach {listing_url} to find a QEMU build "
48
+ f"({e}). Download and install QEMU manually from "
49
+ f"https://www.qemu.org/download/#windows, then try again."
50
+ )
51
+
52
+ candidates = sorted(set(re.findall(r'href="(qemu-w64-setup-[^"]+\.exe)"', html)))
53
+ if not candidates:
54
+ raise RuntimeError(
55
+ f"Found the page at {listing_url} but no installer link "
56
+ f"matched the expected pattern -- the site layout may have "
57
+ f"changed. Download QEMU manually from "
58
+ f"https://www.qemu.org/download/#windows"
59
+ )
60
+ installer_name = candidates[-1] # filenames are date-stamped -> lexicographic sort = latest
61
+ installer_url = listing_url + installer_name
62
+
63
+ _log(f"downloading {installer_name} ...")
64
+ tmp_installer = os.path.join(tempfile.gettempdir(), installer_name)
65
+ try:
66
+ urllib.request.urlretrieve(installer_url, tmp_installer)
67
+ except Exception as e:
68
+ raise RuntimeError(f"Download of {installer_url} failed: {e}")
69
+
70
+ os.makedirs(install_dir, exist_ok=True)
71
+ # NSIS silent-install flags: /S = silent, /D=<dir> = install directory.
72
+ # /D must be the LAST argument and must not be quoted, even if the
73
+ # path contains spaces (an NSIS requirement, not a Python one).
74
+ install_args = ["/S", f"/D={install_dir}"]
75
+
76
+ _log(f"installing silently to {install_dir}...")
77
+ try:
78
+ subprocess.run([tmp_installer] + install_args, capture_output=True, timeout=180)
79
+ except OSError as e:
80
+ # WinError 740 = "The requested operation requires elevation".
81
+ # The official QEMU installer's manifest requires admin no
82
+ # matter which folder it targets -- subprocess.run can't grant
83
+ # that. ShellExecuteW with the "runas" verb triggers the one
84
+ # UAC prompt Windows actually requires here; after you click
85
+ # "Yes" once, /S still keeps everything else silent.
86
+ if getattr(e, "winerror", None) == 740:
87
+ _log(
88
+ "the installer needs admin rights -- a UAC prompt should "
89
+ "appear now. Click \"Yes\" to continue (this is the only "
90
+ "prompt you'll see)."
91
+ )
92
+ import ctypes as _ctypes
93
+ _ctypes.windll.shell32.ShellExecuteW(
94
+ None, "runas", tmp_installer, " ".join(install_args), None, 1
95
+ )
96
+ # ShellExecuteW doesn't block, so poll for the install to finish
97
+ waited = 0
98
+ while not os.path.exists(exe_path) and waited < 120:
99
+ import time as _time
100
+ _time.sleep(2)
101
+ waited += 2
102
+ else:
103
+ raise
104
+
105
+ if not os.path.exists(exe_path):
106
+ raise RuntimeError(
107
+ f"Installer ran but {exe_path} wasn't created. Either the "
108
+ f"UAC prompt wasn't accepted, or this build's silent flags "
109
+ f"or directory layout differ -- try running "
110
+ f"'{tmp_installer}' manually to install with the UI, "
111
+ f"or install via https://www.qemu.org/download/#windows"
112
+ )
113
+
114
+ _log(f"QEMU ready at {exe_path}")
115
+ return exe_path
116
+
117
+
118
+ def ensure_qemu_installed(qemu_binary="qemu-system-x86_64", auto_install=False, verbose=True):
119
+ """
120
+ Check whether a QEMU x86-64 system emulator is available on PATH.
121
+
122
+ QEMU is treated as an optional runtime dependency. By default this
123
+ function does not install anything automatically; it only raises a
124
+ clear error that tells the user how to opt in explicitly.
125
+
126
+ If auto_install is True, it tries to install QEMU using whatever
127
+ package manager fits the current platform:
128
+
129
+ - Linux: apt, dnf, or pacman (whichever is present), via sudo
130
+ - macOS: Homebrew (brew)
131
+ - Windows: winget, falling back to a manual download link
132
+
133
+ Returns the resolved path to the qemu-system-x86_64 binary.
134
+ Raises RuntimeError with manual install instructions if it can't
135
+ find or install QEMU automatically.
136
+ """
137
+ existing = shutil.which(qemu_binary)
138
+ if existing:
139
+ return existing
140
+
141
+ if not auto_install:
142
+ raise RuntimeError(
143
+ f"{qemu_binary} not found on PATH. QEMU is an optional dependency "
144
+ f"for this package, so installation is disabled by default. "
145
+ f"Install it manually or run: python -m Pyliu install-qemu"
146
+ )
147
+
148
+ system = platform.system()
149
+
150
+ def _log(msg):
151
+ if verbose:
152
+ print(f"[ensure_qemu_installed] {msg}")
153
+
154
+ def _try(cmd, timeout=None):
155
+ _log(f"running: {' '.join(cmd)}")
156
+ try:
157
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
158
+ return result.returncode == 0
159
+ except subprocess.TimeoutExpired:
160
+ _log(
161
+ f"'{cmd[0]}' didn't finish within {timeout}s -- it's likely "
162
+ f"waiting on a UAC/consent dialog that isn't visible to this "
163
+ f"script. Giving up on this method and trying the next one."
164
+ )
165
+ return False
166
+
167
+ def _maybe_sudo(cmd):
168
+ # root (e.g. inside containers) has no sudo binary and doesn't
169
+ # need one; everyone else does
170
+ needs_privilege = os.name == "posix" and hasattr(os, "geteuid") and os.geteuid() != 0
171
+ if needs_privilege and shutil.which("sudo"):
172
+ return ["sudo"] + cmd
173
+ return cmd
174
+
175
+ if system == "Linux":
176
+ if shutil.which("apt-get"):
177
+ _log("detected apt -- installing qemu-system-x86")
178
+ # apt-get update can return nonzero due to unrelated broken
179
+ # third-party repos even when the repos we need are fine,
180
+ # so don't gate the install attempt on its exit code --
181
+ # only the final "is the binary on PATH" check matters.
182
+ _try(_maybe_sudo(["apt-get", "update"]), timeout=120)
183
+ ok = _try(_maybe_sudo(["apt-get", "install", "-y", "qemu-system-x86"]), timeout=180)
184
+ elif shutil.which("dnf"):
185
+ _log("detected dnf -- installing qemu-system-x86")
186
+ ok = _try(_maybe_sudo(["dnf", "install", "-y", "qemu-system-x86"]), timeout=180)
187
+ elif shutil.which("pacman"):
188
+ _log("detected pacman -- installing qemu")
189
+ ok = _try(_maybe_sudo(["pacman", "-Sy", "--noconfirm", "qemu-system-x86"]), timeout=180)
190
+ else:
191
+ raise RuntimeError(
192
+ "No supported package manager found (apt/dnf/pacman). "
193
+ "Install QEMU manually: https://www.qemu.org/download/#linux"
194
+ )
195
+
196
+ elif system == "Darwin":
197
+ if not shutil.which("brew"):
198
+ raise RuntimeError(
199
+ "Homebrew not found. Install it from https://brew.sh, "
200
+ "then run: brew install qemu"
201
+ )
202
+ _log("detected Homebrew -- installing qemu")
203
+ ok = _try(["brew", "install", "qemu"], timeout=300)
204
+
205
+ elif system == "Windows":
206
+ raise RuntimeError(
207
+ "QEMU not found. Install manually or run: python -m Pyliu install-qemu"
208
+ )
209
+
210
+ else:
211
+ raise RuntimeError(f"Unsupported platform for auto-install: {system}")
212
+
213
+ resolved = shutil.which(qemu_binary)
214
+ if not resolved:
215
+ raise RuntimeError(
216
+ f"Install attempted but {qemu_binary} still isn't on PATH. "
217
+ f"You may need to open a new shell/terminal for PATH changes "
218
+ f"to take effect, or install QEMU manually: "
219
+ f"https://www.qemu.org/download/"
220
+ )
221
+
222
+
223
+ def install_qemu(qemu_binary="qemu-system-x86_64", verbose=True):
224
+ """Install QEMU explicitly when the user requests it."""
225
+ return ensure_qemu_installed(qemu_binary=qemu_binary, auto_install=True, verbose=verbose)
226
+
227
+
228
+ def main(argv=None):
229
+ """Small CLI entry point for optional QEMU installation."""
230
+ import argparse
231
+
232
+ parser = argparse.ArgumentParser(prog="python -m Pyliu")
233
+ subparsers = parser.add_subparsers(dest="command")
234
+
235
+ install_parser = subparsers.add_parser("install-qemu", help="Install QEMU explicitly")
236
+ install_parser.add_argument("--qemu-binary", default="qemu-system-x86_64")
237
+ install_parser.add_argument("--verbose", action="store_true")
238
+
239
+ args = parser.parse_args(argv)
240
+ if args.command == "install-qemu":
241
+ install_qemu(qemu_binary=args.qemu_binary, verbose=args.verbose)
242
+ print("QEMU installation completed or was accepted by the runtime.")
243
+ return 0
244
+
245
+ parser.print_help()
246
+ return 0
247
+
248
+
249
+ class CPU:
250
+ def __init__(self):
251
+ self.instructions = []
252
+
253
+ def emit(self, instruction):
254
+ self.instructions.append(instruction)
255
+
256
+ def _mem_operand(self, operand):
257
+ operand = str(operand).strip()
258
+ if operand.startswith("[") and operand.endswith("]"):
259
+ return operand
260
+ return f"[{operand}]"
261
+
262
+ def mov(self, dest, src):
263
+ self.emit(f"mov {dest}, {src}")
264
+
265
+ def lea(self, dest, src):
266
+ self.emit(f"lea {dest}, {src}")
267
+
268
+ def xchg(self, a, b):
269
+ self.emit(f"xchg {a}, {b}")
270
+
271
+ def movzx(self, dest, src):
272
+ self.emit(f"movzx {dest}, {src}")
273
+
274
+ def movsx(self, dest, src):
275
+ self.emit(f"movsx {dest}, {src}")
276
+
277
+ def add(self, dest, src):
278
+ self.emit(f"add {dest}, {src}")
279
+
280
+ def sub(self, dest, src):
281
+ self.emit(f"sub {dest}, {src}")
282
+
283
+ def inc(self, dest):
284
+ self.emit(f"inc {dest}")
285
+
286
+ def dec(self, dest):
287
+ self.emit(f"dec {dest}")
288
+
289
+ def imul(self, dest, src):
290
+ self.emit(f"imul {dest}, {src}")
291
+
292
+ def idiv(self, src):
293
+ self.emit(f"idiv {src}")
294
+
295
+ def neg(self, dest):
296
+ self.emit(f"neg {dest}")
297
+
298
+ def and_(self, dest, src):
299
+ self.emit(f"and {dest}, {src}")
300
+
301
+ def or_(self, dest, src):
302
+ self.emit(f"or {dest}, {src}")
303
+
304
+ def xor(self, dest, src):
305
+ self.emit(f"xor {dest}, {src}")
306
+
307
+ def not_(self, dest):
308
+ self.emit(f"not {dest}")
309
+
310
+ def test(self, a, b):
311
+ self.emit(f"test {a}, {b}")
312
+
313
+ def shl(self, dest, count):
314
+ self.emit(f"shl {dest}, {count}")
315
+
316
+ def shr(self, dest, count):
317
+ self.emit(f"shr {dest}, {count}")
318
+
319
+ def sar(self, dest, count):
320
+ self.emit(f"sar {dest}, {count}")
321
+
322
+ def rol(self, dest, count):
323
+ self.emit(f"rol {dest}, {count}")
324
+
325
+ def ror(self, dest, count):
326
+ self.emit(f"ror {dest}, {count}")
327
+
328
+ def cmp(self, a, b):
329
+ self.emit(f"cmp {a}, {b}")
330
+
331
+ def jmp(self, target):
332
+ self.emit(f"jmp {target}")
333
+
334
+ def je(self, target):
335
+ self.emit(f"je {target}")
336
+
337
+ def jz(self, target):
338
+ self.emit(f"jz {target}")
339
+
340
+ def jne(self, target):
341
+ self.emit(f"jne {target}")
342
+
343
+ def jnz(self, target):
344
+ self.emit(f"jnz {target}")
345
+
346
+ def jg(self, target):
347
+ self.emit(f"jg {target}")
348
+
349
+ def jge(self, target):
350
+ self.emit(f"jge {target}")
351
+
352
+ def jl(self, target):
353
+ self.emit(f"jl {target}")
354
+
355
+ def jle(self, target):
356
+ self.emit(f"jle {target}")
357
+
358
+ def ja(self, target):
359
+ self.emit(f"ja {target}")
360
+
361
+ def jb(self, target):
362
+ self.emit(f"jb {target}")
363
+
364
+ def call(self, target):
365
+ self.emit(f"call {target}")
366
+
367
+ def ret(self):
368
+ self.emit("ret")
369
+
370
+ def push(self, src):
371
+ self.emit(f"push {src}")
372
+
373
+ def pop(self, dest):
374
+ self.emit(f"pop {dest}")
375
+
376
+ def syscall(self):
377
+ self.emit("syscall")
378
+
379
+ def int_(self, value):
380
+ self.emit(f"int {value}")
381
+
382
+ def nop(self):
383
+ self.emit("nop")
384
+
385
+ def hlt(self):
386
+ self.emit("hlt")
387
+
388
+ def cpuid(self):
389
+ self.emit("cpuid")
390
+
391
+ def rdtsc(self):
392
+ self.emit("rdtsc")
393
+
394
+ def clc(self):
395
+ self.emit("clc")
396
+
397
+ def stc(self):
398
+ self.emit("stc")
399
+
400
+ def cmc(self):
401
+ self.emit("cmc")
402
+
403
+ def cli(self):
404
+ self.emit("cli")
405
+
406
+ def sti(self):
407
+ self.emit("sti")
408
+
409
+ def load(self, dest, addr):
410
+ self.emit(f"mov {dest}, {self._mem_operand(addr)}")
411
+
412
+ def store(self, addr, src):
413
+ self.emit(f"mov {self._mem_operand(addr)}, {src}")
414
+
415
+ def deref(self, dest, src):
416
+ self.emit(f"mov {dest}, {self._mem_operand(src)}")
417
+
418
+ def addr(self, dest, src):
419
+ self.emit(f"lea {dest}, {self._mem_operand(src)}")
420
+
421
+ def label(self, name):
422
+ self.emit(f"{name}:")
423
+
424
+ def goto(self, target):
425
+ self.emit(f"jmp {target}")
426
+
427
+ def if_true(self, reg, target):
428
+ self.emit(f"test {reg}, {reg}")
429
+ self.emit(f"jnz {target}")
430
+
431
+ def if_false(self, reg, target):
432
+ self.emit(f"test {reg}, {reg}")
433
+ self.emit(f"jz {target}")
434
+
435
+ def loop(self, label_name):
436
+ self.emit(f"jmp {label_name}")
437
+
438
+ # --- bare-metal helpers (used by QEMURuntime) ---
439
+
440
+ def out_serial_char(self, char_reg="al"):
441
+ """Write one byte (from the given 8-bit register) to the
442
+ standard PC serial port (COM1, 0x3F8), so QEMU's
443
+ -serial stdio can capture it as real program output."""
444
+ self.emit("mov dx, 0x3f8")
445
+ self.emit(f"out dx, {char_reg}")
446
+
447
+ def halt_forever(self):
448
+ """Stop execution cleanly instead of falling into whatever
449
+ garbage bytes come after the program in memory."""
450
+ self.emit("cli")
451
+ self.label("__halt_loop")
452
+ self.emit("hlt")
453
+ self.emit("jmp __halt_loop")
454
+
455
+
456
+ class Memory:
457
+ def __init__(self):
458
+ self.vars = {}
459
+ self.stack_offset = 0
460
+ self.counter = 0
461
+ self.frame_size = 0
462
+ self.free_slots = []
463
+
464
+ def _align(self, value, alignment=8):
465
+ return ((value + alignment - 1) // alignment) * alignment
466
+
467
+ def alloc(self, name, size=8):
468
+ if name in self.vars:
469
+ return self.vars[name]["address"]
470
+
471
+ size = max(int(size), 8)
472
+ for idx, (slot_offset, slot_size) in enumerate(self.free_slots):
473
+ if slot_size >= size:
474
+ self.free_slots.pop(idx)
475
+ offset = slot_offset
476
+ self.vars[name] = {"address": f"[rbp-{offset}]", "size": size, "offset": offset}
477
+ self.frame_size = max(self.frame_size, offset)
478
+ return self.vars[name]["address"]
479
+
480
+ self.stack_offset = self._align(self.stack_offset + size, 8)
481
+ offset = self.stack_offset
482
+ self.frame_size = max(self.frame_size, offset)
483
+ self.vars[name] = {"address": f"[rbp-{offset}]", "size": size, "offset": offset}
484
+ return self.vars[name]["address"]
485
+
486
+ def free(self, name):
487
+ entry = self.vars.pop(name, None)
488
+ if entry is not None:
489
+ self.free_slots.append((entry["offset"], entry["size"]))
490
+
491
+ def get(self, name):
492
+ return self.vars[name]["address"]
493
+
494
+ def string(self, value):
495
+ return str(value)
496
+
497
+ def array(self, name, values):
498
+ return [self.alloc(f"{name}_{i}") for i in range(len(values))]
499
+
500
+ def pointer(self, name, target):
501
+ return self.alloc(name)
502
+
503
+ def struct(self, name, fields):
504
+ return {field: self.alloc(f"{name}_{field}") for field in fields}
505
+
506
+ def snapshot(self):
507
+ return {k: v["address"] for k, v in self.vars.items()}
508
+
509
+
510
+ class Compiler:
511
+ """Assembles for the JIT (VirtualAlloc) path. Defaults to 64-bit,
512
+ matching the in-process Runtime below."""
513
+
514
+ def __init__(self, mode=KS_MODE_64):
515
+ self.ks = Ks(KS_ARCH_X86, mode)
516
+ self.last_asm = ""
517
+ self.last_machine_code = b""
518
+
519
+ def _validate(self, program):
520
+ for instruction in program.cpu.instructions:
521
+ if not instruction or instruction.endswith(":"):
522
+ continue
523
+ if instruction.startswith(("mov", "add", "sub", "cmp", "test", "and", "or", "xor", "shl", "shr", "sar", "rol", "ror")):
524
+ parts = [p.strip() for p in instruction.split(" ", 1)[1].split(",")]
525
+ if len(parts) != 2:
526
+ raise ValueError(f"Invalid operands for {instruction}")
527
+ left, right = parts
528
+ if left.startswith("[") and left.endswith("]"):
529
+ left = left[1:-1]
530
+ if right.startswith("[") and right.endswith("]"):
531
+ right = right[1:-1]
532
+ if left in {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"}:
533
+ raise ValueError(f"Invalid destination operand for {instruction}")
534
+ if right in {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} and left.startswith(("[", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9")):
535
+ raise ValueError(f"Invalid immediate operand for {instruction}")
536
+
537
+ def compile(self, program):
538
+ self._validate(program)
539
+ self.last_asm = "\n".join(program.cpu.instructions)
540
+ encoding, _ = self.ks.asm(self.last_asm)
541
+ self.last_machine_code = bytes(encoding)
542
+ program.asm = self.last_asm
543
+ program.machine_code = self.last_machine_code
544
+ return self.last_machine_code
545
+
546
+
547
+ class Runtime:
548
+ """In-process JIT execution via VirtualAlloc (Windows only)."""
549
+
550
+ def __init__(self):
551
+ self.kernel32 = ctypes.windll.kernel32
552
+ self.kernel32.VirtualAlloc.restype = ctypes.c_void_p
553
+ self.MEM_COMMIT = 0x1000
554
+ self.MEM_RESERVE = 0x2000
555
+ self.PAGE_EXECUTE_READWRITE = 0x40
556
+
557
+ def execute(self, machine_code):
558
+ addr = self.kernel32.VirtualAlloc(
559
+ None,
560
+ len(machine_code),
561
+ self.MEM_COMMIT | self.MEM_RESERVE,
562
+ self.PAGE_EXECUTE_READWRITE,
563
+ )
564
+ ctypes.memmove(addr, machine_code, len(machine_code))
565
+ func = ctypes.CFUNCTYPE(ctypes.c_int)(addr)
566
+ return func()
567
+
568
+ def run(self, program):
569
+ program.compile()
570
+ return self.execute(program.machine_code)
571
+
572
+
573
+ class QEMURuntime:
574
+ """
575
+ Boots machine code as a real bare-metal boot sector inside an
576
+ isolated QEMU virtual machine. No OS, no bootloader, no disk
577
+ image beyond the single 512-byte sector this class builds.
578
+
579
+ A crash, bad memory access, or infinite loop in your code is
580
+ contained entirely inside the QEMU subprocess -- your actual
581
+ Python process is never touched, unlike the in-process
582
+ VirtualAlloc Runtime above.
583
+
584
+ Runs your code in 32-bit protected mode by default. A fixed
585
+ 16-bit real-mode stub handles the switch into protected mode
586
+ before jumping into your compiled bytes.
587
+
588
+ LIMITATION: this does not set up long mode (64-bit). Full
589
+ 64-bit support needs paging (page tables + PAE + EFER.LME +
590
+ CR0.PG), which is real follow-up infrastructure, not a small
591
+ addition. Assemble your program body in 32-bit mode for this
592
+ runtime (use QEMURuntime.compile_32, not the 64-bit Compiler
593
+ above).
594
+
595
+ Output: there's no return value the way a JIT'd function has
596
+ one. Instead, write bytes to the serial port with
597
+ CPU.out_serial_char() -- QEMU's -serial stdio captures it as
598
+ real captured output from the guest machine.
599
+ """
600
+
601
+ ORIGIN = 0x7C00
602
+ SECTOR_SIZE = 512
603
+
604
+ def __init__(self, timeout=5, qemu_binary="qemu-system-x86_64", auto_install=False):
605
+ self.timeout = timeout
606
+ self.qemu_binary = ensure_qemu_installed(qemu_binary, auto_install=auto_install)
607
+ self.last_output = ""
608
+ self.last_image_path = None
609
+
610
+ # ---- assembling the user's program body ----
611
+
612
+ def compile_32(self, program):
613
+ ks = Ks(KS_ARCH_X86, KS_MODE_32)
614
+ asm_text = "\n".join(program.cpu.instructions)
615
+ encoding, _ = ks.asm(asm_text)
616
+ machine_code = bytes(encoding)
617
+ program.asm = asm_text
618
+ program.machine_code = machine_code
619
+ return machine_code
620
+
621
+ # ---- GDT, built as raw bytes rather than via the assembler,
622
+ # since Keystone's directive/data-table support (dq/db, label
623
+ # arithmetic like `gdt_end - gdt_start`) isn't reliable enough
624
+ # to trust for a boot sector ----
625
+
626
+ def _build_flat_gdt(self):
627
+ null_entry = struct.pack("<Q", 0)
628
+ # base=0, limit=0xFFFFF, 4KB granularity, 32-bit, present, ring0
629
+ code_entry = struct.pack("<HHBBBB", 0xFFFF, 0x0000, 0x00, 0b10011010, 0b11001111, 0x00)
630
+ data_entry = struct.pack("<HHBBBB", 0xFFFF, 0x0000, 0x00, 0b10010010, 0b11001111, 0x00)
631
+ gdt = null_entry + code_entry + data_entry
632
+ return gdt, len(gdt) - 1
633
+
634
+ def _assemble_real_stub(self, gdt_descriptor_offset, pm_entry_offset):
635
+ ks16 = Ks(KS_ARCH_X86, KS_MODE_16)
636
+ asm = f"""
637
+ cli
638
+ xor ax, ax
639
+ mov ds, ax
640
+ mov es, ax
641
+ mov ss, ax
642
+ mov sp, 0x7c00
643
+ lgdt [0x7c00 + {gdt_descriptor_offset}]
644
+ mov eax, cr0
645
+ or eax, 1
646
+ mov cr0, eax
647
+ ljmp 0x08:0x7c00 + {pm_entry_offset}
648
+ """
649
+ encoding, _ = ks16.asm(asm)
650
+ return bytes(encoding)
651
+
652
+ def _assemble_pm_entry(self):
653
+ ks32 = Ks(KS_ARCH_X86, KS_MODE_32)
654
+ asm = """
655
+ mov ax, 0x10
656
+ mov ds, ax
657
+ mov es, ax
658
+ mov fs, ax
659
+ mov gs, ax
660
+ mov ss, ax
661
+ mov esp, 0x90000
662
+ """
663
+ encoding, _ = ks32.asm(asm)
664
+ return bytes(encoding)
665
+
666
+ def build_boot_sector(self, user_code):
667
+ """
668
+ Lays out one 512-byte sector:
669
+ [0x7C00] 16-bit real-mode stub -> switches to protected mode
670
+ [...] flat GDT (3 entries: null, code, data)
671
+ [...] GDT descriptor (limit + linear base address)
672
+ [...] 32-bit protected-mode entry stub
673
+ [...] your compiled 32-bit user code
674
+ [0x7DFE] boot signature 0x55AA
675
+ """
676
+ # pass 1: assemble the real-mode stub with placeholder offsets,
677
+ # purely to measure its length (Keystone has no multi-pass
678
+ # linker, so offsets are computed by hand here)
679
+ stub_probe = self._assemble_real_stub(0, 0)
680
+ stub_len = len(stub_probe)
681
+
682
+ gdt_bytes, gdt_limit = self._build_flat_gdt()
683
+ gdt_offset = stub_len
684
+ gdt_descriptor_offset = gdt_offset + len(gdt_bytes)
685
+ gdt_descriptor = struct.pack("<HI", gdt_limit, self.ORIGIN + gdt_offset)
686
+ pm_entry_offset = gdt_descriptor_offset + len(gdt_descriptor)
687
+
688
+ # pass 2: reassemble the stub now that real offsets are known
689
+ real_stub = self._assemble_real_stub(gdt_descriptor_offset, pm_entry_offset)
690
+ if len(real_stub) != stub_len:
691
+ raise RuntimeError(
692
+ "Real-mode stub changed size between assembly passes "
693
+ "(offset-dependent encoding shifted length) -- offsets "
694
+ "are no longer valid. This is an internal bug in "
695
+ "build_boot_sector, not your program."
696
+ )
697
+
698
+ pm_entry = self._assemble_pm_entry()
699
+
700
+ sector = bytearray(self.SECTOR_SIZE)
701
+ sector[0:len(real_stub)] = real_stub
702
+ sector[gdt_offset:gdt_offset + len(gdt_bytes)] = gdt_bytes
703
+ sector[gdt_descriptor_offset:gdt_descriptor_offset + len(gdt_descriptor)] = gdt_descriptor
704
+ sector[pm_entry_offset:pm_entry_offset + len(pm_entry)] = pm_entry
705
+
706
+ code_offset = pm_entry_offset + len(pm_entry)
707
+ end_offset = code_offset + len(user_code)
708
+ if end_offset > self.SECTOR_SIZE - 2:
709
+ raise ValueError(
710
+ f"Boot sector overflow: stub + GDT + entry + your code = "
711
+ f"{end_offset} bytes, max is {self.SECTOR_SIZE - 2}. "
712
+ f"Multi-sector loading isn't implemented yet -- shrink "
713
+ f"the program or ask to add sector-spanning support."
714
+ )
715
+
716
+ sector[code_offset:code_offset + len(user_code)] = user_code
717
+ sector[self.SECTOR_SIZE - 2:self.SECTOR_SIZE] = b"\x55\xAA"
718
+ return bytes(sector)
719
+
720
+ # ---- execution ----
721
+
722
+ def execute(self, machine_code):
723
+ image = self.build_boot_sector(machine_code)
724
+
725
+ tmp = tempfile.NamedTemporaryFile(suffix=".img", delete=False)
726
+ tmp.write(image)
727
+ tmp.close()
728
+ self.last_image_path = tmp.name
729
+
730
+ cmd = [
731
+ self.qemu_binary,
732
+ "-drive", f"format=raw,file={tmp.name}",
733
+ "-nographic",
734
+ "-serial", "stdio",
735
+ "-no-reboot",
736
+ "-display", "none",
737
+ "-monitor", "none",
738
+ ]
739
+
740
+ try:
741
+ result = subprocess.run(
742
+ cmd, capture_output=True, timeout=self.timeout, text=True
743
+ )
744
+ self.last_output = result.stdout
745
+ except subprocess.TimeoutExpired as e:
746
+ # Expected for a program that halts/loops forever (e.g. via
747
+ # halt_forever()) rather than triggering a triple-fault --
748
+ # QEMU just keeps running until this timeout kills it.
749
+ out = e.stdout
750
+ if isinstance(out, bytes):
751
+ out = out.decode(errors="replace")
752
+ self.last_output = out or ""
753
+ return self.last_output
754
+
755
+ def run(self, program):
756
+ machine_code = self.compile_32(program)
757
+ return self.execute(machine_code)
758
+
759
+
760
+ class Program:
761
+ def __init__(self, cpu=None, memory=None, compiler=None, runtime=None):
762
+ self.cpu = cpu or CPU()
763
+ self.memory = memory or Memory()
764
+ self.compiler = compiler or Compiler()
765
+ # lazy: Runtime() touches ctypes.windll (Windows-only), so it
766
+ # isn't constructed until .run() is actually called -- this
767
+ # keeps run_on_qemu() usable cross-platform without ever
768
+ # needing the JIT runtime at all.
769
+ self._runtime_override = runtime
770
+ self._runtime = None
771
+ self.asm = ""
772
+ self.machine_code = b""
773
+ self.result = None
774
+
775
+ def reset(self):
776
+ self.cpu.instructions = []
777
+ self.asm = ""
778
+ self.machine_code = b""
779
+ self.result = None
780
+
781
+ def _ensure_frame(self):
782
+ frame_size = max(0, self.memory.frame_size)
783
+ if frame_size <= 0:
784
+ return
785
+ aligned = max(0x20, ((frame_size + 15) // 16) * 16)
786
+ if any(instr.startswith("sub rsp") for instr in self.cpu.instructions):
787
+ return
788
+ if len(self.cpu.instructions) >= 2 and self.cpu.instructions[1] == "mov rbp, rsp":
789
+ self.cpu.instructions.insert(2, f"sub rsp, {aligned}")
790
+
791
+ def begin(self):
792
+ self.reset()
793
+ self.cpu.push("rbp")
794
+ self.cpu.mov("rbp", "rsp")
795
+
796
+ def end(self):
797
+ self.cpu.mov("rsp", "rbp")
798
+ self.cpu.pop("rbp")
799
+ self.cpu.ret()
800
+
801
+ def compile(self):
802
+ self._ensure_frame()
803
+ self.compiler.compile(self)
804
+ return self.machine_code
805
+
806
+ @property
807
+ def runtime(self):
808
+ if self._runtime is None:
809
+ self._runtime = self._runtime_override or Runtime()
810
+ return self._runtime
811
+
812
+ def run(self):
813
+ self.result = self.runtime.run(self)
814
+ return self.result
815
+
816
+ def run_on_qemu(self, timeout=5):
817
+ """Run this program's instructions on a bare-metal QEMU VM
818
+ instead of the default in-process JIT runtime. Program bodies
819
+ for this path should be written in 32-bit terms (eax/ebx/...
820
+ registers), since QEMURuntime assembles in 32-bit mode."""
821
+ qemu_runtime = QEMURuntime(timeout=timeout)
822
+ self.result = qemu_runtime.run(self)
823
+ self.machine_code = getattr(self, "machine_code", b"")
824
+ return self.result
825
+
826
+ def __getattr__(self, name):
827
+ if hasattr(self.cpu, name):
828
+ return getattr(self.cpu, name)
829
+ if hasattr(self.memory, name):
830
+ return getattr(self.memory, name)
831
+ raise AttributeError(name)
832
+
833
+
834
+ class OS:
835
+ def boot(self, program):
836
+ return program.run()
837
+
838
+
839
+ class Playground:
840
+ def __init__(self):
841
+ self.program = Program()
842
+
843
+ def run(self):
844
+ return self.program.run()
845
+
846
+
847
+ class Debugger:
848
+ def inspect(self, program):
849
+ return {
850
+ "asm": program.asm,
851
+ "machine_code": program.machine_code.hex(),
852
+ "memory": program.memory.snapshot(),
853
+ "result": program.result,
854
+ }
pyliu-0.1.0/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Pyliu
2
+
3
+ Pyliu is a lightweight Python assembly experimentation framework for building, compiling, and executing simple assembly-like programs. It combines a small CPU instruction DSL, a compiler layer, and multiple runtime backends so you can explore low-level execution patterns from Python.
4
+
5
+ ## Features
6
+
7
+ - A simple CPU instruction abstraction layer
8
+ - A compiler pipeline for assembling programs into machine code
9
+ - An in-process JIT-style runtime
10
+ - A bare-metal QEMU runtime for isolated execution
11
+ - An optional CLI for installing QEMU explicitly
12
+
13
+ ## Installation
14
+
15
+ Install from the project directory:
16
+
17
+ ```bash
18
+ pip install -e .
19
+ ```
20
+
21
+ Or, if you are using the local virtual environment:
22
+
23
+ ```bash
24
+ .venv\Scripts\python.exe -m pip install -e .
25
+ ```
26
+
27
+ ## Optional QEMU dependency
28
+
29
+ QEMU is treated as an optional dependency for Pyliu.
30
+
31
+ By default, Pyliu does not try to download or install anything automatically. If you want to use the QEMU-backed runtime, install it explicitly:
32
+
33
+ ```bash
34
+ python -m Pyliu install-qemu
35
+ ```
36
+
37
+ Or, if you are using the project virtual environment:
38
+
39
+ ```bash
40
+ .venv\Scripts\python.exe -m Pyliu install-qemu
41
+ ```
42
+
43
+ ## Quick start
44
+
45
+ ```python
46
+ from Pyliu.verification import Program
47
+
48
+ program = Program()
49
+ program.begin()
50
+ program.mov("eax", "1")
51
+ program.ret()
52
+ program.end()
53
+
54
+ print(program.compile())
55
+ ```
56
+
57
+ ## Using the QEMU runtime
58
+
59
+ ```python
60
+ from Pyliu.verification import Program, QEMURuntime
61
+
62
+ program = Program()
63
+ program.begin()
64
+ program.mov("eax", "1")
65
+ program.ret()
66
+ program.end()
67
+
68
+ runtime = QEMURuntime(timeout=5)
69
+ print(runtime.run(program))
70
+ ```
71
+
72
+ ## CLI
73
+
74
+ Pyliu exposes a small CLI for optional QEMU installation:
75
+
76
+ ```bash
77
+ python -m Pyliu --help
78
+ python -m Pyliu install-qemu
79
+ ```
80
+
81
+ ## Project structure
82
+
83
+ ```text
84
+ Pyliu/
85
+ ├── __init__.py
86
+ ├── __main__.py
87
+ ├── verification.py
88
+ └── tests/
89
+ └── test_qemu_optional.py
90
+ ```
91
+
92
+ ## Notes
93
+
94
+ - The QEMU runtime is intended for experimentation and research-oriented workflows.
95
+ - The package is designed to remain safe and non-invasive for PyPI-style packaging by avoiding automatic system modifications during import or normal runtime use.
96
+
97
+ ## License
98
+
99
+ This project is provided as-is for educational and research use.
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyliu
3
+ Version: 0.1.0
4
+ Summary: A lightweight assembly experimentation framework for Python
5
+ Home-page: https://github.com/aa425-aarohcharne/pyliu
6
+ Author: Aaroh
7
+ Author-email: Aaroh <aaroh.charne@gmail.com>
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: keystone-engine
11
+ Dynamic: author
12
+ Dynamic: home-page
13
+ Dynamic: requires-python
14
+
15
+ # Pyliu
16
+
17
+ Pyliu is a lightweight Python assembly experimentation framework for building, compiling, and executing simple assembly-like programs. It combines a small CPU instruction DSL, a compiler layer, and multiple runtime backends so you can explore low-level execution patterns from Python.
18
+
19
+ ## Features
20
+
21
+ - A simple CPU instruction abstraction layer
22
+ - A compiler pipeline for assembling programs into machine code
23
+ - An in-process JIT-style runtime
24
+ - A bare-metal QEMU runtime for isolated execution
25
+ - An optional CLI for installing QEMU explicitly
26
+
27
+ ## Installation
28
+
29
+ Install from the project directory:
30
+
31
+ ```bash
32
+ pip install -e .
33
+ ```
34
+
35
+ Or, if you are using the local virtual environment:
36
+
37
+ ```bash
38
+ .venv\Scripts\python.exe -m pip install -e .
39
+ ```
40
+
41
+ ## Optional QEMU dependency
42
+
43
+ QEMU is treated as an optional dependency for Pyliu.
44
+
45
+ By default, Pyliu does not try to download or install anything automatically. If you want to use the QEMU-backed runtime, install it explicitly:
46
+
47
+ ```bash
48
+ python -m Pyliu install-qemu
49
+ ```
50
+
51
+ Or, if you are using the project virtual environment:
52
+
53
+ ```bash
54
+ .venv\Scripts\python.exe -m Pyliu install-qemu
55
+ ```
56
+
57
+ ## Quick start
58
+
59
+ ```python
60
+ from Pyliu.verification import Program
61
+
62
+ program = Program()
63
+ program.begin()
64
+ program.mov("eax", "1")
65
+ program.ret()
66
+ program.end()
67
+
68
+ print(program.compile())
69
+ ```
70
+
71
+ ## Using the QEMU runtime
72
+
73
+ ```python
74
+ from Pyliu.verification import Program, QEMURuntime
75
+
76
+ program = Program()
77
+ program.begin()
78
+ program.mov("eax", "1")
79
+ program.ret()
80
+ program.end()
81
+
82
+ runtime = QEMURuntime(timeout=5)
83
+ print(runtime.run(program))
84
+ ```
85
+
86
+ ## CLI
87
+
88
+ Pyliu exposes a small CLI for optional QEMU installation:
89
+
90
+ ```bash
91
+ python -m Pyliu --help
92
+ python -m Pyliu install-qemu
93
+ ```
94
+
95
+ ## Project structure
96
+
97
+ ```text
98
+ Pyliu/
99
+ ├── __init__.py
100
+ ├── __main__.py
101
+ ├── verification.py
102
+ └── tests/
103
+ └── test_qemu_optional.py
104
+ ```
105
+
106
+ ## Notes
107
+
108
+ - The QEMU runtime is intended for experimentation and research-oriented workflows.
109
+ - The package is designed to remain safe and non-invasive for PyPI-style packaging by avoiding automatic system modifications during import or normal runtime use.
110
+
111
+ ## License
112
+
113
+ This project is provided as-is for educational and research use.
@@ -0,0 +1,14 @@
1
+ MANIFEST.in
2
+ README.md
3
+ pyproject.toml
4
+ setup.py
5
+ Pyliu/__init__.py
6
+ Pyliu/__main__.py
7
+ Pyliu/verification.py
8
+ pyliu.egg-info/PKG-INFO
9
+ pyliu.egg-info/SOURCES.txt
10
+ pyliu.egg-info/dependency_links.txt
11
+ pyliu.egg-info/entry_points.txt
12
+ pyliu.egg-info/requires.txt
13
+ pyliu.egg-info/top_level.txt
14
+ tests/test_qemu_optional.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyliu = Pyliu.__main__:main
@@ -0,0 +1 @@
1
+ keystone-engine
@@ -0,0 +1 @@
1
+ Pyliu
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyliu"
7
+ version = "0.1.0"
8
+ description = "A lightweight assembly experimentation framework for Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["keystone-engine"]
12
+ authors = [{ name = "Aaroh", email = "aaroh.charne@gmail.com" }]
13
+
14
+ [project.scripts]
15
+ pyliu = "Pyliu.__main__:main"
pyliu-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
pyliu-0.1.0/setup.py ADDED
@@ -0,0 +1,26 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name='pyliu',
5
+ version='0.1.0',
6
+ description='A lightweight assembly experimentation framework for Python',
7
+ long_description=open('README.md', encoding='utf-8').read(),
8
+ long_description_content_type='text/markdown',
9
+ author='Aaroh',
10
+ author_email='aaroh.charne@gmail.com',
11
+ url='https://github.com/aa425-aarohcharne/pyliu',
12
+ packages=find_packages(),
13
+ install_requires=[
14
+ 'keystone-engine',
15
+ ],
16
+ classifiers=[
17
+ 'Programming Language :: Python :: 3',
18
+ 'License :: OSI Approved :: MIT License',
19
+ 'Operating System :: OS Independent',
20
+ 'Development Status :: 3 - Alpha',
21
+ 'Intended Audience :: Developers',
22
+ 'Natural Language :: English',
23
+ 'Topic :: Software Development :: Libraries :: Python Modules',
24
+ ],
25
+ python_requires='>=3.9',
26
+ )
@@ -0,0 +1,23 @@
1
+ import inspect
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
8
+
9
+ from Pyliu import verification
10
+
11
+
12
+ def test_qemu_install_is_not_triggered_on_import():
13
+ assert verification.ensure_qemu_installed.__name__ == "ensure_qemu_installed"
14
+
15
+
16
+ def test_qemu_auto_install_is_disabled_by_default():
17
+ params = inspect.signature(verification.ensure_qemu_installed).parameters
18
+ assert params["auto_install"].default is False
19
+
20
+
21
+ def test_qemu_runtime_requires_explicit_install():
22
+ with pytest.raises(RuntimeError):
23
+ verification.ensure_qemu_installed(qemu_binary="qemu-system-x86_64", auto_install=False)