mixdog 1.3.3 → 1.3.5

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 (210) hide show
  1. package/LICENSES/oh-my-pi-MIT.txt +23 -0
  2. package/NOTICE.md +15 -0
  3. package/README.md +11 -0
  4. package/package.json +3 -2
  5. package/scripts/embed-google-oauth-client.mjs +49 -0
  6. package/scripts/lib/test-spawn-ledger.mjs +46 -1
  7. package/scripts/test.mjs +5 -0
  8. package/src/defaults/shipped-history.json +8 -0
  9. package/src/defaults/skills/browser-use/SKILL.md +21 -9
  10. package/src/defaults/skills/setup/SKILL.md +7 -2
  11. package/src/defaults/skills/setup/references/actions.md +82 -27
  12. package/src/defaults/skills/setup/references/surfaces.md +67 -34
  13. package/src/defaults/skills/setup-guide/SKILL.md +3 -3
  14. package/src/lib/keychain-cjs.cjs +359 -29
  15. package/src/rules/agent-mode/AGENT_MODE.md +10 -2
  16. package/src/rules/agent-mode/MIXY.md +12 -0
  17. package/src/runtime/agent/orchestrator/agent-runtime/heartbeat-completion.mjs +193 -0
  18. package/src/runtime/agent/orchestrator/config.mjs +18 -0
  19. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +2 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  21. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +31 -2
  22. package/src/runtime/agent/orchestrator/mcp/oauth-client.mjs +16 -3
  23. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +42 -7
  24. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +21 -2
  25. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +23 -0
  26. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +11 -1
  27. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +68 -23
  28. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +22 -1
  29. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +18 -5
  30. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -3
  32. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +52 -8
  33. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +22 -6
  35. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +15 -4
  36. package/src/runtime/agent/orchestrator/providers/antigravity-stream.mjs +17 -2
  37. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +19 -2
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/cursor-wire-normalization.mjs +10 -4
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire-protobuf.mjs +10 -217
  41. package/src/runtime/agent/orchestrator/providers/cursor.mjs +7 -0
  42. package/src/runtime/agent/orchestrator/providers/devin-auth.mjs +409 -0
  43. package/src/runtime/agent/orchestrator/providers/devin-wire-families.mjs +187 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-identity.mjs +342 -0
  45. package/src/runtime/agent/orchestrator/providers/devin-wire-models.mjs +541 -0
  46. package/src/runtime/agent/orchestrator/providers/devin-wire-protobuf.mjs +305 -0
  47. package/src/runtime/agent/orchestrator/providers/devin-wire-request.mjs +558 -0
  48. package/src/runtime/agent/orchestrator/providers/devin-wire-schema.mjs +880 -0
  49. package/src/runtime/agent/orchestrator/providers/devin-wire-session.mjs +190 -0
  50. package/src/runtime/agent/orchestrator/providers/devin-wire-tool-args.mjs +351 -0
  51. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +560 -0
  52. package/src/runtime/agent/orchestrator/providers/devin.mjs +491 -0
  53. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +3 -0
  54. package/src/runtime/agent/orchestrator/providers/gemini-output-limit.mjs +46 -0
  55. package/src/runtime/agent/orchestrator/providers/gemini-reasoning-relay.mjs +30 -0
  56. package/src/runtime/agent/orchestrator/providers/gemini-request-body.mjs +7 -11
  57. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +61 -22
  58. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +1 -0
  59. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -4
  60. package/src/runtime/agent/orchestrator/providers/gemini.mjs +25 -2
  61. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +96 -5
  63. package/src/runtime/agent/orchestrator/providers/lib/tail-reminder-split.mjs +23 -5
  64. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +17 -0
  65. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +39 -3
  66. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +2 -0
  68. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +6 -1
  69. package/src/runtime/agent/orchestrator/providers/opencode-go-gemini.mjs +1 -0
  70. package/src/runtime/agent/orchestrator/providers/provider-availability.mjs +19 -0
  71. package/src/runtime/agent/orchestrator/providers/registry.mjs +17 -3
  72. package/src/runtime/agent/orchestrator/providers/wire-protobuf-codec.mjs +368 -0
  73. package/src/runtime/agent/orchestrator/runtime-core/builtin-features.mjs +2 -0
  74. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/reaction-tool-defs.mjs +133 -0
  76. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog-data.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +23 -5
  78. package/src/runtime/agent/orchestrator/runtime-core/tool-profile.mjs +35 -10
  79. package/src/runtime/agent/orchestrator/runtime-core/workflow/agents.mjs +57 -10
  80. package/src/runtime/agent/orchestrator/runtime-core/workflow/context.mjs +9 -6
  81. package/src/runtime/agent/orchestrator/runtime-core/workflow-ids.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +18 -0
  83. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +0 -3
  84. package/src/runtime/agent/orchestrator/session/agent-room.mjs +10 -4
  85. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +15 -3
  86. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -0
  87. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +36 -4
  88. package/src/runtime/agent/orchestrator/session/human-gate/outbound-policy.mjs +6 -0
  89. package/src/runtime/agent/orchestrator/session/human-gate/store.mjs +15 -3
  90. package/src/runtime/agent/orchestrator/session/loop/assistant-commit.mjs +8 -0
  91. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +8 -1
  92. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/segments.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +50 -2
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +3 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +11 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +11 -2
  97. package/src/runtime/agent/orchestrator/session/loop/turn-limits.mjs +60 -0
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-commit.mjs +2 -0
  99. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +14 -0
  100. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +13 -0
  102. package/src/runtime/agent/orchestrator/session/message-ids.mjs +5 -3
  103. package/src/runtime/agent/orchestrator/session/message-reactions.mjs +89 -0
  104. package/src/runtime/agent/orchestrator/session/message-reactions.test-support.mjs +75 -0
  105. package/src/runtime/agent/orchestrator/session/routine-result.d.mts +0 -2
  106. package/src/runtime/agent/orchestrator/session/routine-result.mjs +11 -2
  107. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +20 -2
  108. package/src/runtime/agent/orchestrator/session/store-agent-worker-header-cache.mjs +85 -0
  109. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +125 -12
  110. package/src/runtime/agent/orchestrator/session/tail-reminder-rules.mjs +11 -5
  111. package/src/runtime/agent/orchestrator/session/tool-batch/state.mjs +4 -0
  112. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -0
  113. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +7 -1
  114. package/src/runtime/channels/lib/schedule-errors.mjs +51 -0
  115. package/src/runtime/channels/lib/scheduler.mjs +109 -14
  116. package/src/runtime/channels/lib/schedules-db.mjs +49 -0
  117. package/src/runtime/channels/lib/tool-dispatch.mjs +13 -0
  118. package/src/runtime/channels/lib/worker-main.mjs +9 -0
  119. package/src/runtime/office/com/com-adapter.mjs +17 -1
  120. package/src/runtime/office/com/office-com-host.ps1 +51 -1
  121. package/src/runtime/office/pdf/cfb-fixture.mjs +328 -0
  122. package/src/runtime/office/pdf/cfb-protection.mjs +311 -0
  123. package/src/runtime/office/pdf/document-preview.mjs +326 -8
  124. package/src/runtime/office/pdf/ooxml-crypto.mjs +469 -0
  125. package/src/runtime/office/pdf/ooxml-key-worker.mjs +14 -0
  126. package/src/runtime/office/portable/portable-soffice.mjs +45 -5
  127. package/src/runtime/shared/agent-room-host.mjs +1 -1
  128. package/src/runtime/shared/bot-hops.mjs +62 -2
  129. package/src/runtime/shared/config.mjs +49 -22
  130. package/src/runtime/shared/connector-catalog.d.mts +4 -2
  131. package/src/runtime/shared/connector-catalog.mjs +16 -8
  132. package/src/runtime/shared/developer-options.mjs +32 -1
  133. package/src/runtime/shared/google-oauth-client.generated.json +4 -0
  134. package/src/runtime/shared/google-oauth-client.mjs +34 -9
  135. package/src/runtime/shared/llm/anthropic-betas.mjs +7 -0
  136. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +12 -0
  137. package/src/runtime/shared/llm/provider-catalog-registry.mjs +1 -0
  138. package/src/runtime/shared/llm/usage-rollup.mjs +4 -383
  139. package/src/runtime/shared/llm/well-formed.mjs +66 -0
  140. package/src/runtime/shared/provider-accounts.mjs +224 -58
  141. package/src/runtime/shared/provider-api-key.mjs +19 -8
  142. package/src/runtime/shared/routine-trigger.mjs +42 -0
  143. package/src/runtime/shared/tool-execution-contract.mjs +2 -0
  144. package/src/runtime/shared/transcript-metadata.mjs +38 -0
  145. package/src/runtime/shared/user-data-guard.mjs +30 -2
  146. package/src/runtime/shared/worktrees/activity-gate.mjs +12 -5
  147. package/src/session-runtime/agent-home-delivery.mjs +57 -13
  148. package/src/session-runtime/agent-room-delivery.mjs +28 -1
  149. package/src/session-runtime/agent-task-sessions.mjs +292 -79
  150. package/src/session-runtime/agent-tool-routing.mjs +49 -4
  151. package/src/session-runtime/boot/begin.mjs +3 -2
  152. package/src/session-runtime/boot/tools.mjs +4 -0
  153. package/src/session-runtime/bot-chain.mjs +32 -0
  154. package/src/session-runtime/bot-status.mjs +7 -4
  155. package/src/session-runtime/channel-config-api.mjs +63 -17
  156. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +45 -0
  157. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +72 -5
  158. package/src/session-runtime/internal-tool-executor.mjs +4 -0
  159. package/src/session-runtime/mcp-glue/server-catalog.mjs +7 -1
  160. package/src/session-runtime/mixy-heartbeat.mjs +807 -0
  161. package/src/session-runtime/model-route/route-tuning.mjs +10 -6
  162. package/src/session-runtime/owned-automation-run.mjs +33 -12
  163. package/src/session-runtime/provider-auth/login.mjs +7 -0
  164. package/src/session-runtime/provider-models/row-hydration.mjs +13 -2
  165. package/src/session-runtime/routine-home-turn.mjs +33 -6
  166. package/src/session-runtime/schedule-session-run.mjs +7 -3
  167. package/src/session-runtime/services/agent-tool/close-flow.mjs +14 -2
  168. package/src/session-runtime/services/agent-tool/execute.mjs +6 -1
  169. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +11 -2
  170. package/src/session-runtime/services/agent-tool/render.mjs +3 -1
  171. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-plan.mjs +8 -12
  172. package/src/session-runtime/services/agent-tool/tool-def.mjs +1 -1
  173. package/src/session-runtime/services/channel-admin.mjs +66 -18
  174. package/src/session-runtime/services/channel-worker.mjs +1 -1
  175. package/src/session-runtime/services/provider-admin.mjs +128 -25
  176. package/src/session-runtime/services/schedule-access.mjs +14 -0
  177. package/src/session-runtime/services/usage-dashboard-model.mjs +1 -0
  178. package/src/session-runtime/session-create/session-options.mjs +7 -4
  179. package/src/session-runtime/settings-system-api.mjs +16 -1
  180. package/src/session-runtime/setup-tool/executor.mjs +49 -14
  181. package/src/session-runtime/setup-tool/extended-actions.mjs +29 -3
  182. package/src/session-runtime/setup-tool/settings-contract.mjs +65 -4
  183. package/src/session-runtime/setup-tool/tool-defs.mjs +30 -3
  184. package/src/session-runtime/task-session-route.mjs +99 -0
  185. package/src/session-runtime/tool-policy/policy-stage.mjs +5 -2
  186. package/src/session-runtime/tool-policy/surface-helpers.mjs +2 -3
  187. package/src/session-runtime/tool-surface.mjs +4 -0
  188. package/src/session-runtime/usage-stats-api.mjs +1 -1
  189. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +98 -21
  190. package/src/session-runtime/workflow-agents-api/workflow-packs.mjs +6 -1
  191. package/src/session-runtime/workspace/transitions.mjs +10 -2
  192. package/src/standalone/daemon-session-control.mjs +88 -20
  193. package/src/standalone/daemon.mjs +8 -0
  194. package/src/standalone/session-protocol.mjs +5 -0
  195. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +6 -2
  196. package/src/standalone/session-service/session-calls/session-turns.mjs +6 -0
  197. package/src/standalone/session-service.mjs +13 -0
  198. package/src/standalone/session-tool-output.mjs +8 -5
  199. package/src/tui/app/model-options.mjs +2 -0
  200. package/src/tui/dist/index.mjs +335 -172
  201. package/src/tui/session/notice-surface.mjs +17 -1
  202. package/src/tui/session/session-api/integrations.mjs +1 -0
  203. package/src/tui/session/session-api/lifecycle.mjs +8 -0
  204. package/src/tui/session/session-api/settings.mjs +14 -2
  205. package/src/tui/session/turn-stream.mjs +17 -1
  206. package/src/tui/session/turn.mjs +22 -2
  207. package/src/vendor/statusline/src/gateway/claude-current.mjs +0 -58
  208. package/src/vendor/statusline/src/gateway/route-meta.mjs +1 -361
  209. package/src/vendor/statusline/src/gateway/session-routes.mjs +0 -55
  210. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +0 -285
