@aryacode/dsh-engineering 0.4.7 → 0.4.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +178 -0
- package/README.md +34 -26
- package/cordis.patch.yml +7 -6
- package/entries/config.js +62 -0
- package/entries/ftview.js +5 -4
- package/entries/l5x.js +47 -15
- package/entries/mwi.js +26 -6
- package/entries/optix.js +105 -0
- package/lib/client.js +335 -6
- package/package.json +11 -10
- package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
- package/vendor/mwi/README.md +39 -18
- package/vendor/mwi/SKILL.md +26 -20
- package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -2763
- package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
- package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
- package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
- package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
- package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
- package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
- package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
- package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
- package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
- package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
- package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +128 -128
- package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
- package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
- package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
- package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
- package/vendor/mwi/lib/core/code-engine.js +18 -1
- package/vendor/mwi/lib/edit.d.ts +1 -1
- package/vendor/mwi/lib/edit.js +1 -1
- package/vendor/mwi/lib/read/service.js +8 -1
- package/vendor/mwi/lib/session/backup.d.ts +55 -0
- package/vendor/mwi/lib/session/backup.js +232 -0
- package/vendor/mwi/lib/session/project.d.ts +4 -1
- package/vendor/mwi/lib/session/project.js +13 -2
- package/vendor/mwi/lib/session/service.d.ts +13 -6
- package/vendor/mwi/lib/session/service.js +127 -36
- package/vendor/mwi/lib/session/workspace.d.ts +10 -0
- package/vendor/mwi/lib/session/workspace.js +53 -3
- package/vendor/mwi/lib/skill.d.ts +2 -2
- package/vendor/mwi/lib/skill.js +2 -2
- package/vendor/mwi/lib/tools/edit.d.ts +3 -3
- package/vendor/mwi/lib/tools/edit.js +14 -15
- package/vendor/mwi/lib/tools/read.js +1 -1
- package/vendor/mwi/lib/tools/session.js +13 -9
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
- package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
- package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
- package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
- package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
- package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/workflow.cpython-312.pyc +0 -0
|
@@ -1,765 +1,765 @@
|
|
|
1
|
-
"""OLE/CFB (Compound File Binary) reader and writer for MotionWorks IEC sources.
|
|
2
|
-
|
|
3
|
-
MotionWorks IEC 3 Pro stores the project tree and every POU source as
|
|
4
|
-
``src.st1`` files: OLE2/CFB version-3 containers with 512-byte sectors and
|
|
5
|
-
64-byte mini sectors. Inside a POU source the four streams are::
|
|
6
|
-
|
|
7
|
-
<POU>T.TXT worksheet comments
|
|
8
|
-
<POU>V.VGR binary variable-grid records
|
|
9
|
-
<POU>V.VB textual IEC 61131-3 variable declarations
|
|
10
|
-
<POU>.STB Structured Text body (or)
|
|
11
|
-
<POU>.GB graphical LD/FBD body
|
|
12
|
-
|
|
13
|
-
This module has no MotionWorks-specific knowledge; it only understands CFB.
|
|
14
|
-
|
|
15
|
-
Design notes
|
|
16
|
-
------------
|
|
17
|
-
* The proven implementation this was derived from read exactly one FAT sector
|
|
18
|
-
(128 entries, 64 KiB of file). That is enough for the MotionWorks POU and
|
|
19
|
-
project sources seen so far, but the root ``src.st1`` of a large project is
|
|
20
|
-
already 13-35 KiB and the resource source is larger, so silently truncating
|
|
21
|
-
the FAT is a latent corruption bug. :meth:`CompoundFile._read_fat` now walks
|
|
22
|
-
the DIFAT and reads *all* FAT sectors, and the file header is validated.
|
|
23
|
-
* Writing is done by :meth:`replace_streams`, which handles mini streams,
|
|
24
|
-
normal streams, and migration across the 4096-byte mini-stream cutoff. It
|
|
25
|
-
compacts all mini streams together, because the mini-FAT and the root mini
|
|
26
|
-
stream must always agree.
|
|
27
|
-
* Every write goes to a temporary file in the destination directory and is then
|
|
28
|
-
atomically replaced, so an interrupted write cannot leave a half-written
|
|
29
|
-
project file behind.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
from __future__ import annotations
|
|
33
|
-
|
|
34
|
-
import os
|
|
35
|
-
import struct
|
|
36
|
-
import tempfile
|
|
37
|
-
import time
|
|
38
|
-
from dataclasses import dataclass
|
|
39
|
-
from pathlib import Path
|
|
40
|
-
|
|
41
|
-
MAGIC = bytes.fromhex("D0CF11E0A1B11AE1")
|
|
42
|
-
|
|
43
|
-
FREESECT = 0xFFFFFFFF
|
|
44
|
-
ENDOFCHAIN = 0xFFFFFFFE
|
|
45
|
-
FATSECT = 0xFFFFFFFD
|
|
46
|
-
DIFSECT = 0xFFFFFFFC
|
|
47
|
-
|
|
48
|
-
# Directory entry object types
|
|
49
|
-
OBJ_STORAGE = 1
|
|
50
|
-
OBJ_STREAM = 2
|
|
51
|
-
OBJ_ROOT = 5
|
|
52
|
-
|
|
53
|
-
MAXREGSECT = 0xFFFFFFFA
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class CfbError(ValueError):
|
|
57
|
-
"""Raised when a file is not a supported CFB container."""
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _replace_with_retry(temporary: Path, destination: Path) -> None:
|
|
61
|
-
"""Atomically replace ``destination``, retrying a transient lock.
|
|
62
|
-
|
|
63
|
-
``os.replace`` normally succeeds outright, but on Windows it can fail with
|
|
64
|
-
``PermissionError`` when something else briefly holds the target -- an
|
|
65
|
-
antivirus scanner or a file-sync client noticing the freshly written file. A
|
|
66
|
-
stress run of 387 writes hit this 54 times, so a single attempt is not enough
|
|
67
|
-
for a file the IDE will later open.
|
|
68
|
-
|
|
69
|
-
Retries are short and bounded: a genuine permission problem (a read-only
|
|
70
|
-
attribute, or a lock held by a running application) must still surface rather
|
|
71
|
-
than hang.
|
|
72
|
-
"""
|
|
73
|
-
last_error: OSError | None = None
|
|
74
|
-
for attempt in range(10):
|
|
75
|
-
try:
|
|
76
|
-
os.replace(temporary, destination)
|
|
77
|
-
return
|
|
78
|
-
except PermissionError as exc: # transient lock
|
|
79
|
-
last_error = exc
|
|
80
|
-
time.sleep(0.02 * (attempt + 1))
|
|
81
|
-
# Do not leave the temporary file behind when giving up.
|
|
82
|
-
try:
|
|
83
|
-
temporary.unlink(missing_ok=True)
|
|
84
|
-
except OSError:
|
|
85
|
-
pass
|
|
86
|
-
raise CfbError(
|
|
87
|
-
f"could not replace {destination.name} after 10 attempts; another program "
|
|
88
|
-
f"may be holding the file: {last_error}"
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@dataclass
|
|
93
|
-
class DirectoryEntry:
|
|
94
|
-
index: int
|
|
95
|
-
name: str
|
|
96
|
-
object_type: int
|
|
97
|
-
start_sector: int
|
|
98
|
-
size: int
|
|
99
|
-
left: int = FREESECT
|
|
100
|
-
right: int = FREESECT
|
|
101
|
-
child: int = FREESECT
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class CompoundFile:
|
|
105
|
-
"""Read and rewrite the version-3 CFB files used by MotionWorks IEC."""
|
|
106
|
-
|
|
107
|
-
def __init__(self, path: str | Path):
|
|
108
|
-
self.path = Path(path)
|
|
109
|
-
try:
|
|
110
|
-
self.data = self.path.read_bytes()
|
|
111
|
-
except OSError as exc:
|
|
112
|
-
raise CfbError(f"Cannot read {self.path}: {exc}") from exc
|
|
113
|
-
if len(self.data) < 512 or self.data[:8] != MAGIC:
|
|
114
|
-
raise CfbError(f"Not a CFB (OLE2) file: {self.path}")
|
|
115
|
-
|
|
116
|
-
(self.minor_version, self.major_version, self.byte_order,
|
|
117
|
-
sector_shift, mini_sector_shift) = struct.unpack_from("<HHHHH", self.data, 24)
|
|
118
|
-
if self.byte_order != 0xFFFE:
|
|
119
|
-
raise CfbError("Big-endian CFB files are not supported")
|
|
120
|
-
if self.major_version != 3:
|
|
121
|
-
raise CfbError(
|
|
122
|
-
f"Unsupported CFB major version {self.major_version} "
|
|
123
|
-
f"(expected 3) in {self.path}"
|
|
124
|
-
)
|
|
125
|
-
self.sector_size = 1 << sector_shift
|
|
126
|
-
self.mini_sector_size = 1 << mini_sector_shift
|
|
127
|
-
if self.sector_size != 512 or self.mini_sector_size != 64:
|
|
128
|
-
raise CfbError(
|
|
129
|
-
f"Unsupported CFB sector sizes "
|
|
130
|
-
f"{self.sector_size}/{self.mini_sector_size} in {self.path}"
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
self.mini_cutoff = struct.unpack_from("<I", self.data, 56)[0]
|
|
134
|
-
self.fat = self._read_fat()
|
|
135
|
-
self._validate_fat_coverage()
|
|
136
|
-
self.entries = self._read_directory()
|
|
137
|
-
self.root = next(
|
|
138
|
-
(e for e in self.entries if e.object_type == OBJ_ROOT), None
|
|
139
|
-
)
|
|
140
|
-
if self.root is None:
|
|
141
|
-
raise CfbError(f"No root directory entry in {self.path}")
|
|
142
|
-
self.root_stream = self._read_normal_chain(self.root.start_sector, self.root.size)
|
|
143
|
-
self.minifat = self._read_minifat()
|
|
144
|
-
self._validate_stream_chains()
|
|
145
|
-
|
|
146
|
-
def _physical_sector_count(self) -> int:
|
|
147
|
-
size = len(self.data)
|
|
148
|
-
if size % self.sector_size:
|
|
149
|
-
raise CfbError(
|
|
150
|
-
f"File size {size} is not a multiple of the sector size "
|
|
151
|
-
f"{self.sector_size}"
|
|
152
|
-
)
|
|
153
|
-
return size // self.sector_size - 1
|
|
154
|
-
|
|
155
|
-
def _validate_fat_coverage(self) -> None:
|
|
156
|
-
"""Fail loudly if the FAT cannot address the whole file.
|
|
157
|
-
|
|
158
|
-
A truncated FAT is the single most dangerous failure mode for this
|
|
159
|
-
format: chains past the end of the table either raise (best case) or,
|
|
160
|
-
if the missing entries are silently treated as free, truncate a stream
|
|
161
|
-
to a shorter length and let us write back a corrupted project.
|
|
162
|
-
"""
|
|
163
|
-
physical = self._physical_sector_count()
|
|
164
|
-
if len(self.fat) < physical:
|
|
165
|
-
raise CfbError(
|
|
166
|
-
f"FAT covers {len(self.fat)} sectors but the file has {physical}; "
|
|
167
|
-
f"refusing to parse a truncated FAT in {self.path}"
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
def _validate_stream_chains(self) -> None:
|
|
171
|
-
"""Walk every stream chain up front so corruption is caught on open.
|
|
172
|
-
|
|
173
|
-
:meth:`read_stream` already raises on a broken chain, but only for the
|
|
174
|
-
streams a caller happens to touch. Verifying all of them here means a
|
|
175
|
-
damaged file is rejected before any write path can act on it.
|
|
176
|
-
"""
|
|
177
|
-
for e in self.entries:
|
|
178
|
-
if e.object_type != OBJ_STREAM or e.size == 0:
|
|
179
|
-
continue
|
|
180
|
-
if e.size >= self.mini_cutoff:
|
|
181
|
-
if e.start_sector >= MAXREGSECT:
|
|
182
|
-
raise CfbError(f"Stream {e.name!r} has no sector but size {e.size}")
|
|
183
|
-
self._chain(e.start_sector, self.fat, f"FAT chain of {e.name!r}")
|
|
184
|
-
else:
|
|
185
|
-
if e.start_sector >= MAXREGSECT:
|
|
186
|
-
raise CfbError(f"Stream {e.name!r} has no mini sector but size {e.size}")
|
|
187
|
-
chain = self._chain(
|
|
188
|
-
e.start_sector, self.minifat, f"mini-FAT chain of {e.name!r}"
|
|
189
|
-
)
|
|
190
|
-
needed = (e.size + self.mini_sector_size - 1) // self.mini_sector_size
|
|
191
|
-
if len(chain) < needed:
|
|
192
|
-
raise CfbError(
|
|
193
|
-
f"Stream {e.name!r} needs {needed} mini sectors for "
|
|
194
|
-
f"{e.size} bytes but its chain has {len(chain)}"
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
# ------------------------------------------------------------------ read
|
|
198
|
-
|
|
199
|
-
def _sector_offset(self, sector: int) -> int:
|
|
200
|
-
return (sector + 1) * self.sector_size
|
|
201
|
-
|
|
202
|
-
def _fat_sector_indices(self) -> list[int]:
|
|
203
|
-
"""The sectors holding FAT data, read from the DIFAT.
|
|
204
|
-
|
|
205
|
-
``_read_fat`` keeps this list local. Kept separate so a caller that needs
|
|
206
|
-
to know which sectors hold FAT data -- anything reasoning about free space
|
|
207
|
-
-- can ask without re-parsing the DIFAT itself.
|
|
208
|
-
"""
|
|
209
|
-
indices: list[int] = []
|
|
210
|
-
for i in range(109):
|
|
211
|
-
value = struct.unpack_from("<I", self.data, 76 + i * 4)[0]
|
|
212
|
-
if value < MAXREGSECT:
|
|
213
|
-
indices.append(value)
|
|
214
|
-
|
|
215
|
-
next_difat = struct.unpack_from("<I", self.data, 68)[0]
|
|
216
|
-
difat_count = struct.unpack_from("<I", self.data, 72)[0]
|
|
217
|
-
per_sector = self.sector_size // 4 - 1
|
|
218
|
-
seen: set[int] = set()
|
|
219
|
-
while next_difat < MAXREGSECT and difat_count:
|
|
220
|
-
if next_difat in seen:
|
|
221
|
-
break
|
|
222
|
-
seen.add(next_difat)
|
|
223
|
-
base = self._sector_offset(next_difat)
|
|
224
|
-
if base + self.sector_size > len(self.data):
|
|
225
|
-
break
|
|
226
|
-
for i in range(per_sector):
|
|
227
|
-
value = struct.unpack_from("<I", self.data, base + i * 4)[0]
|
|
228
|
-
if value < MAXREGSECT:
|
|
229
|
-
indices.append(value)
|
|
230
|
-
next_difat = struct.unpack_from(
|
|
231
|
-
"<I", self.data, base + per_sector * 4
|
|
232
|
-
)[0]
|
|
233
|
-
difat_count -= 1
|
|
234
|
-
return indices
|
|
235
|
-
|
|
236
|
-
def _read_fat(self) -> list[int]:
|
|
237
|
-
"""Read every FAT sector listed in the header DIFAT.
|
|
238
|
-
|
|
239
|
-
The header holds 109 DIFAT entries inline; any further DIFAT sectors are
|
|
240
|
-
chained off the header. Reading only the first FAT sector caps the
|
|
241
|
-
addressable file at 128 sectors (~64 KiB), which is not enough for the
|
|
242
|
-
larger MotionWorks POU sources.
|
|
243
|
-
|
|
244
|
-
``fat_sectors`` and the returned FAT table are deliberately separate
|
|
245
|
-
lists: the DIFAT slots live at file offsets 76..511 and must never be
|
|
246
|
-
mistaken for FAT entries, because FAT sector indices are what
|
|
247
|
-
:meth:`_chain` dereferences.
|
|
248
|
-
"""
|
|
249
|
-
fat_sectors: list[int] = []
|
|
250
|
-
for i in range(109):
|
|
251
|
-
value = struct.unpack_from("<I", self.data, 76 + i * 4)[0]
|
|
252
|
-
if value < MAXREGSECT:
|
|
253
|
-
fat_sectors.append(value)
|
|
254
|
-
|
|
255
|
-
# Follow any additional DIFAT sectors to collect the remaining FAT
|
|
256
|
-
# sector numbers.
|
|
257
|
-
next_difat = struct.unpack_from("<I", self.data, 68)[0]
|
|
258
|
-
difat_count = struct.unpack_from("<I", self.data, 72)[0]
|
|
259
|
-
per_sector = self.sector_size // 4 - 1
|
|
260
|
-
seen: set[int] = set()
|
|
261
|
-
while next_difat < MAXREGSECT and difat_count:
|
|
262
|
-
if next_difat in seen:
|
|
263
|
-
raise CfbError("Cyclic DIFAT chain")
|
|
264
|
-
seen.add(next_difat)
|
|
265
|
-
base = self._sector_offset(next_difat)
|
|
266
|
-
if base + self.sector_size > len(self.data):
|
|
267
|
-
raise CfbError("DIFAT sector beyond end of file")
|
|
268
|
-
for i in range(per_sector):
|
|
269
|
-
value = struct.unpack_from("<I", self.data, base + i * 4)[0]
|
|
270
|
-
if value < MAXREGSECT:
|
|
271
|
-
fat_sectors.append(value)
|
|
272
|
-
next_difat = struct.unpack_from(
|
|
273
|
-
"<I", self.data, base + per_sector * 4
|
|
274
|
-
)[0]
|
|
275
|
-
difat_count -= 1
|
|
276
|
-
|
|
277
|
-
if not fat_sectors:
|
|
278
|
-
raise CfbError("No FAT sector referenced by the header")
|
|
279
|
-
|
|
280
|
-
fat: list[int] = []
|
|
281
|
-
count = self.sector_size // 4
|
|
282
|
-
for sector in fat_sectors:
|
|
283
|
-
offset = self._sector_offset(sector)
|
|
284
|
-
if offset + self.sector_size > len(self.data):
|
|
285
|
-
raise CfbError("FAT sector beyond end of file")
|
|
286
|
-
fat.extend(struct.unpack_from(f"<{count}I", self.data, offset))
|
|
287
|
-
return fat
|
|
288
|
-
|
|
289
|
-
def _chain(self, start: int, table: list[int], what: str = "chain") -> list[int]:
|
|
290
|
-
result: list[int] = []
|
|
291
|
-
current = start
|
|
292
|
-
seen: set[int] = set()
|
|
293
|
-
while current < MAXREGSECT:
|
|
294
|
-
if current in seen:
|
|
295
|
-
raise CfbError(f"Cyclic CFB {what}")
|
|
296
|
-
if current >= len(table):
|
|
297
|
-
raise CfbError(
|
|
298
|
-
f"CFB {what} references sector {current} beyond the FAT "
|
|
299
|
-
f"({len(table)} entries)"
|
|
300
|
-
)
|
|
301
|
-
seen.add(current)
|
|
302
|
-
result.append(current)
|
|
303
|
-
current = table[current]
|
|
304
|
-
return result
|
|
305
|
-
|
|
306
|
-
def _read_normal_chain(self, start: int, size: int) -> bytes:
|
|
307
|
-
if size == 0 or start >= MAXREGSECT:
|
|
308
|
-
return b""
|
|
309
|
-
output = bytearray()
|
|
310
|
-
for sector in self._chain(start, self.fat, "FAT chain"):
|
|
311
|
-
offset = self._sector_offset(sector)
|
|
312
|
-
output.extend(self.data[offset : offset + self.sector_size])
|
|
313
|
-
return bytes(output[:size])
|
|
314
|
-
|
|
315
|
-
def _read_directory(self) -> list[DirectoryEntry]:
|
|
316
|
-
first_directory = struct.unpack_from("<I", self.data, 48)[0]
|
|
317
|
-
raw = self._read_normal_chain(first_directory, 1 << 30)
|
|
318
|
-
entries: list[DirectoryEntry] = []
|
|
319
|
-
for index in range(len(raw) // 128):
|
|
320
|
-
offset = index * 128
|
|
321
|
-
name_length = struct.unpack_from("<H", raw, offset + 64)[0]
|
|
322
|
-
object_type = raw[offset + 66]
|
|
323
|
-
if name_length < 2 or object_type == 0:
|
|
324
|
-
continue
|
|
325
|
-
name = raw[offset : offset + name_length - 2].decode("utf-16le")
|
|
326
|
-
entries.append(
|
|
327
|
-
DirectoryEntry(
|
|
328
|
-
index=index,
|
|
329
|
-
name=name,
|
|
330
|
-
object_type=object_type,
|
|
331
|
-
start_sector=struct.unpack_from("<I", raw, offset + 116)[0],
|
|
332
|
-
size=struct.unpack_from("<Q", raw, offset + 120)[0],
|
|
333
|
-
left=struct.unpack_from("<I", raw, offset + 68)[0],
|
|
334
|
-
right=struct.unpack_from("<I", raw, offset + 72)[0],
|
|
335
|
-
child=struct.unpack_from("<I", raw, offset + 76)[0],
|
|
336
|
-
)
|
|
337
|
-
)
|
|
338
|
-
return entries
|
|
339
|
-
|
|
340
|
-
def _read_minifat(self) -> list[int]:
|
|
341
|
-
first = struct.unpack_from("<I", self.data, 60)[0]
|
|
342
|
-
count = struct.unpack_from("<I", self.data, 64)[0]
|
|
343
|
-
if not count or first >= MAXREGSECT:
|
|
344
|
-
return []
|
|
345
|
-
raw = self._read_normal_chain(first, count * self.sector_size)
|
|
346
|
-
return list(struct.unpack(f"<{len(raw) // 4}I", raw))
|
|
347
|
-
|
|
348
|
-
def stream_names(self) -> list[str]:
|
|
349
|
-
return [e.name for e in self.entries if e.object_type == OBJ_STREAM]
|
|
350
|
-
|
|
351
|
-
def entry(self, name: str) -> DirectoryEntry:
|
|
352
|
-
for e in self.entries:
|
|
353
|
-
if e.object_type == OBJ_STREAM and e.name == name:
|
|
354
|
-
return e
|
|
355
|
-
raise KeyError(name)
|
|
356
|
-
|
|
357
|
-
def read_stream(self, name: str) -> bytes:
|
|
358
|
-
e = self.entry(name)
|
|
359
|
-
if e.size == 0 or e.start_sector >= MAXREGSECT:
|
|
360
|
-
return b""
|
|
361
|
-
if e.size >= self.mini_cutoff:
|
|
362
|
-
return self._read_normal_chain(e.start_sector, e.size)
|
|
363
|
-
output = bytearray()
|
|
364
|
-
for mini_sector in self._chain(e.start_sector, self.minifat, "mini-FAT chain"):
|
|
365
|
-
offset = mini_sector * self.mini_sector_size
|
|
366
|
-
output.extend(self.root_stream[offset : offset + self.mini_sector_size])
|
|
367
|
-
return bytes(output[: e.size])
|
|
368
|
-
|
|
369
|
-
def streams(self) -> dict[str, bytes]:
|
|
370
|
-
return {name: self.read_stream(name) for name in self.stream_names()}
|
|
371
|
-
|
|
372
|
-
def summary(self) -> dict[str, int]:
|
|
373
|
-
return {name: e.size for name, e in
|
|
374
|
-
((x.name, x) for x in self.entries if x.object_type == OBJ_STREAM)}
|
|
375
|
-
|
|
376
|
-
# ----------------------------------------------------------------- write
|
|
377
|
-
|
|
378
|
-
def _directory_locations(self, table: list[int]) -> list[int]:
|
|
379
|
-
"""Absolute byte offsets, per directory entry index, of each 128-byte slot.
|
|
380
|
-
|
|
381
|
-
The directory may span several sectors, so this cannot assume the
|
|
382
|
-
entries are contiguous in the file.
|
|
383
|
-
"""
|
|
384
|
-
directory_start = struct.unpack_from("<I", self.data, 48)[0]
|
|
385
|
-
chain = self._chain(directory_start, table, "directory chain")
|
|
386
|
-
locations: list[int] = []
|
|
387
|
-
for sector in chain:
|
|
388
|
-
base = self._sector_offset(sector)
|
|
389
|
-
for slot in range(self.sector_size // 128):
|
|
390
|
-
locations.append(base + slot * 128)
|
|
391
|
-
return locations
|
|
392
|
-
|
|
393
|
-
def replace_streams(
|
|
394
|
-
self, replacements: dict[str, bytes], destination: str | Path | None = None
|
|
395
|
-
) -> Path:
|
|
396
|
-
"""Replace named streams, creating none and deleting none.
|
|
397
|
-
|
|
398
|
-
Supports payloads growing or shrinking across the 4096-byte mini-stream
|
|
399
|
-
cutoff. Unreplaced normal streams keep their existing allocation.
|
|
400
|
-
Sectors released by a replaced stream are reused by that same stream
|
|
401
|
-
first, and any remaining need is satisfied by appending to the file and
|
|
402
|
-
growing the FAT if it has no spare entries. All mini streams are
|
|
403
|
-
re-packed together.
|
|
404
|
-
|
|
405
|
-
FAT growth matters in practice: MotionWorks sizes the FAT so that it
|
|
406
|
-
covers exactly the file's sectors, leaving no free entries, so a stream
|
|
407
|
-
that grows by even one sector cannot be written without it.
|
|
408
|
-
"""
|
|
409
|
-
destination = Path(destination) if destination else self.path
|
|
410
|
-
stream_entries = {e.name: e for e in self.entries if e.object_type == OBJ_STREAM}
|
|
411
|
-
unknown = set(replacements) - set(stream_entries)
|
|
412
|
-
if unknown:
|
|
413
|
-
raise KeyError(f"Unknown streams: {sorted(unknown)}")
|
|
414
|
-
|
|
415
|
-
payloads: dict[str, bytes] = {}
|
|
416
|
-
for name, e in stream_entries.items():
|
|
417
|
-
payloads[name] = replacements.get(name, self.read_stream(name))
|
|
418
|
-
|
|
419
|
-
updated = bytearray(self.data)
|
|
420
|
-
working_fat = list(self.fat)
|
|
421
|
-
# Header bookkeeping that FAT growth must maintain.
|
|
422
|
-
header_num_fat = struct.unpack_from("<I", self.data, 44)[0]
|
|
423
|
-
header_num_difat = struct.unpack_from("<I", self.data, 72)[0]
|
|
424
|
-
|
|
425
|
-
def write_working_fat() -> None:
|
|
426
|
-
"""Serialise ``working_fat`` across all allocated FAT sectors."""
|
|
427
|
-
blob = struct.pack(f"<{len(working_fat)}I", *working_fat)
|
|
428
|
-
written = 0
|
|
429
|
-
for sector in fat_sector_list:
|
|
430
|
-
offset = self._sector_offset(sector)
|
|
431
|
-
chunk = blob[written : written + self.sector_size]
|
|
432
|
-
updated[offset : offset + self.sector_size] = chunk.ljust(
|
|
433
|
-
self.sector_size, b"\x00"
|
|
434
|
-
)
|
|
435
|
-
written += self.sector_size
|
|
436
|
-
|
|
437
|
-
fat_sector_list = [
|
|
438
|
-
struct.unpack_from("<I", self.data, 76 + i * 4)[0]
|
|
439
|
-
for i in range(109)
|
|
440
|
-
]
|
|
441
|
-
fat_sector_list = [s for s in fat_sector_list if s < MAXREGSECT]
|
|
442
|
-
if not fat_sector_list:
|
|
443
|
-
raise CfbError("No FAT sector in header")
|
|
444
|
-
|
|
445
|
-
def fat_capacity() -> int:
|
|
446
|
-
return len(fat_sector_list) * (self.sector_size // 4)
|
|
447
|
-
|
|
448
|
-
def ensure_fat_capacity(needed: int) -> None:
|
|
449
|
-
"""Make sure the FAT can address, and has entries for, ``needed`` sectors.
|
|
450
|
-
|
|
451
|
-
Two different things have to be true, and conflating them caused a real
|
|
452
|
-
bug: the FAT must be *addressable* (enough FAT sectors, measured by
|
|
453
|
-
``fat_capacity``) **and** the working table must actually have an entry
|
|
454
|
-
per sector. MotionWorks writes a FAT that can address far more sectors
|
|
455
|
-
than the file contains -- 128 entries for 28 sectors -- so the capacity
|
|
456
|
-
check passes while the list is short, and a caller assigning to a
|
|
457
|
-
high-numbered sector then raises IndexError. Growing the list here
|
|
458
|
-
makes every call site correct instead of relying on each to remember.
|
|
459
|
-
"""
|
|
460
|
-
nonlocal working_fat, header_num_fat, header_num_difat
|
|
461
|
-
while fat_capacity() < needed:
|
|
462
|
-
# Append a new FAT sector. The FAT must already be able to
|
|
463
|
-
# address that sector's own index, so grow the table if needed.
|
|
464
|
-
next_sector = (len(updated) - self.sector_size) // self.sector_size
|
|
465
|
-
while len(working_fat) <= next_sector:
|
|
466
|
-
working_fat.append(FREESECT)
|
|
467
|
-
updated.extend(b"\x00" * self.sector_size)
|
|
468
|
-
working_fat[next_sector] = FATSECT
|
|
469
|
-
fat_sector_list.append(next_sector)
|
|
470
|
-
header_num_fat = len(fat_sector_list)
|
|
471
|
-
struct.pack_into("<I", updated, 44, header_num_fat)
|
|
472
|
-
# Record the new FAT sector in a free inline DIFAT slot, or in a
|
|
473
|
-
# chained DIFAT sector once the 109 inline slots are used up.
|
|
474
|
-
slot = None
|
|
475
|
-
for i in range(109):
|
|
476
|
-
value = struct.unpack_from("<I", updated, 76 + i * 4)[0]
|
|
477
|
-
if value == FREESECT:
|
|
478
|
-
slot = i
|
|
479
|
-
break
|
|
480
|
-
if slot is not None:
|
|
481
|
-
struct.pack_into("<I", updated, 76 + slot * 4, next_sector)
|
|
482
|
-
else:
|
|
483
|
-
raise CfbError(
|
|
484
|
-
"CFB exceeds the 109 FAT sectors addressable without a "
|
|
485
|
-
"chained DIFAT, which is not supported"
|
|
486
|
-
)
|
|
487
|
-
# Cover every sector the caller is about to allocate, regardless of
|
|
488
|
-
# whether any FAT sector had to be added.
|
|
489
|
-
while len(working_fat) < needed:
|
|
490
|
-
working_fat.append(FREESECT)
|
|
491
|
-
write_working_fat()
|
|
492
|
-
|
|
493
|
-
# Sectors the container itself owns and a stream write must never touch.
|
|
494
|
-
#
|
|
495
|
-
# The MiniFAT is reached through the *header* (offsets 60 and 64), not a
|
|
496
|
-
# directory entry, so nothing in the normal allocation path notices it. On
|
|
497
|
-
# a real POU the MiniFAT chain is [2, 20] while another stream's chain also
|
|
498
|
-
# contains sector 20, and replacing that stream wrote its payload over the
|
|
499
|
-
# MiniFAT -- then the MiniFAT was written back over the stream, so the
|
|
500
|
-
# stream read back with mini-FAT entries in the middle of its content.
|
|
501
|
-
# Reproduced deterministically; the stream must simply not allocate these.
|
|
502
|
-
protected: set[int] = set(self._fat_sector_indices())
|
|
503
|
-
protected.update(self._chain(self.root.start_sector, self.fat, "root mini stream"))
|
|
504
|
-
minifat_start = struct.unpack_from("<I", self.data, 60)[0]
|
|
505
|
-
minifat_count = struct.unpack_from("<I", self.data, 64)[0]
|
|
506
|
-
if minifat_count and minifat_start < MAXREGSECT:
|
|
507
|
-
protected.update(
|
|
508
|
-
self._chain(minifat_start, self.fat, "mini-FAT chain")[:minifat_count]
|
|
509
|
-
)
|
|
510
|
-
|
|
511
|
-
def append_normal(payload: bytes, reuse: list[int]) -> tuple[int, int]:
|
|
512
|
-
"""Write a normal stream, reusing released sectors where possible."""
|
|
513
|
-
count = (len(payload) + self.sector_size - 1) // self.sector_size
|
|
514
|
-
if not count:
|
|
515
|
-
return ENDOFCHAIN, 0
|
|
516
|
-
# Never take a sector the container owns, even if the reuse pool offers
|
|
517
|
-
# it or it looks free.
|
|
518
|
-
sectors = [s for s in reuse if s not in protected][:count]
|
|
519
|
-
remaining = count - len(sectors)
|
|
520
|
-
if remaining:
|
|
521
|
-
# Walk forward taking only sectors that are genuinely free and not
|
|
522
|
-
# owned by the container, instead of assuming a contiguous run of
|
|
523
|
-
# `remaining` sectors is available. `ENDOFCHAIN` is deliberately not
|
|
524
|
-
# treated as free: it marks the end of an allocated chain.
|
|
525
|
-
#
|
|
526
|
-
# Growth is done once, up front, rather than inside the scan:
|
|
527
|
-
# `ensure_fat_capacity` writes FAT sectors into `updated` and extends
|
|
528
|
-
# it, and interleaving that with the scan made the buffer's length
|
|
529
|
-
# change while sector indices were being computed from it -- the
|
|
530
|
-
# payload then missed the sectors it was supposed to occupy.
|
|
531
|
-
extra: list[int] = []
|
|
532
|
-
cursor = (len(updated) - self.sector_size) // self.sector_size
|
|
533
|
-
# A generous scan bound, but the file is only extended to cover the
|
|
534
|
-
# sectors actually taken below -- extending to the scan bound would
|
|
535
|
-
# leave the container several sectors larger than it needs to be.
|
|
536
|
-
scan_end = len(updated) // self.sector_size + remaining + 8
|
|
537
|
-
ensure_fat_capacity(scan_end)
|
|
538
|
-
if len(updated) < scan_end * self.sector_size:
|
|
539
|
-
updated.extend(
|
|
540
|
-
b"\x00" * (scan_end * self.sector_size - len(updated))
|
|
541
|
-
)
|
|
542
|
-
while len(extra) < remaining and cursor < scan_end:
|
|
543
|
-
if cursor not in protected and working_fat[cursor] == FREESECT:
|
|
544
|
-
extra.append(cursor)
|
|
545
|
-
cursor += 1
|
|
546
|
-
if len(extra) < remaining:
|
|
547
|
-
raise CfbError(
|
|
548
|
-
f"could not find {remaining} free sector(s) for a stream"
|
|
549
|
-
)
|
|
550
|
-
# Trim the buffer back to the last sector actually used.
|
|
551
|
-
needed_end = (extra[-1] + 1) * self.sector_size
|
|
552
|
-
if len(updated) > needed_end:
|
|
553
|
-
del updated[needed_end:]
|
|
554
|
-
sectors.extend(extra)
|
|
555
|
-
for index, sector in enumerate(sectors):
|
|
556
|
-
working_fat[sector] = (
|
|
557
|
-
sectors[index + 1] if index + 1 < count else ENDOFCHAIN
|
|
558
|
-
)
|
|
559
|
-
start = index * self.sector_size
|
|
560
|
-
offset = self._sector_offset(sector)
|
|
561
|
-
updated[offset : offset + self.sector_size] = payload[
|
|
562
|
-
start : start + self.sector_size
|
|
563
|
-
].ljust(self.sector_size, b"\x00")
|
|
564
|
-
return sectors[0], len(payload)
|
|
565
|
-
|
|
566
|
-
def zero_sectors(sectors: list[int]) -> None:
|
|
567
|
-
"""Clear whole sectors so a shrunken stream leaves no stale bytes."""
|
|
568
|
-
for sector in sectors:
|
|
569
|
-
offset = self._sector_offset(sector)
|
|
570
|
-
updated[offset : offset + self.sector_size] = b"\x00" * self.sector_size
|
|
571
|
-
|
|
572
|
-
# Release replaced streams that previously occupied normal sectors, and
|
|
573
|
-
# remember those sectors so the replacement can reuse them.
|
|
574
|
-
reuse_pool: dict[str, list[int]] = {}
|
|
575
|
-
for name in replacements:
|
|
576
|
-
e = stream_entries[name]
|
|
577
|
-
if e.size >= self.mini_cutoff and e.size:
|
|
578
|
-
released = self._chain(e.start_sector, self.fat)
|
|
579
|
-
# A sector can belong to two chains at once. On a real POU the
|
|
580
|
-
# MiniFAT chain is [2, 20] and the body stream's chain also contains
|
|
581
|
-
# sector 20; freeing it marked the MiniFAT's own sector free, and the
|
|
582
|
-
# replacement then wrote payload over it, so the stream read back with
|
|
583
|
-
# mini-FAT entries in the middle of its content. Protected sectors
|
|
584
|
-
# keep their original FAT value and are never offered for reuse.
|
|
585
|
-
usable: list[int] = []
|
|
586
|
-
for sector in released:
|
|
587
|
-
if sector in protected:
|
|
588
|
-
working_fat[sector] = self.fat[sector]
|
|
589
|
-
continue
|
|
590
|
-
working_fat[sector] = FREESECT
|
|
591
|
-
usable.append(sector)
|
|
592
|
-
reuse_pool[name] = usable
|
|
593
|
-
# Only sectors that go unused need clearing; the ones we reuse
|
|
594
|
-
# are overwritten below.
|
|
595
|
-
needed = (len(payloads[name]) + self.sector_size - 1) // self.sector_size
|
|
596
|
-
if len(usable) > needed:
|
|
597
|
-
zero_sectors(usable[needed:])
|
|
598
|
-
|
|
599
|
-
mini_streams = [
|
|
600
|
-
(stream_entries[name], payload)
|
|
601
|
-
for name, payload in payloads.items()
|
|
602
|
-
if 0 < len(payload) < self.mini_cutoff
|
|
603
|
-
]
|
|
604
|
-
required = sum(
|
|
605
|
-
(len(payload) + self.mini_sector_size - 1) // self.mini_sector_size
|
|
606
|
-
for _, payload in mini_streams
|
|
607
|
-
)
|
|
608
|
-
|
|
609
|
-
root_chain = self._chain(self.root.start_sector, working_fat, "root mini stream")
|
|
610
|
-
root_capacity = len(root_chain) * self.sector_size
|
|
611
|
-
required_bytes = required * self.mini_sector_size
|
|
612
|
-
if required_bytes > root_capacity:
|
|
613
|
-
extra_count = (
|
|
614
|
-
required_bytes - root_capacity + self.sector_size - 1
|
|
615
|
-
) // self.sector_size
|
|
616
|
-
physical = (len(updated) - self.sector_size) // self.sector_size
|
|
617
|
-
sectors = list(range(physical, physical + extra_count))
|
|
618
|
-
ensure_fat_capacity(sectors[-1] + 1)
|
|
619
|
-
updated.extend(b"\x00" * (extra_count * self.sector_size))
|
|
620
|
-
working_fat[root_chain[-1]] = sectors[0]
|
|
621
|
-
for index, sector in enumerate(sectors):
|
|
622
|
-
working_fat[sector] = (
|
|
623
|
-
sectors[index + 1] if index + 1 < extra_count else ENDOFCHAIN
|
|
624
|
-
)
|
|
625
|
-
root_chain.extend(sectors)
|
|
626
|
-
root_capacity = len(root_chain) * self.sector_size
|
|
627
|
-
|
|
628
|
-
available = root_capacity // self.mini_sector_size
|
|
629
|
-
if required > available:
|
|
630
|
-
raise CfbError("Mini stream capacity exceeded")
|
|
631
|
-
if required > len(self.minifat):
|
|
632
|
-
raise CfbError(
|
|
633
|
-
f"Mini-FAT has {len(self.minifat)} entries but {required} are needed"
|
|
634
|
-
)
|
|
635
|
-
|
|
636
|
-
packed_root = bytearray(root_capacity)
|
|
637
|
-
new_minifat = list(self.minifat)
|
|
638
|
-
for index in range(len(new_minifat)):
|
|
639
|
-
new_minifat[index] = FREESECT
|
|
640
|
-
|
|
641
|
-
allocation: dict[str, tuple[int, int]] = {}
|
|
642
|
-
cursor = 0
|
|
643
|
-
for e, payload in mini_streams:
|
|
644
|
-
count = (len(payload) + self.mini_sector_size - 1) // self.mini_sector_size
|
|
645
|
-
start = cursor if count else ENDOFCHAIN
|
|
646
|
-
for part in range(count):
|
|
647
|
-
mini_sector = cursor + part
|
|
648
|
-
source_start = part * self.mini_sector_size
|
|
649
|
-
target_start = mini_sector * self.mini_sector_size
|
|
650
|
-
packed_root[target_start : target_start + self.mini_sector_size] = (
|
|
651
|
-
payload[source_start : source_start + self.mini_sector_size].ljust(
|
|
652
|
-
self.mini_sector_size, b"\x00"
|
|
653
|
-
)
|
|
654
|
-
)
|
|
655
|
-
new_minifat[mini_sector] = (
|
|
656
|
-
mini_sector + 1 if part + 1 < count else ENDOFCHAIN
|
|
657
|
-
)
|
|
658
|
-
allocation[e.name] = (start, len(payload))
|
|
659
|
-
cursor += count
|
|
660
|
-
|
|
661
|
-
for index, sector in enumerate(root_chain):
|
|
662
|
-
offset = self._sector_offset(sector)
|
|
663
|
-
start = index * self.sector_size
|
|
664
|
-
updated[offset : offset + self.sector_size] = packed_root[
|
|
665
|
-
start : start + self.sector_size
|
|
666
|
-
].ljust(self.sector_size, b"\x00")
|
|
667
|
-
|
|
668
|
-
# Replaced streams that are now normal streams get fresh sectors,
|
|
669
|
-
# reusing whatever their previous allocation released.
|
|
670
|
-
for name, payload in replacements.items():
|
|
671
|
-
if len(payload) >= self.mini_cutoff:
|
|
672
|
-
allocation[name] = append_normal(payload, reuse_pool.get(name, []))
|
|
673
|
-
elif not payload:
|
|
674
|
-
allocation[name] = (ENDOFCHAIN, 0)
|
|
675
|
-
|
|
676
|
-
first_minifat = struct.unpack_from("<I", self.data, 60)[0]
|
|
677
|
-
minifat_count = struct.unpack_from("<I", self.data, 64)[0]
|
|
678
|
-
if minifat_count:
|
|
679
|
-
minifat_chain = self._chain(first_minifat, self.fat, "mini-FAT chain")[
|
|
680
|
-
:minifat_count
|
|
681
|
-
]
|
|
682
|
-
minifat_bytes = struct.pack(f"<{len(new_minifat)}I", *new_minifat)
|
|
683
|
-
written = 0
|
|
684
|
-
for sector in minifat_chain:
|
|
685
|
-
offset = self._sector_offset(sector)
|
|
686
|
-
chunk = minifat_bytes[written : written + self.sector_size]
|
|
687
|
-
updated[offset : offset + len(chunk)] = chunk
|
|
688
|
-
written += len(chunk)
|
|
689
|
-
|
|
690
|
-
locations = self._directory_locations(self.fat)
|
|
691
|
-
struct.pack_into(
|
|
692
|
-
"<Q", updated, locations[self.root.index] + 120, required_bytes
|
|
693
|
-
)
|
|
694
|
-
for name, (start, size) in allocation.items():
|
|
695
|
-
offset = locations[stream_entries[name].index]
|
|
696
|
-
struct.pack_into("<I", updated, offset + 116, start)
|
|
697
|
-
struct.pack_into("<Q", updated, offset + 120, size)
|
|
698
|
-
|
|
699
|
-
# Serialise the FAT across every FAT sector. This was already done by
|
|
700
|
-
# ensure_fat_capacity when the FAT grew; writing it again is harmless
|
|
701
|
-
# and keeps the non-growing path correct.
|
|
702
|
-
write_working_fat()
|
|
703
|
-
|
|
704
|
-
# No tail compaction is attempted. A version that freed the last sector
|
|
705
|
-
# when nothing in the FAT pointed at it looked safe and was not: the sector
|
|
706
|
-
# held live data that only a header field led to, so freeing it truncated
|
|
707
|
-
# real content (the CFB write suite caught it). Discarding a sector
|
|
708
|
-
# because *this* parser cannot find a reference to it is not evidence that
|
|
709
|
-
# nothing references it.
|
|
710
|
-
#
|
|
711
|
-
# The consequence is recorded rather than hidden: after a grow-then-shrink
|
|
712
|
-
# cycle the container stays one 512-byte sector larger than the original,
|
|
713
|
-
# even though every stream reads back byte-identically.
|
|
714
|
-
|
|
715
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
716
|
-
with tempfile.NamedTemporaryFile(
|
|
717
|
-
mode="wb", dir=destination.parent, delete=False, suffix=".tmp"
|
|
718
|
-
) as handle:
|
|
719
|
-
handle.write(updated)
|
|
720
|
-
temporary = Path(handle.name)
|
|
721
|
-
_replace_with_retry(temporary, destination)
|
|
722
|
-
return destination
|
|
723
|
-
|
|
724
|
-
def verify_streams(self, expected: dict[str, bytes]) -> None:
|
|
725
|
-
"""Re-open the file and assert the streams read back byte-identically."""
|
|
726
|
-
check = CompoundFile(self.path)
|
|
727
|
-
for name, payload in expected.items():
|
|
728
|
-
actual = check.read_stream(name)
|
|
729
|
-
if actual != payload:
|
|
730
|
-
raise CfbError(
|
|
731
|
-
f"Read-back verification failed for stream {name!r}: "
|
|
732
|
-
f"wrote {len(payload)} bytes, read {len(actual)}"
|
|
733
|
-
)
|
|
734
|
-
|
|
735
|
-
def rename_streams(self, renames: dict[str, str], destination: str | Path | None = None) -> Path:
|
|
736
|
-
"""Rename directory entries in place.
|
|
737
|
-
|
|
738
|
-
CFB directory names are UTF-16LE, null-terminated, and limited to 64
|
|
739
|
-
bytes including the terminator. Renaming is how a cloned POU template
|
|
740
|
-
gets its own stream names without re-serialising the payloads.
|
|
741
|
-
"""
|
|
742
|
-
destination = Path(destination) if destination else self.path
|
|
743
|
-
updated = bytearray(self.data)
|
|
744
|
-
locations = self._directory_locations(self.fat)
|
|
745
|
-
by_name = {e.name: e for e in self.entries if e.object_type == OBJ_STREAM}
|
|
746
|
-
unknown = set(renames) - set(by_name)
|
|
747
|
-
if unknown:
|
|
748
|
-
raise KeyError(f"Unknown streams: {sorted(unknown)}")
|
|
749
|
-
for old, new in renames.items():
|
|
750
|
-
encoded = new.encode("utf-16le") + b"\x00\x00"
|
|
751
|
-
if len(encoded) > 64:
|
|
752
|
-
raise CfbError(
|
|
753
|
-
f"CFB stream name too long ({len(encoded)} > 64 bytes): {new!r}"
|
|
754
|
-
)
|
|
755
|
-
offset = locations[by_name[old].index]
|
|
756
|
-
updated[offset : offset + 64] = encoded.ljust(64, b"\x00")
|
|
757
|
-
struct.pack_into("<H", updated, offset + 64, len(encoded))
|
|
758
|
-
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
759
|
-
with tempfile.NamedTemporaryFile(
|
|
760
|
-
mode="wb", dir=destination.parent, delete=False, suffix=".tmp"
|
|
761
|
-
) as handle:
|
|
762
|
-
handle.write(updated)
|
|
763
|
-
temporary = Path(handle.name)
|
|
764
|
-
_replace_with_retry(temporary, destination)
|
|
765
|
-
return destination
|
|
1
|
+
"""OLE/CFB (Compound File Binary) reader and writer for MotionWorks IEC sources.
|
|
2
|
+
|
|
3
|
+
MotionWorks IEC 3 Pro stores the project tree and every POU source as
|
|
4
|
+
``src.st1`` files: OLE2/CFB version-3 containers with 512-byte sectors and
|
|
5
|
+
64-byte mini sectors. Inside a POU source the four streams are::
|
|
6
|
+
|
|
7
|
+
<POU>T.TXT worksheet comments
|
|
8
|
+
<POU>V.VGR binary variable-grid records
|
|
9
|
+
<POU>V.VB textual IEC 61131-3 variable declarations
|
|
10
|
+
<POU>.STB Structured Text body (or)
|
|
11
|
+
<POU>.GB graphical LD/FBD body
|
|
12
|
+
|
|
13
|
+
This module has no MotionWorks-specific knowledge; it only understands CFB.
|
|
14
|
+
|
|
15
|
+
Design notes
|
|
16
|
+
------------
|
|
17
|
+
* The proven implementation this was derived from read exactly one FAT sector
|
|
18
|
+
(128 entries, 64 KiB of file). That is enough for the MotionWorks POU and
|
|
19
|
+
project sources seen so far, but the root ``src.st1`` of a large project is
|
|
20
|
+
already 13-35 KiB and the resource source is larger, so silently truncating
|
|
21
|
+
the FAT is a latent corruption bug. :meth:`CompoundFile._read_fat` now walks
|
|
22
|
+
the DIFAT and reads *all* FAT sectors, and the file header is validated.
|
|
23
|
+
* Writing is done by :meth:`replace_streams`, which handles mini streams,
|
|
24
|
+
normal streams, and migration across the 4096-byte mini-stream cutoff. It
|
|
25
|
+
compacts all mini streams together, because the mini-FAT and the root mini
|
|
26
|
+
stream must always agree.
|
|
27
|
+
* Every write goes to a temporary file in the destination directory and is then
|
|
28
|
+
atomically replaced, so an interrupted write cannot leave a half-written
|
|
29
|
+
project file behind.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import os
|
|
35
|
+
import struct
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
MAGIC = bytes.fromhex("D0CF11E0A1B11AE1")
|
|
42
|
+
|
|
43
|
+
FREESECT = 0xFFFFFFFF
|
|
44
|
+
ENDOFCHAIN = 0xFFFFFFFE
|
|
45
|
+
FATSECT = 0xFFFFFFFD
|
|
46
|
+
DIFSECT = 0xFFFFFFFC
|
|
47
|
+
|
|
48
|
+
# Directory entry object types
|
|
49
|
+
OBJ_STORAGE = 1
|
|
50
|
+
OBJ_STREAM = 2
|
|
51
|
+
OBJ_ROOT = 5
|
|
52
|
+
|
|
53
|
+
MAXREGSECT = 0xFFFFFFFA
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class CfbError(ValueError):
|
|
57
|
+
"""Raised when a file is not a supported CFB container."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _replace_with_retry(temporary: Path, destination: Path) -> None:
|
|
61
|
+
"""Atomically replace ``destination``, retrying a transient lock.
|
|
62
|
+
|
|
63
|
+
``os.replace`` normally succeeds outright, but on Windows it can fail with
|
|
64
|
+
``PermissionError`` when something else briefly holds the target -- an
|
|
65
|
+
antivirus scanner or a file-sync client noticing the freshly written file. A
|
|
66
|
+
stress run of 387 writes hit this 54 times, so a single attempt is not enough
|
|
67
|
+
for a file the IDE will later open.
|
|
68
|
+
|
|
69
|
+
Retries are short and bounded: a genuine permission problem (a read-only
|
|
70
|
+
attribute, or a lock held by a running application) must still surface rather
|
|
71
|
+
than hang.
|
|
72
|
+
"""
|
|
73
|
+
last_error: OSError | None = None
|
|
74
|
+
for attempt in range(10):
|
|
75
|
+
try:
|
|
76
|
+
os.replace(temporary, destination)
|
|
77
|
+
return
|
|
78
|
+
except PermissionError as exc: # transient lock
|
|
79
|
+
last_error = exc
|
|
80
|
+
time.sleep(0.02 * (attempt + 1))
|
|
81
|
+
# Do not leave the temporary file behind when giving up.
|
|
82
|
+
try:
|
|
83
|
+
temporary.unlink(missing_ok=True)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
raise CfbError(
|
|
87
|
+
f"could not replace {destination.name} after 10 attempts; another program "
|
|
88
|
+
f"may be holding the file: {last_error}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class DirectoryEntry:
|
|
94
|
+
index: int
|
|
95
|
+
name: str
|
|
96
|
+
object_type: int
|
|
97
|
+
start_sector: int
|
|
98
|
+
size: int
|
|
99
|
+
left: int = FREESECT
|
|
100
|
+
right: int = FREESECT
|
|
101
|
+
child: int = FREESECT
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CompoundFile:
|
|
105
|
+
"""Read and rewrite the version-3 CFB files used by MotionWorks IEC."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, path: str | Path):
|
|
108
|
+
self.path = Path(path)
|
|
109
|
+
try:
|
|
110
|
+
self.data = self.path.read_bytes()
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
raise CfbError(f"Cannot read {self.path}: {exc}") from exc
|
|
113
|
+
if len(self.data) < 512 or self.data[:8] != MAGIC:
|
|
114
|
+
raise CfbError(f"Not a CFB (OLE2) file: {self.path}")
|
|
115
|
+
|
|
116
|
+
(self.minor_version, self.major_version, self.byte_order,
|
|
117
|
+
sector_shift, mini_sector_shift) = struct.unpack_from("<HHHHH", self.data, 24)
|
|
118
|
+
if self.byte_order != 0xFFFE:
|
|
119
|
+
raise CfbError("Big-endian CFB files are not supported")
|
|
120
|
+
if self.major_version != 3:
|
|
121
|
+
raise CfbError(
|
|
122
|
+
f"Unsupported CFB major version {self.major_version} "
|
|
123
|
+
f"(expected 3) in {self.path}"
|
|
124
|
+
)
|
|
125
|
+
self.sector_size = 1 << sector_shift
|
|
126
|
+
self.mini_sector_size = 1 << mini_sector_shift
|
|
127
|
+
if self.sector_size != 512 or self.mini_sector_size != 64:
|
|
128
|
+
raise CfbError(
|
|
129
|
+
f"Unsupported CFB sector sizes "
|
|
130
|
+
f"{self.sector_size}/{self.mini_sector_size} in {self.path}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
self.mini_cutoff = struct.unpack_from("<I", self.data, 56)[0]
|
|
134
|
+
self.fat = self._read_fat()
|
|
135
|
+
self._validate_fat_coverage()
|
|
136
|
+
self.entries = self._read_directory()
|
|
137
|
+
self.root = next(
|
|
138
|
+
(e for e in self.entries if e.object_type == OBJ_ROOT), None
|
|
139
|
+
)
|
|
140
|
+
if self.root is None:
|
|
141
|
+
raise CfbError(f"No root directory entry in {self.path}")
|
|
142
|
+
self.root_stream = self._read_normal_chain(self.root.start_sector, self.root.size)
|
|
143
|
+
self.minifat = self._read_minifat()
|
|
144
|
+
self._validate_stream_chains()
|
|
145
|
+
|
|
146
|
+
def _physical_sector_count(self) -> int:
|
|
147
|
+
size = len(self.data)
|
|
148
|
+
if size % self.sector_size:
|
|
149
|
+
raise CfbError(
|
|
150
|
+
f"File size {size} is not a multiple of the sector size "
|
|
151
|
+
f"{self.sector_size}"
|
|
152
|
+
)
|
|
153
|
+
return size // self.sector_size - 1
|
|
154
|
+
|
|
155
|
+
def _validate_fat_coverage(self) -> None:
|
|
156
|
+
"""Fail loudly if the FAT cannot address the whole file.
|
|
157
|
+
|
|
158
|
+
A truncated FAT is the single most dangerous failure mode for this
|
|
159
|
+
format: chains past the end of the table either raise (best case) or,
|
|
160
|
+
if the missing entries are silently treated as free, truncate a stream
|
|
161
|
+
to a shorter length and let us write back a corrupted project.
|
|
162
|
+
"""
|
|
163
|
+
physical = self._physical_sector_count()
|
|
164
|
+
if len(self.fat) < physical:
|
|
165
|
+
raise CfbError(
|
|
166
|
+
f"FAT covers {len(self.fat)} sectors but the file has {physical}; "
|
|
167
|
+
f"refusing to parse a truncated FAT in {self.path}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _validate_stream_chains(self) -> None:
|
|
171
|
+
"""Walk every stream chain up front so corruption is caught on open.
|
|
172
|
+
|
|
173
|
+
:meth:`read_stream` already raises on a broken chain, but only for the
|
|
174
|
+
streams a caller happens to touch. Verifying all of them here means a
|
|
175
|
+
damaged file is rejected before any write path can act on it.
|
|
176
|
+
"""
|
|
177
|
+
for e in self.entries:
|
|
178
|
+
if e.object_type != OBJ_STREAM or e.size == 0:
|
|
179
|
+
continue
|
|
180
|
+
if e.size >= self.mini_cutoff:
|
|
181
|
+
if e.start_sector >= MAXREGSECT:
|
|
182
|
+
raise CfbError(f"Stream {e.name!r} has no sector but size {e.size}")
|
|
183
|
+
self._chain(e.start_sector, self.fat, f"FAT chain of {e.name!r}")
|
|
184
|
+
else:
|
|
185
|
+
if e.start_sector >= MAXREGSECT:
|
|
186
|
+
raise CfbError(f"Stream {e.name!r} has no mini sector but size {e.size}")
|
|
187
|
+
chain = self._chain(
|
|
188
|
+
e.start_sector, self.minifat, f"mini-FAT chain of {e.name!r}"
|
|
189
|
+
)
|
|
190
|
+
needed = (e.size + self.mini_sector_size - 1) // self.mini_sector_size
|
|
191
|
+
if len(chain) < needed:
|
|
192
|
+
raise CfbError(
|
|
193
|
+
f"Stream {e.name!r} needs {needed} mini sectors for "
|
|
194
|
+
f"{e.size} bytes but its chain has {len(chain)}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------ read
|
|
198
|
+
|
|
199
|
+
def _sector_offset(self, sector: int) -> int:
|
|
200
|
+
return (sector + 1) * self.sector_size
|
|
201
|
+
|
|
202
|
+
def _fat_sector_indices(self) -> list[int]:
|
|
203
|
+
"""The sectors holding FAT data, read from the DIFAT.
|
|
204
|
+
|
|
205
|
+
``_read_fat`` keeps this list local. Kept separate so a caller that needs
|
|
206
|
+
to know which sectors hold FAT data -- anything reasoning about free space
|
|
207
|
+
-- can ask without re-parsing the DIFAT itself.
|
|
208
|
+
"""
|
|
209
|
+
indices: list[int] = []
|
|
210
|
+
for i in range(109):
|
|
211
|
+
value = struct.unpack_from("<I", self.data, 76 + i * 4)[0]
|
|
212
|
+
if value < MAXREGSECT:
|
|
213
|
+
indices.append(value)
|
|
214
|
+
|
|
215
|
+
next_difat = struct.unpack_from("<I", self.data, 68)[0]
|
|
216
|
+
difat_count = struct.unpack_from("<I", self.data, 72)[0]
|
|
217
|
+
per_sector = self.sector_size // 4 - 1
|
|
218
|
+
seen: set[int] = set()
|
|
219
|
+
while next_difat < MAXREGSECT and difat_count:
|
|
220
|
+
if next_difat in seen:
|
|
221
|
+
break
|
|
222
|
+
seen.add(next_difat)
|
|
223
|
+
base = self._sector_offset(next_difat)
|
|
224
|
+
if base + self.sector_size > len(self.data):
|
|
225
|
+
break
|
|
226
|
+
for i in range(per_sector):
|
|
227
|
+
value = struct.unpack_from("<I", self.data, base + i * 4)[0]
|
|
228
|
+
if value < MAXREGSECT:
|
|
229
|
+
indices.append(value)
|
|
230
|
+
next_difat = struct.unpack_from(
|
|
231
|
+
"<I", self.data, base + per_sector * 4
|
|
232
|
+
)[0]
|
|
233
|
+
difat_count -= 1
|
|
234
|
+
return indices
|
|
235
|
+
|
|
236
|
+
def _read_fat(self) -> list[int]:
|
|
237
|
+
"""Read every FAT sector listed in the header DIFAT.
|
|
238
|
+
|
|
239
|
+
The header holds 109 DIFAT entries inline; any further DIFAT sectors are
|
|
240
|
+
chained off the header. Reading only the first FAT sector caps the
|
|
241
|
+
addressable file at 128 sectors (~64 KiB), which is not enough for the
|
|
242
|
+
larger MotionWorks POU sources.
|
|
243
|
+
|
|
244
|
+
``fat_sectors`` and the returned FAT table are deliberately separate
|
|
245
|
+
lists: the DIFAT slots live at file offsets 76..511 and must never be
|
|
246
|
+
mistaken for FAT entries, because FAT sector indices are what
|
|
247
|
+
:meth:`_chain` dereferences.
|
|
248
|
+
"""
|
|
249
|
+
fat_sectors: list[int] = []
|
|
250
|
+
for i in range(109):
|
|
251
|
+
value = struct.unpack_from("<I", self.data, 76 + i * 4)[0]
|
|
252
|
+
if value < MAXREGSECT:
|
|
253
|
+
fat_sectors.append(value)
|
|
254
|
+
|
|
255
|
+
# Follow any additional DIFAT sectors to collect the remaining FAT
|
|
256
|
+
# sector numbers.
|
|
257
|
+
next_difat = struct.unpack_from("<I", self.data, 68)[0]
|
|
258
|
+
difat_count = struct.unpack_from("<I", self.data, 72)[0]
|
|
259
|
+
per_sector = self.sector_size // 4 - 1
|
|
260
|
+
seen: set[int] = set()
|
|
261
|
+
while next_difat < MAXREGSECT and difat_count:
|
|
262
|
+
if next_difat in seen:
|
|
263
|
+
raise CfbError("Cyclic DIFAT chain")
|
|
264
|
+
seen.add(next_difat)
|
|
265
|
+
base = self._sector_offset(next_difat)
|
|
266
|
+
if base + self.sector_size > len(self.data):
|
|
267
|
+
raise CfbError("DIFAT sector beyond end of file")
|
|
268
|
+
for i in range(per_sector):
|
|
269
|
+
value = struct.unpack_from("<I", self.data, base + i * 4)[0]
|
|
270
|
+
if value < MAXREGSECT:
|
|
271
|
+
fat_sectors.append(value)
|
|
272
|
+
next_difat = struct.unpack_from(
|
|
273
|
+
"<I", self.data, base + per_sector * 4
|
|
274
|
+
)[0]
|
|
275
|
+
difat_count -= 1
|
|
276
|
+
|
|
277
|
+
if not fat_sectors:
|
|
278
|
+
raise CfbError("No FAT sector referenced by the header")
|
|
279
|
+
|
|
280
|
+
fat: list[int] = []
|
|
281
|
+
count = self.sector_size // 4
|
|
282
|
+
for sector in fat_sectors:
|
|
283
|
+
offset = self._sector_offset(sector)
|
|
284
|
+
if offset + self.sector_size > len(self.data):
|
|
285
|
+
raise CfbError("FAT sector beyond end of file")
|
|
286
|
+
fat.extend(struct.unpack_from(f"<{count}I", self.data, offset))
|
|
287
|
+
return fat
|
|
288
|
+
|
|
289
|
+
def _chain(self, start: int, table: list[int], what: str = "chain") -> list[int]:
|
|
290
|
+
result: list[int] = []
|
|
291
|
+
current = start
|
|
292
|
+
seen: set[int] = set()
|
|
293
|
+
while current < MAXREGSECT:
|
|
294
|
+
if current in seen:
|
|
295
|
+
raise CfbError(f"Cyclic CFB {what}")
|
|
296
|
+
if current >= len(table):
|
|
297
|
+
raise CfbError(
|
|
298
|
+
f"CFB {what} references sector {current} beyond the FAT "
|
|
299
|
+
f"({len(table)} entries)"
|
|
300
|
+
)
|
|
301
|
+
seen.add(current)
|
|
302
|
+
result.append(current)
|
|
303
|
+
current = table[current]
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
def _read_normal_chain(self, start: int, size: int) -> bytes:
|
|
307
|
+
if size == 0 or start >= MAXREGSECT:
|
|
308
|
+
return b""
|
|
309
|
+
output = bytearray()
|
|
310
|
+
for sector in self._chain(start, self.fat, "FAT chain"):
|
|
311
|
+
offset = self._sector_offset(sector)
|
|
312
|
+
output.extend(self.data[offset : offset + self.sector_size])
|
|
313
|
+
return bytes(output[:size])
|
|
314
|
+
|
|
315
|
+
def _read_directory(self) -> list[DirectoryEntry]:
|
|
316
|
+
first_directory = struct.unpack_from("<I", self.data, 48)[0]
|
|
317
|
+
raw = self._read_normal_chain(first_directory, 1 << 30)
|
|
318
|
+
entries: list[DirectoryEntry] = []
|
|
319
|
+
for index in range(len(raw) // 128):
|
|
320
|
+
offset = index * 128
|
|
321
|
+
name_length = struct.unpack_from("<H", raw, offset + 64)[0]
|
|
322
|
+
object_type = raw[offset + 66]
|
|
323
|
+
if name_length < 2 or object_type == 0:
|
|
324
|
+
continue
|
|
325
|
+
name = raw[offset : offset + name_length - 2].decode("utf-16le")
|
|
326
|
+
entries.append(
|
|
327
|
+
DirectoryEntry(
|
|
328
|
+
index=index,
|
|
329
|
+
name=name,
|
|
330
|
+
object_type=object_type,
|
|
331
|
+
start_sector=struct.unpack_from("<I", raw, offset + 116)[0],
|
|
332
|
+
size=struct.unpack_from("<Q", raw, offset + 120)[0],
|
|
333
|
+
left=struct.unpack_from("<I", raw, offset + 68)[0],
|
|
334
|
+
right=struct.unpack_from("<I", raw, offset + 72)[0],
|
|
335
|
+
child=struct.unpack_from("<I", raw, offset + 76)[0],
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
return entries
|
|
339
|
+
|
|
340
|
+
def _read_minifat(self) -> list[int]:
|
|
341
|
+
first = struct.unpack_from("<I", self.data, 60)[0]
|
|
342
|
+
count = struct.unpack_from("<I", self.data, 64)[0]
|
|
343
|
+
if not count or first >= MAXREGSECT:
|
|
344
|
+
return []
|
|
345
|
+
raw = self._read_normal_chain(first, count * self.sector_size)
|
|
346
|
+
return list(struct.unpack(f"<{len(raw) // 4}I", raw))
|
|
347
|
+
|
|
348
|
+
def stream_names(self) -> list[str]:
|
|
349
|
+
return [e.name for e in self.entries if e.object_type == OBJ_STREAM]
|
|
350
|
+
|
|
351
|
+
def entry(self, name: str) -> DirectoryEntry:
|
|
352
|
+
for e in self.entries:
|
|
353
|
+
if e.object_type == OBJ_STREAM and e.name == name:
|
|
354
|
+
return e
|
|
355
|
+
raise KeyError(name)
|
|
356
|
+
|
|
357
|
+
def read_stream(self, name: str) -> bytes:
|
|
358
|
+
e = self.entry(name)
|
|
359
|
+
if e.size == 0 or e.start_sector >= MAXREGSECT:
|
|
360
|
+
return b""
|
|
361
|
+
if e.size >= self.mini_cutoff:
|
|
362
|
+
return self._read_normal_chain(e.start_sector, e.size)
|
|
363
|
+
output = bytearray()
|
|
364
|
+
for mini_sector in self._chain(e.start_sector, self.minifat, "mini-FAT chain"):
|
|
365
|
+
offset = mini_sector * self.mini_sector_size
|
|
366
|
+
output.extend(self.root_stream[offset : offset + self.mini_sector_size])
|
|
367
|
+
return bytes(output[: e.size])
|
|
368
|
+
|
|
369
|
+
def streams(self) -> dict[str, bytes]:
|
|
370
|
+
return {name: self.read_stream(name) for name in self.stream_names()}
|
|
371
|
+
|
|
372
|
+
def summary(self) -> dict[str, int]:
|
|
373
|
+
return {name: e.size for name, e in
|
|
374
|
+
((x.name, x) for x in self.entries if x.object_type == OBJ_STREAM)}
|
|
375
|
+
|
|
376
|
+
# ----------------------------------------------------------------- write
|
|
377
|
+
|
|
378
|
+
def _directory_locations(self, table: list[int]) -> list[int]:
|
|
379
|
+
"""Absolute byte offsets, per directory entry index, of each 128-byte slot.
|
|
380
|
+
|
|
381
|
+
The directory may span several sectors, so this cannot assume the
|
|
382
|
+
entries are contiguous in the file.
|
|
383
|
+
"""
|
|
384
|
+
directory_start = struct.unpack_from("<I", self.data, 48)[0]
|
|
385
|
+
chain = self._chain(directory_start, table, "directory chain")
|
|
386
|
+
locations: list[int] = []
|
|
387
|
+
for sector in chain:
|
|
388
|
+
base = self._sector_offset(sector)
|
|
389
|
+
for slot in range(self.sector_size // 128):
|
|
390
|
+
locations.append(base + slot * 128)
|
|
391
|
+
return locations
|
|
392
|
+
|
|
393
|
+
def replace_streams(
|
|
394
|
+
self, replacements: dict[str, bytes], destination: str | Path | None = None
|
|
395
|
+
) -> Path:
|
|
396
|
+
"""Replace named streams, creating none and deleting none.
|
|
397
|
+
|
|
398
|
+
Supports payloads growing or shrinking across the 4096-byte mini-stream
|
|
399
|
+
cutoff. Unreplaced normal streams keep their existing allocation.
|
|
400
|
+
Sectors released by a replaced stream are reused by that same stream
|
|
401
|
+
first, and any remaining need is satisfied by appending to the file and
|
|
402
|
+
growing the FAT if it has no spare entries. All mini streams are
|
|
403
|
+
re-packed together.
|
|
404
|
+
|
|
405
|
+
FAT growth matters in practice: MotionWorks sizes the FAT so that it
|
|
406
|
+
covers exactly the file's sectors, leaving no free entries, so a stream
|
|
407
|
+
that grows by even one sector cannot be written without it.
|
|
408
|
+
"""
|
|
409
|
+
destination = Path(destination) if destination else self.path
|
|
410
|
+
stream_entries = {e.name: e for e in self.entries if e.object_type == OBJ_STREAM}
|
|
411
|
+
unknown = set(replacements) - set(stream_entries)
|
|
412
|
+
if unknown:
|
|
413
|
+
raise KeyError(f"Unknown streams: {sorted(unknown)}")
|
|
414
|
+
|
|
415
|
+
payloads: dict[str, bytes] = {}
|
|
416
|
+
for name, e in stream_entries.items():
|
|
417
|
+
payloads[name] = replacements.get(name, self.read_stream(name))
|
|
418
|
+
|
|
419
|
+
updated = bytearray(self.data)
|
|
420
|
+
working_fat = list(self.fat)
|
|
421
|
+
# Header bookkeeping that FAT growth must maintain.
|
|
422
|
+
header_num_fat = struct.unpack_from("<I", self.data, 44)[0]
|
|
423
|
+
header_num_difat = struct.unpack_from("<I", self.data, 72)[0]
|
|
424
|
+
|
|
425
|
+
def write_working_fat() -> None:
|
|
426
|
+
"""Serialise ``working_fat`` across all allocated FAT sectors."""
|
|
427
|
+
blob = struct.pack(f"<{len(working_fat)}I", *working_fat)
|
|
428
|
+
written = 0
|
|
429
|
+
for sector in fat_sector_list:
|
|
430
|
+
offset = self._sector_offset(sector)
|
|
431
|
+
chunk = blob[written : written + self.sector_size]
|
|
432
|
+
updated[offset : offset + self.sector_size] = chunk.ljust(
|
|
433
|
+
self.sector_size, b"\x00"
|
|
434
|
+
)
|
|
435
|
+
written += self.sector_size
|
|
436
|
+
|
|
437
|
+
fat_sector_list = [
|
|
438
|
+
struct.unpack_from("<I", self.data, 76 + i * 4)[0]
|
|
439
|
+
for i in range(109)
|
|
440
|
+
]
|
|
441
|
+
fat_sector_list = [s for s in fat_sector_list if s < MAXREGSECT]
|
|
442
|
+
if not fat_sector_list:
|
|
443
|
+
raise CfbError("No FAT sector in header")
|
|
444
|
+
|
|
445
|
+
def fat_capacity() -> int:
|
|
446
|
+
return len(fat_sector_list) * (self.sector_size // 4)
|
|
447
|
+
|
|
448
|
+
def ensure_fat_capacity(needed: int) -> None:
|
|
449
|
+
"""Make sure the FAT can address, and has entries for, ``needed`` sectors.
|
|
450
|
+
|
|
451
|
+
Two different things have to be true, and conflating them caused a real
|
|
452
|
+
bug: the FAT must be *addressable* (enough FAT sectors, measured by
|
|
453
|
+
``fat_capacity``) **and** the working table must actually have an entry
|
|
454
|
+
per sector. MotionWorks writes a FAT that can address far more sectors
|
|
455
|
+
than the file contains -- 128 entries for 28 sectors -- so the capacity
|
|
456
|
+
check passes while the list is short, and a caller assigning to a
|
|
457
|
+
high-numbered sector then raises IndexError. Growing the list here
|
|
458
|
+
makes every call site correct instead of relying on each to remember.
|
|
459
|
+
"""
|
|
460
|
+
nonlocal working_fat, header_num_fat, header_num_difat
|
|
461
|
+
while fat_capacity() < needed:
|
|
462
|
+
# Append a new FAT sector. The FAT must already be able to
|
|
463
|
+
# address that sector's own index, so grow the table if needed.
|
|
464
|
+
next_sector = (len(updated) - self.sector_size) // self.sector_size
|
|
465
|
+
while len(working_fat) <= next_sector:
|
|
466
|
+
working_fat.append(FREESECT)
|
|
467
|
+
updated.extend(b"\x00" * self.sector_size)
|
|
468
|
+
working_fat[next_sector] = FATSECT
|
|
469
|
+
fat_sector_list.append(next_sector)
|
|
470
|
+
header_num_fat = len(fat_sector_list)
|
|
471
|
+
struct.pack_into("<I", updated, 44, header_num_fat)
|
|
472
|
+
# Record the new FAT sector in a free inline DIFAT slot, or in a
|
|
473
|
+
# chained DIFAT sector once the 109 inline slots are used up.
|
|
474
|
+
slot = None
|
|
475
|
+
for i in range(109):
|
|
476
|
+
value = struct.unpack_from("<I", updated, 76 + i * 4)[0]
|
|
477
|
+
if value == FREESECT:
|
|
478
|
+
slot = i
|
|
479
|
+
break
|
|
480
|
+
if slot is not None:
|
|
481
|
+
struct.pack_into("<I", updated, 76 + slot * 4, next_sector)
|
|
482
|
+
else:
|
|
483
|
+
raise CfbError(
|
|
484
|
+
"CFB exceeds the 109 FAT sectors addressable without a "
|
|
485
|
+
"chained DIFAT, which is not supported"
|
|
486
|
+
)
|
|
487
|
+
# Cover every sector the caller is about to allocate, regardless of
|
|
488
|
+
# whether any FAT sector had to be added.
|
|
489
|
+
while len(working_fat) < needed:
|
|
490
|
+
working_fat.append(FREESECT)
|
|
491
|
+
write_working_fat()
|
|
492
|
+
|
|
493
|
+
# Sectors the container itself owns and a stream write must never touch.
|
|
494
|
+
#
|
|
495
|
+
# The MiniFAT is reached through the *header* (offsets 60 and 64), not a
|
|
496
|
+
# directory entry, so nothing in the normal allocation path notices it. On
|
|
497
|
+
# a real POU the MiniFAT chain is [2, 20] while another stream's chain also
|
|
498
|
+
# contains sector 20, and replacing that stream wrote its payload over the
|
|
499
|
+
# MiniFAT -- then the MiniFAT was written back over the stream, so the
|
|
500
|
+
# stream read back with mini-FAT entries in the middle of its content.
|
|
501
|
+
# Reproduced deterministically; the stream must simply not allocate these.
|
|
502
|
+
protected: set[int] = set(self._fat_sector_indices())
|
|
503
|
+
protected.update(self._chain(self.root.start_sector, self.fat, "root mini stream"))
|
|
504
|
+
minifat_start = struct.unpack_from("<I", self.data, 60)[0]
|
|
505
|
+
minifat_count = struct.unpack_from("<I", self.data, 64)[0]
|
|
506
|
+
if minifat_count and minifat_start < MAXREGSECT:
|
|
507
|
+
protected.update(
|
|
508
|
+
self._chain(minifat_start, self.fat, "mini-FAT chain")[:minifat_count]
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def append_normal(payload: bytes, reuse: list[int]) -> tuple[int, int]:
|
|
512
|
+
"""Write a normal stream, reusing released sectors where possible."""
|
|
513
|
+
count = (len(payload) + self.sector_size - 1) // self.sector_size
|
|
514
|
+
if not count:
|
|
515
|
+
return ENDOFCHAIN, 0
|
|
516
|
+
# Never take a sector the container owns, even if the reuse pool offers
|
|
517
|
+
# it or it looks free.
|
|
518
|
+
sectors = [s for s in reuse if s not in protected][:count]
|
|
519
|
+
remaining = count - len(sectors)
|
|
520
|
+
if remaining:
|
|
521
|
+
# Walk forward taking only sectors that are genuinely free and not
|
|
522
|
+
# owned by the container, instead of assuming a contiguous run of
|
|
523
|
+
# `remaining` sectors is available. `ENDOFCHAIN` is deliberately not
|
|
524
|
+
# treated as free: it marks the end of an allocated chain.
|
|
525
|
+
#
|
|
526
|
+
# Growth is done once, up front, rather than inside the scan:
|
|
527
|
+
# `ensure_fat_capacity` writes FAT sectors into `updated` and extends
|
|
528
|
+
# it, and interleaving that with the scan made the buffer's length
|
|
529
|
+
# change while sector indices were being computed from it -- the
|
|
530
|
+
# payload then missed the sectors it was supposed to occupy.
|
|
531
|
+
extra: list[int] = []
|
|
532
|
+
cursor = (len(updated) - self.sector_size) // self.sector_size
|
|
533
|
+
# A generous scan bound, but the file is only extended to cover the
|
|
534
|
+
# sectors actually taken below -- extending to the scan bound would
|
|
535
|
+
# leave the container several sectors larger than it needs to be.
|
|
536
|
+
scan_end = len(updated) // self.sector_size + remaining + 8
|
|
537
|
+
ensure_fat_capacity(scan_end)
|
|
538
|
+
if len(updated) < scan_end * self.sector_size:
|
|
539
|
+
updated.extend(
|
|
540
|
+
b"\x00" * (scan_end * self.sector_size - len(updated))
|
|
541
|
+
)
|
|
542
|
+
while len(extra) < remaining and cursor < scan_end:
|
|
543
|
+
if cursor not in protected and working_fat[cursor] == FREESECT:
|
|
544
|
+
extra.append(cursor)
|
|
545
|
+
cursor += 1
|
|
546
|
+
if len(extra) < remaining:
|
|
547
|
+
raise CfbError(
|
|
548
|
+
f"could not find {remaining} free sector(s) for a stream"
|
|
549
|
+
)
|
|
550
|
+
# Trim the buffer back to the last sector actually used.
|
|
551
|
+
needed_end = (extra[-1] + 1) * self.sector_size
|
|
552
|
+
if len(updated) > needed_end:
|
|
553
|
+
del updated[needed_end:]
|
|
554
|
+
sectors.extend(extra)
|
|
555
|
+
for index, sector in enumerate(sectors):
|
|
556
|
+
working_fat[sector] = (
|
|
557
|
+
sectors[index + 1] if index + 1 < count else ENDOFCHAIN
|
|
558
|
+
)
|
|
559
|
+
start = index * self.sector_size
|
|
560
|
+
offset = self._sector_offset(sector)
|
|
561
|
+
updated[offset : offset + self.sector_size] = payload[
|
|
562
|
+
start : start + self.sector_size
|
|
563
|
+
].ljust(self.sector_size, b"\x00")
|
|
564
|
+
return sectors[0], len(payload)
|
|
565
|
+
|
|
566
|
+
def zero_sectors(sectors: list[int]) -> None:
|
|
567
|
+
"""Clear whole sectors so a shrunken stream leaves no stale bytes."""
|
|
568
|
+
for sector in sectors:
|
|
569
|
+
offset = self._sector_offset(sector)
|
|
570
|
+
updated[offset : offset + self.sector_size] = b"\x00" * self.sector_size
|
|
571
|
+
|
|
572
|
+
# Release replaced streams that previously occupied normal sectors, and
|
|
573
|
+
# remember those sectors so the replacement can reuse them.
|
|
574
|
+
reuse_pool: dict[str, list[int]] = {}
|
|
575
|
+
for name in replacements:
|
|
576
|
+
e = stream_entries[name]
|
|
577
|
+
if e.size >= self.mini_cutoff and e.size:
|
|
578
|
+
released = self._chain(e.start_sector, self.fat)
|
|
579
|
+
# A sector can belong to two chains at once. On a real POU the
|
|
580
|
+
# MiniFAT chain is [2, 20] and the body stream's chain also contains
|
|
581
|
+
# sector 20; freeing it marked the MiniFAT's own sector free, and the
|
|
582
|
+
# replacement then wrote payload over it, so the stream read back with
|
|
583
|
+
# mini-FAT entries in the middle of its content. Protected sectors
|
|
584
|
+
# keep their original FAT value and are never offered for reuse.
|
|
585
|
+
usable: list[int] = []
|
|
586
|
+
for sector in released:
|
|
587
|
+
if sector in protected:
|
|
588
|
+
working_fat[sector] = self.fat[sector]
|
|
589
|
+
continue
|
|
590
|
+
working_fat[sector] = FREESECT
|
|
591
|
+
usable.append(sector)
|
|
592
|
+
reuse_pool[name] = usable
|
|
593
|
+
# Only sectors that go unused need clearing; the ones we reuse
|
|
594
|
+
# are overwritten below.
|
|
595
|
+
needed = (len(payloads[name]) + self.sector_size - 1) // self.sector_size
|
|
596
|
+
if len(usable) > needed:
|
|
597
|
+
zero_sectors(usable[needed:])
|
|
598
|
+
|
|
599
|
+
mini_streams = [
|
|
600
|
+
(stream_entries[name], payload)
|
|
601
|
+
for name, payload in payloads.items()
|
|
602
|
+
if 0 < len(payload) < self.mini_cutoff
|
|
603
|
+
]
|
|
604
|
+
required = sum(
|
|
605
|
+
(len(payload) + self.mini_sector_size - 1) // self.mini_sector_size
|
|
606
|
+
for _, payload in mini_streams
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
root_chain = self._chain(self.root.start_sector, working_fat, "root mini stream")
|
|
610
|
+
root_capacity = len(root_chain) * self.sector_size
|
|
611
|
+
required_bytes = required * self.mini_sector_size
|
|
612
|
+
if required_bytes > root_capacity:
|
|
613
|
+
extra_count = (
|
|
614
|
+
required_bytes - root_capacity + self.sector_size - 1
|
|
615
|
+
) // self.sector_size
|
|
616
|
+
physical = (len(updated) - self.sector_size) // self.sector_size
|
|
617
|
+
sectors = list(range(physical, physical + extra_count))
|
|
618
|
+
ensure_fat_capacity(sectors[-1] + 1)
|
|
619
|
+
updated.extend(b"\x00" * (extra_count * self.sector_size))
|
|
620
|
+
working_fat[root_chain[-1]] = sectors[0]
|
|
621
|
+
for index, sector in enumerate(sectors):
|
|
622
|
+
working_fat[sector] = (
|
|
623
|
+
sectors[index + 1] if index + 1 < extra_count else ENDOFCHAIN
|
|
624
|
+
)
|
|
625
|
+
root_chain.extend(sectors)
|
|
626
|
+
root_capacity = len(root_chain) * self.sector_size
|
|
627
|
+
|
|
628
|
+
available = root_capacity // self.mini_sector_size
|
|
629
|
+
if required > available:
|
|
630
|
+
raise CfbError("Mini stream capacity exceeded")
|
|
631
|
+
if required > len(self.minifat):
|
|
632
|
+
raise CfbError(
|
|
633
|
+
f"Mini-FAT has {len(self.minifat)} entries but {required} are needed"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
packed_root = bytearray(root_capacity)
|
|
637
|
+
new_minifat = list(self.minifat)
|
|
638
|
+
for index in range(len(new_minifat)):
|
|
639
|
+
new_minifat[index] = FREESECT
|
|
640
|
+
|
|
641
|
+
allocation: dict[str, tuple[int, int]] = {}
|
|
642
|
+
cursor = 0
|
|
643
|
+
for e, payload in mini_streams:
|
|
644
|
+
count = (len(payload) + self.mini_sector_size - 1) // self.mini_sector_size
|
|
645
|
+
start = cursor if count else ENDOFCHAIN
|
|
646
|
+
for part in range(count):
|
|
647
|
+
mini_sector = cursor + part
|
|
648
|
+
source_start = part * self.mini_sector_size
|
|
649
|
+
target_start = mini_sector * self.mini_sector_size
|
|
650
|
+
packed_root[target_start : target_start + self.mini_sector_size] = (
|
|
651
|
+
payload[source_start : source_start + self.mini_sector_size].ljust(
|
|
652
|
+
self.mini_sector_size, b"\x00"
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
new_minifat[mini_sector] = (
|
|
656
|
+
mini_sector + 1 if part + 1 < count else ENDOFCHAIN
|
|
657
|
+
)
|
|
658
|
+
allocation[e.name] = (start, len(payload))
|
|
659
|
+
cursor += count
|
|
660
|
+
|
|
661
|
+
for index, sector in enumerate(root_chain):
|
|
662
|
+
offset = self._sector_offset(sector)
|
|
663
|
+
start = index * self.sector_size
|
|
664
|
+
updated[offset : offset + self.sector_size] = packed_root[
|
|
665
|
+
start : start + self.sector_size
|
|
666
|
+
].ljust(self.sector_size, b"\x00")
|
|
667
|
+
|
|
668
|
+
# Replaced streams that are now normal streams get fresh sectors,
|
|
669
|
+
# reusing whatever their previous allocation released.
|
|
670
|
+
for name, payload in replacements.items():
|
|
671
|
+
if len(payload) >= self.mini_cutoff:
|
|
672
|
+
allocation[name] = append_normal(payload, reuse_pool.get(name, []))
|
|
673
|
+
elif not payload:
|
|
674
|
+
allocation[name] = (ENDOFCHAIN, 0)
|
|
675
|
+
|
|
676
|
+
first_minifat = struct.unpack_from("<I", self.data, 60)[0]
|
|
677
|
+
minifat_count = struct.unpack_from("<I", self.data, 64)[0]
|
|
678
|
+
if minifat_count:
|
|
679
|
+
minifat_chain = self._chain(first_minifat, self.fat, "mini-FAT chain")[
|
|
680
|
+
:minifat_count
|
|
681
|
+
]
|
|
682
|
+
minifat_bytes = struct.pack(f"<{len(new_minifat)}I", *new_minifat)
|
|
683
|
+
written = 0
|
|
684
|
+
for sector in minifat_chain:
|
|
685
|
+
offset = self._sector_offset(sector)
|
|
686
|
+
chunk = minifat_bytes[written : written + self.sector_size]
|
|
687
|
+
updated[offset : offset + len(chunk)] = chunk
|
|
688
|
+
written += len(chunk)
|
|
689
|
+
|
|
690
|
+
locations = self._directory_locations(self.fat)
|
|
691
|
+
struct.pack_into(
|
|
692
|
+
"<Q", updated, locations[self.root.index] + 120, required_bytes
|
|
693
|
+
)
|
|
694
|
+
for name, (start, size) in allocation.items():
|
|
695
|
+
offset = locations[stream_entries[name].index]
|
|
696
|
+
struct.pack_into("<I", updated, offset + 116, start)
|
|
697
|
+
struct.pack_into("<Q", updated, offset + 120, size)
|
|
698
|
+
|
|
699
|
+
# Serialise the FAT across every FAT sector. This was already done by
|
|
700
|
+
# ensure_fat_capacity when the FAT grew; writing it again is harmless
|
|
701
|
+
# and keeps the non-growing path correct.
|
|
702
|
+
write_working_fat()
|
|
703
|
+
|
|
704
|
+
# No tail compaction is attempted. A version that freed the last sector
|
|
705
|
+
# when nothing in the FAT pointed at it looked safe and was not: the sector
|
|
706
|
+
# held live data that only a header field led to, so freeing it truncated
|
|
707
|
+
# real content (the CFB write suite caught it). Discarding a sector
|
|
708
|
+
# because *this* parser cannot find a reference to it is not evidence that
|
|
709
|
+
# nothing references it.
|
|
710
|
+
#
|
|
711
|
+
# The consequence is recorded rather than hidden: after a grow-then-shrink
|
|
712
|
+
# cycle the container stays one 512-byte sector larger than the original,
|
|
713
|
+
# even though every stream reads back byte-identically.
|
|
714
|
+
|
|
715
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
716
|
+
with tempfile.NamedTemporaryFile(
|
|
717
|
+
mode="wb", dir=destination.parent, delete=False, suffix=".tmp"
|
|
718
|
+
) as handle:
|
|
719
|
+
handle.write(updated)
|
|
720
|
+
temporary = Path(handle.name)
|
|
721
|
+
_replace_with_retry(temporary, destination)
|
|
722
|
+
return destination
|
|
723
|
+
|
|
724
|
+
def verify_streams(self, expected: dict[str, bytes]) -> None:
|
|
725
|
+
"""Re-open the file and assert the streams read back byte-identically."""
|
|
726
|
+
check = CompoundFile(self.path)
|
|
727
|
+
for name, payload in expected.items():
|
|
728
|
+
actual = check.read_stream(name)
|
|
729
|
+
if actual != payload:
|
|
730
|
+
raise CfbError(
|
|
731
|
+
f"Read-back verification failed for stream {name!r}: "
|
|
732
|
+
f"wrote {len(payload)} bytes, read {len(actual)}"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def rename_streams(self, renames: dict[str, str], destination: str | Path | None = None) -> Path:
|
|
736
|
+
"""Rename directory entries in place.
|
|
737
|
+
|
|
738
|
+
CFB directory names are UTF-16LE, null-terminated, and limited to 64
|
|
739
|
+
bytes including the terminator. Renaming is how a cloned POU template
|
|
740
|
+
gets its own stream names without re-serialising the payloads.
|
|
741
|
+
"""
|
|
742
|
+
destination = Path(destination) if destination else self.path
|
|
743
|
+
updated = bytearray(self.data)
|
|
744
|
+
locations = self._directory_locations(self.fat)
|
|
745
|
+
by_name = {e.name: e for e in self.entries if e.object_type == OBJ_STREAM}
|
|
746
|
+
unknown = set(renames) - set(by_name)
|
|
747
|
+
if unknown:
|
|
748
|
+
raise KeyError(f"Unknown streams: {sorted(unknown)}")
|
|
749
|
+
for old, new in renames.items():
|
|
750
|
+
encoded = new.encode("utf-16le") + b"\x00\x00"
|
|
751
|
+
if len(encoded) > 64:
|
|
752
|
+
raise CfbError(
|
|
753
|
+
f"CFB stream name too long ({len(encoded)} > 64 bytes): {new!r}"
|
|
754
|
+
)
|
|
755
|
+
offset = locations[by_name[old].index]
|
|
756
|
+
updated[offset : offset + 64] = encoded.ljust(64, b"\x00")
|
|
757
|
+
struct.pack_into("<H", updated, offset + 64, len(encoded))
|
|
758
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
759
|
+
with tempfile.NamedTemporaryFile(
|
|
760
|
+
mode="wb", dir=destination.parent, delete=False, suffix=".tmp"
|
|
761
|
+
) as handle:
|
|
762
|
+
handle.write(updated)
|
|
763
|
+
temporary = Path(handle.name)
|
|
764
|
+
_replace_with_retry(temporary, destination)
|
|
765
|
+
return destination
|