-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathmacos_root.py
More file actions
581 lines (479 loc) · 24.7 KB
/
Copy pathmacos_root.py
File metadata and controls
581 lines (479 loc) · 24.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
"""Root BlueStacks Air by injecting ``su`` into its Android system image.
Why this exists instead of the conf toggle
------------------------------------------
Windows BlueStacks ships a guest ``su`` that ``bst.instance.<name>.
enable_root_access`` unlocks, which is why ``config_handler`` can root it by
editing one line. BlueStacks Air ships no ``su`` anywhere -- verified against
the live guest *and* offline against the image's ext4 (``/system/bin``,
``/system/xbin``, ``/system/app``, ``/system/priv-app`` and the ramdisk are all
clean). The conf keys still exist in Air's ``bluestacks.conf``, and the player
still resets ``bst.feature.rooting`` to ``"0"`` on every launch, but nothing
consumes them: there is no ``su`` for them to gate. Rooting Air means putting
one there. See ``macos_su`` for the binary and why it is built rather than
downloaded.
What gets modified
------------------
``BlueStacks.app/Contents/img/Root.qcow2`` -- one qcow2 holding a single ext4
partition (MBR, type 0x83, starting at 1 MiB) whose ``/android/system`` tree the
guest bind-mounts as ``/system``. So ``/android/system/xbin/su`` in the image is
``/system/xbin/su`` in Android, which is already on the default ``PATH``.
Two consequences follow from this file living in the app bundle, and both are
deliberate behaviour rather than limitations worth hiding:
* **Rooting is install-wide, not per-instance.** Every Air instance boots this
same image; there is no per-instance ``Root.qcow2`` (one placed in the
instance directory is ignored -- tested). The UI reflects that.
* **Writing it needs the App Management privilege, not root.** The file's mode
is ``rw-rw-rw-``, so the user can already write it; what refuses the write is
macOS App Management, and that is granted to an *application*, so elevating
does not inherit it -- a root ``cp`` through ``osascript`` is denied where a
direct write succeeds. Hence the direct write first and elevation only as a
fallback (``_install_image``). New files cannot be created in
``Contents/img`` at all (root-owned directory), so the image is overwritten
in place and the backup lives outside the bundle.
* **Any edit invalidates the bundle's code signature**, because ``Root.qcow2``
is a sealed resource. BlueStacks still launches, but ``codesign --verify``
fails until the change is undone -- which is why undo restores the backup
byte-for-byte rather than editing the modification back out.
The pipeline
------------
``qemu-img`` (BlueStacks' own copy) converts qcow2 to raw and back; ``debugfs``
edits the ext4 in place through e2fsprogs' ``?offset=`` syntax, so the partition
never has to be sliced out and spliced back. ``e2fsck`` runs before the image is
handed back to BlueStacks: a corrupt system image would fail to boot, and it is
cheap insurance next to a 1.7 GB rewrite.
"""
from __future__ import annotations
import contextlib
import json
import logging
import os
import shutil
import struct
import subprocess
import tempfile
import macos_locator
import macos_su
import platform_support
logger = logging.getLogger(__name__)
# Path *inside the image*; the guest bind-mounts /android/system as /system.
SU_IMAGE_PATH = "/android/system/xbin/su"
SU_GUEST_PATH = "/system/xbin/su"
SU_MODE = 0o106755 # regular file | setuid | rwxr-xr-x
# Kept beside bluestacks.conf, not in the bundle: Contents/img is root-owned, so
# a sibling backup cannot be created there even with the image world-writable.
BACKUP_NAME = "Root.qcow2.prepatch.bak"
# ONE state file for every edit this tool makes to the system image, not one
# per feature. Both root and the hosts block live in the same qcow2 and are
# validated by fingerprinting that qcow2, so per-feature state files would
# invalidate each other: applying the hosts block rewrites the image, which
# would make root's separately-stored fingerprint stop matching and silently
# report the instance as un-rooted. A single record with a single fingerprint
# cannot drift from itself.
STATE_NAME = "Root.qcow2.modstate.json"
# debugfs/e2fsck are not part of macOS. Homebrew keeps e2fsprogs keg-only, so
# its binaries are never on PATH even when installed -- look there explicitly
# before falling back to whatever PATH offers.
_E2FS_SEARCH_DIRS = (
"/opt/homebrew/opt/e2fsprogs/sbin", # Apple Silicon Homebrew
"/opt/homebrew/sbin",
"/usr/local/opt/e2fsprogs/sbin", # Intel Homebrew
"/usr/local/sbin",
"/opt/local/sbin", # MacPorts
)
INSTALL_HINT = (
"This needs the e2fsprogs tools (debugfs, e2fsck) to edit the Android "
"system image.\n\nInstall them with:\n brew install e2fsprogs"
)
# macOS App Management (TCC). Since Ventura, writing into another application's
# bundle is refused with EPERM unless the *responsible* app holds this
# privilege -- and, unlike a permissions problem, elevating does not help:
# running the copy as root through osascript is denied identically. The only
# fix is the user granting it, so say exactly that instead of surfacing a bare
# "Operation not permitted".
APP_MANAGEMENT_HINT = (
"macOS blocked the write into BlueStacks.app.\n\n"
"Modifying another app's bundle needs the \"App Management\" privilege, "
"which administrator rights alone do not provide. Grant it here:\n\n"
" System Settings > Privacy & Security > App Management\n\n"
"...then enable this app (or your terminal, if you are running from "
"source) and try again."
)
_EPERM_MARKERS = ("Operation not permitted", "not permitted")
class RootError(RuntimeError):
"""Rooting could not be completed; the message is user-facing."""
# --------------------------------------------------------------------------
# Tool discovery
# --------------------------------------------------------------------------
def _find_tool(name: str) -> str | None:
for directory in _E2FS_SEARCH_DIRS:
candidate = os.path.join(directory, name)
if os.access(candidate, os.X_OK):
return candidate
return shutil.which(name)
def find_e2fs_tools() -> tuple[str, str]:
"""Return ``(debugfs, e2fsck)`` paths, or raise with an install hint."""
debugfs, e2fsck = _find_tool("debugfs"), _find_tool("e2fsck")
if not debugfs or not e2fsck:
raise RootError(INSTALL_HINT)
return debugfs, e2fsck
def find_qemu_img(app_path: str) -> str:
"""BlueStacks' bundled ``qemu-img``; it is always present in the bundle."""
tool = macos_locator.bundled_tool(app_path, macos_locator.QEMU_IMG_NAME)
if not tool:
raise RootError(
"Could not find qemu-img inside %s. Is this a complete BlueStacks "
"Air installation?" % app_path)
return tool
def _run(argv: list[str], *, label: str) -> subprocess.CompletedProcess:
logger.debug("running %s", argv)
proc = subprocess.run(argv, capture_output=True, text=True)
if proc.returncode != 0:
raise RootError("%s failed: %s" % (label, (proc.stderr or proc.stdout).strip()))
return proc
# --------------------------------------------------------------------------
# Image geometry
# --------------------------------------------------------------------------
def partition_offset(raw_path: str) -> int:
"""Byte offset of the first MBR partition in ``raw_path``.
Read rather than hard-coded at 1 MiB: a BlueStacks update is free to lay the
image out differently, and silently editing the wrong offset would corrupt
the system image instead of failing.
"""
with open(raw_path, "rb") as fh:
mbr = fh.read(512)
if len(mbr) < 512 or mbr[510:512] != b"\x55\xaa":
raise RootError("Android system image has no MBR signature; refusing to edit it.")
for i in range(4):
entry = mbr[446 + i * 16: 446 + (i + 1) * 16]
part_type = entry[4]
start_lba = struct.unpack("<I", entry[8:12])[0]
if part_type == 0x83 and start_lba: # 0x83 = Linux
return start_lba * 512
raise RootError("No Linux partition found in the Android system image.")
def _debugfs(debugfs: str, image: str, offset: int, commands: str, *,
writable: bool) -> str:
"""Drive debugfs over the ext4 at ``offset`` inside ``image``."""
argv = [debugfs]
if writable:
argv.append("-w")
argv += ["-f", "/dev/stdin", "%s?offset=%d" % (image, offset)]
proc = subprocess.run(argv, input=commands, capture_output=True, text=True)
# debugfs reports per-command failures on stdout and still exits 0, so the
# caller checks the text; a non-zero exit is a hard failure.
if proc.returncode != 0:
raise RootError("debugfs failed: %s" % (proc.stderr or proc.stdout).strip())
return proc.stdout
# --------------------------------------------------------------------------
# Recorded state
# --------------------------------------------------------------------------
# Reading the real answer means converting 1.7 GB of qcow2, which cannot run on
# the 5-second status refresh. Instead the injected state is recorded next to
# the backup, fingerprinted with the image's size and mtime. If BlueStacks
# updates and replaces the image, the fingerprint stops matching and the state
# reads "not rooted" -- which is exactly what happened to the guest.
def _state_path(data_dir: str) -> str:
return os.path.join(data_dir, STATE_NAME)
def backup_path(data_dir: str) -> str:
return os.path.join(data_dir, BACKUP_NAME)
def _fingerprint(image: str) -> dict:
st = os.stat(image)
return {"size": st.st_size, "mtime": int(st.st_mtime)}
def read_modstate(app_path: str, data_dir: str = macos_locator.DATA_DIR) -> dict:
"""Everything this tool has done to the current system image.
``{"root": bool, "hosts": {...}|None}``. Returns empty when the image no
longer matches what was recorded -- a BlueStacks update replaces the image
and takes every edit with it, so reporting the old contents would claim
modifications the guest no longer has.
"""
image = macos_locator.root_image_path(app_path)
if not os.path.isfile(image):
return {}
try:
with open(_state_path(data_dir), encoding="utf-8") as fh:
state = json.load(fh)
except (OSError, ValueError):
return {}
if state.get("image") != _fingerprint(image):
logger.info("Android system image changed since it was modified (a "
"BlueStacks update would do this); reporting it as stock.")
return {}
return state
def write_modstate(app_path: str, data_dir: str, state: dict) -> None:
"""Record ``state``, stamped with the image as it is right now.
Must be called *after* the image has been written back, so the fingerprint
describes the image the user actually has.
"""
image = macos_locator.root_image_path(app_path)
payload = {k: v for k, v in state.items() if v}
if not payload:
_clear_state(data_dir)
return
payload["image"] = _fingerprint(image)
if payload.get("root"):
# Fingerprint of the exact binary that was injected, so a future
# version can tell its own su from one somebody else put there.
payload["su_sha256"] = macos_su.su_sha256()
payload["su_path"] = SU_GUEST_PATH
with open(_state_path(data_dir), "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2)
def _clear_state(data_dir: str) -> None:
try:
os.unlink(_state_path(data_dir))
except FileNotFoundError:
pass
def image_root_state(app_path: str, data_dir: str = macos_locator.DATA_DIR) -> bool:
"""True when this tool's ``su`` is believed to be in the current image."""
return bool(read_modstate(app_path, data_dir).get("root"))
def verify_image_rooted(app_path: str) -> bool:
"""Authoritative -- and slow -- check that ``su`` really is in the image.
Converts the whole image, so this is for explicit verification only; the UI
uses :func:`image_root_state`.
"""
debugfs, _ = find_e2fs_tools()
qemu_img = find_qemu_img(app_path)
image = macos_locator.root_image_path(app_path)
workdir = tempfile.mkdtemp(prefix="bsroot-verify-")
try:
raw = os.path.join(workdir, "Root.raw")
_run([qemu_img, "convert", "-O", "raw", image, raw], label="qemu-img convert")
out = _debugfs(debugfs, raw, partition_offset(raw),
"stat %s\nquit\n" % SU_IMAGE_PATH, writable=False)
return "Inode" in out and "File not found" not in out
finally:
shutil.rmtree(workdir, ignore_errors=True)
# --------------------------------------------------------------------------
# The operation
# --------------------------------------------------------------------------
def _copy_in_place(source: str, dest: str) -> None:
"""Overwrite ``dest``'s contents, keeping its inode, owner and mode.
Opened ``r+b`` and truncated rather than replaced: ``Contents/img`` is
root-owned, so no new file can be created there to rename over, and the
image itself is what must stay put.
"""
with open(source, "rb") as src, open(dest, "r+b") as dst:
shutil.copyfileobj(src, dst, length=8 * 1024 * 1024)
dst.truncate()
def _install_image(source: str, image: str, *, label: str) -> None:
"""Write ``source`` over the bundle's image, unelevated where possible.
Order matters, and not for the obvious reason. ``Root.qcow2`` ships mode
``rw-rw-rw-``, so the logged-in user can already write it -- the only thing
standing in the way is macOS App Management. And that privilege is granted
to an *application*, not to a user: a root shell spawned through
``osascript`` does **not** inherit the grant its parent holds. Escalating
therefore fails exactly where a plain write succeeds (observed: a direct
write permitted while ``cp`` under ``with administrator privileges`` was
refused on the same file, seconds apart).
So try the direct write first -- which also means the common case needs no
password prompt at all -- and keep elevation as the fallback for an install
whose image is not user-writable.
"""
try:
_copy_in_place(source, image)
return
except OSError as direct_exc:
logger.info("Direct write to %s failed (%s); trying with administrator "
"rights.", image, direct_exc)
direct_blocked = isinstance(direct_exc, PermissionError)
try:
platform_support.run_elevated(
"/bin/cp %s %s\n" % (platform_support.shell_quote(source),
platform_support.shell_quote(image)),
label=label)
except platform_support.ElevationError as exc:
# EPERM from *both* paths is the App Management signature: a plain
# permissions problem would have been fixed by running as root.
if direct_blocked or any(marker in str(exc) for marker in _EPERM_MARKERS):
raise RootError(APP_MANAGEMENT_HINT) from exc
raise RootError(str(exc)) from exc
# The edits this tool can have applied to the system image. Root and the hosts
# block share one image, so neither can decide on its own whether undoing means
# "restore the pristine backup" or "edit the current image" -- that depends on
# whether the *other* one is still applied.
MODIFICATIONS = ("root", "hosts")
def applied_modifications(state: dict) -> set[str]:
"""Which of :data:`MODIFICATIONS` a modstate record says are in place."""
return {key for key in MODIFICATIONS if state.get(key)}
def ensure_backup(image: str, data_dir: str, *, image_is_pristine: bool,
step=None) -> str | None:
"""Keep one pristine copy of the system image, shared by every edit.
Refreshed only while the image is unmodified, which is what makes it
*pristine*: overwriting it from an already-rooted image would turn "undo"
into "restore the rooted image". Not refreshing at all would be worse in a
different way -- a BlueStacks update replaces the image, and a stale backup
would undo to the previous build's ``/system``.
"""
backup = backup_path(data_dir)
if os.path.isfile(backup) and not image_is_pristine:
return backup
if step:
step("Backing up the original system image (this is how it is undone)...")
_require_free_space(backup, os.path.getsize(image) + 2**30)
shutil.copy2(image, backup)
return backup
def restore_pristine(image: str, data_dir: str, step=None) -> list[str]:
"""Put the untouched image back and drop the backup.
Byte-for-byte, which also repairs the app bundle's code-signature seal --
any edit to ``Root.qcow2`` invalidates it, so restoring identical bytes is
the only way to leave the install exactly as it was found.
"""
backup = backup_path(data_dir)
results = []
if step:
step("Restoring the original Android system image...")
_install_image(backup, image,
label="restore the original BlueStacks system image")
results.append("restored %s from backup" % os.path.basename(image))
try:
os.unlink(backup)
results.append("removed the backup copy")
except OSError:
logger.warning("Could not remove backup %s", backup, exc_info=True)
return results
def _require_free_space(path: str, needed: int) -> None:
free = shutil.disk_usage(os.path.dirname(path) or "/").free
if free < needed:
raise RootError(
"Not enough free disk space: this needs about %.1f GB free and "
"only %.1f GB is available." % (needed / 2**30, free / 2**30))
class ImageSession:
"""An unpacked system image, open for editing.
Yielded by :func:`open_image`. ``run`` drives debugfs against the guest
filesystem; ``read_file`` and ``write_file`` are the file-level helpers the
hosts editor needs (``macos_hosts``).
"""
def __init__(self, raw: str, offset: int, debugfs: str, workdir: str):
self.raw = raw
self.offset = offset
self.workdir = workdir
self._debugfs = debugfs
def run(self, commands: str) -> str:
out = _debugfs(self._debugfs, self.raw, self.offset, commands, writable=True)
if "Bad magic" in out or "Filesystem not open" in out:
raise RootError("Could not open the guest filesystem: %s" % out.strip())
return out
def read_file(self, path: str) -> str | None:
"""Contents of a file in the guest, or None when it does not exist."""
dest = os.path.join(self.workdir, "dump.tmp")
if os.path.exists(dest):
os.unlink(dest)
self.run("dump %s %s\nquit\n" % (path, dest))
if not os.path.isfile(dest):
return None
with open(dest, encoding="utf-8", errors="replace") as fh:
return fh.read()
def write_file(self, path: str, content: str, *, mode: int = 0o100644) -> None:
"""Replace a file in the guest, root-owned with ``mode``."""
src = os.path.join(self.workdir, "write.tmp")
with open(src, "w", encoding="utf-8", newline="\n") as fh:
fh.write(content)
parent, name = path.rsplit("/", 1)
# debugfs `write` refuses to overwrite, and links the destination as a
# bare name in the current directory -- hence the rm, the cd, and the
# unqualified name.
self.run(
"rm {path}\ncd {parent}\nwrite {src} {name}\n"
"sif {name} mode 0{mode:o}\nsif {name} uid 0\nsif {name} gid 0\nquit\n"
.format(path=path, parent=parent, src=src, name=name, mode=mode))
@contextlib.contextmanager
def open_image(app_path: str, progress=None, *, results: list[str] | None = None):
"""Unpack the Air system image, yield an :class:`ImageSession`, repack it.
The image is only written back if the body completes without raising, so a
failed edit leaves BlueStacks exactly as it was rather than installing a
half-modified system image.
"""
def _step(msg: str) -> None:
logger.info(msg)
if progress:
progress(msg)
if not platform_support.IS_MACOS:
raise RootError("Editing the BlueStacks Air system image is macOS-only.")
image = macos_locator.root_image_path(app_path)
if not os.path.isfile(image):
raise RootError("Android system image not found at %s" % image)
debugfs, e2fsck = find_e2fs_tools()
qemu_img = find_qemu_img(app_path)
# A 10 GiB sparse raw plus the rebuilt qcow2; the raw only ever holds the
# ~1.7 GB that is actually allocated, but leave room for both.
_require_free_space(tempfile.gettempdir(), 6 * 2**30)
workdir = tempfile.mkdtemp(prefix="bsimg-")
try:
raw = os.path.join(workdir, "Root.raw")
_step("Unpacking the Android system image...")
_run([qemu_img, "convert", "-O", "raw", image, raw], label="qemu-img convert")
offset = partition_offset(raw)
logger.info("ext4 partition at offset %d", offset)
yield ImageSession(raw, offset, debugfs, workdir)
_step("Checking the guest filesystem...")
# e2fsck exits 1 when it fixed something, which is a success here; only
# 4+ (uncorrected errors) means the image is unusable.
check = subprocess.run([e2fsck, "-fy", "%s?offset=%d" % (raw, offset)],
capture_output=True, text=True)
if check.returncode >= 4:
raise RootError("The guest filesystem failed its check and was not "
"written back:\n%s" % (check.stdout or check.stderr))
if check.returncode == 1 and results is not None:
results.append("e2fsck repaired the filesystem")
_step("Repacking the system image...")
new_image = os.path.join(workdir, "Root.qcow2")
_run([qemu_img, "convert", "-O", "qcow2", raw, new_image],
label="qemu-img convert")
_step("Writing the image back into BlueStacks...")
_install_image(new_image, image, label="update the BlueStacks system image")
finally:
shutil.rmtree(workdir, ignore_errors=True)
def set_root(app_path: str, enabled: bool, progress=None,
data_dir: str = macos_locator.DATA_DIR) -> list[str]:
"""Add (or remove) ``su`` in the Air system image. Returns log lines."""
def _step(msg: str) -> None:
logger.info(msg)
if progress:
progress(msg)
if not platform_support.IS_MACOS:
raise RootError("BlueStacks Air rooting is macOS-only.")
image = macos_locator.root_image_path(app_path)
if not os.path.isfile(image):
raise RootError("Android system image not found at %s" % image)
backup = backup_path(data_dir)
state = read_modstate(app_path, data_dir)
active = applied_modifications(state)
results: list[str] = []
# Undoing the *last* edit means putting the pristine image back, which is
# faster than editing, and is the only thing that repairs the bundle's
# code-signature seal. Deliberately ahead of the tool lookup below: this
# path is a plain file copy, and someone who has since removed e2fsprogs
# must never be trapped in a rooted state by a dependency undo does not use.
if not enabled and not (active - {"root"}) and os.path.isfile(backup):
results += restore_pristine(image, data_dir, _step)
_clear_state(data_dir) # nothing is applied any more, by definition
_step("Root removed. Restart BlueStacks for the change to take effect.")
return results
if enabled:
ensure_backup(image, data_dir, image_is_pristine=not active, step=_step)
results.append("backed up to %s" % backup)
with open_image(app_path, progress, results=results) as img:
if enabled:
_step("Installing su into the guest system...")
su_path = os.path.join(img.workdir, "su")
with open(su_path, "wb") as fh:
fh.write(macos_su.build_su())
parent, name = SU_IMAGE_PATH.rsplit("/", 1)
# rm first so re-running is idempotent rather than failing on an
# existing inode; a missing file makes rm a harmless no-op.
img.run(
"rm {img}\ncd {parent}\nwrite {src} {name}\n"
"sif {name} mode 0{mode:o}\nsif {name} uid 0\nsif {name} gid 0\nquit\n"
.format(img=SU_IMAGE_PATH, parent=parent, src=su_path,
name=name, mode=SU_MODE))
results.append("installed %s (mode %04o, uid 0)"
% (SU_GUEST_PATH, SU_MODE & 0o7777))
else:
# No backup to fall back on -- remove the inode we added.
_step("Removing su from the guest system...")
img.run("rm %s\nquit\n" % SU_IMAGE_PATH)
results.append("removed %s" % SU_GUEST_PATH)
# Rewrite the whole record: the image just changed, so every edit still
# applied has to be re-stamped against the new fingerprint, not just root's.
state["root"] = enabled
write_modstate(app_path, data_dir, state)
_step("Done. Restart BlueStacks for the change to take effect.")
return results