187 lines
6.0 KiB
Python
187 lines
6.0 KiB
Python
"""Blockgeräte auflisten, mounten und wieder aushängen.
|
|
|
|
Alle Operationen setzen root-Rechte voraus (Offline-Recovery läuft aus einem
|
|
Live-System). NTFS wird über ntfs-3g gemountet; Fast-Startup/Hibernation von
|
|
Windows wird mit ``remove_hiberfile`` behandelt, sonst verweigert ntfs-3g das
|
|
Schreiben.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
class DirtyVolume(RuntimeError):
|
|
"""NTFS konnte nicht RW gemountet werden (Dirty-Bit / Fast-Startup / Hibernation).
|
|
|
|
Das Volume laesst sich nach einem ``fix_ntfs()`` in aller Regel wieder
|
|
schreibbar einhaengen.
|
|
"""
|
|
|
|
|
|
# Textbausteine, an denen ntfs-3g einen unsauberen Zustand meldet
|
|
_DIRTY_MARKERS = (
|
|
"unclean",
|
|
"was not safely closed",
|
|
"dirty",
|
|
"hibernat",
|
|
"metadata kept in windows cache",
|
|
"falling back to read-only",
|
|
"windows is in",
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Partition:
|
|
path: str # z.B. /dev/sda2
|
|
size: str # menschenlesbar, z.B. "256G"
|
|
fstype: str # ext4, ntfs, vfat, ...
|
|
label: str # Datenträgerbezeichnung, kann leer sein
|
|
mountpoint: str # aktueller Mountpunkt laut lsblk (meist leer)
|
|
our_mount: str = "" # von uns gesetzter Mountpunkt
|
|
os_kind: str = "" # "linux", "windows" oder "" (unbekannt/nicht gemountet)
|
|
|
|
@property
|
|
def display(self) -> str:
|
|
bits = [self.path, self.size or "?", self.fstype or "?"]
|
|
if self.label:
|
|
bits.append(f"[{self.label}]")
|
|
if self.os_kind:
|
|
bits.append(f"-> {self.os_kind.upper()}")
|
|
return " ".join(bits)
|
|
|
|
|
|
def list_partitions() -> list[Partition]:
|
|
"""Alle mountbaren Partitionen zurueckgeben (ohne Swap/leere FS)."""
|
|
out = subprocess.run(
|
|
["lsblk", "-J", "-b", "-o",
|
|
"PATH,SIZE,FSTYPE,LABEL,MOUNTPOINT,TYPE"],
|
|
capture_output=True, text=True, check=True,
|
|
).stdout
|
|
data = json.loads(out)
|
|
parts: list[Partition] = []
|
|
|
|
def walk(nodes):
|
|
for n in nodes:
|
|
fstype = n.get("fstype") or ""
|
|
ntype = n.get("type") or ""
|
|
if ntype in ("part", "lvm", "crypt") and fstype not in ("", "swap"):
|
|
parts.append(Partition(
|
|
path=n.get("path") or "",
|
|
size=_human(n.get("size")),
|
|
fstype=fstype,
|
|
label=n.get("label") or "",
|
|
mountpoint=n.get("mountpoint") or "",
|
|
))
|
|
if n.get("children"):
|
|
walk(n["children"])
|
|
|
|
walk(data.get("blockdevices", []))
|
|
return parts
|
|
|
|
|
|
def _human(nbytes) -> str:
|
|
try:
|
|
n = int(nbytes)
|
|
except (TypeError, ValueError):
|
|
return ""
|
|
for unit in ("B", "K", "M", "G", "T", "P"):
|
|
if n < 1024 or unit == "P":
|
|
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
|
n /= 1024
|
|
return ""
|
|
|
|
|
|
def mount(part: Partition, force: bool = False) -> str:
|
|
"""Partition read-write mounten und OS-Typ erkennen. Gibt Mountpunkt zurueck.
|
|
|
|
Bei NTFS wird ein unsauberer Zustand (Dirty-Bit / Fast-Startup /
|
|
Hibernation) als :class:`DirtyVolume` gemeldet, damit die Oberflaeche
|
|
nachfragen kann. Mit ``force=True`` wird nach einem ``fix_ntfs()`` ein
|
|
erzwungener Schreib-Mount versucht.
|
|
"""
|
|
if part.our_mount and os.path.ismount(part.our_mount):
|
|
return part.our_mount
|
|
|
|
mp = tempfile.mkdtemp(prefix="puk-")
|
|
if part.fstype == "ntfs":
|
|
# remove_hiberfile: raeumt hiberfile.sys weg (Fast-Startup/Hibernation).
|
|
# force: Schreib-Mount trotz gesetztem Dirty-Flag erzwingen.
|
|
opts = "rw,remove_hiberfile"
|
|
if force:
|
|
opts += ",force"
|
|
cmd = ["mount", "-t", "ntfs-3g", "-o", opts, part.path, mp]
|
|
else:
|
|
cmd = ["mount", "-o", "rw", part.path, mp]
|
|
|
|
res = subprocess.run(cmd, capture_output=True, text=True)
|
|
if res.returncode != 0:
|
|
os.rmdir(mp)
|
|
err = (res.stderr or res.stdout or "").strip()
|
|
if part.fstype == "ntfs" and _looks_dirty(err):
|
|
raise DirtyVolume(err or "NTFS-Volume ist unsauber (Dirty-Bit).")
|
|
raise RuntimeError(err or f"mount fehlgeschlagen ({part.path})")
|
|
|
|
part.our_mount = mp
|
|
part.os_kind = detect_os(mp)
|
|
return mp
|
|
|
|
|
|
def _looks_dirty(message: str) -> bool:
|
|
low = message.lower()
|
|
return any(m in low for m in _DIRTY_MARKERS)
|
|
|
|
|
|
def fix_ntfs(part: Partition) -> str:
|
|
"""Dirty-Bit loeschen und NTFS-Journal zuruecksetzen (ntfsfix).
|
|
|
|
Darf nur auf einem *nicht* gemounteten Volume laufen. Gibt die Ausgabe
|
|
von ntfsfix zurueck.
|
|
"""
|
|
if part.our_mount and os.path.ismount(part.our_mount):
|
|
raise RuntimeError("Volume ist noch gemountet -- erst aushaengen.")
|
|
if shutil.which("ntfsfix") is None:
|
|
raise RuntimeError("ntfsfix nicht gefunden (Paket ntfs-3g).")
|
|
res = subprocess.run(["ntfsfix", "-d", part.path],
|
|
capture_output=True, text=True)
|
|
out = (res.stdout + res.stderr).strip()
|
|
if res.returncode != 0:
|
|
raise RuntimeError(out or "ntfsfix fehlgeschlagen")
|
|
return out
|
|
|
|
|
|
def detect_os(mountpoint: str) -> str:
|
|
if os.path.exists(os.path.join(mountpoint, "etc", "shadow")):
|
|
return "linux"
|
|
# Windows: SAM-Hive, Gross-/Kleinschreibung variiert je nach FS
|
|
for cfg in ("Windows/System32/config", "WINDOWS/system32/config"):
|
|
if os.path.exists(os.path.join(mountpoint, cfg, "SAM")):
|
|
return "windows"
|
|
return ""
|
|
|
|
|
|
def unmount(part: Partition) -> None:
|
|
if part.our_mount and os.path.ismount(part.our_mount):
|
|
subprocess.run(["umount", part.our_mount],
|
|
capture_output=True, text=True)
|
|
if part.our_mount and os.path.isdir(part.our_mount):
|
|
try:
|
|
os.rmdir(part.our_mount)
|
|
except OSError:
|
|
pass
|
|
part.our_mount = ""
|
|
part.os_kind = ""
|
|
|
|
|
|
def unmount_all(parts: list[Partition]) -> None:
|
|
for p in parts:
|
|
try:
|
|
unmount(p)
|
|
except Exception:
|
|
pass
|