@aryacode/dsh-engineering 0.4.6 → 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.
Files changed (161) hide show
  1. package/CHANGELOG.md +199 -0
  2. package/README.md +37 -26
  3. package/cordis.patch.yml +7 -6
  4. package/entries/config.js +62 -0
  5. package/entries/ftview.js +5 -4
  6. package/entries/l5x.js +61 -13
  7. package/entries/mwi.js +26 -6
  8. package/entries/optix.js +105 -0
  9. package/lib/client.js +335 -6
  10. package/package.json +11 -10
  11. package/vendor/l5x/README.md +27 -5
  12. package/vendor/l5x/studio-bridge/NuGet.Config +1 -1
  13. package/vendor/mwi/README.md +39 -18
  14. package/vendor/mwi/SKILL.md +26 -20
  15. package/vendor/mwi/bridge/mw_bridge.ps1 +2763 -2763
  16. package/vendor/mwi/bridge/sdk-interop-importer.cs +30 -30
  17. package/vendor/mwi/bridge/sdk-variable-reader.cs +22 -22
  18. package/vendor/mwi/code/engine/motionworks_iec_mcp/build.py +339 -339
  19. package/vendor/mwi/code/engine/motionworks_iec_mcp/cfb.py +765 -765
  20. package/vendor/mwi/code/engine/motionworks_iec_mcp/declarations.py +399 -399
  21. package/vendor/mwi/code/engine/motionworks_iec_mcp/ide.py +313 -313
  22. package/vendor/mwi/code/engine/motionworks_iec_mcp/manuals.py +520 -520
  23. package/vendor/mwi/code/engine/motionworks_iec_mcp/mwt_bind.py +133 -133
  24. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/catalog.json +672 -672
  25. package/vendor/mwi/code/engine/motionworks_iec_mcp/reference_data/patterns.json +86 -86
  26. package/vendor/mwi/code/engine/motionworks_iec_mcp/restore.py +208 -208
  27. package/vendor/mwi/code/engine/motionworks_iec_mcp/staging.py +128 -128
  28. package/vendor/mwi/code/engine/motionworks_iec_mcp/stlint.py +673 -673
  29. package/vendor/mwi/code/engine/motionworks_iec_mcp/tree_writer.py +790 -790
  30. package/vendor/mwi/code/engine/motionworks_iec_mcp/writer.py +642 -642
  31. package/vendor/mwi/lib/core/code-engine.d.ts +13 -0
  32. package/vendor/mwi/lib/core/code-engine.js +18 -1
  33. package/vendor/mwi/lib/edit.d.ts +1 -1
  34. package/vendor/mwi/lib/edit.js +1 -1
  35. package/vendor/mwi/lib/read/service.js +8 -1
  36. package/vendor/mwi/lib/session/backup.d.ts +55 -0
  37. package/vendor/mwi/lib/session/backup.js +232 -0
  38. package/vendor/mwi/lib/session/project.d.ts +4 -1
  39. package/vendor/mwi/lib/session/project.js +13 -2
  40. package/vendor/mwi/lib/session/service.d.ts +13 -6
  41. package/vendor/mwi/lib/session/service.js +127 -36
  42. package/vendor/mwi/lib/session/workspace.d.ts +10 -0
  43. package/vendor/mwi/lib/session/workspace.js +53 -3
  44. package/vendor/mwi/lib/skill.d.ts +2 -2
  45. package/vendor/mwi/lib/skill.js +2 -2
  46. package/vendor/mwi/lib/tools/edit.d.ts +3 -3
  47. package/vendor/mwi/lib/tools/edit.js +14 -15
  48. package/vendor/mwi/lib/tools/read.js +1 -1
  49. package/vendor/mwi/lib/tools/session.js +13 -9
  50. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix +81 -81
  51. package/vendor/optix/templates/arya-optix-ipc/project/AryaNewHMIProject_IPC.optix.design +38 -38
  52. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Alarms/Alarms.yaml +3 -3
  53. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/AryaNewHMIProject_IPC.yaml +67 -67
  54. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  55. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Converters/Converters.yaml +2 -2
  56. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/DataStores/DataStores.yaml +2 -2
  57. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Loggers/Loggers.yaml +2 -2
  58. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/MQTT/MQTT.yaml +2 -2
  59. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Model/Model.yaml +2 -2
  60. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  61. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  62. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Recipes/Recipes.yaml +2 -2
  63. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Reports/Reports.yaml +2 -2
  64. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  65. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Groups/Groups.yaml +3 -3
  66. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Roles/Roles.yaml +3 -3
  67. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Security.yaml +7 -7
  68. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Security/Users/Users.yaml +3 -3
  69. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/System/System.yaml +2 -2
  70. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/Translations/Translations.yaml +16 -16
  71. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  72. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/Screens/Screens.yaml +2 -2
  73. package/vendor/optix/templates/arya-optix-ipc/project/Nodes/UI/UI.yaml +250 -250
  74. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  75. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.code-workspace +9 -9
  76. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.csproj +19 -19
  77. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.references +172 -172
  78. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/AryaNewHMIProject_IPC.sln +21 -21
  79. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +20 -20
  80. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +11 -11
  81. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  82. package/vendor/optix/templates/arya-optix-ipc/project/ProjectFiles/UserDefinedModule.xml +26 -26
  83. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix +97 -97
  84. package/vendor/optix/templates/arya-optix-panel/project/AryaNewHMIProject_OptixPanel.optix.design +38 -38
  85. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Alarms/Alarms.yaml +3 -3
  86. package/vendor/optix/templates/arya-optix-panel/project/Nodes/AryaNewHMIProject_OptixPanel.yaml +67 -67
  87. package/vendor/optix/templates/arya-optix-panel/project/Nodes/CommDrivers/CommDrivers.yaml +2 -2
  88. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Converters/Converters.yaml +2 -2
  89. package/vendor/optix/templates/arya-optix-panel/project/Nodes/DataStores/DataStores.yaml +2 -2
  90. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Loggers/Loggers.yaml +2 -2
  91. package/vendor/optix/templates/arya-optix-panel/project/Nodes/MQTT/MQTT.yaml +2 -2
  92. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Model/Model.yaml +2 -2
  93. package/vendor/optix/templates/arya-optix-panel/project/Nodes/NetLogic/NetLogic.yaml +26 -26
  94. package/vendor/optix/templates/arya-optix-panel/project/Nodes/OPC-UA/OPC-UA.yaml +2 -2
  95. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Recipes/Recipes.yaml +2 -2
  96. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Reports/Reports.yaml +2 -2
  97. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Retentivity/Retentivity.yaml +57 -57
  98. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Groups/Groups.yaml +3 -3
  99. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Roles/Roles.yaml +3 -3
  100. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Security.yaml +7 -7
  101. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Security/Users/Users.yaml +3 -3
  102. package/vendor/optix/templates/arya-optix-panel/project/Nodes/System/System.yaml +444 -444
  103. package/vendor/optix/templates/arya-optix-panel/project/Nodes/Translations/Translations.yaml +81 -81
  104. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Keyboards/Keyboards.yaml +2 -2
  105. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/Screens/Screens.yaml +2 -2
  106. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidget.yaml +163 -163
  107. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/DeviceSettingsWidget/DeviceSettingsWidgetComponents/DeviceSettingsWidgetComponents.yaml +1663 -1663
  108. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FTRemoteAccessWidgetComponents/FTRemoteAccessWidgetComponents.yaml +2471 -2471
  109. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/FactoryTalkRemoteAccessWidget/FactoryTalkRemoteAccessWidget.yaml +218 -218
  110. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidget.yaml +524 -524
  111. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/NetworkSettingsWidget/NetworkSettingsWidgetComponents/NetworkSettingsWidgetComponents.yaml +690 -690
  112. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/SettingsWidgets/SettingsWidgets.yaml +1265 -1265
  113. package/vendor/optix/templates/arya-optix-panel/project/Nodes/UI/UI.yaml +252 -252
  114. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/.vscode/launch.json +18 -18
  115. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ApplicationNameLogic.cs +19 -19
  116. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.code-workspace +9 -9
  117. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.csproj +19 -19
  118. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.references +172 -172
  119. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/AryaNewHMIProject_OptixPanel.sln +21 -21
  120. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/ConnectionSwitchLogic.cs +74 -74
  121. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DateAndTimeWidgetLogic.cs +420 -420
  122. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/DeviceSettingsWidgetLogic.cs +34 -34
  123. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTOptixStudioVersionLogic.cs +47 -47
  124. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/FTRemoteAccessWidgetSetupLogic.cs +153 -153
  125. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionApprovalLogic.cs +118 -118
  126. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/IncomingConnectionReceiverLogic.cs +166 -166
  127. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkConfiguratorEditModelsLogic.cs +56 -56
  128. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfaceLogic.cs +138 -138
  129. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelReaderLogic.cs +87 -87
  130. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkInterfacesEditModelToSystemWriterLogic.cs +139 -139
  131. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/NetworkSettingsLogic.cs +168 -168
  132. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/FTRemoteAccessWidgetDataObject.cs +106 -106
  133. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/TypeConstants.cs +37 -37
  134. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/Private/UITypeDefinitions.cs +91 -91
  135. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/RequestAssistanceSwitchLogic.cs +93 -93
  136. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/StudioMCPBridge.cs +4340 -4340
  137. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/NetSolution/SystemToNetworkInterfacesEditModelWriterLogic.cs +77 -77
  138. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/UserDefinedModule.xml +111 -111
  139. package/vendor/optix/templates/arya-optix-panel/project/ProjectFiles/WarningIcon.svg +4 -4
  140. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/__init__.cpython-312.pyc +0 -0
  141. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/block_interfaces.cpython-312.pyc +0 -0
  142. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/cfb.cpython-312.pyc +0 -0
  143. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/datatypes.cpython-312.pyc +0 -0
  144. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/declarations.cpython-312.pyc +0 -0
  145. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/errors.cpython-312.pyc +0 -0
  146. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/graphical_listing.cpython-312.pyc +0 -0
  147. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/grid.cpython-312.pyc +0 -0
  148. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/knowledge.cpython-312.pyc +0 -0
  149. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/mwt_bind.cpython-312.pyc +0 -0
  150. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/navigation.cpython-312.pyc +0 -0
  151. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/program_checks.cpython-312.pyc +0 -0
  152. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/project.cpython-312.pyc +0 -0
  153. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/staging.cpython-312.pyc +0 -0
  154. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/structure.cpython-312.pyc +0 -0
  155. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/task_bindings.cpython-312.pyc +0 -0
  156. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tasks.cpython-312.pyc +0 -0
  157. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/tree.cpython-312.pyc +0 -0
  158. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/validation.cpython-312.pyc +0 -0
  159. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variable_edit.cpython-312.pyc +0 -0
  160. package/vendor/mwi/code/engine/motionworks_iec_mcp/__pycache__/variables.cpython-312.pyc +0 -0
  161. 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