first commit

This commit is contained in:
duffyduck
2026-10-22 12:32:47 +02:00
commit fd3fe19539
18 changed files with 1029 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
"""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