diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f50e8..f94664d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.1.0/). - Neue automatisierte Vertragstests in `tests/test_metadata.py` zur Verifikation von NOTICE, bilingualer 18-Punkte-Parität, dualen Mermaid-Diagrammen, 20/20 Keywords und Version Freeze (`1.0.1`). ### Behoben / Fixed +- Kein Snippet-Verlust mehr beim Umbenennen: `edit_library_item` löschte den alten Eintrag, bevor der neue gespeichert war, und ignorierte das Speicherergebnis. Lehnt die Namensprüfung den neuen Namen ab (z. B. `Regex: Gruppen` oder `Kap. 1.`), war das Snippet weg. `LibraryManager.rename_snippet` speichert jetzt zuerst und entfernt den alten Eintrag erst nach Erfolg; ein Fehler wird im Dialog gemeldet. +- Sicherheit: Snippet-Bibliothek akzeptiert nur noch einzelne, harmlose Pfadbestandteile als Themen- und Snippet-Namen (`LibraryManager._snippet_path`). Zuvor konnte eine importierte `pythonbox-snippets-v1`-Datei über Namen wie `../../ordner` oder absolute Pfade beliebige `.py`-Dateien außerhalb der Bibliothek überschreiben; solche Einträge werden jetzt übersprungen und als `skipped` gezählt. +- Zeilenenden bleiben beim Speichern erhalten: `MultiTabEditor.open_file` merkt sich das Zeilenende der Datei (`detect_newline`), `save_tab` schreibt es zurück (Dateien mit gemischten Zeilenenden werden auf das häufigere vereinheitlicht; ein UTF-8-BOM bleibt wie bisher nicht erhalten). Zuvor wurden LF-Dateien unter Windows beim Speichern still nach CRLF umgeschrieben, wodurch Git jede Zeile als geändert meldete. Neue, noch nie gespeicherte Dateien verwenden weiterhin den Plattformstandard. +- Keine `libpyside: Failed to disconnect`-Warnung mehr bei jedem Tab-Wechsel: `on_editor_changed` verbindet `cursorPositionInfo` jetzt mit `Qt.ConnectionType.UniqueConnection` statt vorab zu trennen. - Sichere Literalersetzung in `SearchReplaceBar.replace_all`: Case-insensitive Textersetzung verwendet jetzt einen Callable-Ersatz (`lambda _m: replace_text`) in `re.sub()`. Dies verhindert Abstürze mit `re.error: bad escape \u` bei Pfadangaben mit Backslashes (z. B. Windows-Pfade wie `C:\Users\...`) sowie Fehlinterpretationen von Regex-Gruppenreferenzen und stellt identisches Literal-Verhalten wie bei case-sensitiver Ersetzung sicher. ## [1.0.1] - 2026-09-18 diff --git a/PythonBox_v8.py b/PythonBox_v8.py index 7095b52..a505013 100644 --- a/PythonBox_v8.py +++ b/PythonBox_v8.py @@ -107,6 +107,19 @@ def build_external_python_command(script_path: Path) -> List[str]: ) +def detect_newline(text: str) -> Optional[str]: + """Ermittelt das vorherrschende Zeilenende eines Textes. + + Gibt ``"\r\n"`` oder ``"\n"`` zurück, bzw. ``None``, wenn der Text keinen + Zeilenumbruch enthält (dann gilt beim Speichern der Plattformstandard). + """ + crlf = text.count('\r\n') + lf = text.count('\n') - crlf + if crlf == 0 and lf == 0: + return None + return '\r\n' if crlf >= lf else '\n' + + def _utc_export_timestamp() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -2608,6 +2621,8 @@ def __init__(self, editor: CodeEditor, highlighter: PythonSyntaxHighlighter, self.highlighter = highlighter self.file_path = file_path self.is_modified = False + # Zeilenende der geöffneten Datei ("\n" oder "\r\n"); None = Plattformstandard. + self.newline: Optional[str] = None class MultiTabEditor(QWidget): @@ -2754,7 +2769,14 @@ def save_tab(self, index: int) -> bool: tab.file_path = file_path try: - Path(tab.file_path).write_text(tab.editor.toPlainText(), encoding='utf-8') + text = tab.editor.toPlainText() + if tab.newline: + # Zeilenenden der Originaldatei beibehalten (kein stilles LF -> CRLF). + Path(tab.file_path).write_text( + text.replace('\n', tab.newline), encoding='utf-8', newline='' + ) + else: + Path(tab.file_path).write_text(text, encoding='utf-8') tab.editor.document().setModified(False) self.tab_widget.setTabText(index, Path(tab.file_path).name) return True @@ -2771,7 +2793,10 @@ def open_file(self, file_path: str, replace_current_untitled: bool = False) -> i return idx try: - content = Path(file_path).read_text(encoding='utf-8') + with open(file_path, 'r', encoding='utf-8', newline='') as handle: + raw = handle.read() + newline = detect_newline(raw) + content = raw.replace('\r\n', '\n').replace('\r', '\n') if replace_current_untitled and self.tab_widget.count() == 1: current_index = self.tab_widget.currentIndex() current_tab = self.tabs.get(current_index) @@ -2784,11 +2809,15 @@ def open_file(self, file_path: str, replace_current_untitled: bool = False) -> i current_tab.editor.setPlainText(content) current_tab.editor.document().setModified(False) current_tab.file_path = file_path + current_tab.newline = newline self.tab_widget.setTabText(current_index, Path(file_path).name) self.tab_widget.setCurrentIndex(current_index) return current_index - return self.new_tab(file_path, content) + index = self.new_tab(file_path, content) + if index in self.tabs: + self.tabs[index].newline = newline + return index except Exception as e: QMessageBox.critical(self, "Fehler", f"Öffnen fehlgeschlagen: {e}") return -1 @@ -2913,6 +2942,31 @@ def _save_locks(self): json.dump(list(self.locked_items), f) os.replace(tmp, self.lock_file) + @staticmethod + def _is_safe_component(value) -> bool: + """Prüft, ob ``value`` ein einzelner, harmloser Pfadbestandteil ist. + + Themen- und Snippet-Namen stammen aus Dialogen und aus importierten + JSON-Dateien. Ohne diese Prüfung könnte ein Name wie ``../../x`` oder + ein absoluter Pfad Dateien außerhalb der Bibliothek überschreiben. + """ + if not isinstance(value, str) or not value or value != value.strip(): + return False + if value in (".", "..") or value.endswith("."): + return False + return not any(ch in '/\\:' or ch < ' ' for ch in value) + + def _snippet_path(self, topic, name) -> Optional[Path]: + """Liefert den Snippet-Pfad oder ``None``, wenn er die Bibliothek verließe.""" + if not (self._is_safe_component(topic) and self._is_safe_component(name)): + return None + path = self.root / topic / f"{name}.py" + try: + path.resolve().relative_to(self.root.resolve()) + except (OSError, ValueError): + return None + return path + def is_locked(self, topic, name): return f"{topic}/{name}" in self.locked_items @@ -2934,26 +2988,49 @@ def get_items(self, topic): return sorted([f.stem for f in path.glob("*.py")]) def get_content(self, topic, name): - path = self.root / topic / f"{name}.py" - if path.exists(): return path.read_text(encoding='utf-8') + path = self._snippet_path(topic, name) + if path is not None and path.exists(): return path.read_text(encoding='utf-8') return "" def save_snippet(self, topic, name, content): if self.is_locked(topic, name): return False, "Eintrag ist gesperrt." + path = self._snippet_path(topic, name) + if path is None: + return False, "Ungültiger Themen- oder Snippet-Name." try: - topic_dir = self.root / topic - topic_dir.mkdir(exist_ok=True) - (topic_dir / f"{name}.py").write_text(content, encoding='utf-8') + path.parent.mkdir(exist_ok=True) + path.write_text(content, encoding='utf-8') return True, "Gespeichert." except Exception as e: return False, str(e) + def rename_snippet(self, old_topic, old_name, topic, name, content): + """Speichert unter dem neuen Namen und entfernt erst danach den alten Eintrag. + + Scheitert das Speichern (ungültiger Name, gesperrtes Ziel, Schreibfehler), + bleibt der alte Eintrag unverändert erhalten. + """ + old_path = self._snippet_path(old_topic, old_name) + ok, msg = self.save_snippet(topic, name, content) + if not ok: + return False, msg + new_path = self._snippet_path(topic, name) + same_file = False + if old_path is not None and new_path is not None and old_path.exists(): + try: + same_file = os.path.samefile(old_path, new_path) + except OSError: + same_file = False + if not same_file: + self.delete_item(old_topic, old_name) + return True, msg + def delete_item(self, topic, name): if self.is_locked(topic, name): return False - path = self.root / topic / f"{name}.py" - if path.exists(): + path = self._snippet_path(topic, name) + if path is not None and path.exists(): os.remove(path) key = f"{topic}/{name}" if key in self.locked_items: @@ -3439,12 +3516,11 @@ def on_editor_changed(self, editor: CodeEditor): """Wird aufgerufen wenn der aktive Editor wechselt""" if editor: self.search_bar.set_editor(editor) - # Disconnect first to avoid duplicate connections when switching back - try: - editor.cursorPositionInfo.disconnect(self.update_cursor_position) - except RuntimeError: - pass - editor.cursorPositionInfo.connect(self.update_cursor_position) + # UniqueConnection verhindert Doppelverbindungen beim Zurückwechseln, + # ohne die libpyside-Warnung eines fehlgeschlagenen disconnect(). + editor.cursorPositionInfo.connect( + self.update_cursor_position, Qt.ConnectionType.UniqueConnection + ) # NEU v7: Git-Status aktualisieren (nur wenn git_label existiert) if hasattr(self, 'git_label'): @@ -4142,9 +4218,11 @@ def edit_library_item(self, item): if data['locked']: QMessageBox.warning(self, "Gesperrt", "Namen ändern bei gesperrten Items nicht erlaubt.") return - self.lib_manager.delete_item(data['topic'], data['name']) - - self.lib_manager.save_snippet(t, n, c) + ok, msg = self.lib_manager.rename_snippet(data['topic'], data['name'], t, n, c) + else: + ok, msg = self.lib_manager.save_snippet(t, n, c) + if not ok: + QMessageBox.warning(self, "Fehler", msg) self.load_library_tree() def delete_library_item(self, item): diff --git a/README.md b/README.md index 68b2dc5..ed0577c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/) [![PySide6](https://img.shields.io/badge/PySide6-6.5+-green.svg)](https://pypi.org/project/PySide6/) [![PythonBox tests](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml/badge.svg)](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml) -[![Tests](https://img.shields.io/badge/Tests-121%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/) +[![Tests](https://img.shields.io/badge/Tests-131%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/) [![Security](https://img.shields.io/badge/Security-RunAsInvoker-success.svg)](SECURITY.md) [![Zero-Egress](https://img.shields.io/badge/Zero--Egress-100%25%20Offline-success.svg)](SECURITY.md) [![SLA](https://img.shields.io/badge/SLA-48h%20Response-blue.svg)](SECURITY.md) diff --git a/README_de.md b/README_de.md index b3c890e..ce199a5 100644 --- a/README_de.md +++ b/README_de.md @@ -10,7 +10,7 @@ [![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/) [![PySide6](https://img.shields.io/badge/PySide6-6.5+-green.svg)](https://pypi.org/project/PySide6/) [![PythonBox tests](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml/badge.svg)](https://github.com/dev-bricks/pythonbox/actions/workflows/tests.yml) -[![Tests](https://img.shields.io/badge/Tests-121%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/) +[![Tests](https://img.shields.io/badge/Tests-131%20passed%20%7C%20100%25%20green-brightgreen.svg)](tests/) [![Security](https://img.shields.io/badge/Security-RunAsInvoker-success.svg)](SECURITY.md) [![Zero-Egress](https://img.shields.io/badge/Zero--Egress-100%25%20Offline-success.svg)](SECURITY.md) [![SLA](https://img.shields.io/badge/SLA-48h%20Response-blue.svg)](SECURITY.md) diff --git a/tests/test_pythonbox_regressions.py b/tests/test_pythonbox_regressions.py index 1b00cfb..2ab9644 100644 --- a/tests/test_pythonbox_regressions.py +++ b/tests/test_pythonbox_regressions.py @@ -458,6 +458,103 @@ def test_snippet_library_json_roundtrip_preserves_content_and_locks(self): self.assertEqual("print('eins')\n", imported_manager.get_content("Tests", "Alpha")) self.assertTrue(imported_manager.is_locked("Tests", "Alpha")) + def test_snippet_import_rejects_names_that_escape_the_library(self): + """Importierte Themen-/Snippet-Namen dürfen die Bibliothek nicht verlassen.""" + module = load_pythonbox_module() + + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "library" + outside = Path(temp_dir) / "outside" + outside.mkdir() + victim = outside / "victim.py" + victim.write_text("original\n", encoding="utf-8") + manager = module.LibraryManager(root=root) + + payload = { + "schema": module.SNIPPET_EXPORT_SCHEMA, + "topics": [ + {"name": "../outside", "snippets": [{"name": "victim", "content": "x"}]}, + {"name": str(outside), "snippets": [{"name": "victim", "content": "x"}]}, + {"name": "Tests", "snippets": [ + {"name": "../../outside/victim", "content": "x"}, + {"name": "..", "content": "x"}, + {"name": "sub\\victim", "content": "x"}, + {"name": "Gut", "content": "print('ok')\n"}, + ]}, + ], + } + result = manager.import_payload(payload) + + self.assertEqual(1, result["imported"]) + self.assertEqual(5, result["skipped"]) + self.assertEqual("original\n", victim.read_text(encoding="utf-8")) + self.assertEqual("print('ok')\n", manager.get_content("Tests", "Gut")) + self.assertEqual(["victim.py"], sorted(f.name for f in outside.iterdir())) + + def test_snippet_file_operations_refuse_unsafe_names(self): + module = load_pythonbox_module() + + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "library" + victim = Path(temp_dir) / "victim.py" + victim.write_text("original\n", encoding="utf-8") + manager = module.LibraryManager(root=root) + + for bad_name in ("zeile\numbruch", "tab\tname", "nul\0"): + self.assertFalse(manager.save_snippet("Tests", bad_name, "x")[0]) + + success, message = manager.save_snippet("..", "victim", "x") + self.assertFalse(success) + self.assertIn("Ungültig", message) + self.assertEqual("", manager.get_content("..", "victim")) + self.assertFalse(manager.delete_item("..", "victim")) + self.assertEqual("original\n", victim.read_text(encoding="utf-8")) + + +class LineEndingRegressionTests(unittest.TestCase): + """Speichern darf die Zeilenenden einer geöffneten Datei nicht umschreiben.""" + + def test_detect_newline_reports_dominant_style(self): + module = load_pythonbox_module() + + self.assertIsNone(module.detect_newline("ohne umbruch")) + self.assertEqual("\n", module.detect_newline("a\nb\n")) + self.assertEqual("\r\n", module.detect_newline("a\r\nb\r\n")) + self.assertEqual("\n", module.detect_newline("a\nb\nc\r\n")) + + def _roundtrip(self, original: bytes) -> bytes: + module = load_pythonbox_module() + app = module.QApplication.instance() or module.QApplication([]) + + with tempfile.TemporaryDirectory() as temp_dir: + file_path = Path(temp_dir) / "sample.py" + file_path.write_bytes(original) + + editor = module.MultiTabEditor() + try: + index = editor.open_file(str(file_path)) + self.assertGreaterEqual(index, 0) + tab = editor.tabs[index] + self.assertNotIn("\r", tab.editor.toPlainText()) + tab.editor.appendPlainText("x = 1") + self.assertTrue(editor.save_tab(index)) + return file_path.read_bytes() + finally: + editor.deleteLater() + app.processEvents() + + def test_save_preserves_lf_line_endings(self): + saved = self._roundtrip(b"a = 1\nb = 2\n") + self.assertNotIn(b"\r", saved) + self.assertTrue(saved.startswith(b"a = 1\nb = 2\n")) + self.assertIn(b"x = 1", saved) + + def test_save_preserves_crlf_line_endings(self): + saved = self._roundtrip(b"a = 1\r\nb = 2\r\n") + self.assertTrue(saved.startswith(b"a = 1\r\nb = 2\r\n")) + self.assertIn(b"x = 1", saved) + self.assertEqual(saved.count(b"\n"), saved.count(b"\r\n")) + class MinimapToggleRegressionTests(unittest.TestCase): """Regression tests for toggle_minimap cleanup.""" @@ -1167,5 +1264,61 @@ def test_tree_drop_has_try_block(self): "tree_drop must use try/except when reading dropped .py file content") +class LibraryRenameRegressionTests(unittest.TestCase): + """Umbenennen darf ein Snippet nie verlieren, wenn der neue Name abgelehnt wird.""" + + def _manager(self, root): + module = load_pythonbox_module() + return module.LibraryManager(root=Path(root)) + + def test_rename_to_rejected_name_keeps_old_snippet(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + self.assertTrue(manager.save_snippet("Thema", "Alpha", "x = 1\n")[0]) + for bad in ("Regex: Gruppen", "Kap. 1.", "../flucht", ""): + with self.subTest(name=bad): + ok, msg = manager.rename_snippet("Thema", "Alpha", "Thema", bad, "x = 2\n") + self.assertFalse(ok) + self.assertTrue(msg) + self.assertEqual("x = 1\n", manager.get_content("Thema", "Alpha")) + + def test_rename_to_valid_name_moves_snippet(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + manager.save_snippet("Thema", "Alpha", "x = 1\n") + ok, _msg = manager.rename_snippet("Thema", "Alpha", "Anderes", "Beta", "x = 2\n") + self.assertTrue(ok) + self.assertEqual("x = 2\n", manager.get_content("Anderes", "Beta")) + self.assertFalse((Path(root) / "Thema" / "Alpha.py").exists()) + + def test_rename_to_locked_target_keeps_both(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + manager.save_snippet("Thema", "Alpha", "a\n") + manager.save_snippet("Thema", "Beta", "b\n") + manager.toggle_lock("Thema", "Beta") + ok, _msg = manager.rename_snippet("Thema", "Alpha", "Thema", "Beta", "neu\n") + self.assertFalse(ok) + self.assertEqual("a\n", manager.get_content("Thema", "Alpha")) + self.assertEqual("b\n", manager.get_content("Thema", "Beta")) + + def test_rename_onto_same_file_does_not_delete_it(self): + with tempfile.TemporaryDirectory() as root: + manager = self._manager(root) + manager.save_snippet("Thema", "Alpha", "a\n") + with mock.patch.object(os.path, "samefile", return_value=True): + ok, _msg = manager.rename_snippet("Thema", "Alpha", "Thema", "Alpha", "neu\n") + self.assertTrue(ok) + self.assertEqual("neu\n", manager.get_content("Thema", "Alpha")) + + def test_edit_dialog_uses_rename_snippet_and_reports_failure(self): + source = SOURCE.read_text(encoding="utf-8") + start = source.index("def edit_library_item") + body = source[start:source.index("def delete_library_item", start)] + self.assertIn("rename_snippet(", body) + self.assertNotIn("delete_item(", body) + self.assertIn("QMessageBox.warning(self, \"Fehler\", msg)", body) + + if __name__ == "__main__": unittest.main()