514 lines
28 KiB
Python
514 lines
28 KiB
Python
"""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)
|