"""Native FCStd label restoration and bounded untrusted-archive checks.""" import hashlib import json import os from pathlib import Path import sys import traceback import zipfile import FreeCAD as App _SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if _SOURCE.is_dir(): sys.path.insert(0, str(_SOURCE)) import qi_io def _hash(path): return hashlib.sha256(Path(path).read_bytes()).hexdigest() def run(output_dir): out = Path(output_dir).absolute() out.mkdir(parents=True, exist_ok=True) initial_docs = set(App.listDocuments()) active = App.ActiveDocument.Name if App.ActiveDocument else None report = {"status": "running", "passed": 0, "failed": 0, "skipped": 0, "checks": [], "artifacts": [], "source": qi_io.__file__} def check(name, fn): try: result = fn() report["checks"].append({"name": name, "passed": True, "status": "passed", "detail": result}) report["passed"] += 1 except Exception as exc: report["checks"].append({"name": name, "passed": False, "status": "failed", "error": str(exc), "traceback": traceback.format_exc()}) report["failed"] += 1 def native_label_roundtrip(): import Part # noqa: F401 doc = App.newDocument("QIIOFixture") body = doc.addObject("Part::Box", "Box") body.Label = "Брусок" doc.recompute() path = out / "ascii-part.FCStd" doc.saveAs(str(path)) label = "Деталь — русское имя № 1" doc.Label = label doc.save() name = doc.Name before = _hash(path) App.closeDocument(name) opened = App.openDocument(str(path)) native_label = opened.Label assert qi_io.restore_document_label(opened, path), "Saved native Label not found" assert opened.Label == label, "Saved Russian Label was not restored" assert qi_io.restore_document_label(opened, path), "Repeated valid restoration failed" assert opened.Label == label, "Repeated restoration changed Label" assert _hash(path) == before, "Restoration wrote the native project" report["artifacts"].append(str(path)) return {"native_open_label": native_label, "restored_label": opened.Label, "sha256": before, "file_unchanged": True} def missing_invalid_other_formats(): doc = App.newDocument("QIIOInvalid") doc.Label = "Оставить имя" bad = out / "broken.FCStd" bad.write_bytes(b"This is not a ZIP archive") other = out / "other.step" other.write_text("ISO-10303-21;", encoding="ascii") before_bad, before_other = _hash(bad), _hash(other) for path in (out / "missing.FCStd", bad, other, out / "missing.txt"): assert not qi_io.restore_document_label(doc, path), "Invalid archive accepted" assert doc.Label == "Оставить имя", "Failure changed live Label" assert _hash(bad) == before_bad and _hash(other) == before_other return {"rejected": 4, "files_and_label_unchanged": True} def gui_modified_state_and_history(): import FreeCADGui as Gui import qi_commands import qi_ui from PySide6 import QtTest path = out / "ascii-part.FCStd" assert path.is_file(), "Native label fixture missing" before = _hash(path) for name, candidate in list(App.listDocuments().items()): if name not in initial_docs and candidate.FileName and Path(candidate.FileName) == path: App.closeDocument(name) opened = App.openDocument(str(path)) gui_doc = Gui.getDocument(opened.Name) assert gui_doc.Modified is False, "Native opening did not start clean" qi_ui.update_title() assert not Gui.getMainWindow().isWindowModified(), "Clean project shows an unsaved title marker" history = (opened.UndoCount, opened.RedoCount, opened.UndoNames, opened.RedoNames) assert qi_io.restore_document_label(opened, path) assert gui_doc.Modified is False, "Restoring a saved title marked a clean project dirty" assert (opened.UndoCount, opened.RedoCount, opened.UndoNames, opened.RedoNames) == history, "Restoration altered undo history" opened.openTransaction("Несохранённое изменение размера") opened.getObject("Box").Length = 17 opened.recompute() opened.commitTransaction() assert not opened.isTouched(), "Fixture must exercise a recomputed document" assert gui_doc.Modified is True, "Recomputed dimension edit lost its native modified state" qi_ui.update_title() assert Gui.getMainWindow().isWindowModified(), "Recompute hid the unsaved title marker" opened.Label = "Временное имя" assert gui_doc.Modified is True, "Fixture did not create real unsaved changes" history = (opened.UndoCount, opened.RedoCount, opened.UndoNames, opened.RedoNames) assert qi_io.restore_document_label(opened, path) assert gui_doc.Modified is True, "Restoration cleared genuine unsaved changes" assert opened.getObject("Box").Length.Value == 17, "Restoration changed geometry" assert (opened.UndoCount, opened.RedoCount, opened.UndoNames, opened.RedoNames) == history, "Restoration altered undo/redo history" assert _hash(path) == before, "GUI restoration wrote the project" states = {} def state(): qi_ui.update_title() return {"gui_document_modified": bool(gui_doc.Modified), "window_modified": bool(Gui.getMainWindow().isWindowModified())} def history_state(): return (opened.UndoCount, opened.RedoCount, opened.UndoNames, opened.RedoNames) def verify_saved(saved, length, label): # Reopen an exact byte copy under a different path, so the native # loader cannot return the already open document as the proof. copy = out / (saved.stem + "-reopened.FCStd") copy.write_bytes(saved.read_bytes()) restored = App.openDocument(str(copy)) try: assert qi_io.restore_document_label(restored, copy) assert restored.Label == label, "Saving lost the Russian document label" assert restored.getObject("Box").Length.Value == length, "Saving lost the edited dimension" assert abs(restored.getObject("Box").Shape.Volume - length * 100) < 1e-6 assert not Gui.getDocument(restored.Name).Modified, "Reopened saved project is falsely dirty" finally: App.closeDocument(restored.Name) App.setActiveDocument(opened.Name) qi_ui.update_title() # Native App persistence returns None and does not clear the GUI flag; # the native GUI save command clears that flag separately. states["before_native_save"] = state() native_result = opened.saveAs(str(out / "native-save-state.FCStd")) assert native_result is None, "Pinned native saveAs return contract changed" assert opened.save() is None, "Pinned native save return contract changed" states["after_native_save"] = state() assert gui_doc.Modified and Gui.getMainWindow().isWindowModified() opened.Label = "Деталь — русское имя № 1" opened.openTransaction("Размер перед сохранением копии") opened.getObject("Box").Length = 19 opened.recompute() opened.commitTransaction() saved_copy = out / "title-indicator.FCStd" chooser = qi_commands.QtWidgets.QFileDialog.getSaveFileName warning = qi_commands.QtWidgets.QMessageBox.warning original_test = os.environ.pop("QI_TEST", None) messages = [] qi_commands.QtWidgets.QMessageBox.warning = lambda parent, title, text, *args: messages.append({"title": title, "text": text}) try: qi_commands.register_commands() qi_commands.QtWidgets.QFileDialog.getSaveFileName = lambda *args, **kwargs: (str(saved_copy), "") states["before_qi_save_as"] = state() history = history_state() Gui.runCommand("QI_SaveAs") QtTest.QTest.qWait(150) states["after_qi_save_as"] = state() assert not messages and saved_copy.is_file(), "QI_SaveAs failed" assert history_state() == history, "Successful save changed undo/redo history" assert not gui_doc.Modified and not Gui.getMainWindow().isWindowModified(), "QI_SaveAs did not clear the unsaved marker" verify_saved(saved_copy, 19, "Деталь — русское имя № 1") opened.openTransaction("Размер перед обычным сохранением") opened.getObject("Box").Length = 23 opened.recompute() opened.commitTransaction() states["before_qi_save"] = state() history = history_state() Gui.runCommand("QI_Save") QtTest.QTest.qWait(150) states["after_qi_save"] = state() assert not messages and not gui_doc.Modified and not Gui.getMainWindow().isWindowModified() assert history_state() == history verify_saved(saved_copy, 23, "Деталь — русское имя № 1") opened.openTransaction("Несохранённый размер для проверки отказа") opened.getObject("Box").Length = 29 opened.recompute() opened.commitTransaction() saved_hash = _hash(saved_copy) history = history_state() states["before_failed_save"] = state() assert gui_doc.Modified and Gui.getMainWindow().isWindowModified() if os.name == "nt": import ctypes from ctypes import wintypes kernel = ctypes.WinDLL("kernel32", use_last_error=True) kernel.GetFileAttributesW.argtypes = [wintypes.LPCWSTR] kernel.GetFileAttributesW.restype = wintypes.DWORD kernel.SetFileAttributesW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD] kernel.SetFileAttributesW.restype = wintypes.BOOL attributes = kernel.GetFileAttributesW(str(saved_copy)) assert attributes != 0xFFFFFFFF assert kernel.SetFileAttributesW(str(saved_copy), attributes | 1) try: Gui.runCommand("QI_Save") QtTest.QTest.qWait(150) finally: assert kernel.SetFileAttributesW(str(saved_copy), attributes) assert len(messages) == 1 and any("а" <= char.lower() <= "я" for char in messages[0]["text"]) states["after_failed_save"] = state() assert states["after_failed_save"] == states["before_failed_save"] assert _hash(saved_copy) == saved_hash, "Failed save overwrote the previous project" assert history_state() == history and opened.getObject("Box").Length.Value == 29 verify_saved(saved_copy, 23, "Деталь — русское имя № 1") qi_commands.QtWidgets.QFileDialog.getSaveFileName = lambda *args, **kwargs: ("", "") states["before_cancel_save_as"] = state() count = len(messages) Gui.runCommand("QI_SaveAs") QtTest.QTest.qWait(100) states["after_cancel_save_as"] = state() assert states["after_cancel_save_as"] == states["before_cancel_save_as"] assert len(messages) == count and history_state() == history and _hash(saved_copy) == saved_hash finally: qi_commands.QtWidgets.QFileDialog.getSaveFileName = chooser qi_commands.QtWidgets.QMessageBox.warning = warning if original_test is not None: os.environ["QI_TEST"] = original_test assert _hash(path) == before, "Saving the copy changed the original fixture" report["artifacts"].append(str(saved_copy)) return {"clean_preserved": True, "dirty_preserved": True, "undo_redo_unchanged": True, "file_unchanged": True, "title_marker_after_recompute": True, "title_marker_cleared_on_save": True, "native_save_return": native_result, "states": states, "saved_geometry_reopened_mm": [19, 23], "failed_save_errors": messages, "failed_and_cancelled_saves_preserve_dirty_history_and_file": True} exchange_paths = {} open_anchor = None def gui_open_inputs(): import Part import Mesh if not exchange_paths: source = App.newDocument("QIIOExchangeFixture") try: box = source.addObject("Part::Feature", "Box") box.Shape = Part.makeBox(10, 10, 10) source.recompute() for suffix in (".step", ".iges", ".brep", ".stl"): path = out / ("Обмен" + suffix) (Mesh if suffix == ".stl" else Part).export([box], str(path)) assert path.is_file() and path.stat().st_size > 0 exchange_paths[suffix] = path report["artifacts"].append(str(path)) exchange_paths[".fcstd"] = out / "ascii-part.FCStd" finally: App.closeDocument(source.Name) return exchange_paths def gui_previous_document(): nonlocal open_anchor import Part if open_anchor is None or open_anchor.Name not in App.listDocuments(): open_anchor = App.newDocument("QIIOPreviousModel") obj = open_anchor.addObject("Part::Feature", "PreviousSolid") obj.Shape = Part.makeBox(3, 4, 5) open_anchor.Label = "Предыдущая открытая модель" open_anchor.recompute() App.setActiveDocument(open_anchor.Name) return open_anchor def gui_geometry(doc, suffix): if suffix == ".stl": meshes = [obj.Mesh for obj in doc.Objects if hasattr(obj, "Mesh") and obj.Mesh.CountFacets > 0] assert meshes and sum(mesh.CountFacets for mesh in meshes) >= 12, "STL has no imported triangles" box = meshes[0].BoundBox assert all(abs(value - 10) < 1e-6 for value in (box.XLength, box.YLength, box.ZLength)) return {"facets": sum(mesh.CountFacets for mesh in meshes), "dimensions_mm": [box.XLength, box.YLength, box.ZLength]} shapes = [obj.Shape for obj in doc.Objects if hasattr(obj, "Shape") and not obj.Shape.isNull()] assert shapes and all(shape.isValid() for shape in shapes), "Native importer did not create valid geometry" assert abs(sum(shape.Volume for shape in shapes) - 1000) < 1e-4, "Imported solid has incorrect volume" return {"volume_mm3": sum(shape.Volume for shape in shapes)} def gui_open_case(suffix): import FreeCADGui as Gui import qi_commands from PySide6 import QtTest path = gui_open_inputs()[suffix] before_hash = _hash(path) # Close only suite-owned instances of the native fixture so this case # tests a genuine open, rather than finding an earlier test document. for name, candidate in list(App.listDocuments().items()): if name not in initial_docs and candidate.FileName and Path(candidate.FileName) == path: App.closeDocument(name) previous = gui_previous_document() baseline = App.openDocument(str(path)) if suffix == ".fcstd" else None if baseline is None: if suffix == ".stl": import Mesh Mesh.open(str(path)) else: import Part Part.open(str(path)) baseline = App.ActiveDocument assert baseline is not None and baseline.Name != previous.Name QtTest.QTest.qWait(250) native_modified = Gui.getDocument(baseline.Name).Modified gui_geometry(baseline, suffix) App.closeDocument(baseline.Name) App.setActiveDocument(previous.Name) chooser = qi_commands.QtWidgets.QFileDialog.getOpenFileName qi_commands.QtWidgets.QFileDialog.getOpenFileName = lambda *args, **kwargs: (str(path), "") opened = None try: qi_commands.register_commands() names = set(App.listDocuments()) Gui.runCommand("QI_Open") QtTest.QTest.qWait(350) opened = App.ActiveDocument assert opened and opened.Name not in names and opened.Name != previous.Name, "QI_Open kept the previous model active" geometry = gui_geometry(opened, suffix) assert Gui.getDocument(opened.Name).Modified == native_modified, "QI_Open added a false modified state" detail = {"suffix": suffix, "document": opened.Name, "native_modified": native_modified, "opened_modified": Gui.getDocument(opened.Name).Modified, "geometry": geometry} if suffix == ".fcstd": assert opened.Label == "Деталь — русское имя № 1" assert Gui.getDocument(opened.Name).Modified is False assert opened.UndoCount == 0 and opened.RedoCount == 0 detail["saved_russian_label"] = opened.Label # Opening an already open project must preserve genuine edits. opened.openTransaction("Несохранённая правка открытой детали") opened.getObject("Box").Length = 17 opened.recompute() opened.Label = "Несохранённая метка" opened.commitTransaction() history = (opened.UndoCount, opened.RedoCount) Gui.runCommand("QI_Open") QtTest.QTest.qWait(250) assert App.ActiveDocument.Name == opened.Name assert opened.Label == "Несохранённая метка" and opened.getObject("Box").Length.Value == 17 assert Gui.getDocument(opened.Name).Modified is True assert (opened.UndoCount, opened.RedoCount) == history detail["existing_unsaved_edits_preserved"] = True assert _hash(path) == before_hash, "QI_Open wrote the source file" assert previous.getObject("PreviousSolid").Shape.Volume == 60 detail["source_unchanged"] = True return detail finally: qi_commands.QtWidgets.QFileDialog.getOpenFileName = chooser if opened is not None and opened.Name in App.listDocuments() and opened.Name not in initial_docs: App.closeDocument(opened.Name) if previous.Name in App.listDocuments(): App.setActiveDocument(previous.Name) def gui_open_failures(): import FreeCADGui as Gui import qi_commands from PySide6 import QtTest previous = gui_previous_document() names = set(App.listDocuments()) previous_state = (previous.Label, Gui.getDocument(previous.Name).Modified, previous.UndoCount, previous.RedoCount, previous.getObject("PreviousSolid").Shape.Volume) unsupported = out / "unsupported.txt" unsupported.write_text("Unsupported file", encoding="utf-8") empty = out / "empty.step" empty.write_bytes(b"") invalid = out / "invalid-open.FCStd" invalid.write_bytes(b"This is not a FreeCAD ZIP") bad_step = out / "invalid-open.step" bad_step.write_bytes(b"This is not a STEP model\n") paths = [unsupported, out / "does-not-exist.FCStd", empty, invalid, bad_step] hashes = {path: _hash(path) for path in paths if path.is_file()} chooser = qi_commands.QtWidgets.QFileDialog.getOpenFileName warning = qi_commands.QtWidgets.QMessageBox.warning original_test = os.environ.pop("QI_TEST", None) messages = [] qi_commands.QtWidgets.QMessageBox.warning = lambda parent, title, text, *args: messages.append({"title": title, "text": text}) locked_handle = None try: # Windows sharing mode 0 gives a real unreadable file without # changing filesystem permissions or the original project. if os.name == "nt": import ctypes from ctypes import wintypes locked = out / "locked-open.FCStd" locked.write_bytes((out / "ascii-part.FCStd").read_bytes()) kernel = ctypes.WinDLL("kernel32", use_last_error=True) kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE] kernel.CreateFileW.restype = wintypes.HANDLE kernel.CloseHandle.argtypes = [wintypes.HANDLE] locked_handle = kernel.CreateFileW(str(locked), 0x80000000, 0, None, 3, 0, None) assert locked_handle not in (None, ctypes.c_void_p(-1).value), "Cannot create exclusive file lock fixture" paths.append(locked) for path in paths: App.setActiveDocument(previous.Name) qi_commands.QtWidgets.QFileDialog.getOpenFileName = lambda *args, p=path, **kwargs: (str(p), "") old_count = len(messages) Gui.runCommand("QI_Open") QtTest.QTest.qWait(100) assert len(messages) == old_count + 1, "Rejected open did not show a user-facing error" assert any("а" <= char.lower() <= "я" for char in messages[-1]["text"]), "Open error is not Russian" assert set(App.listDocuments()) == names, "Failed import left a misleading empty document" assert App.ActiveDocument.Name == previous.Name, "Failed open replaced the previous model" assert (previous.Label, Gui.getDocument(previous.Name).Modified, previous.UndoCount, previous.RedoCount, previous.getObject("PreviousSolid").Shape.Volume) == previous_state # Canceling the chooser must leave the same model and show no error. qi_commands.QtWidgets.QFileDialog.getOpenFileName = lambda *args, **kwargs: ("", "") old_count = len(messages) Gui.runCommand("QI_Open") assert len(messages) == old_count and App.ActiveDocument.Name == previous.Name assert all(_hash(path) == digest for path, digest in hashes.items()), "A failed open wrote a source file" return {"rejected": len(paths), "errors": messages, "previous_model_preserved": True, "cancel_preserved": True} finally: if locked_handle is not None: kernel.CloseHandle(locked_handle) qi_commands.QtWidgets.QFileDialog.getOpenFileName = chooser qi_commands.QtWidgets.QMessageBox.warning = warning if original_test is not None: os.environ["QI_TEST"] = original_test def malformed_xml_missing_label(): doc = App.newDocument("QIIOXML") doc.Label = "Исходное имя" fixtures = { "bad-xml.FCStd": b"", "missing-label.FCStd": b"", "wrong-root.FCStd": b"", "nested-label.FCStd": b"", } for name, content in fixtures.items(): path = out / name with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("Document.xml", content) before = _hash(path) assert not qi_io.restore_document_label(doc, path) assert doc.Label == "Исходное имя" assert _hash(path) == before no_member = out / "no-document.FCStd" with zipfile.ZipFile(no_member, "w") as archive: archive.writestr("Other.xml", b"") assert not qi_io.restore_document_label(doc, no_member) return {"rejected": len(fixtures) + 1, "child_label_ignored": True} def reject_dtd_and_entities(): doc = App.newDocument("QIIODTD") doc.Label = "Без изменений" xml = "]>" for encoding in ("utf-8", "utf-16", "utf-32"): path = out / ("dtd-" + encoding + ".FCStd") data = xml.format(encoding=encoding).encode(encoding) with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("Document.xml", data) before = _hash(path) assert not qi_io.restore_document_label(doc, path), "DTD accepted for " + encoding assert doc.Label == "Без изменений" assert _hash(path) == before return {"rejected_encodings": ["utf-8", "utf-16", "utf-32"]} def reject_oversize_without_extraction(): doc = App.newDocument("QIIOCap") doc.Label = "Лимит" path = out / "oversize.FCStd" with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED) as archive: archive.writestr("Document.xml", b" " * (qi_io.MAX_DOCUMENT_XML_BYTES + 1)) archive.writestr("../must-not-extract.txt", b"Forbidden extraction marker") before = _hash(path) assert not qi_io.restore_document_label(doc, path) assert doc.Label == "Лимит" and _hash(path) == before assert not (out.parent / "must-not-extract.txt").exists(), "ZIP traversal entry extracted" return {"max_xml_bytes": qi_io.MAX_DOCUMENT_XML_BYTES, "oversize_rejected": True, "no_extraction": True} try: for name, fn in ( ("native_label_roundtrip", native_label_roundtrip), ("missing_invalid_other_formats", missing_invalid_other_formats), ("malformed_xml_missing_label", malformed_xml_missing_label), ("reject_dtd_and_entities", reject_dtd_and_entities), ("reject_oversize_without_extraction", reject_oversize_without_extraction)): check(name, fn) if App.GuiUp: check("gui_modified_state_and_history", gui_modified_state_and_history) for suffix in (".fcstd", ".step", ".iges", ".brep", ".stl"): check("gui_qi_open_" + suffix[1:], lambda s=suffix: gui_open_case(s)) check("gui_qi_open_failures", gui_open_failures) else: for name in ("gui_modified_state_and_history", "gui_qi_open_fcstd", "gui_qi_open_step", "gui_qi_open_iges", "gui_qi_open_brep", "gui_qi_open_stl", "gui_qi_open_failures"): report["skipped"] += 1 report["checks"].append({"name": name, "passed": None, "status": "skipped", "reason": "Native GUI opening and Modified state require GUI."}) finally: for name in set(App.listDocuments()) - initial_docs: App.closeDocument(name) if active in App.listDocuments(): App.setActiveDocument(active) report["status"] = "passed" if report["failed"] == 0 else "failed" report["success"] = report["failed"] == 0 (out / "io-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report if __name__ == "__main__": output = sys.argv[1] if len(sys.argv) > 1 else str(Path(__file__).resolve().parents[1] / "artifacts" / "io") result = run(output) print(json.dumps({key: result[key] for key in ("status", "passed", "failed")})) for item in result["checks"]: if item["status"] == "failed": print(item["name"] + ": " + item["error"]) raise SystemExit(0 if result["success"] else 1)