diff --git a/MethodenAnalyser3.py b/MethodenAnalyser3.py index c06dd5a..847be23 100644 --- a/MethodenAnalyser3.py +++ b/MethodenAnalyser3.py @@ -11,7 +11,9 @@ import pkgutil import re import sqlite3 +import stat import sys +import tempfile import threading import tkinter as tk import warnings @@ -1490,10 +1492,9 @@ def _remove_unused_imports(source: str, tree: ast.AST, unused_set: set[str]) -> """Remove AST import spans without deleting neighboring executable code.""" raw = source.encode("utf-8") offsets = [0] - # AST columns count UTF-8 bytes; only LF advances AST line numbers. - # str.splitlines() would incorrectly count form-feed as a new line. - for line in raw.split(b"\n")[:-1]: - offsets.append(offsets[-1] + len(line) + 1) + # AST columns count UTF-8 bytes. Recognize physical CR/LF boundaries + # without counting form-feed or rewriting untouched newline sequences. + offsets.extend(match.end() for match in re.finditer(rb"\r\n|\r|\n", raw)) nodes = sorted(_unused_import_nodes(tree, unused_set), key=lambda node: (node.lineno, node.col_offset), reverse=True) changed_lines: set[int] = set() @@ -1519,6 +1520,64 @@ def _remove_unused_imports(source: str, tree: ast.AST, unused_set: set[str]) -> return updated, changed_lines +def _stage_auto_fix_bytes(path: pathlib.Path, data: bytes) -> pathlib.Path: + """Fully write and flush a same-directory temporary file.""" + staged = None + complete = False + try: + with tempfile.NamedTemporaryFile(mode="wb", dir=path.parent, + prefix=f".{path.name}.autofix-", delete=False) as handle: + staged = pathlib.Path(handle.name) + if handle.write(data) != len(data): + raise OSError("Incomplete Auto-Fix write") + handle.flush() + os.fsync(handle.fileno()) + complete = True + return staged + finally: + if staged is not None and not complete: + staged.unlink(missing_ok=True) + + +def _commit_auto_fix(path: str, original: bytes, updated: bytes) -> str: + """Publish a complete backup exclusively, then atomically replace the source.""" + source = pathlib.Path(path).resolve(strict=True) + mode = stat.S_IMODE(source.stat().st_mode) + staged_source = staged_backup = None + try: + staged_source = _stage_auto_fix_bytes(source, updated) + staged_backup = _stage_auto_fix_bytes(source, original) + if source.read_bytes() != original: + raise OSError("Source changed during Auto-Fix") + index = 0 + while True: + suffix = f".bak.{index}" if index else ".bak" + backup = pathlib.Path(str(source) + suffix) + try: + # Same-directory hard-link publication is atomic and exclusive: + # an existing or concurrently created recovery copy is preserved. + os.link(staged_backup, backup) + except FileExistsError: + index += 1 + else: + break + os.chmod(staged_source, mode) + if source.read_bytes() != original: + raise OSError("Source changed during Auto-Fix") + os.replace(staged_source, source) + return str(backup) + finally: + for staged in (staged_source, staged_backup): + if staged is not None: + try: + # A staged source may carry the original read-only flag. + # Clear it only on our temporary path before cleanup. + staged.chmod(stat.S_IRUSR | stat.S_IWUSR) + except FileNotFoundError: + continue + staged.unlink(missing_ok=True) + + def auto_fix_unused_imports(output_widget: scrolledtext.ScrolledText, status_widget: tk.Label | None = None) -> None: """ Entfernt ungenutzte Imports aus der zuletzt analysierten Datei. @@ -1551,24 +1610,22 @@ def auto_fix_unused_imports(output_widget: scrolledtext.ScrolledText, status_wid try: # Datei lesen mit Encoding-Fallback — erkanntes Encoding für Schreibzugriff merken detected_encoding = "utf-8" + original_bytes = pathlib.Path(_last_analysis_path).read_bytes() try: - with open(_last_analysis_path, "r", encoding="utf-8") as f: - lines = f.readlines() + original_text = original_bytes.decode("utf-8") except UnicodeDecodeError: detected_encoding = "latin-1" - with open(_last_analysis_path, "r", encoding="latin-1") as f: - lines = f.readlines() - - # AST parsen (readlines() beibehalten — splitlines() würde bei \x0c - # Zeilennummern gegenüber AST-lineno verschieben und falsche Zeilen löschen) - tree = ast.parse("".join(lines)) + original_text = original_bytes.decode("latin-1") + # Parse the original text; physical byte offsets preserve mixed + # newline sequences and avoid treating form-feed as a new line. + tree = ast.parse(original_text) # Import-Zeilen markieren die entfernt werden sollen # Findings may be stale after an editor change or the confirmation # dialog. Remove only names approved earlier AND still unused now. - current_result = analyze_source("".join(lines), _last_analysis_path) + current_result = analyze_source(original_text, _last_analysis_path) unused_set = set(_last_analysis_result.unused_imports) & set(current_result.unused_imports) - updated_source, lines_to_remove = _remove_unused_imports("".join(lines), tree, unused_set) + updated_source, lines_to_remove = _remove_unused_imports(original_text, tree, unused_set) if not lines_to_remove: if status_widget is not None: @@ -1581,13 +1638,8 @@ def auto_fix_unused_imports(output_widget: scrolledtext.ScrolledText, status_wid # Backup und Ausgabe im erkannten Encoding — verhindert Korrumpierung von # latin-1-Dateien mit nicht-ASCII-Zeichen und # coding: latin-1 Deklaration - backup_path = _last_analysis_path + ".bak" - with open(backup_path, "w", encoding=detected_encoding) as f: - f.writelines(lines) - - # Neue Datei ohne ungenutzte Imports - with open(_last_analysis_path, "w", encoding=detected_encoding) as f: - f.write(updated_source) + updated_bytes = updated_source.encode(detected_encoding) + backup_path = _commit_auto_fix(_last_analysis_path, original_bytes, updated_bytes) # Ausgabe output_widget.insert(tk.END, f"\n\n{_t('gui_autofix_success_header')}\n") diff --git a/README.md b/README.md index 5d0a298..96db3f3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Version 3.0.2 CI Status - Tests 100% Green + Tests 100% Green Python 3.10-3.13 Cross-Platform GUI Desktop + CLI + PWA @@ -173,7 +173,7 @@ MethodenAnalyser enforces 10 strict architectural, runtime, and security invaria | **INV-ENCODING-04** | Lossless Multi-Encoding Resilience | Default UTF-8 reading with automatic Latin-1 fallback. Never corrupts original character encodings, UTF-8 BOMs, or German umlauts. | | **INV-ISOLATION-05** | Loopback Web Companion Isolation | Local helper companion binds strictly to `127.0.0.1` by default. Deliberate `--host 0.0.0.0` LAN test mode requires explicit user configuration. | | **INV-TRAVERSAL-06** | Anti-Path-Traversal & ZIP Safety | Web companion rejects path traversal (`..`), bounds member counts, and enforces maximum archive byte limits to prevent ZIP bombs. | -| **INV-AUTOFIX-07** | Reversible Auto-Fix with Backup | Auto-Fix requires explicit confirmation, rechecks current source usage and creates a `.bak` backup before writing modifications. | +| **INV-AUTOFIX-07** | Reversible Auto-Fix with Backup | Auto-Fix requires explicit confirmation, rechecks current source usage and publishes a byte-exact backup without replacing existing copies (`.bak`, `.bak.1`, …), then atomically replaces the source from a flushed temporary file. Detected source changes abort the write; encoding, newline style and file mode are retained. | | **INV-PLATFORM-08** | Multi-Platform Parity | 100% standard library core verified across Windows Server 2025, Ubuntu Linux, and macOS 26. | | **INV-SYNC-09** | Cloud-Sync & Multi-Agent Lock Safety | Fails closed on lock detection (`LOCK.*`), ignores cloud sync conflict files (`*-conflict-*`), preventing concurrency races. | | **INV-SLA-10** | 48h Security SLA & 5-Day Triage | Strict vulnerability commitment: 48-hour initial response SLA and 5-business-day triage commitment with coordinated disclosure. | diff --git a/README_de.md b/README_de.md index 366c144..c2b96c0 100644 --- a/README_de.md +++ b/README_de.md @@ -5,7 +5,7 @@

