e0bc4785e9
Flashes the diskOS custom UI onto the FiiO Snowsky Disc over Ingenic mask-ROM USB, building the image from your own stock firmware. Runs from source via install.sh.
259 lines
9.6 KiB
Python
259 lines
9.6 KiB
Python
"""Per-user state so the tool can always put the device back to bone-stock and
|
|
so `remove` can clean up after itself.
|
|
|
|
Everything the tool creates lives under ONE directory (state_dir()); nothing is
|
|
written system-wide. `remove` deletes that directory (and, if the user asks, the
|
|
tool itself) - that's the whole uninstall footprint on Linux/macOS.
|
|
|
|
Contents:
|
|
state.json what we've done (installed?, versions, timestamps)
|
|
stock/stock.bin the saved stock-rootfs image, rebuilt from the user's firmware (for restore-stock)
|
|
stock/stock.sha256 its checksum
|
|
build/ scratch for extract/build (cleaned opportunistically)
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import time
|
|
|
|
from . import errors
|
|
|
|
APP = "diskos-installer"
|
|
# A marker file dropped in our state dir. wipe_all() refuses to recursively delete any directory
|
|
# that does not contain it, so a mis-set DISKOS_INSTALLER_HOME can never delete an arbitrary tree.
|
|
SENTINEL = ".diskos-installer-state"
|
|
# The diskOS install/restore lifecycle values written to state.json["phase"]. Used to recognise a
|
|
# legacy (pre-sentinel) state dir by CONTENT, specifically enough that a foreign project's state.json
|
|
# cannot masquerade as ours. Keep in sync with service.py.
|
|
_KNOWN_PHASES = {"prepared", "flash-started", "flash-verified", "restore-started", "restore-verified"}
|
|
|
|
|
|
def _fsync_dir(path):
|
|
"""Fsync a directory so a rename/create inside it is durable across a crash."""
|
|
try:
|
|
fd = os.open(path, os.O_RDONLY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _state_base():
|
|
"""Compute the state-dir PATH only - no directory creation, no side effects.
|
|
Used by wipe_all() so validation never re-creates the sentinel it is about to check."""
|
|
env = os.environ.get("DISKOS_INSTALLER_HOME")
|
|
if env:
|
|
return env
|
|
if sys.platform == "darwin":
|
|
return os.path.expanduser(f"~/Library/Application Support/{APP}")
|
|
xdg = os.environ.get("XDG_DATA_HOME")
|
|
return os.path.join(xdg, APP) if xdg else os.path.expanduser(f"~/.local/share/{APP}")
|
|
|
|
|
|
def _is_our_statedir(base):
|
|
"""STRONG test that `base` is (or was) a diskOS state dir - governs whether we may adopt it and,
|
|
in wipe_all(), recursively delete it. Requires GENUINE evidence, never a merely-common basename,
|
|
so an unrelated directory that happens to contain a 'build/' or a 'state.json' is NEVER matched:
|
|
- our sentinel file, OR
|
|
- our exact stock-recovery pair stock/stock.bin + stock/stock.sha256, OR
|
|
- a state.json that parses AND matches our specific schema (a boolean `installed` plus a
|
|
`phase` drawn from our known lifecycle values) - not merely the generic key NAMES, so an
|
|
unrelated project's state.json cannot be mistaken for ours and later deleted."""
|
|
if os.path.isfile(os.path.join(base, SENTINEL)):
|
|
return True
|
|
if (os.path.isfile(os.path.join(base, "stock", "stock.bin"))
|
|
and os.path.isfile(os.path.join(base, "stock", "stock.sha256"))):
|
|
return True
|
|
sj = os.path.join(base, "state.json")
|
|
if os.path.isfile(sj):
|
|
try:
|
|
with open(sj) as f:
|
|
d = json.load(f)
|
|
if (isinstance(d, dict) and isinstance(d.get("installed"), bool)
|
|
and isinstance(d.get("phase"), str) and d.get("phase") in _KNOWN_PHASES):
|
|
return True
|
|
except (OSError, ValueError, TypeError):
|
|
pass
|
|
return False
|
|
|
|
|
|
def state_dir():
|
|
"""A single, per-user, non-system directory for all tool state (created on first use)."""
|
|
base = _state_base()
|
|
marker = os.path.join(base, SENTINEL)
|
|
# SAFETY, only for an EXPLICITLY-set home: refuse to adopt a pre-existing, non-empty directory
|
|
# that shows NO sign of being ours, so a mis-set DISKOS_INSTALLER_HOME (e.g. $HOME) can't be
|
|
# tagged here and later wiped by 'remove'. The DEFAULT XDG/APP path is always ours; and an
|
|
# existing state dir (even one predating the sentinel) is recognised by its contents.
|
|
if (os.environ.get("DISKOS_INSTALLER_HOME") and os.path.isdir(base)
|
|
and not _is_our_statedir(base)):
|
|
try:
|
|
nonempty = bool(os.listdir(base))
|
|
except OSError:
|
|
nonempty = True
|
|
if nonempty:
|
|
raise errors.PreflightError(
|
|
f"DISKOS_INSTALLER_HOME={base!r} is a non-empty directory that is not a diskOS "
|
|
"state dir - refusing to use it. Point it at a new or empty path.", code="E142")
|
|
os.makedirs(base, exist_ok=True)
|
|
if not os.path.exists(marker):
|
|
try:
|
|
with open(marker, "w") as f:
|
|
f.write("diskOS installer state directory - safe to delete via 'diskos-installer remove'\n")
|
|
except OSError:
|
|
pass
|
|
return base
|
|
|
|
|
|
def _state_path():
|
|
return os.path.join(state_dir(), "state.json")
|
|
|
|
|
|
def load():
|
|
"""Return the saved state. A corrupt file is reported (not silently erased) so
|
|
'never installed' and 'state damaged' stay distinguishable."""
|
|
p = _state_path()
|
|
if not os.path.exists(p):
|
|
return {}
|
|
try:
|
|
with open(p) as f:
|
|
return json.load(f)
|
|
except ValueError:
|
|
return {"_corrupt": True}
|
|
except OSError:
|
|
return {}
|
|
|
|
|
|
def save(d):
|
|
d = {k: v for k, v in d.items() if k != "_corrupt"}
|
|
d["updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
tmp = _state_path() + ".tmp"
|
|
with open(tmp, "w") as f:
|
|
json.dump(d, f, indent=2)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp, _state_path())
|
|
_fsync_dir(state_dir())
|
|
|
|
|
|
def sha256_file(path, progress=None):
|
|
h = hashlib.sha256()
|
|
total = os.path.getsize(path)
|
|
read = 0
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
read += len(chunk)
|
|
if progress:
|
|
progress(read, total)
|
|
return h.hexdigest()
|
|
|
|
|
|
def stock_paths():
|
|
d = os.path.join(state_dir(), "stock")
|
|
os.makedirs(d, exist_ok=True)
|
|
return os.path.join(d, "stock.bin"), os.path.join(d, "stock.sha256")
|
|
|
|
|
|
def save_stock_image(src_bin, progress=None):
|
|
"""Copy the saved stock-rootfs flashable image into state so restore-stock can
|
|
always reflash exactly it. Atomic + validated: the previous good image is only
|
|
replaced once the new one is fully written, hashed, magic-checked, and fsynced -
|
|
so a crash/disk-full can't destroy your recovery image. Returns its sha256."""
|
|
dst, shafile = stock_paths()
|
|
d = os.path.dirname(dst)
|
|
tmp_bin, tmp_sha = dst + ".tmp", shafile + ".tmp"
|
|
|
|
h = hashlib.sha256()
|
|
total = os.path.getsize(src_bin)
|
|
read = 0
|
|
with open(src_bin, "rb") as s, open(tmp_bin, "wb") as o:
|
|
while True:
|
|
chunk = s.read(1 << 20)
|
|
if not chunk:
|
|
break
|
|
o.write(chunk)
|
|
h.update(chunk)
|
|
read += len(chunk)
|
|
if progress:
|
|
progress(read, total)
|
|
o.flush()
|
|
os.fsync(o.fileno())
|
|
digest = h.hexdigest()
|
|
|
|
# validate BEFORE committing (never publish a truncated/non-squashfs image)
|
|
with open(tmp_bin, "rb") as f:
|
|
if f.read(4) != b"hsqs":
|
|
os.unlink(tmp_bin)
|
|
raise ValueError("refusing to save a non-squashfs stock image (bad magic).")
|
|
with open(tmp_sha, "w") as f:
|
|
f.write(digest + "\n")
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
|
|
# commit: rename sha first, then the image, then fsync the dir. If interrupted
|
|
# between renames, have_stock_image() sees a mismatch and reports 'no valid image'
|
|
# rather than trusting a half-committed pair.
|
|
os.replace(tmp_sha, shafile)
|
|
os.replace(tmp_bin, dst)
|
|
_fsync_dir(d)
|
|
return digest
|
|
|
|
|
|
def stock_image_exists():
|
|
"""Fast existence check (no hashing) - safe to call on the UI thread."""
|
|
dst, shafile = stock_paths()
|
|
return os.path.exists(dst) and os.path.exists(shafile)
|
|
|
|
|
|
def have_stock_image():
|
|
dst, shafile = stock_paths()
|
|
try:
|
|
if not (os.path.exists(dst) and os.path.exists(shafile)):
|
|
return False
|
|
want = open(shafile).read().strip()
|
|
return sha256_file(dst) == want
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def build_dir():
|
|
d = os.path.join(state_dir(), "build")
|
|
os.makedirs(d, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def wipe_all():
|
|
"""Delete the entire tool state directory (the full uninstall footprint).
|
|
Returns (path, errors): errors is a list of (path, message) for anything that
|
|
could NOT be removed, so the caller reports the truth instead of a false success."""
|
|
d = _state_base() # PATH only - never re-create the sentinel we are about to verify
|
|
errs = []
|
|
# SAFETY: only ever recursively delete a directory we can PROVE is our own state dir. This
|
|
# guards against DISKOS_INSTALLER_HOME being set to '/', '$HOME', the cwd, or any other tree.
|
|
real = os.path.realpath(d)
|
|
forbidden = {os.path.realpath(os.sep), os.path.realpath(os.path.expanduser("~"))}
|
|
try:
|
|
forbidden.add(os.path.realpath(os.getcwd()))
|
|
except OSError:
|
|
pass
|
|
if real in forbidden:
|
|
return d, [(d, "refusing to delete a root/home/current directory")]
|
|
if not (os.path.isdir(d) and _is_our_statedir(d)):
|
|
return d, [(d, "not a diskOS state dir (no sentinel or known state) - refusing to delete")]
|
|
try:
|
|
if os.stat(d).st_uid != os.getuid():
|
|
return d, [(d, "state dir is not owned by you - refusing to delete")]
|
|
except OSError as e:
|
|
return d, [(d, str(e))]
|
|
shutil.rmtree(d, onerror=lambda fn, path, exc: errs.append((path, str(exc[1]))))
|
|
return d, errs
|