Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
87 changes: 71 additions & 16 deletions PythonBox_v8.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -2934,26 +2988,28 @@ 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 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:
Expand Down Expand Up @@ -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'):
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion README_de.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions tests/test_pythonbox_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading