narraeon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +164 -0
- package/README.zh-CN.md +164 -0
- package/dist/node/cli/NarraeonCli.js +163 -0
- package/dist/node/cli/main.js +38 -0
- package/dist/node/protocol/appPreferences.js +5 -0
- package/dist/node/protocol/contentTree.js +14 -0
- package/dist/node/protocol/modelConnections.js +110 -0
- package/dist/node/protocol/playerViews.js +1 -0
- package/dist/node/protocol/v1.js +278 -0
- package/dist/node/runtime/V1Runtime.js +625 -0
- package/dist/node/runtime/artifact/FileNativeArtifactStore.js +1914 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +92 -0
- package/dist/node/runtime/config/appPaths.js +19 -0
- package/dist/node/runtime/content/ContentLimits.js +1 -0
- package/dist/node/runtime/content/ContentTreeFile.js +1 -0
- package/dist/node/runtime/content/ContentWorkspace.js +601 -0
- package/dist/node/runtime/content/CurrentTreeContentLibrary.js +721 -0
- package/dist/node/runtime/content/FileNativeContentTree.js +512 -0
- package/dist/node/runtime/extension/FrontendExtensionBundle.js +229 -0
- package/dist/node/runtime/extension/PlayerViewPanelProjector.js +121 -0
- package/dist/node/runtime/model/AiFailureLog.js +332 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +1246 -0
- package/dist/node/runtime/model/ModelConnectionStore.js +378 -0
- package/dist/node/runtime/model/ModelHost.js +89 -0
- package/dist/node/runtime/model/ModelHostStream.js +367 -0
- package/dist/node/runtime/model/ProviderExchangeState.js +1 -0
- package/dist/node/runtime/model/ProviderStream.js +90 -0
- package/dist/node/runtime/model/SettingAuthorStream.js +118 -0
- package/dist/node/runtime/play/FileNativeContinuityCorrection.js +304 -0
- package/dist/node/runtime/play/FileNativePlayPresetStore.js +1856 -0
- package/dist/node/runtime/play/PlayCallChain.js +1280 -0
- package/dist/node/runtime/play/PlayDocumentTools.js +1121 -0
- package/dist/node/runtime/play/PlayFollowupRequests.js +183 -0
- package/dist/node/runtime/play/PlayPresetWorkbench.js +185 -0
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +1425 -0
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +192 -0
- package/dist/node/runtime/prompt/MaterialSelection.js +95 -0
- package/dist/node/runtime/setting/DocumentCandidateSettingImprovement.js +1726 -0
- package/dist/node/runtime/world/FileNativeWorldStore.js +2028 -0
- package/dist/node/runtime/world/PlayerViewRenderer.js +322 -0
- package/dist/node/runtime/world/WorldDocumentStore.js +2808 -0
- package/dist/node/runtime/world/WorldOperationCoordinator.js +490 -0
- package/dist/node/runtime/world/WorldStateRevision.js +75 -0
- package/dist/node/server/createRuntime.js +6 -0
- package/dist/node/server/createServer.js +218 -0
- package/dist/node/server/main.js +25 -0
- package/dist/node/server/startWebServer.js +28 -0
- package/dist/node/shared/default-play-prompts.js +85 -0
- package/dist/node/shared/default-preset-host.js +538 -0
- package/dist/node/shared/default-setting-improvement-prompt.js +77 -0
- package/dist/node/shared/first-party-action-choices.js +164 -0
- package/dist/node/shared/first-party-generic-panels.js +105 -0
- package/dist/node/shared/first-party-play-preset-templates.js +41 -0
- package/dist/node/shared/first-party-player-view.js +179 -0
- package/dist/node/shared/play-preset-prompt-roles.js +4 -0
- package/dist/web/assets/index-CRGTZ9Ak.css +1 -0
- package/dist/web/assets/index-DDx1itzT.js +1192 -0
- package/dist/web/index.html +14 -0
- package/package.json +86 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import { isAbsolute, win32 } from "node:path";
|
|
2
|
+
import { inflateRawSync } from "node:zlib";
|
|
3
|
+
import { portableContentTreePathKey } from "../../protocol/contentTree.js";
|
|
4
|
+
import { defaultAppLocale, } from "../../protocol/appPreferences.js";
|
|
5
|
+
import { defaultContentWorkspaceLimits, maximumPortableContentArchiveBytes, } from "./ContentLimits.js";
|
|
6
|
+
import { CurrentTreeContentLibrary, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
7
|
+
import { inspectContentPackageCurrentTree, isContentPackageWorldDocumentPath, worldDocumentTextRequirement, } from "./FileNativeContentTree.js";
|
|
8
|
+
export { CurrentTreeContentPackageBusyError, CurrentTreeContentLibraryError, CurrentTreeContentPackageNotFoundError, InvalidContentTreeError, } from "./CurrentTreeContentLibrary.js";
|
|
9
|
+
const portableTreeUtf8Decoder = new TextDecoder("utf-8", {
|
|
10
|
+
fatal: true,
|
|
11
|
+
ignoreBOM: true,
|
|
12
|
+
});
|
|
13
|
+
const zipPathUtf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
|
14
|
+
const defaultLimits = defaultContentWorkspaceLimits;
|
|
15
|
+
export class ContentPackageImportError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ContentPackageImportError";
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ContentWorkspace {
|
|
24
|
+
#currentTreeLibrary;
|
|
25
|
+
#limits;
|
|
26
|
+
#beforePortableContentExport;
|
|
27
|
+
#locale;
|
|
28
|
+
#mutationTail = Promise.resolve();
|
|
29
|
+
constructor(dataRoot, options = {}) {
|
|
30
|
+
this.#limits = normalizeLimits(options.limits);
|
|
31
|
+
this.#locale = options.locale ?? (() => defaultAppLocale);
|
|
32
|
+
this.#beforePortableContentExport = options.beforePortableContentExport;
|
|
33
|
+
this.#currentTreeLibrary = new CurrentTreeContentLibrary(dataRoot, {
|
|
34
|
+
limits: this.#limits,
|
|
35
|
+
...options.currentTree,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
createCurrentTreeContentPackage() {
|
|
39
|
+
return this.#currentTreeLibrary.createPackage(minimalFileNativeContentScaffold(this.#locale()));
|
|
40
|
+
}
|
|
41
|
+
listCurrentTreeContentPackages() {
|
|
42
|
+
return this.#currentTreeLibrary.listPackages();
|
|
43
|
+
}
|
|
44
|
+
readCurrentTreeContentPackage(localId) {
|
|
45
|
+
return this.#currentTreeLibrary.readPackage(localId);
|
|
46
|
+
}
|
|
47
|
+
beginCurrentTreeContentPackageOperation(localId) {
|
|
48
|
+
return this.#currentTreeLibrary.acquireOperationLease(localId);
|
|
49
|
+
}
|
|
50
|
+
inspectCurrentTreeContentPackage(files) {
|
|
51
|
+
return inspectContentPackageCurrentTree(files);
|
|
52
|
+
}
|
|
53
|
+
replaceCurrentTreeContentPackage(localId, files, options = {}) {
|
|
54
|
+
return this.#currentTreeLibrary.replaceCurrentTree(localId, files, options);
|
|
55
|
+
}
|
|
56
|
+
importPortableContentPackageArchive(archive, options = {}) {
|
|
57
|
+
return this.#mutate(async () => {
|
|
58
|
+
const files = readPortableContentArchive(archive, this.#limits, options.signal);
|
|
59
|
+
options.signal?.throwIfAborted();
|
|
60
|
+
const currentTree = files.map(toCurrentTreeFile);
|
|
61
|
+
return this.#currentTreeLibrary.importPackage(currentTree, options.signal === undefined ? {} : { signal: options.signal });
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
copyCurrentTreeContentPackage(localId, options = {}) {
|
|
65
|
+
return this.#mutate(() => this.#currentTreeLibrary.copyPackage(localId, options.signal === undefined ? {} : { signal: options.signal }));
|
|
66
|
+
}
|
|
67
|
+
deleteCurrentTreeContentPackage(localId, options = {}) {
|
|
68
|
+
return this.#mutate(() => this.#currentTreeLibrary.deletePackage(localId, options));
|
|
69
|
+
}
|
|
70
|
+
renameCurrentTreeContentPackage(localId, name, options = {}) {
|
|
71
|
+
return this.#mutate(() => this.#currentTreeLibrary.renamePackage(localId, name, options));
|
|
72
|
+
}
|
|
73
|
+
exportCurrentTreeContentPackage(localId, options = {}) {
|
|
74
|
+
return this.#mutate(async () => {
|
|
75
|
+
options.signal?.throwIfAborted();
|
|
76
|
+
const package_ = await this.#currentTreeLibrary.readPackage(localId);
|
|
77
|
+
if (package_.status !== "usable") {
|
|
78
|
+
throw new InvalidContentTreeError("Only a complete, valid content package can be exported; fix every validation issue first");
|
|
79
|
+
}
|
|
80
|
+
await this.#beforePortableContentExport?.();
|
|
81
|
+
options.signal?.throwIfAborted();
|
|
82
|
+
const archive = createPortableContentArchive(package_.files);
|
|
83
|
+
await crashProcessAtPortableContentExportEdge("after_archive_created");
|
|
84
|
+
options.signal?.throwIfAborted();
|
|
85
|
+
return {
|
|
86
|
+
fileName: `${portableArchiveBaseName(package_.displayName)}.zip`,
|
|
87
|
+
archive,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async #mutate(operation) {
|
|
92
|
+
const previous = this.#mutationTail;
|
|
93
|
+
let release = () => undefined;
|
|
94
|
+
this.#mutationTail = new Promise((resolve) => {
|
|
95
|
+
release = resolve;
|
|
96
|
+
});
|
|
97
|
+
await previous;
|
|
98
|
+
try {
|
|
99
|
+
return await operation();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
release();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A new package is immediately valid and editable. These files are a protocol
|
|
108
|
+
* scaffold, not a sample story: an authoring model replaces the placeholders
|
|
109
|
+
* and extends the tree without first having to reconstruct hidden control
|
|
110
|
+
* formats from diagnostics.
|
|
111
|
+
*/
|
|
112
|
+
export function minimalFileNativeContentScaffold(locale = defaultAppLocale) {
|
|
113
|
+
if (locale === "zh-CN") {
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
path: "opening.md",
|
|
117
|
+
contents: `你站在一个尚待创作的世界入口,眼前的地点、人物与局面仍等待作者补全。风从没有名字的方向吹过来,一个还没有面孔的人正朝你走近,脚步声一下比一下清楚,最后停在你面前。
|
|
118
|
+
`,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
path: "world/current-situation.yaml",
|
|
122
|
+
contents: `$document:
|
|
123
|
+
id: situation.current
|
|
124
|
+
ref: current-situation
|
|
125
|
+
title: 待创作世界
|
|
126
|
+
summary: 当前正在发生且接下来不能忘记的短期局面。
|
|
127
|
+
aliases: []
|
|
128
|
+
地点: 未设定
|
|
129
|
+
在场: []
|
|
130
|
+
正在发生: 未设定
|
|
131
|
+
短期连续性: []
|
|
132
|
+
`,
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
path: "control/frame.yaml",
|
|
136
|
+
contents: defaultWorldFrame,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
path: "control/blocks/world.md",
|
|
140
|
+
contents: `<!-- 本文件只写这个世界特有的规则。通用裁决与状态维护判据由主持预设提供,每条游玩调用链都会加载,不要在这里重复。 -->
|
|
141
|
+
|
|
142
|
+
# 本世界的题材与专属规则
|
|
143
|
+
|
|
144
|
+
## 题材与边界
|
|
145
|
+
|
|
146
|
+
(待创作:这个世界的题材、基调,以及可以发生和不该发生的事。)
|
|
147
|
+
|
|
148
|
+
## 本世界的文档与保存位置
|
|
149
|
+
|
|
150
|
+
(待创作:这个世界有哪些文档类型,某类结果该写进哪一份。例如「人物的修为变化写入该人物文档的『修为』字段」。未在这里列出的按通用判据处理。)
|
|
151
|
+
|
|
152
|
+
## 专属规则
|
|
153
|
+
|
|
154
|
+
(待创作:只有这个世界成立的语义规则,例如修为顺序、关系或货币的含义。若该规则已是独立世界文档,这里只需指出该去看哪一份。)
|
|
155
|
+
`,
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
path: "control/player-views.yaml",
|
|
159
|
+
contents: defaultPlayerViews,
|
|
160
|
+
},
|
|
161
|
+
];
|
|
162
|
+
}
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
path: "opening.md",
|
|
166
|
+
contents: `You stand at the threshold of a world still waiting to be written. Its places, people, and immediate circumstances remain for the author to define. Wind blows from a direction without a name. A faceless figure approaches, each footstep clearer than the last, then stops before you.
|
|
167
|
+
`,
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
path: "world/current-situation.yaml",
|
|
171
|
+
contents: `$document:
|
|
172
|
+
id: situation.current
|
|
173
|
+
ref: current-situation
|
|
174
|
+
title: Unwritten world
|
|
175
|
+
summary: The short-term situation happening now that must not be forgotten next.
|
|
176
|
+
aliases: []
|
|
177
|
+
location: Not set
|
|
178
|
+
present: []
|
|
179
|
+
in_progress: Not set
|
|
180
|
+
short_term_continuity: []
|
|
181
|
+
`,
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
path: "control/frame.yaml",
|
|
185
|
+
contents: defaultWorldFrame,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
path: "control/blocks/world.md",
|
|
189
|
+
contents: `<!-- Keep only rules unique to this world in this file. The host preset supplies general adjudication and state-maintenance criteria to every play call chain; do not repeat them here. -->
|
|
190
|
+
|
|
191
|
+
# This world's genre and special rules
|
|
192
|
+
|
|
193
|
+
## Genre and boundaries
|
|
194
|
+
|
|
195
|
+
(To be written: this world's genre, tone, and what may or should not happen.)
|
|
196
|
+
|
|
197
|
+
## World documents and where changes belong
|
|
198
|
+
|
|
199
|
+
(To be written: which document types this world has and where each kind of result belongs. For example, "write changes to a character's rank in the Rank field of that character's document." Use the general criteria for anything not listed here.)
|
|
200
|
+
|
|
201
|
+
## Special rules
|
|
202
|
+
|
|
203
|
+
(To be written: semantic rules unique to this world, such as rank order or the meaning of relationships and currency. If a rule already has its own world document, only point to that document here.)
|
|
204
|
+
`,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
path: "control/player-views.yaml",
|
|
208
|
+
contents: defaultPlayerViews,
|
|
209
|
+
},
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
const defaultWorldFrame = `format: narraeon.world-frame/v1
|
|
213
|
+
bindings:
|
|
214
|
+
currentSituation: situation.current
|
|
215
|
+
instructions:
|
|
216
|
+
- markdown: blocks/world.md
|
|
217
|
+
context:
|
|
218
|
+
- slot: { kind: catalog, directory: characters, maxEntries: 24, required: false }
|
|
219
|
+
- slot: { kind: catalog, directory: locations, maxEntries: 24, required: false }
|
|
220
|
+
- slot: { kind: catalog, directory: items, maxEntries: 24, required: false }
|
|
221
|
+
- slot: { kind: catalog, directory: rules, maxEntries: 24, required: false }
|
|
222
|
+
- slot: { kind: current_situation }
|
|
223
|
+
- slot: { kind: history, recent: 2 }
|
|
224
|
+
- slot: { kind: additional_materials }
|
|
225
|
+
`;
|
|
226
|
+
const defaultPlayerViews = `format: narraeon.player-views/v1
|
|
227
|
+
views: []
|
|
228
|
+
`;
|
|
229
|
+
function readPortableContentArchive(archive, limits, signal) {
|
|
230
|
+
signal?.throwIfAborted();
|
|
231
|
+
if (archive.byteLength > maximumPortableContentArchiveBytes(limits)) {
|
|
232
|
+
throw new ContentPackageImportError("limit_exceeded", "The ZIP content package exceeds the safe size limit");
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const files = parseZipArchive(archive, limits);
|
|
236
|
+
signal?.throwIfAborted();
|
|
237
|
+
return files;
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
if (error instanceof ContentPackageImportError || isAbortError(error)) {
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
throw new ContentPackageImportError("invalid_zip", "The ZIP content package is invalid or damaged");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function parseZipArchive(archive, limits) {
|
|
247
|
+
const endRecords = findZipEndRecords(archive);
|
|
248
|
+
if (endRecords.length !== 1) {
|
|
249
|
+
throw invalidZip("The ZIP archive contains an ambiguous EOCD record");
|
|
250
|
+
}
|
|
251
|
+
return parseZipArchiveAtEnd(archive, limits, endRecords[0]);
|
|
252
|
+
}
|
|
253
|
+
function createPortableContentArchive(files) {
|
|
254
|
+
const localRecords = [];
|
|
255
|
+
const centralRecords = [];
|
|
256
|
+
let localOffset = 0;
|
|
257
|
+
for (const file of [...files].sort((left, right) => compareStrings(left.path, right.path))) {
|
|
258
|
+
const path = Buffer.from(file.path, "utf8");
|
|
259
|
+
const contents = file.encoding === "base64"
|
|
260
|
+
? Buffer.from(file.contents, "base64")
|
|
261
|
+
: Buffer.from(file.contents, "utf8");
|
|
262
|
+
const checksum = crc32(contents);
|
|
263
|
+
const localHeader = Buffer.alloc(30);
|
|
264
|
+
localHeader.writeUInt32LE(0x04034b50, 0);
|
|
265
|
+
localHeader.writeUInt16LE(20, 4);
|
|
266
|
+
localHeader.writeUInt16LE(0x0800, 6);
|
|
267
|
+
localHeader.writeUInt16LE(0, 8);
|
|
268
|
+
localHeader.writeUInt32LE(checksum, 14);
|
|
269
|
+
localHeader.writeUInt32LE(contents.byteLength, 18);
|
|
270
|
+
localHeader.writeUInt32LE(contents.byteLength, 22);
|
|
271
|
+
localHeader.writeUInt16LE(path.byteLength, 26);
|
|
272
|
+
const localRecord = Buffer.concat([localHeader, path, contents]);
|
|
273
|
+
localRecords.push(localRecord);
|
|
274
|
+
const centralHeader = Buffer.alloc(46);
|
|
275
|
+
centralHeader.writeUInt32LE(0x02014b50, 0);
|
|
276
|
+
centralHeader.writeUInt16LE((3 << 8) | 20, 4);
|
|
277
|
+
centralHeader.writeUInt16LE(20, 6);
|
|
278
|
+
centralHeader.writeUInt16LE(0x0800, 8);
|
|
279
|
+
centralHeader.writeUInt16LE(0, 10);
|
|
280
|
+
centralHeader.writeUInt32LE(checksum, 16);
|
|
281
|
+
centralHeader.writeUInt32LE(contents.byteLength, 20);
|
|
282
|
+
centralHeader.writeUInt32LE(contents.byteLength, 24);
|
|
283
|
+
centralHeader.writeUInt16LE(path.byteLength, 28);
|
|
284
|
+
centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
285
|
+
centralHeader.writeUInt32LE(localOffset, 42);
|
|
286
|
+
centralRecords.push(Buffer.concat([centralHeader, path]));
|
|
287
|
+
localOffset += localRecord.byteLength;
|
|
288
|
+
}
|
|
289
|
+
const centralDirectory = Buffer.concat(centralRecords);
|
|
290
|
+
const end = Buffer.alloc(22);
|
|
291
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
292
|
+
end.writeUInt16LE(files.length, 8);
|
|
293
|
+
end.writeUInt16LE(files.length, 10);
|
|
294
|
+
end.writeUInt32LE(centralDirectory.byteLength, 12);
|
|
295
|
+
end.writeUInt32LE(localOffset, 16);
|
|
296
|
+
return Buffer.concat([...localRecords, centralDirectory, end]);
|
|
297
|
+
}
|
|
298
|
+
function portableArchiveBaseName(displayName) {
|
|
299
|
+
const normalized = displayName
|
|
300
|
+
.normalize("NFKC")
|
|
301
|
+
.replaceAll(/[^\p{L}\p{N}._-]+/gu, "-")
|
|
302
|
+
.replaceAll(/^[.-]+|[.-]+$/gu, "")
|
|
303
|
+
.slice(0, 80);
|
|
304
|
+
return normalized.length === 0 ? "content-package" : normalized;
|
|
305
|
+
}
|
|
306
|
+
function parseZipArchiveAtEnd(archive, limits, endOffset) {
|
|
307
|
+
const diskNumber = archive.readUInt16LE(endOffset + 4);
|
|
308
|
+
const centralDisk = archive.readUInt16LE(endOffset + 6);
|
|
309
|
+
const diskEntries = archive.readUInt16LE(endOffset + 8);
|
|
310
|
+
const totalEntries = archive.readUInt16LE(endOffset + 10);
|
|
311
|
+
const centralBytes = archive.readUInt32LE(endOffset + 12);
|
|
312
|
+
const centralOffset = archive.readUInt32LE(endOffset + 16);
|
|
313
|
+
if (diskNumber !== 0 || centralDisk !== 0 || diskEntries !== totalEntries) {
|
|
314
|
+
throw invalidZip("Multi-disk ZIP content packages are not supported");
|
|
315
|
+
}
|
|
316
|
+
if (totalEntries === 0xffff ||
|
|
317
|
+
centralBytes === 0xffffffff ||
|
|
318
|
+
centralOffset === 0xffffffff) {
|
|
319
|
+
throw invalidZip("V1 does not support ZIP64 content packages");
|
|
320
|
+
}
|
|
321
|
+
const centralEnd = centralOffset + centralBytes;
|
|
322
|
+
if (centralEnd > endOffset || centralEnd < centralOffset) {
|
|
323
|
+
throw invalidZip("The ZIP central directory is out of bounds");
|
|
324
|
+
}
|
|
325
|
+
const files = [];
|
|
326
|
+
const normalizedPathKeys = new Set();
|
|
327
|
+
const dataRanges = [];
|
|
328
|
+
let cursor = centralOffset;
|
|
329
|
+
let totalBytes = 0;
|
|
330
|
+
for (let index = 0; index < totalEntries; index += 1) {
|
|
331
|
+
assertBufferRange(archive, cursor, 46, "The ZIP central-directory entry is incomplete");
|
|
332
|
+
if (archive.readUInt32LE(cursor) !== 0x02014b50) {
|
|
333
|
+
throw invalidZip("The ZIP central-directory signature is invalid");
|
|
334
|
+
}
|
|
335
|
+
const flags = archive.readUInt16LE(cursor + 8);
|
|
336
|
+
const method = archive.readUInt16LE(cursor + 10);
|
|
337
|
+
const expectedCrc = archive.readUInt32LE(cursor + 16);
|
|
338
|
+
const compressedBytes = archive.readUInt32LE(cursor + 20);
|
|
339
|
+
const uncompressedBytes = archive.readUInt32LE(cursor + 24);
|
|
340
|
+
const pathBytes = archive.readUInt16LE(cursor + 28);
|
|
341
|
+
const extraBytes = archive.readUInt16LE(cursor + 30);
|
|
342
|
+
const commentBytes = archive.readUInt16LE(cursor + 32);
|
|
343
|
+
const startDisk = archive.readUInt16LE(cursor + 34);
|
|
344
|
+
const externalAttributes = archive.readUInt32LE(cursor + 38);
|
|
345
|
+
const localOffset = archive.readUInt32LE(cursor + 42);
|
|
346
|
+
const recordBytes = 46 + pathBytes + extraBytes + commentBytes;
|
|
347
|
+
assertBufferRange(archive, cursor, recordBytes, "The ZIP central-directory entry is out of bounds");
|
|
348
|
+
if (cursor + recordBytes > centralEnd) {
|
|
349
|
+
throw invalidZip("A ZIP central-directory entry exceeds its declared range");
|
|
350
|
+
}
|
|
351
|
+
const rawPath = decodeZipPath(archive.subarray(cursor + 46, cursor + 46 + pathBytes));
|
|
352
|
+
const normalizedPath = normalizePackagePath(rawPath);
|
|
353
|
+
const normalizedPathKey = portableContentTreePathKey(normalizedPath);
|
|
354
|
+
if (normalizedPathKeys.has(normalizedPathKey)) {
|
|
355
|
+
throw new ContentPackageImportError("unsafe_path", `The ZIP content package contains a duplicate normalized path: ${normalizedPath}`);
|
|
356
|
+
}
|
|
357
|
+
normalizedPathKeys.add(normalizedPathKey);
|
|
358
|
+
if (startDisk !== 0) {
|
|
359
|
+
throw invalidZip("A ZIP entry refers to another disk");
|
|
360
|
+
}
|
|
361
|
+
if ((flags & 0x2041) !== 0) {
|
|
362
|
+
throw invalidZip("ZIP content packages must not contain encrypted entries");
|
|
363
|
+
}
|
|
364
|
+
if (method !== 0 && method !== 8) {
|
|
365
|
+
throw invalidZip(`A ZIP entry uses an unsupported compression method: ${normalizedPath}`);
|
|
366
|
+
}
|
|
367
|
+
const unixMode = externalAttributes >>> 16;
|
|
368
|
+
const unixType = unixMode & 0xf000;
|
|
369
|
+
const directoryByMode = unixType === 0x4000;
|
|
370
|
+
const directoryByName = rawPath.endsWith("/");
|
|
371
|
+
const directoryByDosAttribute = (externalAttributes & 0x10) !== 0;
|
|
372
|
+
const isDirectory = directoryByMode || directoryByName || directoryByDosAttribute;
|
|
373
|
+
if ((externalAttributes & 0x08) !== 0) {
|
|
374
|
+
throw new ContentPackageImportError("unsupported_file_type", `ZIP content packages must not contain volume-label entries: ${normalizedPath}`);
|
|
375
|
+
}
|
|
376
|
+
if (unixType === 0xa000) {
|
|
377
|
+
throw new ContentPackageImportError("unsupported_file_type", `ZIP content packages must not contain symbolic links: ${normalizedPath}`);
|
|
378
|
+
}
|
|
379
|
+
if (unixType !== 0 && unixType !== 0x4000 && unixType !== 0x8000) {
|
|
380
|
+
throw new ContentPackageImportError("unsupported_file_type", `ZIP content packages must not contain special files: ${normalizedPath}`);
|
|
381
|
+
}
|
|
382
|
+
if (isDirectory) {
|
|
383
|
+
if (compressedBytes !== 0 || uncompressedBytes !== 0) {
|
|
384
|
+
throw invalidZip(`ZIP directory entries must not contain data: ${normalizedPath}`);
|
|
385
|
+
}
|
|
386
|
+
cursor += recordBytes;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (files.length + 1 > limits.maxFiles) {
|
|
390
|
+
throw new ContentPackageImportError("limit_exceeded", "The content package exceeds the file-count limit");
|
|
391
|
+
}
|
|
392
|
+
if (uncompressedBytes > limits.maxFileBytes) {
|
|
393
|
+
throw new ContentPackageImportError("limit_exceeded", `A content-package file exceeds the size limit: ${normalizedPath}`);
|
|
394
|
+
}
|
|
395
|
+
totalBytes += uncompressedBytes;
|
|
396
|
+
if (totalBytes > limits.maxTotalBytes) {
|
|
397
|
+
throw new ContentPackageImportError("limit_exceeded", "The content package exceeds the total extracted-size limit");
|
|
398
|
+
}
|
|
399
|
+
assertBufferRange(archive, localOffset, 30, "The ZIP local entry is incomplete");
|
|
400
|
+
if (archive.readUInt32LE(localOffset) !== 0x04034b50) {
|
|
401
|
+
throw invalidZip("The ZIP local-entry signature is invalid");
|
|
402
|
+
}
|
|
403
|
+
const localFlags = archive.readUInt16LE(localOffset + 6);
|
|
404
|
+
const localMethod = archive.readUInt16LE(localOffset + 8);
|
|
405
|
+
const localPathBytes = archive.readUInt16LE(localOffset + 26);
|
|
406
|
+
const localExtraBytes = archive.readUInt16LE(localOffset + 28);
|
|
407
|
+
const dataStart = localOffset + 30 + localPathBytes + localExtraBytes;
|
|
408
|
+
const dataEnd = dataStart + compressedBytes;
|
|
409
|
+
assertBufferRange(archive, localOffset, 30 + localPathBytes + localExtraBytes + compressedBytes, "The ZIP local-entry data is out of bounds");
|
|
410
|
+
if (dataEnd > centralOffset) {
|
|
411
|
+
throw invalidZip("A ZIP local entry overlaps the central directory");
|
|
412
|
+
}
|
|
413
|
+
if (localFlags !== flags ||
|
|
414
|
+
localMethod !== method ||
|
|
415
|
+
decodeZipPath(archive.subarray(localOffset + 30, localOffset + 30 + localPathBytes)) !== rawPath) {
|
|
416
|
+
throw invalidZip("A ZIP local entry does not match the central directory");
|
|
417
|
+
}
|
|
418
|
+
if (dataRanges.some((range) => localOffset < range.end && dataEnd > range.start)) {
|
|
419
|
+
throw invalidZip("ZIP entry data ranges overlap");
|
|
420
|
+
}
|
|
421
|
+
dataRanges.push({ start: localOffset, end: dataEnd });
|
|
422
|
+
const compressed = archive.subarray(dataStart, dataEnd);
|
|
423
|
+
let contents;
|
|
424
|
+
if (method === 0) {
|
|
425
|
+
if (compressedBytes !== uncompressedBytes) {
|
|
426
|
+
throw invalidZip(`A stored ZIP entry has an inconsistent size: ${normalizedPath}`);
|
|
427
|
+
}
|
|
428
|
+
contents = Buffer.from(compressed);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
try {
|
|
432
|
+
contents = inflateRawSync(compressed, {
|
|
433
|
+
maxOutputLength: limits.maxFileBytes + 1,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
throw invalidZip(`A deflated ZIP entry cannot be decompressed: ${normalizedPath}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (contents.byteLength !== uncompressedBytes ||
|
|
441
|
+
crc32(contents) !== expectedCrc) {
|
|
442
|
+
throw invalidZip(`A ZIP entry failed its size or CRC check: ${normalizedPath}`);
|
|
443
|
+
}
|
|
444
|
+
files.push({ path: normalizedPath, contents });
|
|
445
|
+
cursor += recordBytes;
|
|
446
|
+
}
|
|
447
|
+
if (cursor !== centralEnd) {
|
|
448
|
+
throw invalidZip("The ZIP central-directory size does not match its entries");
|
|
449
|
+
}
|
|
450
|
+
return files;
|
|
451
|
+
}
|
|
452
|
+
function findZipEndRecords(archive) {
|
|
453
|
+
const minimumEndBytes = 22;
|
|
454
|
+
if (archive.byteLength < minimumEndBytes) {
|
|
455
|
+
throw invalidZip("The ZIP file is too short");
|
|
456
|
+
}
|
|
457
|
+
const earliest = Math.max(0, archive.byteLength - 65_557);
|
|
458
|
+
const candidates = [];
|
|
459
|
+
for (let offset = archive.byteLength - minimumEndBytes; offset >= earliest; offset -= 1) {
|
|
460
|
+
const centralBytes = archive.readUInt32LE(offset + 12);
|
|
461
|
+
const centralOffset = archive.readUInt32LE(offset + 16);
|
|
462
|
+
if (archive.readUInt32LE(offset) === 0x06054b50 &&
|
|
463
|
+
offset + minimumEndBytes + archive.readUInt16LE(offset + 20) ===
|
|
464
|
+
archive.byteLength &&
|
|
465
|
+
centralOffset + centralBytes === offset) {
|
|
466
|
+
candidates.push(offset);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (candidates.length === 0) {
|
|
470
|
+
throw invalidZip("The ZIP file has no end-of-central-directory record");
|
|
471
|
+
}
|
|
472
|
+
return candidates;
|
|
473
|
+
}
|
|
474
|
+
function decodeZipPath(contents) {
|
|
475
|
+
try {
|
|
476
|
+
return zipPathUtf8Decoder.decode(contents);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
throw invalidZip("A ZIP entry path is not valid UTF-8");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function assertBufferRange(buffer, offset, bytes, message) {
|
|
483
|
+
if (!Number.isSafeInteger(offset) ||
|
|
484
|
+
!Number.isSafeInteger(bytes) ||
|
|
485
|
+
offset < 0 ||
|
|
486
|
+
bytes < 0 ||
|
|
487
|
+
offset + bytes > buffer.byteLength ||
|
|
488
|
+
offset + bytes < offset) {
|
|
489
|
+
throw invalidZip(message);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function invalidZip(message) {
|
|
493
|
+
return new ContentPackageImportError("invalid_zip", message);
|
|
494
|
+
}
|
|
495
|
+
function crc32(contents) {
|
|
496
|
+
let crc = 0xffffffff;
|
|
497
|
+
for (const byte of contents) {
|
|
498
|
+
crc = (crc >>> 8) ^ crc32Table[(crc ^ byte) & 0xff];
|
|
499
|
+
}
|
|
500
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
501
|
+
}
|
|
502
|
+
const crc32Table = Uint32Array.from({ length: 256 }, (_, index) => {
|
|
503
|
+
let value = index;
|
|
504
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
505
|
+
value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
|
|
506
|
+
}
|
|
507
|
+
return value >>> 0;
|
|
508
|
+
});
|
|
509
|
+
function toCurrentTreeFile(file) {
|
|
510
|
+
try {
|
|
511
|
+
return {
|
|
512
|
+
path: file.path,
|
|
513
|
+
contents: portableTreeUtf8Decoder.decode(file.contents),
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
if (isContentPackageWorldDocumentPath(file.path)) {
|
|
518
|
+
throw new ContentPackageImportError("unsupported_file_type", worldDocumentTextRequirement(file.path));
|
|
519
|
+
}
|
|
520
|
+
return {
|
|
521
|
+
path: file.path,
|
|
522
|
+
contents: file.contents.toString("base64"),
|
|
523
|
+
encoding: "base64",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function normalizePackagePath(input) {
|
|
528
|
+
if (input.length === 0 ||
|
|
529
|
+
input.includes("\0") ||
|
|
530
|
+
isAbsolute(input) ||
|
|
531
|
+
win32.isAbsolute(input)) {
|
|
532
|
+
throw new ContentPackageImportError("unsafe_path", "A content-package path must be a non-empty relative path");
|
|
533
|
+
}
|
|
534
|
+
const segments = input.replaceAll("\\", "/").split("/");
|
|
535
|
+
if (segments.some((segment) => segment === "..")) {
|
|
536
|
+
throw new ContentPackageImportError("unsafe_path", "A content-package path must not traverse to a parent directory");
|
|
537
|
+
}
|
|
538
|
+
const normalizedSegments = segments
|
|
539
|
+
.filter((segment) => segment.length > 0 && segment !== ".")
|
|
540
|
+
.map((segment) => segment.normalize("NFC"));
|
|
541
|
+
if (normalizedSegments.length === 0) {
|
|
542
|
+
throw new ContentPackageImportError("unsafe_path", "A normalized content-package path must not be empty");
|
|
543
|
+
}
|
|
544
|
+
for (const segment of normalizedSegments) {
|
|
545
|
+
assertPortablePathSegment(segment);
|
|
546
|
+
}
|
|
547
|
+
const normalized = normalizedSegments.join("/");
|
|
548
|
+
if (Buffer.byteLength(normalized, "utf8") > 4_096) {
|
|
549
|
+
throw new ContentPackageImportError("unsafe_path", "A content-package path exceeds the safe length limit");
|
|
550
|
+
}
|
|
551
|
+
return normalized;
|
|
552
|
+
}
|
|
553
|
+
function assertPortablePathSegment(segment) {
|
|
554
|
+
if (Buffer.byteLength(segment, "utf8") > 255 ||
|
|
555
|
+
hasUnsafePortableCharacter(segment) ||
|
|
556
|
+
segment.endsWith(".") ||
|
|
557
|
+
segment.endsWith(" ")) {
|
|
558
|
+
throw new ContentPackageImportError("unsafe_path", "A content-package path contains a name that is unsafe across platforms");
|
|
559
|
+
}
|
|
560
|
+
const baseName = segment.split(".", 1)[0]?.toUpperCase();
|
|
561
|
+
if (baseName !== undefined &&
|
|
562
|
+
/^(?:AUX|CON|NUL|PRN|COM[1-9]|LPT[1-9])$/u.test(baseName)) {
|
|
563
|
+
throw new ContentPackageImportError("unsafe_path", "A content-package path uses a reserved device name");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
function hasUnsafePortableCharacter(segment) {
|
|
567
|
+
return [...segment].some((character) => {
|
|
568
|
+
const codePoint = character.codePointAt(0);
|
|
569
|
+
return (codePoint === undefined ||
|
|
570
|
+
codePoint <= 0x1f ||
|
|
571
|
+
codePoint === 0x7f ||
|
|
572
|
+
'<>:"|?*'.includes(character));
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
function normalizeLimits(input) {
|
|
576
|
+
const limits = { ...defaultLimits, ...input };
|
|
577
|
+
for (const [name, value] of Object.entries(limits)) {
|
|
578
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
579
|
+
throw new TypeError(`${name} must be a positive safe integer`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (limits.maxFileBytes > limits.maxTotalBytes) {
|
|
583
|
+
throw new TypeError("maxFileBytes must not exceed maxTotalBytes");
|
|
584
|
+
}
|
|
585
|
+
return limits;
|
|
586
|
+
}
|
|
587
|
+
function isAbortError(error) {
|
|
588
|
+
return error instanceof Error && error.name === "AbortError";
|
|
589
|
+
}
|
|
590
|
+
function compareStrings(left, right) {
|
|
591
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
592
|
+
}
|
|
593
|
+
async function crashProcessAtPortableContentExportEdge(edge) {
|
|
594
|
+
if (process.env.NODE_ENV !== "test" ||
|
|
595
|
+
process.env.NARRAEON_INTERNAL_TEST_CRASH_AT_PORTABLE_CONTENT_EXPORT_EDGE !==
|
|
596
|
+
edge) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
process.kill(process.pid, "SIGKILL");
|
|
600
|
+
await new Promise(() => undefined);
|
|
601
|
+
}
|