extract_all: CIFS-safe fallback extraction via /var/tmp with cp437 filename repair; delete macOS AppleDouble junk

This commit is contained in:
2026-10-06 16:51:33 +02:00
parent 624b11cc0f
commit 2b5dadc0a8
+101 -22
View File
@@ -32,6 +32,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from collections import deque from collections import deque
from pathlib import Path from pathlib import Path
@@ -75,25 +76,51 @@ def free_bytes(path: Path) -> int:
return shutil.disk_usage(path).free return shutil.disk_usage(path).free
def extract(archive: Path, dest: Path) -> tuple[bool, str, Path]: def _fix_component(s: str) -> str:
"""7z x into dest. Empty -p avoids interactive prompts (no password). """Repair filenames 7z wrote with raw legacy bytes (cp437 zip entries).
Returns (ok, error, dest_used): on a name collision (the archive stem Raw non-UTF8 bytes in a name cannot be created on the CIFS share
exists as a *file*, e.g. 'model.stl.zip' next to 'model.stl') the (observed: errno 2 for a name containing byte 0x87). Decode the raw
destination gets a '_dup' suffix instead of crashing. bytes as cp437 so e.g. a raw-0x87 name becomes a proper 'ç' name.
""" """
actual = dest
for _ in range(5):
try: try:
actual.mkdir(parents=True, exist_ok=True) s.encode("utf-8")
break return s
except FileExistsError: except UnicodeEncodeError:
actual = dest.with_name(dest.name + "_dup") pass
except (NotADirectoryError, FileExistsError) as exc: raw = os.fsencode(s)
return False, f"cannot create destination {actual}: {exc}", dest try:
else: return raw.decode("utf-8")
return False, f"cannot create destination {dest} (repeated collisions)", dest except UnicodeDecodeError:
cmd = ["7z", "x", "-y", "-p", f"-o{actual}", str(archive)] return raw.decode("cp437", "replace")
def _move_tree(tmp: Path, dest: Path) -> int:
"""Move a freshly extracted local tree onto the (possibly CIFS) dest.
Returns the number of files that could not be moved."""
bad = 0
for f in sorted(tmp.rglob("*")):
rel = f.relative_to(tmp)
tgt = dest.joinpath(*(_fix_component(p) for p in rel.parts))
if f.is_dir():
tgt.mkdir(parents=True, exist_ok=True)
continue
tgt.parent.mkdir(parents=True, exist_ok=True)
try:
if tgt.exists() and tgt.is_file() \
and tgt.stat().st_size == f.stat().st_size:
f.unlink()
continue
if tgt.exists():
tgt.unlink()
shutil.move(str(f), str(tgt))
except OSError as exc:
bad += 1
log.error("Cannot place %s: %s", tgt, exc)
return bad
def _run_7z(cmd: list[str]) -> tuple[int, str]:
try: try:
# 7z prints legacy-encoded inner filenames (non-UTF8 bytes); decode # 7z prints legacy-encoded inner filenames (non-UTF8 bytes); decode
# tolerantly or the whole run crashes (seen with byte 0x87). # tolerantly or the whole run crashes (seen with byte 0x87).
@@ -102,15 +129,54 @@ def extract(archive: Path, dest: Path) -> tuple[bool, str, Path]:
encoding="utf-8", errors="replace", timeout=EXTRACT_TIMEOUT, encoding="utf-8", errors="replace", timeout=EXTRACT_TIMEOUT,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return False, f"extraction timed out after {EXTRACT_TIMEOUT}s", actual return -1, f"extraction timed out after {EXTRACT_TIMEOUT}s"
except OSError as exc: except OSError as exc:
return False, f"7z failed to run: {exc}", actual return -1, f"7z failed to run: {exc}"
if proc.returncode in (0, 1): # 0 ok, 1 warnings (e.g. minor CRC) tail = "\n".join((proc.stdout or "").splitlines()[-5:])
if proc.returncode == 1: return proc.returncode, tail
def extract(archive: Path, dest: Path) -> tuple[bool, str, Path]:
"""7z x into dest. Empty -p avoids interactive prompts (no password).
Returns (ok, error, dest_used): on a name collision (the archive stem
exists as a *file*, e.g. 'model.stl.zip' next to 'model.stl') the
destination gets a '_dup' suffix instead of crashing.
If 7z cannot write directly onto the (CIFS) destination - legacy
cp437 entry names - the archive is extracted to a local temp dir and
moved over with repaired filenames.
"""
actual = dest
for _ in range(5):
try:
actual.mkdir(parents=True, exist_ok=True)
break
except FileExistsError:
actual = dest.with_name(dest.name + "_dup")
except NotADirectoryError as exc:
return False, f"cannot create destination {actual}: {exc}", dest
else:
return False, f"cannot create destination {dest} (repeated collisions)", dest
rc, tail = _run_7z(["7z", "x", "-y", "-p", f"-o{actual}", str(archive)])
if rc in (0, 1): # 0 ok, 1 warnings (e.g. minor CRC)
if rc == 1:
log.warning("7z reported warnings extracting %s", archive.name) log.warning("7z reported warnings extracting %s", archive.name)
return True, "", actual return True, "", actual
tail = "\n".join((proc.stdout or "").splitlines()[-5:]) if rc == -1:
return False, f"7z exit {proc.returncode}: {tail}", actual return False, tail, actual
# Fallback: local extraction, then move with repaired filenames.
tmp = Path(tempfile.mkdtemp(prefix="7zext-", dir="/var/tmp"))
try:
rc2, tail2 = _run_7z(["7z", "x", "-y", "-p", f"-o{tmp}", str(archive)])
if rc2 not in (0, 1):
return False, f"7z exit {rc2}: {tail2}", actual
bad = _move_tree(tmp, actual)
if bad:
return False, f"{bad} files could not be moved onto the share", actual
return True, "", actual
finally:
shutil.rmtree(tmp, ignore_errors=True)
def move_to_broken(path: Path, out_root: Path, src_root: Path, note: str) -> Path: def move_to_broken(path: Path, out_root: Path, src_root: Path, note: str) -> Path:
@@ -122,6 +188,11 @@ def move_to_broken(path: Path, out_root: Path, src_root: Path, note: str) -> Pat
return shutil.move(str(path), str(dest)) return shutil.move(str(path), str(dest))
def is_macosx_junk(p: Path) -> bool:
"""AppleDouble resource-fork 'zips' (.__MACOSX/._x.zip) - no content."""
return "__MACOSX" in p.parts or p.name.startswith("._")
def expand_nested(dest: Path, out_root: Path, manifest, src_root: Path, def expand_nested(dest: Path, out_root: Path, manifest, src_root: Path,
done: set[str]) -> int: done: set[str]) -> int:
"""Extract archives inside `dest` until none remain. Returns count.""" """Extract archives inside `dest` until none remain. Returns count."""
@@ -132,6 +203,10 @@ def expand_nested(dest: Path, out_root: Path, manifest, src_root: Path,
for p in sorted(cur.rglob("*")): for p in sorted(cur.rglob("*")):
if not p.is_file() or not is_archive(p): if not p.is_file() or not is_archive(p):
continue continue
if is_macosx_junk(p):
p.unlink()
log.info("Junk (macOS AppleDouble) removed: %s", p.name)
continue
stem_dir = p.with_name(display_unit(p.name)) stem_dir = p.with_name(display_unit(p.name))
if str(stem_dir.relative_to(out_root)) in done: if str(stem_dir.relative_to(out_root)) in done:
# already extracted in a previous run; drop the archive copy # already extracted in a previous run; drop the archive copy
@@ -240,6 +315,10 @@ def sweep(out_root: Path, manifest, done: set[str]) -> int:
continue continue
if ".broken" in p.relative_to(out_root).parts: if ".broken" in p.relative_to(out_root).parts:
continue continue
if is_macosx_junk(p):
p.unlink()
log.info("Junk (macOS AppleDouble) removed: %s", p.name)
continue
stem_dir = p.with_name(display_unit(p.name)) stem_dir = p.with_name(display_unit(p.name))
if str(stem_dir.relative_to(out_root)) in done: if str(stem_dir.relative_to(out_root)) in done:
p.unlink() p.unlink()