Version 3.0.2 CI-Status - Tests 100% Grün + Tests 100% Grün Python 3.10-3.13 Plattformübergreifend GUI Desktop + CLI + PWA @@ -173,7 +173,7 @@ MethodenAnalyser garantiert 10 verbindliche Architektur-, Laufzeit- und Sicherhe | **INV-ENCODING-04** | Verlustfreie Multi-Encoding-Resilienz | Liest Dateien standardmäßig als UTF-8 mit automatischem Latin-1 Fallback. Verändert niemals Original-Encodings oder deutsche Umlaute. | | **INV-ISOLATION-05** | Loopback Web-Begleiter-Isolation | Der lokale Hilfsserver bindet sich standardmäßig ausschließlich an `127.0.0.1`. Der LAN-Modus `--host 0.0.0.0` erfordert explizite Aktivierung. | | **INV-TRAVERSAL-06** | Pfad-Traversal- & ZIP-Sicherheit | Der Web-Begleiter blockiert Directory-Traversal (`..`), begrenzt Dateizahlen und unkomprimierte Byte-Mengen zur Abwehr von ZIP-Bomben. | -| **INV-AUTOFIX-07** | Reversibler Auto-Fix mit Backup | Auto-Fix erfordert eine explizite Bestätigung, prüft die aktuelle Verwendung erneut und legt vor jeder Änderung eine `.bak`-Sicherheitskopie an. | +| **INV-AUTOFIX-07** | Reversibler Auto-Fix mit Backup | Auto-Fix erfordert eine explizite Bestätigung, prüft die aktuelle Verwendung erneut und veröffentlicht eine bytegenaue Sicherung ohne bestehende Backups zu überschreiben (`.bak`, `.bak.1`, …) und ersetzt die Quelle anschließend atomar aus einer vollständig geschriebenen temporären Datei. Erkannte Quelländerungen brechen den Schreibvorgang ab; Kodierung, Zeilenumbruchstil und Dateimodus bleiben erhalten. | | **INV-PLATFORM-08** | Plattformübergreifende Parität | Vollständige Standardbibliothek-Kompatibilität, verifiziert auf Windows Server 2025, Ubuntu Linux und macOS 26. | | **INV-SYNC-09** | Cloud-Sync- & Lock-Resilienz | Beachtet Dateisperren (`LOCK.*`) im Fail-Closed-Verfahren und ignoriert Cloud-Sync-Konfliktdateien (`*-conflict-*`). | | **INV-SLA-10** | 48h Sicherheits-SLA & 5-Tage-Triage | Verbindliches Sicherheitsversprechen: Eingangsbestätigung binnen 48 Stunden, Triage und Behebungsplan binnen 5 Werktagen. | diff --git a/llms.txt b/llms.txt index d9048e9..ea5b237 100644 --- a/llms.txt +++ b/llms.txt @@ -35,7 +35,7 @@ The tool strictly adheres to 10 architectural and security invariants: - INV-ENCODING-04: Lossless Multi-Encoding Resilience (UTF-8 default with Latin-1 fallback) - INV-ISOLATION-05: Loopback Web Companion Isolation (binds to 127.0.0.1 by default) - INV-TRAVERSAL-06: Path Traversal & ZIP Safety Bounds (blocks `..` and bounds member sizes) -- INV-AUTOFIX-07: Reversible Auto-Fix with `.bak` Backup; current source is rechecked after confirmation, and only approved imports that remain unused are removed. Import spans preserve neighboring statements; empty suites retain pass, and the result is syntax-checked before writing. +- INV-AUTOFIX-07: Reversible Auto-Fix with `.bak` Backup; current source is rechecked after confirmation, and only approved imports that remain unused are removed. Import spans preserve neighboring statements; empty suites retain pass, and the result is syntax-checked before writing. Auto-Fix stages and flushes complete byte payloads, exclusively publishes numbered backups via same-directory hard links and atomically replaces the source. Detected source changes abort; original bytes remain in the backup. Unsupported backup publication fails before source replacement. - INV-PLATFORM-08: Cross-Platform Parity across Windows, Linux, and macOS - INV-SYNC-09: Cloud-Sync & Multi-Agent Lock Resilience (fail-closed against `LOCK.*`) - INV-SLA-10: 48h Initial Response & 5-Business-Day Triage SLA diff --git a/tests/test_autofix_atomic_write.py b/tests/test_autofix_atomic_write.py new file mode 100644 index 0000000..dff83f3 --- /dev/null +++ b/tests/test_autofix_atomic_write.py @@ -0,0 +1,157 @@ +"""Auto-fix must preserve source bytes and existing recovery copies on failure.""" +import stat +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +import MethodenAnalyser3 as m + + +def prepare(monkeypatch, path, raw): + path.write_bytes(raw) + monkeypatch.setattr(m, "_last_analysis_path", str(path)) + monkeypatch.setattr(m, "_last_analysis_result", m.analyze_file(str(path))) + dialogs = Mock() + dialogs.askyesno.return_value = True + monkeypatch.setattr(m, "messagebox", dialogs) + return dialogs + + +def test_existing_backup_is_not_overwritten(monkeypatch, tmp_path): + path = tmp_path / "source.py" + raw = b"import os\nanswer = 42\n" + dialogs = prepare(monkeypatch, path, raw) + backup = path.with_suffix(".py.bak") + backup.write_bytes(b"older recovery copy") + m.auto_fix_unused_imports(Mock()) + dialogs.showerror.assert_not_called() + assert path.read_text(encoding="utf-8") == "answer = 42\n" + assert backup.read_bytes() == b"older recovery copy" + assert path.with_suffix(".py.bak.1").read_bytes() == raw + + +@pytest.mark.parametrize("mode", [0o644, 0o444]) +def test_publication_failure_preserves_original(monkeypatch, tmp_path, mode): + path = tmp_path / "source.py" + raw = b"import os\nanswer = 42\n" + dialogs = prepare(monkeypatch, path, raw) + path.chmod(mode) + original_mode = stat.S_IMODE(path.stat().st_mode) + def fail(*args): + raise OSError("publication failed") + monkeypatch.setattr(m.os, "replace", fail) + m.auto_fix_unused_imports(Mock()) + assert path.read_bytes() == raw + assert stat.S_IMODE(path.stat().st_mode) == original_mode + assert dialogs.showerror.call_count == 1 + dialogs.showinfo.assert_not_called() + assert not list(tmp_path.glob(".*.autofix-*")) + path.chmod(0o644) + + +@pytest.mark.parametrize("newline", [b"\r\n", b"\n", b"\r"]) +def test_backup_preserves_exact_newline_bytes(monkeypatch, tmp_path, newline): + path = tmp_path / "source.py" + raw = b"import os" + newline + b"answer = 42" + newline + dialogs = prepare(monkeypatch, path, raw) + m.auto_fix_unused_imports(Mock()) + dialogs.showerror.assert_not_called() + assert path.with_suffix(".py.bak").read_bytes() == raw + assert path.read_bytes() == b"answer = 42" + newline + + +@pytest.mark.parametrize("failure", ["partial", "fsync", "backup_link"]) +def test_staging_failure_preserves_source_and_existing_backup(monkeypatch, tmp_path, failure): + path = tmp_path / "source.py" + raw = b"import os\nanswer = 42\n" + dialogs = prepare(monkeypatch, path, raw) + backup = path.with_suffix(".py.bak") + backup.write_bytes(b"older backup") + def fail(*args): + raise OSError("injected failure") + if failure == "partial": + real_temp = m.tempfile.NamedTemporaryFile + @contextmanager + def partial_temp(*args, **kwargs): + with real_temp(*args, **kwargs) as handle: + yield SimpleNamespace(name=handle.name, flush=handle.flush, fileno=handle.fileno, + write=lambda data: handle.write(data[:len(data) // 2])) + monkeypatch.setattr(m.tempfile, "NamedTemporaryFile", partial_temp) + elif failure == "fsync": + monkeypatch.setattr(m.os, "fsync", fail) + else: + monkeypatch.setattr(m.os, "link", fail) + m.auto_fix_unused_imports(Mock()) + assert path.read_bytes() == raw + assert backup.read_bytes() == b"older backup" + assert dialogs.showerror.call_count == 1 + dialogs.showinfo.assert_not_called() + assert not list(tmp_path.glob(".*.autofix-*")) + + +def test_concurrently_created_backup_is_preserved(monkeypatch, tmp_path): + path = tmp_path / "source.py" + raw = b"import os\nanswer = 42\n" + dialogs = prepare(monkeypatch, path, raw) + real_link = m.os.link + backup = path.with_suffix(".py.bak") + def competing_link(source, target): + if target == backup: + backup.write_bytes(b"other writer") + real_link(source, target) + monkeypatch.setattr(m.os, "link", competing_link) + m.auto_fix_unused_imports(Mock()) + dialogs.showerror.assert_not_called() + assert backup.read_bytes() == b"other writer" + assert path.with_suffix(".py.bak.1").read_bytes() == raw + assert path.read_bytes() == b"answer = 42\n" + + +def test_detected_source_change_is_not_overwritten(monkeypatch, tmp_path): + path = tmp_path / "source.py" + raw = b"import os\nanswer = 42\n" + newer = b"import os\nprint(os.getcwd())\n" + dialogs = prepare(monkeypatch, path, raw) + real_link = m.os.link + def edit_during_backup(source, target): + real_link(source, target) + path.write_bytes(newer) + monkeypatch.setattr(m.os, "link", edit_during_backup) + m.auto_fix_unused_imports(Mock()) + assert path.read_bytes() == newer + assert path.with_suffix(".py.bak").read_bytes() == raw + assert dialogs.showerror.call_count == 1 + dialogs.showinfo.assert_not_called() + assert not list(tmp_path.glob(".*.autofix-*")) + + +def test_success_preserves_file_mode(monkeypatch, tmp_path): + path = tmp_path / "source.py" + dialogs = prepare(monkeypatch, path, b"import os\nanswer = 42\n") + path.chmod(0o755) + original_mode = stat.S_IMODE(path.stat().st_mode) + m.auto_fix_unused_imports(Mock()) + dialogs.showerror.assert_not_called() + assert stat.S_IMODE(path.stat().st_mode) == original_mode + + +def test_removing_only_import_can_publish_empty_file(monkeypatch, tmp_path): + path = tmp_path / "source.py" + raw = b"import os\n" + dialogs = prepare(monkeypatch, path, raw) + m.auto_fix_unused_imports(Mock()) + dialogs.showerror.assert_not_called() + assert path.read_bytes() == b"" + assert path.with_suffix(".py.bak").read_bytes() == raw + + +def test_mixed_newlines_preserve_untouched_bytes(monkeypatch, tmp_path): + path = tmp_path / "source.py" + raw = b"import os\r\nanswer = 42\nlabel = 'ok'\r" + dialogs = prepare(monkeypatch, path, raw) + m.auto_fix_unused_imports(Mock()) + dialogs.showerror.assert_not_called() + assert path.read_bytes() == b"answer = 42\nlabel = 'ok'\r" + assert path.with_suffix(".py.bak").read_bytes() == raw