Files

514 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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"<Document><Properties>",
"missing-label.FCStd": b"<Document><Properties /></Document>",
"wrong-root.FCStd": b"<Other><Properties><Property name='Label'><String value='wrong'/></Property></Properties></Other>",
"nested-label.FCStd": b"<Document><Objects><Object><Properties><Property name='Label'><String value='child'/></Property></Properties></Object></Objects></Document>",
}
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"<Document />")
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 = "<?xml version='1.0' encoding='{encoding}'?><!DOCTYPE Document [<!ENTITY label 'injected'>]><Document><Properties><Property name='Label'><String value='&label;'/></Property></Properties></Document>"
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)