diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f50e8..f57fe0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ 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 +- 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 unverändert zurück. 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..5431dfb 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 value for ch in '/\\:\0') + + 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,17 +2988,19 @@ 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) @@ -2952,8 +3008,8 @@ def save_snippet(self, topic, name, content): 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 +3495,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'): diff --git a/README.md b/README.md index 68b2dc5..e49d4bc 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-126%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..afd8b3d 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-126%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..cc739cf 100644 --- a/tests/test_pythonbox_regressions.py +++ b/tests/test_pythonbox_regressions.py @@ -458,6 +458,100 @@ 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) + + 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."""