@@ -0,0 +1,328 @@
1
+ // A minimal Compound File Binary ([MS-CFB]) writer for tests: real container structure (header, FAT, DIFAT, mini
2
+ // FAT, mini stream, directory with sibling trees), laid out the way Office writes it.
3
+ import { createCipheriv, createHash, createHmac, randomBytes } from 'node:crypto';
4
+
5
+ const END_OF_CHAIN = 0xfffffffe;
6
+ const FAT_SECTOR = 0xfffffffd;
7
+ const DIFAT_SECTOR = 0xfffffffc;
8
+ const NO_STREAM = 0xffffffff;
9
+ const MINI_SECTOR_SIZE = 64;
10
+ const MINI_STREAM_CUTOFF = 4096;
11
+ const HEADER_DIFAT_ENTRIES = 109;
12
+
13
+ /**
14
+ * Entries under the root storage: `{ name, data }` is a stream, `{ name, children }` a storage of further entries.
15
+ * `version` is the CFB major version: 3 (512-byte sectors) or 4 (4096-byte sectors).
16
+ */
17
+ export function buildCfb(entries, { version = 3 } = {}) {
18
+ const sectorSize = version === 4 ? 4096 : 512;
19
+ const entriesPerSector = sectorSize / 4;
20
+ const root = { name: 'Root Entry', type: 5, children: entries };
21
+
22
+ // Directory ids in storage order, root first; the siblings under each storage form one balanced tree.
23
+ const nodes = [];
24
+ const register = (node) => {
25
+ node.id = nodes.length;
26
+ nodes.push(node);
27
+ for (const child of node.children ?? []) register(child);
28
+ };
29
+ register(root);
30
+ const link = (siblings) => {
31
+ const ordered = [...siblings].sort(
32
+ (a, b) => a.name.length - b.name.length || (a.name.toUpperCase() < b.name.toUpperCase() ? -1 : 1)
33
+ );
34
+ const build = (low, high) => {
35
+ if (low > high) return NO_STREAM;
36
+ const middle = (low + high) >> 1;
37
+ const node = ordered[middle];
38
+ node.left = build(low, middle - 1);
39
+ node.right = build(middle + 1, high);
40
+ return node.id;
41
+ };
42
+ return build(0, ordered.length - 1);
43
+ };
44
+ for (const node of nodes) {
45
+ node.left ??= NO_STREAM;
46
+ node.right ??= NO_STREAM;
47
+ node.type ??= node.children ? 1 : 2;
48
+ if (node.children) node.child = link(node.children);
49
+ }
50
+
51
+ const sectors = [];
52
+ const fat = [];
53
+ const allocate = (bytes) => {
54
+ const count = Math.ceil(bytes.length / sectorSize);
55
+ const start = sectors.length;
56
+ for (let i = 0; i < count; i++) {
57
+ const sector = Buffer.alloc(sectorSize);
58
+ bytes.copy(sector, 0, i * sectorSize, (i + 1) * sectorSize);
59
+ sectors.push(sector);
60
+ fat.push(i === count - 1 ? END_OF_CHAIN : sectors.length);
61
+ }
62
+ return count ? start : END_OF_CHAIN;
63
+ };
64
+
65
+ const miniStreams = [];
66
+ const miniFat = [];
67
+ for (const node of nodes) {
68
+ if (node.type !== 2) continue;
69
+ node.size = node.data.length;
70
+ if (node.size >= MINI_STREAM_CUTOFF) {
71
+ node.start = allocate(node.data);
72
+ } else if (node.size === 0) {
73
+ node.start = END_OF_CHAIN;
74
+ } else {
75
+ const count = Math.ceil(node.size / MINI_SECTOR_SIZE);
76
+ node.start = miniFat.length;
77
+ for (let i = 0; i < count; i++) miniFat.push(i === count - 1 ? END_OF_CHAIN : miniFat.length + 1);
78
+ miniStreams.push(Buffer.concat([node.data, Buffer.alloc(count * MINI_SECTOR_SIZE - node.size)]));
79
+ }
80
+ }
81
+ const miniStream = Buffer.concat(miniStreams);
82
+ root.size = miniStream.length;
83
+ root.start = allocate(miniStream);
84
+
85
+ const miniFatBytes = Buffer.alloc(Math.ceil(miniFat.length / entriesPerSector) * entriesPerSector * 4, 0xff);
86
+ miniFat.forEach((next, i) => {
87
+ miniFatBytes.writeUInt32LE(next, i * 4);
88
+ });
89
+ const miniFatStart = allocate(miniFatBytes);
90
+
91
+ const directoryBytes = Buffer.alloc(Math.ceil(nodes.length / (sectorSize / 128)) * sectorSize);
92
+ directoryBytes.fill(0xff, 0, directoryBytes.length);
93
+ nodes.forEach((node, i) => {
94
+ const at = i * 128;
95
+ directoryBytes.fill(0, at, at + 128);
96
+ directoryBytes.write(node.name, at, 'utf16le');
97
+ directoryBytes.writeUInt16LE((node.name.length + 1) * 2, at + 64);
98
+ directoryBytes[at + 66] = node.type;
99
+ directoryBytes[at + 67] = 1;
100
+ directoryBytes.writeUInt32LE(node.left, at + 68);
101
+ directoryBytes.writeUInt32LE(node.right, at + 72);
102
+ directoryBytes.writeUInt32LE(node.child ?? NO_STREAM, at + 76);
103
+ directoryBytes.writeUInt32LE(node.start ?? 0, at + 116);
104
+ directoryBytes.writeUInt32LE(node.size ?? 0, at + 120);
105
+ });
106
+ // Entries past the last one are unallocated: type 0, with every id unset.
107
+ for (let at = nodes.length * 128; at < directoryBytes.length; at += 128) directoryBytes.fill(0, at, at + 68);
108
+ const directoryStart = allocate(directoryBytes);
109
+
110
+ // The FAT describes every sector including its own and the DIFAT's, so their counts settle together.
111
+ let fatCount = 0;
112
+ let difatCount = 0;
113
+ while (fatCount * entriesPerSector < fat.length + fatCount + difatCount) {
114
+ fatCount++;
115
+ difatCount =
116
+ fatCount > HEADER_DIFAT_ENTRIES ? Math.ceil((fatCount - HEADER_DIFAT_ENTRIES) / (entriesPerSector - 1)) : 0;
117
+ }
118
+ const fatSectorIds = Array.from({ length: fatCount }, (_, i) => sectors.length + i);
119
+ const difatSectorIds = Array.from({ length: difatCount }, (_, i) => sectors.length + fatCount + i);
120
+ const fatEntries = [...fat, ...fatSectorIds.map(() => FAT_SECTOR), ...difatSectorIds.map(() => DIFAT_SECTOR)];
121
+ const fatBytes = Buffer.alloc(fatCount * sectorSize, 0xff);
122
+ fatEntries.forEach((next, i) => {
123
+ fatBytes.writeUInt32LE(next, i * 4);
124
+ });
125
+ for (let i = 0; i < fatCount; i++) sectors.push(fatBytes.subarray(i * sectorSize, (i + 1) * sectorSize));
126
+ const beyondHeader = fatSectorIds.slice(HEADER_DIFAT_ENTRIES);
127
+ for (let i = 0; i < difatCount; i++) {
128
+ const sector = Buffer.alloc(sectorSize, 0xff);
129
+ beyondHeader.slice(i * (entriesPerSector - 1), (i + 1) * (entriesPerSector - 1)).forEach((id, j) => {
130
+ sector.writeUInt32LE(id, j * 4);
131
+ });
132
+ sector.writeUInt32LE(i === difatCount - 1 ? END_OF_CHAIN : difatSectorIds[i + 1], sectorSize - 4);
133
+ sectors.push(sector);
134
+ }
135
+
136
+ const header = Buffer.alloc(sectorSize);
137
+ Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]).copy(header, 0);
138
+ header.writeUInt16LE(0x3e, 24);
139
+ header.writeUInt16LE(version, 26);
140
+ header.writeUInt16LE(0xfffe, 28);
141
+ header.writeUInt16LE(version === 4 ? 12 : 9, 30);
142
+ header.writeUInt16LE(6, 32);
143
+ header.writeUInt32LE(version === 4 ? directoryBytes.length / sectorSize : 0, 40);
144
+ header.writeUInt32LE(fatCount, 44);
145
+ header.writeUInt32LE(directoryStart, 48);
146
+ header.writeUInt32LE(MINI_STREAM_CUTOFF, 56);
147
+ header.writeUInt32LE(miniFatStart, 60);
148
+ header.writeUInt32LE(miniFatBytes.length / sectorSize, 64);
149
+ header.writeUInt32LE(difatCount ? difatSectorIds[0] : END_OF_CHAIN, 68);
150
+ header.writeUInt32LE(difatCount, 72);
151
+ header.fill(0xff, 76, 512);
152
+ fatSectorIds.slice(0, HEADER_DIFAT_ENTRIES).forEach((id, i) => {
153
+ header.writeUInt32LE(id, 76 + i * 4);
154
+ });
155
+ return Buffer.concat([header, ...sectors]);
156
+ }
157
+
158
+ /** The root-level streams of an encrypted OOXML document ([MS-OFFCRYPTO] 2.3.4), with the given ciphertext size. */
159
+ export function encryptedOoxmlEntries(packageBytes = 5000) {
160
+ return [
161
+ { name: 'EncryptionInfo', data: Buffer.from([4, 0, 4, 0, 0x40, 0, 0, 0, ...Buffer.from('<encryption/>')]) },
162
+ { name: 'EncryptedPackage', data: Buffer.concat([Buffer.alloc(8), Buffer.alloc(packageBytes, 0x5a)]) },
163
+ {
164
+ name: '\u0006DataSpaces',
165
+ children: [
166
+ { name: 'Version', data: Buffer.alloc(76) },
167
+ { name: 'DataSpaceMap', data: Buffer.alloc(112) },
168
+ ],
169
+ },
170
+ ];
171
+ }
172
+
173
+ const sha = (algorithm, ...parts) => {
174
+ const digest = createHash(algorithm);
175
+ for (const part of parts) digest.update(part);
176
+ return digest.digest();
177
+ };
178
+ const le32 = (value) => {
179
+ const bytes = Buffer.alloc(4);
180
+ bytes.writeUInt32LE(value, 0);
181
+ return bytes;
182
+ };
183
+ const padTo = (bytes, block) => Buffer.concat([bytes, Buffer.alloc((block - (bytes.length % block)) % block)]);
184
+ const aes = (mode, key, iv, data) => {
185
+ const cipher = createCipheriv(`aes-${key.length * 8}-${mode}`, key, iv);
186
+ cipher.setAutoPadding(false);
187
+ return Buffer.concat([cipher.update(data), cipher.final()]);
188
+ };
189
+ const spun = (algorithm, salt, password, count) => {
190
+ let value = sha(algorithm, salt, Buffer.from(password, 'utf16le'));
191
+ for (let i = 0; i < count; i++) value = sha(algorithm, le32(i), value);
192
+ return value;
193
+ };
194
+ const withSize = (plain, ciphertext) => {
195
+ const size = Buffer.alloc(8);
196
+ size.writeBigUInt64LE(BigInt(plain.length), 0);
197
+ return Buffer.concat([size, ciphertext]);
198
+ };
199
+
200
+ // 2.3.4.10 HashAlgorithm values the fixture writes, with their OpenSSL names.
201
+ const AGILE_HASHES = { 'SHA-1': 'sha1', SHA1: 'sha1', SHA256: 'sha256', SHA384: 'sha384', SHA512: 'sha512' };
202
+ // Truncated to, or padded with 0x36 up to, `length` bytes (2.3.4.11, 2.3.4.12).
203
+ const fitted = (bytes, length) =>
204
+ bytes.length >= length
205
+ ? bytes.subarray(0, length)
206
+ : Buffer.concat([bytes, Buffer.alloc(length - bytes.length, 0x36)]);
207
+
208
+ /**
209
+ * The root-level streams of `plain` encrypted with `password` the way Office writes it, following [MS-OFFCRYPTO]
210
+ * in the encrypting direction: agile encryption (2.3.4.10–2.3.4.15, AES-256-CBC, SHA-512 or `hashAlgorithm`) by
211
+ * default, or standard encryption (2.3.4.5–2.3.4.9, AES-128-ECB, SHA-1). `spinCount` is lowered for agile tests to
212
+ * keep them fast. The keyData salt is `keySaltSize` bytes. Agile encryption carries dataIntegrity (2.3.4.14) unless
213
+ * `integrity` is false, its HMAC key a salt of `hmacSaltSize` bytes: hashSize, as Excel writes it, unless given.
214
+ */
215
+ export function encryptOoxmlEntries(
216
+ plain,
217
+ password,
218
+ {
219
+ scheme = 'agile',
220
+ spinCount = 1000,
221
+ hashAlgorithm = 'SHA512',
222
+ keySaltSize = 16,
223
+ integrity = true,
224
+ hmacSaltSize,
225
+ } = {}
226
+ ) {
227
+ let encryptionInfo;
228
+ let encryptedPackage;
229
+ if (scheme === 'agile') {
230
+ const algorithm = AGILE_HASHES[hashAlgorithm];
231
+ const hashSize = sha(algorithm).length;
232
+ const secret = randomBytes(32);
233
+ const keySalt = randomBytes(keySaltSize);
234
+ const passwordSalt = randomBytes(16);
235
+ const hashed = spun(algorithm, passwordSalt, password, spinCount);
236
+ const keyFor = (block) => fitted(sha(algorithm, hashed, Buffer.from(block)), 32);
237
+ const verifierInput = randomBytes(16);
238
+ const encrypted = (block, data) => aes('cbc', keyFor(block), passwordSalt, padTo(data, 16)).toString('base64');
239
+ const segments = [];
240
+ for (let offset = 0, index = 0; offset < plain.length; offset += 4096, index++) {
241
+ const iv = fitted(sha(algorithm, keySalt, le32(index)), 16);
242
+ segments.push(aes('cbc', secret, iv, padTo(plain.subarray(offset, offset + 4096), 16)));
243
+ }
244
+ encryptedPackage = withSize(plain, Buffer.concat(segments));
245
+ // 2.3.4.14: an HMAC of the whole stream keyed by a random salt, both encrypted with the package key.
246
+ const sealed = (block, data) =>
247
+ aes('cbc', secret, fitted(sha(algorithm, keySalt, Buffer.from(block)), 16), padTo(data, 16)).toString('base64');
248
+ const hmacSalt = randomBytes(hmacSaltSize ?? hashSize);
249
+ const hmac = createHmac(algorithm, hmacSalt).update(encryptedPackage).digest();
250
+ const dataIntegrity = integrity
251
+ ? `<dataIntegrity encryptedHmacKey="${sealed([0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6], hmacSalt)}"` +
252
+ ` encryptedHmacValue="${sealed([0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33], hmac)}"/>`
253
+ : '';
254
+ const cipher = `blockSize="16" keyBits="256" hashSize="${hashSize}" cipherAlgorithm="AES" cipherChaining="ChainingModeCBC" hashAlgorithm="${hashAlgorithm}"`;
255
+ const xml =
256
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n` +
257
+ `<encryption xmlns="http://schemas.microsoft.com/office/2006/encryption" xmlns:p="http://schemas.microsoft.com/office/2006/keyEncryptor/password">` +
258
+ `<keyData saltSize="${keySaltSize}" ${cipher} saltValue="${keySalt.toString('base64')}"/>` +
259
+ dataIntegrity +
260
+ `<keyEncryptors><keyEncryptor uri="http://schemas.microsoft.com/office/2006/keyEncryptor/password">` +
261
+ `<p:encryptedKey spinCount="${spinCount}" saltSize="16" ${cipher} saltValue="${passwordSalt.toString('base64')}"` +
262
+ ` encryptedVerifierHashInput="${encrypted([0xfe, 0xa7, 0xd2, 0x76, 0x3b, 0x4b, 0x9e, 0x79], verifierInput)}"` +
263
+ ` encryptedVerifierHashValue="${encrypted([0xd7, 0xaa, 0x0f, 0x6d, 0x30, 0x61, 0x34, 0x4e], sha(algorithm, verifierInput))}"` +
264
+ ` encryptedKeyValue="${encrypted([0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6], secret)}"/>` +
265
+ `</keyEncryptor></keyEncryptors></encryption>`;
266
+ encryptionInfo = Buffer.concat([Buffer.from([4, 0, 4, 0, 0x40, 0, 0, 0]), Buffer.from(xml, 'utf8')]);
267
+ } else {
268
+ const salt = randomBytes(16);
269
+ const final = sha('sha1', spun('sha1', salt, password, 50_000), le32(0));
270
+ const derive = (fill) => {
271
+ const buffer = Buffer.alloc(64, fill);
272
+ for (let i = 0; i < final.length; i++) buffer[i] ^= final[i];
273
+ return sha('sha1', buffer);
274
+ };
275
+ const key = Buffer.concat([derive(0x36), derive(0x5c)]).subarray(0, 16);
276
+ const verifier = randomBytes(16);
277
+ const csp = Buffer.from('Microsoft Enhanced RSA and AES Cryptographic Provider\0', 'utf16le');
278
+ const header = Buffer.concat([...[0x24, 0, 0x660e, 0x8004, 128, 0x18, 0, 0].map(le32), csp]);
279
+ encryptionInfo = Buffer.concat([
280
+ Buffer.from([3, 0, 2, 0]),
281
+ le32(0x24),
282
+ le32(header.length),
283
+ header,
284
+ le32(16),
285
+ salt,
286
+ aes('ecb', key, null, verifier),
287
+ le32(20),
288
+ aes('ecb', key, null, padTo(sha('sha1', verifier), 16)),
289
+ ]);
290
+ encryptedPackage = withSize(plain, aes('ecb', key, null, padTo(plain, 16)));
291
+ }
292
+ return [
293
+ { name: 'EncryptionInfo', data: encryptionInfo },
294
+ { name: 'EncryptedPackage', data: encryptedPackage },
295
+ {
296
+ name: '\u0006DataSpaces',
297
+ children: [
298
+ { name: 'Version', data: Buffer.alloc(76) },
299
+ { name: 'DataSpaceMap', data: Buffer.alloc(112) },
300
+ ],
301
+ },
302
+ ];
303
+ }
304
+
305
+ /** A binary Word document's "WordDocument" stream whose FibBase ([MS-DOC] 2.5.1) has fEncrypted set or clear. */
306
+ export function wordDocumentStream(encrypted) {
307
+ const data = Buffer.alloc(6000, 3);
308
+ data.writeUInt16LE(0xa5ec, 0);
309
+ data.writeUInt16LE(0x00c1, 2);
310
+ data.writeUInt16LE(encrypted ? 0x0100 | 0x0200 : 0x0200, 10);
311
+ return { name: 'WordDocument', data };
312
+ }
313
+
314
+ /** A "Current User" stream holding a CurrentUserAtom ([MS-PPT] 2.3.2) with this headerToken. */
315
+ export function currentUserStream(headerToken, userName = 'Mixdog') {
316
+ const name = Buffer.from(userName, 'latin1');
317
+ const atom = Buffer.alloc(28 + name.length + 4);
318
+ atom.writeUInt16LE(0x0ff6, 2);
319
+ atom.writeUInt32LE(atom.length - 8, 4);
320
+ atom.writeUInt32LE(0x14, 8);
321
+ atom.writeUInt32LE(headerToken, 12);
322
+ atom.writeUInt16LE(name.length, 20);
323
+ atom.writeUInt16LE(0x03f4, 22);
324
+ atom[24] = 3;
325
+ name.copy(atom, 28);
326
+ atom.writeUInt32LE(8, 28 + name.length);
327
+ return { name: 'Current User', data: atom };
328
+ }
@@ -0,0 +1,311 @@
1
+ // Password protection, read from the Compound File Binary container itself.
2
+ //
3
+ // An encrypted Office document is a CFB container ([MS-CFB]) whose root storage holds well-known streams. The CFB
4
+ // signature alone proves nothing (an unencrypted legacy .doc renamed .docx has it too), so the root-level stream
5
+ // names are listed the way [MS-CFB] lays them out: the header (2.2), the FAT (2.3) and DIFAT (2.5) to follow the
6
+ // directory sector chain, the directory entries (2.6.1, 2.6.2) and the sibling tree under the root (2.6.4). Only the
7
+ // header, the directory sectors and at most one 64-byte slice of one stream are read, never the whole file. Any
8
+ // malformed or truncated container is "not protected", and the renderer then decides exactly as it would have.
9
+ //
10
+ // The file is untrusted: every count and size it declares is checked against its real length before it is used,
11
+ // every sector chain is walked with a record of the sectors it visited (a sector seen twice is a cycle, and the
12
+ // container malformed), and no walk ever goes past a fixed number of sectors, whatever the file declares.
13
+ import { open } from 'node:fs/promises';
14
+
15
+ const SIGNATURE = Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
16
+ const HEADER_SIZE = 512;
17
+ const HEADER_DIFAT_ENTRIES = 109;
18
+ const DIRECTORY_ENTRY_SIZE = 128;
19
+ const MINI_SECTOR_SIZE = 64;
20
+ // [MS-CFB] 2.2: the mini stream cutoff MUST be 4096 bytes.
21
+ const MINI_STREAM_CUTOFF = 4096;
22
+ const MAX_REGULAR_SECTOR = 0xfffffffa;
23
+ const END_OF_CHAIN = 0xfffffffe;
24
+ const NO_STREAM = 0xffffffff;
25
+ const ENTRY_STREAM = 2;
26
+ const ENTRY_ROOT_STORAGE = 5;
27
+ const MAX_DIRECTORY_BYTES = 1024 * 1024;
28
+ // Beyond this an encrypted package is not unlocked for a preview (it is decrypted in memory).
29
+ const MAX_ENCRYPTION_INFO_BYTES = 64 * 1024;
30
+ const MAX_ENCRYPTED_PACKAGE_BYTES = 256 * 1024 * 1024;
31
+ // The most sectors any one chain walk visits: the 512-byte sectors of the largest stream ever read.
32
+ const MAX_CHAIN_SECTORS = MAX_ENCRYPTED_PACKAGE_BYTES / 512;
33
+
34
+ async function readExactly(handle, position, length) {
35
+ const buffer = Buffer.alloc(length);
36
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
37
+ if (bytesRead !== length) throw new Error('truncated compound file');
38
+ return buffer;
39
+ }
40
+
41
+ // Stream names are compared case-insensitively by uppercase mapping ([MS-CFB] 2.6.4), so they are keyed uppercase.
42
+ const streamKey = (name) => name.toUpperCase();
43
+
44
+ async function openCompoundFile(handle) {
45
+ const header = await readExactly(handle, 0, HEADER_SIZE);
46
+ const majorVersion = header.readUInt16LE(26);
47
+ const sectorShift = header.readUInt16LE(30);
48
+ if (
49
+ !header.subarray(0, SIGNATURE.length).equals(SIGNATURE) ||
50
+ header.readUInt16LE(28) !== 0xfffe ||
51
+ sectorShift !== { 3: 9, 4: 12 }[majorVersion] ||
52
+ header.readUInt16LE(32) !== 6 ||
53
+ header.readUInt32LE(56) !== MINI_STREAM_CUTOFF
54
+ ) {
55
+ throw new Error('not a compound file');
56
+ }
57
+ const sectorSize = 1 << sectorShift;
58
+ const entriesPerSector = sectorSize / 4;
59
+ // Sector n starts at byte (n + 1) * sectorSize, after the header sector; a last partial sector still counts.
60
+ const sectorsInFile = Math.max(0, Math.ceil((await handle.stat()).size / sectorSize) - 1);
61
+ const fatSectorCount = header.readUInt32LE(44);
62
+ const miniFatSectorCount = header.readUInt32LE(64);
63
+ const difatSectorCount = header.readUInt32LE(72);
64
+ // A count of sectors larger than the file has sectors is false.
65
+ if (Math.max(fatSectorCount, miniFatSectorCount, difatSectorCount) > sectorsInFile) {
66
+ throw new Error('sector counts beyond the file');
67
+ }
68
+
69
+ const readSector = (sector, offset, length) => {
70
+ if (sector > MAX_REGULAR_SECTOR || sector >= sectorsInFile) throw new Error('sector number out of range');
71
+ return readExactly(handle, (sector + 1) * sectorSize + offset, length);
72
+ };
73
+
74
+ // The DIFAT sector chain (2.5), followed lazily through the last entry of each of its sectors.
75
+ const difatSectors = [];
76
+ const difatSeen = new Set();
77
+ async function difatSector(hop) {
78
+ if (hop >= Math.min(difatSectorCount, MAX_CHAIN_SECTORS)) throw new Error('sector beyond the DIFAT');
79
+ while (difatSectors.length <= hop) {
80
+ const sector = difatSectors.length
81
+ ? (await readSector(difatSectors.at(-1), (entriesPerSector - 1) * 4, 4)).readUInt32LE(0)
82
+ : header.readUInt32LE(68);
83
+ if (difatSeen.has(sector)) throw new Error('cyclic DIFAT chain');
84
+ difatSeen.add(sector);
85
+ difatSectors.push(sector);
86
+ }
87
+ return difatSectors[hop];
88
+ }
89
+
90
+ // Location of the index-th FAT sector: the first 109 are in the header, the rest in the DIFAT sector chain (2.5).
91
+ async function fatSectorLocation(index) {
92
+ if (index >= fatSectorCount) throw new Error('sector beyond the FAT');
93
+ if (index < HEADER_DIFAT_ENTRIES) return header.readUInt32LE(76 + index * 4);
94
+ const perDifatSector = entriesPerSector - 1;
95
+ const past = index - HEADER_DIFAT_ENTRIES;
96
+ const sector = await difatSector(Math.floor(past / perDifatSector));
97
+ return (await readSector(sector, (past % perDifatSector) * 4, 4)).readUInt32LE(0);
98
+ }
99
+
100
+ // A whole stream walks one FAT entry per sector, so each FAT sector is read once and kept.
101
+ const fatSectors = new Map();
102
+ async function nextSector(sector) {
103
+ const index = Math.floor(sector / entriesPerSector);
104
+ let fat = fatSectors.get(index);
105
+ if (!fat) {
106
+ fat = await readSector(await fatSectorLocation(index), 0, sectorSize);
107
+ fatSectors.set(index, fat);
108
+ }
109
+ return fat.readUInt32LE((sector % entriesPerSector) * 4);
110
+ }
111
+
112
+ // A sector chain from `first`, followed lazily and remembered: `chain(n)` is its n-th sector, or null past its
113
+ // end. A chain longer than `limit` sectors (or MAX_CHAIN_SECTORS), one that leaves the file, or one that comes
114
+ // back to a sector it already visited is malformed.
115
+ function sectorChain(first, limit) {
116
+ const sectors = [];
117
+ const seen = new Set();
118
+ let ended = false;
119
+ return async (index) => {
120
+ while (sectors.length <= index) {
121
+ if (ended) return null;
122
+ const sector = sectors.length ? await nextSector(sectors.at(-1)) : first;
123
+ if (sector === END_OF_CHAIN) {
124
+ ended = true;
125
+ return null;
126
+ }
127
+ if (sector > MAX_REGULAR_SECTOR || sector >= sectorsInFile) throw new Error('sector outside the file');
128
+ if (seen.has(sector)) throw new Error('cyclic sector chain');
129
+ if (sectors.length >= Math.min(limit, MAX_CHAIN_SECTORS)) throw new Error('sector chain too long');
130
+ seen.add(sector);
131
+ sectors.push(sector);
132
+ }
133
+ return sectors[index];
134
+ };
135
+ }
136
+
137
+ const directoryParts = [];
138
+ const directorySectors = sectorChain(header.readUInt32LE(48), MAX_DIRECTORY_BYTES / sectorSize);
139
+ for (
140
+ let sector = await directorySectors(0);
141
+ sector !== null;
142
+ sector = await directorySectors(directoryParts.length)
143
+ ) {
144
+ directoryParts.push(await readSector(sector, 0, sectorSize));
145
+ }
146
+ const directory = Buffer.concat(directoryParts);
147
+
148
+ function entryAt(id) {
149
+ const at = id * DIRECTORY_ENTRY_SIZE;
150
+ if (at + DIRECTORY_ENTRY_SIZE > directory.length) throw new Error('directory entry out of range');
151
+ const nameBytes = directory.readUInt16LE(at + 64);
152
+ if (nameBytes > 64) throw new Error('directory entry name too long');
153
+ return {
154
+ name: streamKey(directory.toString('utf16le', at, at + Math.max(nameBytes - 2, 0))),
155
+ type: directory[at + 66],
156
+ left: directory.readUInt32LE(at + 68),
157
+ right: directory.readUInt32LE(at + 72),
158
+ child: directory.readUInt32LE(at + 76),
159
+ start: directory.readUInt32LE(at + 116),
160
+ // Version 3 files may leave the high half of the size uninitialized; it is ignored there.
161
+ size: majorVersion === 3 ? directory.readUInt32LE(at + 120) : Number(directory.readBigUInt64LE(at + 120)),
162
+ };
163
+ }
164
+
165
+ const root = entryAt(0);
166
+ if (root.type !== ENTRY_ROOT_STORAGE) throw new Error('missing root storage');
167
+ // Only the siblings directly under the root: a storage's own children are never followed.
168
+ const streams = new Map();
169
+ const visited = new Set();
170
+ const pending = [root.child];
171
+ while (pending.length) {
172
+ const id = pending.pop();
173
+ if (id === NO_STREAM) continue;
174
+ if (visited.has(id)) throw new Error('cyclic directory tree');
175
+ visited.add(id);
176
+ const entry = entryAt(id);
177
+ if (entry.type === ENTRY_STREAM) streams.set(entry.name, entry);
178
+ pending.push(entry.left, entry.right);
179
+ }
180
+
181
+ // The mini stream ([MS-CFB] 2.6.2) is the root entry's own stream: root.size bytes, which cannot be more than the
182
+ // file holds, in a regular sector chain followed lazily and shared by every read.
183
+ let miniStreamSectors = null;
184
+ async function readMiniStream(offset, length) {
185
+ // Nothing past the root entry's size is stream data. A read never crosses a sector: mini sectors are 64-byte
186
+ // aligned and a read is at most one of them.
187
+ if (offset + length > root.size) throw new Error('mini stream too short');
188
+ if (root.size > sectorsInFile * sectorSize) throw new Error('mini stream larger than the file');
189
+ miniStreamSectors ??= sectorChain(root.start, Math.ceil(root.size / sectorSize));
190
+ const sector = await miniStreamSectors(Math.floor(offset / sectorSize));
191
+ if (sector === null) throw new Error('mini stream sector missing');
192
+ return readSector(sector, offset % sectorSize, length);
193
+ }
194
+
195
+ // The mini FAT (header offset 60, at most miniFatSectorCount sectors) maps each mini sector to the next one.
196
+ let miniFatSectors = null;
197
+ async function nextMiniSector(mini) {
198
+ miniFatSectors ??= sectorChain(header.readUInt32LE(60), miniFatSectorCount);
199
+ const sector = await miniFatSectors(Math.floor(mini / entriesPerSector));
200
+ if (sector === null) throw new Error('mini FAT too short');
201
+ return (await readSector(sector, (mini % entriesPerSector) * 4, 4)).readUInt32LE(0);
202
+ }
203
+
204
+ return {
205
+ hasStream: (name) => streams.has(streamKey(name)),
206
+ // The first `length` (at most 64) bytes of a root-level stream. A stream under the mini-stream cutoff lives in
207
+ // the root entry's mini stream ([MS-CFB] 2.6.2), whose mini sector `start` sits at a 64-byte multiple.
208
+ async readStreamHead(name, length) {
209
+ const entry = streams.get(streamKey(name));
210
+ if (!entry || entry.size < length) throw new Error('stream missing or too short');
211
+ if (entry.size >= MINI_STREAM_CUTOFF) return readSector(entry.start, 0, length);
212
+ return readMiniStream(entry.start * MINI_SECTOR_SIZE, length);
213
+ },
214
+ // A whole root-level stream of at most `maxBytes`. Regular sectors are read in contiguous runs; a stream under
215
+ // the cutoff follows its mini FAT chain through the root entry's mini stream ([MS-CFB] 2.3), each mini sector
216
+ // once.
217
+ async readStream(name, maxBytes) {
218
+ const entry = streams.get(streamKey(name));
219
+ if (!entry || entry.size > maxBytes) throw new Error('stream missing or too large');
220
+ if (entry.size >= MINI_STREAM_CUTOFF) {
221
+ const sectorCount = Math.ceil(entry.size / sectorSize);
222
+ const chain = sectorChain(entry.start, sectorCount);
223
+ const parts = [];
224
+ let run = null;
225
+ for (let index = 0; index < sectorCount; index++) {
226
+ const sector = await chain(index);
227
+ if (sector === null) throw new Error('stream shorter than its size');
228
+ if (run && sector === run.first + run.count) run.count++;
229
+ else {
230
+ run = { first: sector, count: 1 };
231
+ parts.push(run);
232
+ }
233
+ }
234
+ const data = Buffer.concat(
235
+ await Promise.all(parts.map((part) => readSector(part.first, 0, part.count * sectorSize)))
236
+ );
237
+ return data.subarray(0, entry.size);
238
+ }
239
+ const miniCount = Math.ceil(entry.size / MINI_SECTOR_SIZE);
240
+ const parts = [];
241
+ const seen = new Set();
242
+ let mini = entry.start;
243
+ for (let index = 0; index < miniCount; index++) {
244
+ if (index) mini = await nextMiniSector(mini);
245
+ if (seen.has(mini)) throw new Error('cyclic mini sector chain');
246
+ seen.add(mini);
247
+ parts.push(await readMiniStream(mini * MINI_SECTOR_SIZE, MINI_SECTOR_SIZE));
248
+ }
249
+ return Buffer.concat(parts).subarray(0, entry.size);
250
+ },
251
+ };
252
+ }
253
+
254
+ async function inspectCompoundFile(path, decide, fallback = false) {
255
+ let handle;
256
+ try {
257
+ handle = await open(path, 'r');
258
+ return await decide(await openCompoundFile(handle));
259
+ } catch {
260
+ return fallback;
261
+ } finally {
262
+ await handle?.close();
263
+ }
264
+ }
265
+
266
+ /**
267
+ * The \EncryptionInfo and \EncryptedPackage streams of an encrypted OOXML document ([MS-OFFCRYPTO] 2.3.4.4,
268
+ * 2.3.4.5), or null when the container cannot be read or the package is too large to unlock.
269
+ */
270
+ export const readEncryptedOoxml = (path) =>
271
+ inspectCompoundFile(
272
+ path,
273
+ async (file) => ({
274
+ encryptionInfo: await file.readStream('EncryptionInfo', MAX_ENCRYPTION_INFO_BYTES),
275
+ encryptedPackage: await file.readStream('EncryptedPackage', MAX_ENCRYPTED_PACKAGE_BYTES),
276
+ }),
277
+ null
278
+ );
279
+
280
+ /** Only the \EncryptionInfo stream, enough to tell whether the encryption is one a preview can unlock. */
281
+ export const readOoxmlEncryptionInfo = (path) =>
282
+ inspectCompoundFile(path, (file) => file.readStream('EncryptionInfo', MAX_ENCRYPTION_INFO_BYTES), null);
283
+
284
+ /**
285
+ * [MS-DOC] 2.5.1 FibBase, at the start of the "WordDocument" stream: wIdent 0xA5EC, and fEncrypted (bit 0x0100 of
286
+ * the flags word at offset 10) set when the document is encrypted or XOR-obfuscated, both opened by a password.
287
+ */
288
+ export const isEncryptedWordDocument = (path) =>
289
+ inspectCompoundFile(path, async (file) => {
290
+ const fib = await file.readStreamHead('WordDocument', 12);
291
+ return fib.readUInt16LE(0) === 0xa5ec && (fib.readUInt16LE(10) & 0x0100) !== 0;
292
+ });
293
+
294
+ /**
295
+ * [MS-OFFCRYPTO] 2.3.4 (ECMA-376 Document Encryption): an encrypted OOXML document is a CFB container holding the
296
+ * root-level streams \EncryptionInfo (2.3.4.5) and \EncryptedPackage (2.3.4.4).
297
+ */
298
+ export const isEncryptedOoxml = (path) =>
299
+ inspectCompoundFile(path, (file) => file.hasStream('EncryptionInfo') && file.hasStream('EncryptedPackage'));
300
+
301
+ /**
302
+ * [MS-PPT] binary presentation: an "EncryptedSummary" stream (2.1.6) exists only in an encrypted document, and an
303
+ * encrypted file's "Current User" stream (2.1.1), which is never encrypted, holds a CurrentUserAtom (2.3.2:
304
+ * RecordHeader with recType 0x0FF6, size, headerToken) whose headerToken 0xF3D1C4DF means the file MUST be encrypted.
305
+ */
306
+ export const isEncryptedBinaryPresentation = (path) =>
307
+ inspectCompoundFile(path, async (file) => {
308
+ if (file.hasStream('EncryptedSummary')) return true;
309
+ const atom = await file.readStreamHead('Current User', 16);
310
+ return atom.readUInt16LE(2) === 0x0ff6 && atom.readUInt32LE(12) === 0xf3d1c4df;
311
+ });