@world-engines/project-format 0.1.0-alpha.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 +46 -0
- package/dist/account-project-root.d.ts +5 -0
- package/dist/account-project-root.js +12 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +8 -0
- package/dist/file-tree.d.ts +5 -0
- package/dist/file-tree.js +55 -0
- package/dist/gallery-migration.d.ts +28 -0
- package/dist/gallery-migration.js +65 -0
- package/dist/gallery.d.ts +16 -0
- package/dist/gallery.js +86 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/integrity.d.ts +6 -0
- package/dist/integrity.js +26 -0
- package/dist/lossless-blob.d.ts +49 -0
- package/dist/lossless-blob.js +270 -0
- package/dist/manifest.d.ts +89 -0
- package/dist/manifest.js +206 -0
- package/dist/neutral-upload.d.ts +48 -0
- package/dist/neutral-upload.js +102 -0
- package/dist/scenario-part.d.ts +8 -0
- package/dist/scenario-part.js +193 -0
- package/dist/tar.d.ts +7 -0
- package/dist/tar.js +169 -0
- package/dist/wesp.d.ts +7 -0
- package/dist/wesp.js +57 -0
- package/dist/windows-executable.d.ts +4 -0
- package/dist/windows-executable.js +68 -0
- package/package.json +51 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
export const LOSSLESS_BLOB_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
2
|
+
export const LOSSLESS_BLOB_MAX_BYTES = 512 * 1024 * 1024;
|
|
3
|
+
export const LOSSLESS_BLOB_MAX_CHUNKS = 128;
|
|
4
|
+
export class LosslessBlobError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "LosslessBlobError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 流式编码原始 bytes。writer 返回的 object_key 仅是不透明定位符;鉴权属于运输层。
|
|
12
|
+
* 失败时已写 chunk 不会出现在 descriptor 中,writer 应把它们留在临时 staging 中清理。
|
|
13
|
+
*/
|
|
14
|
+
export async function encodeLosslessBlob(source, writer, options) {
|
|
15
|
+
const codec = options.codec ?? "gzip-v1";
|
|
16
|
+
const createSha256 = options.createSha256;
|
|
17
|
+
if (codec !== "identity" && codec !== "gzip-v1") {
|
|
18
|
+
throw new LosslessBlobError("codec 必须是 identity 或 gzip-v1");
|
|
19
|
+
}
|
|
20
|
+
const rawHash = createSha256();
|
|
21
|
+
let rawLength = 0;
|
|
22
|
+
const checkedSource = mapBytes(source, (bytes) => {
|
|
23
|
+
rawLength = checkedAdd(rawLength, bytes.byteLength, LOSSLESS_BLOB_MAX_BYTES, "raw");
|
|
24
|
+
rawHash.update(bytes);
|
|
25
|
+
});
|
|
26
|
+
const encodedSource = codec === "identity"
|
|
27
|
+
? checkedSource
|
|
28
|
+
: transformBytes(checkedSource, new CompressionStream("gzip"));
|
|
29
|
+
const encodedHash = createSha256();
|
|
30
|
+
const chunks = [];
|
|
31
|
+
const objectKeys = new Set();
|
|
32
|
+
let encodedLength = 0;
|
|
33
|
+
let pending = new Uint8Array(LOSSLESS_BLOB_CHUNK_BYTES);
|
|
34
|
+
let pendingLength = 0;
|
|
35
|
+
const flush = async () => {
|
|
36
|
+
if (pendingLength === 0)
|
|
37
|
+
return;
|
|
38
|
+
if (chunks.length >= LOSSLESS_BLOB_MAX_CHUNKS) {
|
|
39
|
+
throw new LosslessBlobError(`encoded chunk 数超过 ${LOSSLESS_BLOB_MAX_CHUNKS}`);
|
|
40
|
+
}
|
|
41
|
+
const bytes = pending.slice(0, pendingLength);
|
|
42
|
+
const sha256 = createSha256().update(bytes).digestHex();
|
|
43
|
+
const index = chunks.length;
|
|
44
|
+
const objectKey = await writer({ index, bytes, sha256 });
|
|
45
|
+
if (typeof objectKey !== "string" || objectKey.length === 0) {
|
|
46
|
+
throw new LosslessBlobError(`writer 为 chunk ${index} 返回了空 object_key`);
|
|
47
|
+
}
|
|
48
|
+
if (objectKeys.has(objectKey)) {
|
|
49
|
+
throw new LosslessBlobError(`writer 返回了重复 object_key: ${objectKey}`);
|
|
50
|
+
}
|
|
51
|
+
objectKeys.add(objectKey);
|
|
52
|
+
chunks.push({ index, byte_length: bytes.byteLength, sha256, object_key: objectKey });
|
|
53
|
+
pending = new Uint8Array(LOSSLESS_BLOB_CHUNK_BYTES);
|
|
54
|
+
pendingLength = 0;
|
|
55
|
+
};
|
|
56
|
+
for await (const bytes of encodedSource) {
|
|
57
|
+
assertBytes(bytes);
|
|
58
|
+
encodedLength = checkedAdd(encodedLength, bytes.byteLength, LOSSLESS_BLOB_MAX_BYTES, "encoded");
|
|
59
|
+
encodedHash.update(bytes);
|
|
60
|
+
let offset = 0;
|
|
61
|
+
while (offset < bytes.byteLength) {
|
|
62
|
+
const copied = Math.min(LOSSLESS_BLOB_CHUNK_BYTES - pendingLength, bytes.byteLength - offset);
|
|
63
|
+
pending.set(bytes.subarray(offset, offset + copied), pendingLength);
|
|
64
|
+
pendingLength += copied;
|
|
65
|
+
offset += copied;
|
|
66
|
+
if (pendingLength === LOSSLESS_BLOB_CHUNK_BYTES)
|
|
67
|
+
await flush();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await flush();
|
|
71
|
+
const descriptor = {
|
|
72
|
+
schema_version: 1,
|
|
73
|
+
codec,
|
|
74
|
+
raw: { byte_length: rawLength, sha256: rawHash.digestHex() },
|
|
75
|
+
encoded: { byte_length: encodedLength, sha256: encodedHash.digestHex() },
|
|
76
|
+
chunks,
|
|
77
|
+
};
|
|
78
|
+
validateLosslessBlobDescriptor(descriptor);
|
|
79
|
+
return descriptor;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 返回逐块验证后的原始 byte 流。只有迭代到 EOF 且未抛错,整个 blob 才 verified;
|
|
83
|
+
* 调用方必须写临时 staging,并在迭代成功结束后原子 commit,不能发布此前产出的前缀。
|
|
84
|
+
*/
|
|
85
|
+
export function decodeLosslessBlob(candidate, reader, options) {
|
|
86
|
+
const descriptor = validateLosslessBlobDescriptor(candidate);
|
|
87
|
+
return decodeVerified(descriptor, reader, options.createSha256);
|
|
88
|
+
}
|
|
89
|
+
export function validateLosslessBlobDescriptor(candidate) {
|
|
90
|
+
assertExactObject(candidate, ["schema_version", "codec", "raw", "encoded", "chunks"], "descriptor");
|
|
91
|
+
if (candidate.schema_version !== 1)
|
|
92
|
+
throw new LosslessBlobError("schema_version 必须是 1");
|
|
93
|
+
if (candidate.codec !== "identity" && candidate.codec !== "gzip-v1") {
|
|
94
|
+
throw new LosslessBlobError("codec 必须是 identity 或 gzip-v1");
|
|
95
|
+
}
|
|
96
|
+
const raw = validateDigest(candidate.raw, "raw");
|
|
97
|
+
const encoded = validateDigest(candidate.encoded, "encoded");
|
|
98
|
+
if (!Array.isArray(candidate.chunks) || candidate.chunks.length > LOSSLESS_BLOB_MAX_CHUNKS) {
|
|
99
|
+
throw new LosslessBlobError(`chunks 必须是至多 ${LOSSLESS_BLOB_MAX_CHUNKS} 项的数组`);
|
|
100
|
+
}
|
|
101
|
+
const chunks = [];
|
|
102
|
+
const keys = new Set();
|
|
103
|
+
let sum = 0;
|
|
104
|
+
for (let index = 0; index < candidate.chunks.length; index += 1) {
|
|
105
|
+
const value = candidate.chunks[index];
|
|
106
|
+
assertExactObject(value, ["index", "byte_length", "sha256", "object_key"], `chunks[${index}]`);
|
|
107
|
+
if (value.index !== index)
|
|
108
|
+
throw new LosslessBlobError(`chunks[${index}].index 必须严格连续且有序`);
|
|
109
|
+
const byteLength = value.byte_length;
|
|
110
|
+
if (typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength <= 0 || byteLength > LOSSLESS_BLOB_CHUNK_BYTES) {
|
|
111
|
+
throw new LosslessBlobError(`chunks[${index}].byte_length 超出范围`);
|
|
112
|
+
}
|
|
113
|
+
if (index < candidate.chunks.length - 1 && byteLength !== LOSSLESS_BLOB_CHUNK_BYTES) {
|
|
114
|
+
throw new LosslessBlobError(`非末尾 chunk ${index} 必须恰好为 4 MiB`);
|
|
115
|
+
}
|
|
116
|
+
assertSha256(value.sha256, `chunks[${index}].sha256`);
|
|
117
|
+
if (typeof value.object_key !== "string" || value.object_key.length === 0 || keys.has(value.object_key)) {
|
|
118
|
+
throw new LosslessBlobError(`chunks[${index}].object_key 必须非空且唯一`);
|
|
119
|
+
}
|
|
120
|
+
keys.add(value.object_key);
|
|
121
|
+
sum = checkedAdd(sum, byteLength, LOSSLESS_BLOB_MAX_BYTES, "chunks encoded");
|
|
122
|
+
chunks.push({
|
|
123
|
+
index,
|
|
124
|
+
byte_length: byteLength,
|
|
125
|
+
sha256: value.sha256,
|
|
126
|
+
object_key: value.object_key,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (sum !== encoded.byte_length)
|
|
130
|
+
throw new LosslessBlobError("encoded.byte_length 与 chunk 总长不一致");
|
|
131
|
+
if ((encoded.byte_length === 0) !== (chunks.length === 0)) {
|
|
132
|
+
throw new LosslessBlobError("空 encoded blob 与 chunks 不一致");
|
|
133
|
+
}
|
|
134
|
+
if (candidate.codec === "identity" &&
|
|
135
|
+
(raw.byte_length !== encoded.byte_length || raw.sha256 !== encoded.sha256)) {
|
|
136
|
+
throw new LosslessBlobError("identity codec 的 raw/encoded digest 必须一致");
|
|
137
|
+
}
|
|
138
|
+
return { schema_version: 1, codec: candidate.codec, raw, encoded, chunks };
|
|
139
|
+
}
|
|
140
|
+
async function* decodeVerified(descriptor, reader, createSha256) {
|
|
141
|
+
// 第一遍先完成所有 encoded 完整性验证,避免把未验证 encoded 前缀送入 inflater。
|
|
142
|
+
const encodedHash = createSha256();
|
|
143
|
+
let encodedLength = 0;
|
|
144
|
+
for (const chunk of descriptor.chunks) {
|
|
145
|
+
const bytes = await readVerifiedChunk(chunk, reader, createSha256);
|
|
146
|
+
encodedLength = checkedAdd(encodedLength, bytes.byteLength, descriptor.encoded.byte_length, "encoded");
|
|
147
|
+
encodedHash.update(bytes);
|
|
148
|
+
}
|
|
149
|
+
if (encodedLength !== descriptor.encoded.byte_length)
|
|
150
|
+
throw new LosslessBlobError("encoded byte_length 不匹配");
|
|
151
|
+
if (encodedHash.digestHex() !== descriptor.encoded.sha256)
|
|
152
|
+
throw new LosslessBlobError("encoded SHA-256 不匹配");
|
|
153
|
+
async function* verifiedEncoded() {
|
|
154
|
+
for (const chunk of descriptor.chunks)
|
|
155
|
+
yield await readVerifiedChunk(chunk, reader, createSha256);
|
|
156
|
+
}
|
|
157
|
+
const rawSource = descriptor.codec === "identity"
|
|
158
|
+
? verifiedEncoded()
|
|
159
|
+
: transformBytes(verifiedEncoded(), new DecompressionStream("gzip"));
|
|
160
|
+
const rawHash = createSha256();
|
|
161
|
+
let rawLength = 0;
|
|
162
|
+
for await (const bytes of rawSource) {
|
|
163
|
+
assertBytes(bytes);
|
|
164
|
+
rawLength = checkedAdd(rawLength, bytes.byteLength, descriptor.raw.byte_length, "raw");
|
|
165
|
+
rawHash.update(bytes);
|
|
166
|
+
yield bytes;
|
|
167
|
+
}
|
|
168
|
+
if (rawLength !== descriptor.raw.byte_length)
|
|
169
|
+
throw new LosslessBlobError("raw byte_length 不匹配");
|
|
170
|
+
if (rawHash.digestHex() !== descriptor.raw.sha256)
|
|
171
|
+
throw new LosslessBlobError("raw SHA-256 不匹配");
|
|
172
|
+
}
|
|
173
|
+
async function readVerifiedChunk(chunk, reader, createSha256) {
|
|
174
|
+
const chunkHash = createSha256();
|
|
175
|
+
let chunkLength = 0;
|
|
176
|
+
const result = new Uint8Array(chunk.byte_length);
|
|
177
|
+
const source = await reader(chunk);
|
|
178
|
+
if (source === null || typeof source !== "object" || !(Symbol.asyncIterator in source)) {
|
|
179
|
+
throw new LosslessBlobError(`reader 未返回 chunk ${chunk.index} 的 AsyncIterable`);
|
|
180
|
+
}
|
|
181
|
+
for await (const bytes of source) {
|
|
182
|
+
assertBytes(bytes);
|
|
183
|
+
chunkLength = checkedAdd(chunkLength, bytes.byteLength, chunk.byte_length, `chunk ${chunk.index}`);
|
|
184
|
+
chunkHash.update(bytes);
|
|
185
|
+
result.set(bytes, chunkLength - bytes.byteLength);
|
|
186
|
+
}
|
|
187
|
+
if (chunkLength !== chunk.byte_length)
|
|
188
|
+
throw new LosslessBlobError(`chunk ${chunk.index} 被截断`);
|
|
189
|
+
if (chunkHash.digestHex() !== chunk.sha256)
|
|
190
|
+
throw new LosslessBlobError(`chunk ${chunk.index} SHA-256 不匹配`);
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
async function* mapBytes(source, observe) {
|
|
194
|
+
for await (const bytes of source) {
|
|
195
|
+
assertBytes(bytes);
|
|
196
|
+
observe(bytes);
|
|
197
|
+
yield bytes;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function* transformBytes(source, transform) {
|
|
201
|
+
const writer = transform.writable.getWriter();
|
|
202
|
+
const reader = transform.readable.getReader();
|
|
203
|
+
const pumping = (async () => {
|
|
204
|
+
try {
|
|
205
|
+
for await (const bytes of source)
|
|
206
|
+
await writer.write(bytes);
|
|
207
|
+
await writer.close();
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
await writer.abort(error).catch(() => undefined);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
})().then(() => ({ ok: true }), (error) => ({ ok: false, error }));
|
|
214
|
+
let completed = false;
|
|
215
|
+
try {
|
|
216
|
+
while (true) {
|
|
217
|
+
const result = await reader.read();
|
|
218
|
+
if (result.done)
|
|
219
|
+
break;
|
|
220
|
+
yield result.value;
|
|
221
|
+
}
|
|
222
|
+
const result = await pumping;
|
|
223
|
+
if (!result.ok)
|
|
224
|
+
throw result.error;
|
|
225
|
+
completed = true;
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (!completed) {
|
|
229
|
+
await reader.cancel().catch(() => undefined);
|
|
230
|
+
await writer.abort().catch(() => undefined);
|
|
231
|
+
await pumping;
|
|
232
|
+
}
|
|
233
|
+
reader.releaseLock();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function validateDigest(value, field) {
|
|
237
|
+
assertExactObject(value, ["byte_length", "sha256"], field);
|
|
238
|
+
const byteLength = value.byte_length;
|
|
239
|
+
if (typeof byteLength !== "number" || !Number.isSafeInteger(byteLength) || byteLength < 0 || byteLength > LOSSLESS_BLOB_MAX_BYTES) {
|
|
240
|
+
throw new LosslessBlobError(`${field}.byte_length 超出范围`);
|
|
241
|
+
}
|
|
242
|
+
assertSha256(value.sha256, `${field}.sha256`);
|
|
243
|
+
return { byte_length: byteLength, sha256: value.sha256 };
|
|
244
|
+
}
|
|
245
|
+
function assertExactObject(value, keys, field) {
|
|
246
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
247
|
+
throw new LosslessBlobError(`${field} 必须是对象`);
|
|
248
|
+
}
|
|
249
|
+
const actual = Object.keys(value).sort();
|
|
250
|
+
const expected = [...keys].sort();
|
|
251
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
252
|
+
throw new LosslessBlobError(`${field} 字段 schema 不匹配`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function assertSha256(value, field) {
|
|
256
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/.test(value)) {
|
|
257
|
+
throw new LosslessBlobError(`${field} 必须是小写 SHA-256 hex`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function assertBytes(value) {
|
|
261
|
+
if (!(value instanceof Uint8Array))
|
|
262
|
+
throw new LosslessBlobError("byte source 只能产生 Uint8Array");
|
|
263
|
+
}
|
|
264
|
+
function checkedAdd(current, addition, maximum, field) {
|
|
265
|
+
const result = current + addition;
|
|
266
|
+
if (!Number.isSafeInteger(result) || result > maximum) {
|
|
267
|
+
throw new LosslessBlobError(`${field} 超过 ${maximum} bytes`);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export declare const LOCAL_AUTHOR_PROJECT_MANIFEST_SCHEMA_VERSION = 2;
|
|
2
|
+
export interface LocalAuthorProjectManifestV1 {
|
|
3
|
+
schema_version: 1;
|
|
4
|
+
project_id: string;
|
|
5
|
+
project_revision: number;
|
|
6
|
+
created_at_ms: number;
|
|
7
|
+
updated_at_ms: number;
|
|
8
|
+
scenario: {
|
|
9
|
+
local_scenario_id: string;
|
|
10
|
+
source_path: "scenario/source.wes";
|
|
11
|
+
source_sha256: string;
|
|
12
|
+
assets_digest: string;
|
|
13
|
+
};
|
|
14
|
+
view: {
|
|
15
|
+
local_view_id: string;
|
|
16
|
+
root_path: "view";
|
|
17
|
+
source_tree_sha256: string;
|
|
18
|
+
package_lock_sha256: string;
|
|
19
|
+
};
|
|
20
|
+
snapshot: {
|
|
21
|
+
snapshot_id: string;
|
|
22
|
+
canonical_digest: string;
|
|
23
|
+
previous_snapshot_id: string | null;
|
|
24
|
+
};
|
|
25
|
+
toolchain: {
|
|
26
|
+
template_version: string;
|
|
27
|
+
project_host_version: string;
|
|
28
|
+
authoring_bridge_version: string;
|
|
29
|
+
desktop_package_version: string;
|
|
30
|
+
agent_kit_version: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export interface LocalAuthorProjectManifestV2 extends Omit<LocalAuthorProjectManifestV1, "schema_version"> {
|
|
34
|
+
schema_version: 2;
|
|
35
|
+
gallery: {
|
|
36
|
+
root_path: "gallery";
|
|
37
|
+
scenario_root_path: "gallery/scenario";
|
|
38
|
+
view_root_path: "gallery/view";
|
|
39
|
+
content_tree_sha256: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export type LocalAuthorProjectManifest = LocalAuthorProjectManifestV1 | LocalAuthorProjectManifestV2;
|
|
43
|
+
export interface CanonicalProjectDigestInputV1 {
|
|
44
|
+
schema_version: 1;
|
|
45
|
+
project_id: string;
|
|
46
|
+
project_revision: number;
|
|
47
|
+
scenario_source_sha256: string;
|
|
48
|
+
scenario_assets_digest: string;
|
|
49
|
+
view_source_tree_sha256: string;
|
|
50
|
+
view_package_lock_sha256: string;
|
|
51
|
+
}
|
|
52
|
+
export interface CanonicalProjectDigestInputV2 extends Omit<CanonicalProjectDigestInputV1, "schema_version"> {
|
|
53
|
+
schema_version: 2;
|
|
54
|
+
gallery_root_path: "gallery";
|
|
55
|
+
gallery_scenario_root_path: "gallery/scenario";
|
|
56
|
+
gallery_view_root_path: "gallery/view";
|
|
57
|
+
gallery_content_tree_sha256: string;
|
|
58
|
+
}
|
|
59
|
+
export type CanonicalProjectDigestInput = CanonicalProjectDigestInputV1 | CanonicalProjectDigestInputV2;
|
|
60
|
+
/**
|
|
61
|
+
* 格式迁移必须在写入前持久化此 preimage,并在 apply 前重新校验。
|
|
62
|
+
* 它只绑定根 manifest 已定义的冻结 authority,不复制 Scenario/View 的第二份内容 authority。
|
|
63
|
+
*/
|
|
64
|
+
export interface LocalAuthorProjectManifestMigrationPreimageV1 {
|
|
65
|
+
readonly schema_version: 1;
|
|
66
|
+
readonly project_id: string;
|
|
67
|
+
readonly project_revision: number;
|
|
68
|
+
readonly canonical_digest: string;
|
|
69
|
+
}
|
|
70
|
+
/** M1 只允许可验证的同版本 no-op;跨版本迁移必须先显式加入新 schema 的实现。 */
|
|
71
|
+
export interface LocalAuthorProjectManifestMigrationPlanV1 {
|
|
72
|
+
readonly schema_version: 1;
|
|
73
|
+
readonly kind: "noop";
|
|
74
|
+
readonly from_schema_version: 1;
|
|
75
|
+
readonly to_schema_version: 1;
|
|
76
|
+
readonly preimage: LocalAuthorProjectManifestMigrationPreimageV1;
|
|
77
|
+
}
|
|
78
|
+
export declare function validateLocalAuthorProjectManifest(manifest: LocalAuthorProjectManifestV1): LocalAuthorProjectManifestV1;
|
|
79
|
+
export declare function validateLocalAuthorProjectManifest(manifest: LocalAuthorProjectManifestV2): LocalAuthorProjectManifestV2;
|
|
80
|
+
export declare function validateLocalAuthorProjectManifest(manifest: LocalAuthorProjectManifest): LocalAuthorProjectManifest;
|
|
81
|
+
export declare function canonicalProjectDigestInput(manifest: LocalAuthorProjectManifest): CanonicalProjectDigestInput;
|
|
82
|
+
export declare function canonicalProjectDigestBytes(manifest: LocalAuthorProjectManifest): Uint8Array;
|
|
83
|
+
export declare function computeCanonicalProjectDigest(manifest: LocalAuthorProjectManifest, subtle?: SubtleCrypto): Promise<string>;
|
|
84
|
+
export declare function verifyLocalAuthorProjectManifestSnapshot(manifest: LocalAuthorProjectManifestV1, subtle?: SubtleCrypto): Promise<LocalAuthorProjectManifestV1>;
|
|
85
|
+
export declare function verifyLocalAuthorProjectManifestSnapshot(manifest: LocalAuthorProjectManifestV2, subtle?: SubtleCrypto): Promise<LocalAuthorProjectManifestV2>;
|
|
86
|
+
export declare function verifyLocalAuthorProjectManifestSnapshot(manifest: LocalAuthorProjectManifest, subtle?: SubtleCrypto): Promise<LocalAuthorProjectManifest>;
|
|
87
|
+
export declare function createLocalAuthorProjectManifestMigrationPreimage(manifest: LocalAuthorProjectManifestV1, subtle?: SubtleCrypto): Promise<LocalAuthorProjectManifestMigrationPreimageV1>;
|
|
88
|
+
export declare function planLocalAuthorProjectManifestMigration(manifest: LocalAuthorProjectManifestV1, targetSchemaVersion: number, subtle?: SubtleCrypto): Promise<LocalAuthorProjectManifestMigrationPlanV1>;
|
|
89
|
+
export declare function assertLocalAuthorProjectManifestMigrationPreimage(manifest: LocalAuthorProjectManifestV1, preimage: LocalAuthorProjectManifestMigrationPreimageV1, subtle?: SubtleCrypto): Promise<LocalAuthorProjectManifestV1>;
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { ProjectFormatError } from "./errors.js";
|
|
2
|
+
import { assertSha256Hex, sha256Hex, sha256HexEquals } from "./integrity.js";
|
|
3
|
+
import { LOCAL_AUTHOR_GALLERY_PATHS } from "./gallery.js";
|
|
4
|
+
export const LOCAL_AUTHOR_PROJECT_MANIFEST_SCHEMA_VERSION = 2;
|
|
5
|
+
const CANONICAL_PROJECT_DIGEST_DOMAIN_V1 = "worldengine.local-author-project.digest/v1";
|
|
6
|
+
const CANONICAL_PROJECT_DIGEST_DOMAIN_V2 = "worldengine.local-author-project.digest/v2";
|
|
7
|
+
function assertNonEmptyString(value, field) {
|
|
8
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
9
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", `${field} 不能为空`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function assertSafeNonNegativeInteger(value, field) {
|
|
13
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
14
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", `${field} 必须是非负安全整数`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function assertExactObject(value, field, keys) {
|
|
18
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
19
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", `${field} 必须是对象`);
|
|
20
|
+
}
|
|
21
|
+
const actualKeys = Object.keys(value);
|
|
22
|
+
if (actualKeys.length !== keys.length || actualKeys.some((key) => !keys.includes(key))) {
|
|
23
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", `${field} 包含未知或缺失字段`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function assertSnapshotPreviousId(value, snapshotId) {
|
|
27
|
+
if (value === null)
|
|
28
|
+
return;
|
|
29
|
+
assertNonEmptyString(value, "snapshot.previous_snapshot_id");
|
|
30
|
+
if (value === snapshotId) {
|
|
31
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", "snapshot.previous_snapshot_id 不能指向自身");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function validateLocalAuthorProjectManifest(manifest) {
|
|
35
|
+
if (manifest.schema_version !== 1 && manifest.schema_version !== LOCAL_AUTHOR_PROJECT_MANIFEST_SCHEMA_VERSION) {
|
|
36
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", "仅支持 manifest schema_version 1 或 2");
|
|
37
|
+
}
|
|
38
|
+
assertExactObject(manifest, "manifest", [
|
|
39
|
+
"schema_version",
|
|
40
|
+
"project_id",
|
|
41
|
+
"project_revision",
|
|
42
|
+
"created_at_ms",
|
|
43
|
+
"updated_at_ms",
|
|
44
|
+
"scenario",
|
|
45
|
+
"view",
|
|
46
|
+
"snapshot",
|
|
47
|
+
"toolchain",
|
|
48
|
+
...(manifest.schema_version === 2 ? ["gallery"] : []),
|
|
49
|
+
]);
|
|
50
|
+
assertExactObject(manifest.scenario, "scenario", [
|
|
51
|
+
"local_scenario_id",
|
|
52
|
+
"source_path",
|
|
53
|
+
"source_sha256",
|
|
54
|
+
"assets_digest",
|
|
55
|
+
]);
|
|
56
|
+
assertExactObject(manifest.view, "view", [
|
|
57
|
+
"local_view_id",
|
|
58
|
+
"root_path",
|
|
59
|
+
"source_tree_sha256",
|
|
60
|
+
"package_lock_sha256",
|
|
61
|
+
]);
|
|
62
|
+
assertExactObject(manifest.snapshot, "snapshot", [
|
|
63
|
+
"snapshot_id",
|
|
64
|
+
"canonical_digest",
|
|
65
|
+
"previous_snapshot_id",
|
|
66
|
+
]);
|
|
67
|
+
assertExactObject(manifest.toolchain, "toolchain", [
|
|
68
|
+
"template_version",
|
|
69
|
+
"project_host_version",
|
|
70
|
+
"authoring_bridge_version",
|
|
71
|
+
"desktop_package_version",
|
|
72
|
+
"agent_kit_version",
|
|
73
|
+
]);
|
|
74
|
+
if (manifest.schema_version === 2) {
|
|
75
|
+
assertExactObject(manifest.gallery, "gallery", ["root_path", "scenario_root_path", "view_root_path", "content_tree_sha256"]);
|
|
76
|
+
if (manifest.gallery.root_path !== LOCAL_AUTHOR_GALLERY_PATHS.root || manifest.gallery.scenario_root_path !== LOCAL_AUTHOR_GALLERY_PATHS.scenario || manifest.gallery.view_root_path !== LOCAL_AUTHOR_GALLERY_PATHS.view) {
|
|
77
|
+
throw new ProjectFormatError("E_PROJECT_PATH_INVALID", "gallery root paths 必须使用固定本地 authority");
|
|
78
|
+
}
|
|
79
|
+
assertSha256Hex(manifest.gallery.content_tree_sha256, "gallery.content_tree_sha256");
|
|
80
|
+
}
|
|
81
|
+
assertNonEmptyString(manifest.project_id, "project_id");
|
|
82
|
+
assertSafeNonNegativeInteger(manifest.project_revision, "project_revision");
|
|
83
|
+
assertSafeNonNegativeInteger(manifest.created_at_ms, "created_at_ms");
|
|
84
|
+
assertSafeNonNegativeInteger(manifest.updated_at_ms, "updated_at_ms");
|
|
85
|
+
if (manifest.updated_at_ms < manifest.created_at_ms) {
|
|
86
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", "updated_at_ms 不能早于 created_at_ms");
|
|
87
|
+
}
|
|
88
|
+
assertNonEmptyString(manifest.scenario.local_scenario_id, "scenario.local_scenario_id");
|
|
89
|
+
assertNonEmptyString(manifest.view.local_view_id, "view.local_view_id");
|
|
90
|
+
if (manifest.scenario.source_path !== "scenario/source.wes") {
|
|
91
|
+
throw new ProjectFormatError("E_PROJECT_PATH_INVALID", "scenario.source_path 必须固定为 scenario/source.wes");
|
|
92
|
+
}
|
|
93
|
+
if (manifest.view.root_path !== "view") {
|
|
94
|
+
throw new ProjectFormatError("E_PROJECT_PATH_INVALID", "view.root_path 必须固定为 view");
|
|
95
|
+
}
|
|
96
|
+
assertSha256Hex(manifest.scenario.source_sha256, "scenario.source_sha256");
|
|
97
|
+
assertSha256Hex(manifest.scenario.assets_digest, "scenario.assets_digest");
|
|
98
|
+
assertSha256Hex(manifest.view.source_tree_sha256, "view.source_tree_sha256");
|
|
99
|
+
assertSha256Hex(manifest.view.package_lock_sha256, "view.package_lock_sha256");
|
|
100
|
+
assertNonEmptyString(manifest.snapshot.snapshot_id, "snapshot.snapshot_id");
|
|
101
|
+
assertSha256Hex(manifest.snapshot.canonical_digest, "snapshot.canonical_digest");
|
|
102
|
+
assertSnapshotPreviousId(manifest.snapshot.previous_snapshot_id, manifest.snapshot.snapshot_id);
|
|
103
|
+
for (const [field, value] of Object.entries(manifest.toolchain)) {
|
|
104
|
+
assertNonEmptyString(value, `toolchain.${field}`);
|
|
105
|
+
}
|
|
106
|
+
return manifest;
|
|
107
|
+
}
|
|
108
|
+
export function canonicalProjectDigestInput(manifest) {
|
|
109
|
+
validateLocalAuthorProjectManifest(manifest);
|
|
110
|
+
const base = {
|
|
111
|
+
project_id: manifest.project_id,
|
|
112
|
+
project_revision: manifest.project_revision,
|
|
113
|
+
scenario_source_sha256: manifest.scenario.source_sha256,
|
|
114
|
+
scenario_assets_digest: manifest.scenario.assets_digest,
|
|
115
|
+
view_source_tree_sha256: manifest.view.source_tree_sha256,
|
|
116
|
+
view_package_lock_sha256: manifest.view.package_lock_sha256,
|
|
117
|
+
};
|
|
118
|
+
return manifest.schema_version === 1 ? { ...base, schema_version: 1 } : {
|
|
119
|
+
...base,
|
|
120
|
+
schema_version: 2,
|
|
121
|
+
gallery_root_path: manifest.gallery.root_path,
|
|
122
|
+
gallery_scenario_root_path: manifest.gallery.scenario_root_path,
|
|
123
|
+
gallery_view_root_path: manifest.gallery.view_root_path,
|
|
124
|
+
gallery_content_tree_sha256: manifest.gallery.content_tree_sha256,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function canonicalProjectDigestBytes(manifest) {
|
|
128
|
+
const input = canonicalProjectDigestInput(manifest);
|
|
129
|
+
const encoder = new TextEncoder();
|
|
130
|
+
const values = [
|
|
131
|
+
manifest.schema_version === 1 ? CANONICAL_PROJECT_DIGEST_DOMAIN_V1 : CANONICAL_PROJECT_DIGEST_DOMAIN_V2,
|
|
132
|
+
String(input.schema_version),
|
|
133
|
+
input.project_id,
|
|
134
|
+
String(input.project_revision),
|
|
135
|
+
input.scenario_source_sha256,
|
|
136
|
+
input.scenario_assets_digest,
|
|
137
|
+
input.view_source_tree_sha256,
|
|
138
|
+
input.view_package_lock_sha256,
|
|
139
|
+
...(input.schema_version === 2 ? [input.gallery_root_path, input.gallery_scenario_root_path, input.gallery_view_root_path, input.gallery_content_tree_sha256] : []),
|
|
140
|
+
].map((value) => encoder.encode(value));
|
|
141
|
+
const totalSize = values.reduce((size, value) => size + 4 + value.byteLength, 0);
|
|
142
|
+
const canonical = new Uint8Array(totalSize);
|
|
143
|
+
const view = new DataView(canonical.buffer);
|
|
144
|
+
let offset = 0;
|
|
145
|
+
for (const value of values) {
|
|
146
|
+
view.setUint32(offset, value.byteLength);
|
|
147
|
+
offset += 4;
|
|
148
|
+
canonical.set(value, offset);
|
|
149
|
+
offset += value.byteLength;
|
|
150
|
+
}
|
|
151
|
+
return canonical;
|
|
152
|
+
}
|
|
153
|
+
export async function computeCanonicalProjectDigest(manifest, subtle) {
|
|
154
|
+
return sha256Hex(canonicalProjectDigestBytes(manifest), subtle);
|
|
155
|
+
}
|
|
156
|
+
export async function verifyLocalAuthorProjectManifestSnapshot(manifest, subtle) {
|
|
157
|
+
validateLocalAuthorProjectManifest(manifest);
|
|
158
|
+
const expectedDigest = await computeCanonicalProjectDigest(manifest, subtle);
|
|
159
|
+
if (!sha256HexEquals(manifest.snapshot.canonical_digest, expectedDigest)) {
|
|
160
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", "snapshot.canonical_digest 与冻结 authority 不匹配");
|
|
161
|
+
}
|
|
162
|
+
return manifest;
|
|
163
|
+
}
|
|
164
|
+
export async function createLocalAuthorProjectManifestMigrationPreimage(manifest, subtle) {
|
|
165
|
+
validateLocalAuthorProjectManifest(manifest);
|
|
166
|
+
return Object.freeze({
|
|
167
|
+
schema_version: 1,
|
|
168
|
+
project_id: manifest.project_id,
|
|
169
|
+
project_revision: manifest.project_revision,
|
|
170
|
+
canonical_digest: await computeCanonicalProjectDigest(manifest, subtle),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
export async function planLocalAuthorProjectManifestMigration(manifest, targetSchemaVersion, subtle) {
|
|
174
|
+
validateLocalAuthorProjectManifest(manifest);
|
|
175
|
+
if (targetSchemaVersion !== manifest.schema_version) {
|
|
176
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", `manifest noop plan 不支持从 schema_version 1 迁移到 ${String(targetSchemaVersion)}`);
|
|
177
|
+
}
|
|
178
|
+
return Object.freeze({
|
|
179
|
+
schema_version: 1,
|
|
180
|
+
kind: "noop",
|
|
181
|
+
from_schema_version: 1,
|
|
182
|
+
to_schema_version: 1,
|
|
183
|
+
preimage: await createLocalAuthorProjectManifestMigrationPreimage(manifest, subtle),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
export async function assertLocalAuthorProjectManifestMigrationPreimage(manifest, preimage, subtle) {
|
|
187
|
+
assertExactObject(preimage, "migration.preimage", [
|
|
188
|
+
"schema_version",
|
|
189
|
+
"project_id",
|
|
190
|
+
"project_revision",
|
|
191
|
+
"canonical_digest",
|
|
192
|
+
]);
|
|
193
|
+
if (preimage.schema_version !== 1) {
|
|
194
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", "不支持 migration preimage schema_version");
|
|
195
|
+
}
|
|
196
|
+
assertNonEmptyString(preimage.project_id, "migration.preimage.project_id");
|
|
197
|
+
assertSafeNonNegativeInteger(preimage.project_revision, "migration.preimage.project_revision");
|
|
198
|
+
assertSha256Hex(preimage.canonical_digest, "migration.preimage.canonical_digest");
|
|
199
|
+
const actual = await createLocalAuthorProjectManifestMigrationPreimage(manifest, subtle);
|
|
200
|
+
if (actual.project_id !== preimage.project_id ||
|
|
201
|
+
actual.project_revision !== preimage.project_revision ||
|
|
202
|
+
!sha256HexEquals(actual.canonical_digest, preimage.canonical_digest)) {
|
|
203
|
+
throw new ProjectFormatError("E_PROJECT_MANIFEST_INVALID", "migration preimage 已过期或不匹配");
|
|
204
|
+
}
|
|
205
|
+
return manifest;
|
|
206
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { LosslessBlobByteSource, LosslessBlobSha256 } from "./lossless-blob.js";
|
|
2
|
+
export type NeutralUploadObjectKind = "manifest" | "revision_index" | "section" | "local_project_wesp" | "view";
|
|
3
|
+
export type NeutralUploadChunkCodec = "identity-v1" | "gzip-member-v1";
|
|
4
|
+
export interface NeutralUploadChunkRefV2 {
|
|
5
|
+
readonly index: number;
|
|
6
|
+
readonly raw_offset: number;
|
|
7
|
+
readonly raw_byte_length: number;
|
|
8
|
+
readonly raw_sha256: string;
|
|
9
|
+
readonly codec: NeutralUploadChunkCodec;
|
|
10
|
+
readonly encoded_byte_length: number;
|
|
11
|
+
readonly encoded_sha256: string;
|
|
12
|
+
}
|
|
13
|
+
export interface NeutralUploadLogicalObjectRefV2 {
|
|
14
|
+
readonly object_id: string;
|
|
15
|
+
readonly object_kind: NeutralUploadObjectKind;
|
|
16
|
+
readonly section_key?: string;
|
|
17
|
+
readonly media_type?: string;
|
|
18
|
+
readonly canonical_encoding?: string;
|
|
19
|
+
readonly raw_byte_length: number;
|
|
20
|
+
readonly raw_sha256: string;
|
|
21
|
+
readonly chunks: readonly NeutralUploadChunkRefV2[];
|
|
22
|
+
}
|
|
23
|
+
export interface NeutralUploadLogicalObjectInput {
|
|
24
|
+
readonly object_id: string;
|
|
25
|
+
readonly object_kind: NeutralUploadObjectKind;
|
|
26
|
+
readonly section_key?: string;
|
|
27
|
+
readonly media_type?: string;
|
|
28
|
+
readonly canonical_encoding?: string;
|
|
29
|
+
readonly bytes: LosslessBlobByteSource;
|
|
30
|
+
}
|
|
31
|
+
export type NeutralUploadChunkWriter = (chunk: Readonly<{
|
|
32
|
+
object_id: string;
|
|
33
|
+
ref: NeutralUploadChunkRefV2;
|
|
34
|
+
bytes: Uint8Array;
|
|
35
|
+
}>) => Promise<void> | void;
|
|
36
|
+
export interface EncodeNeutralUploadOptions {
|
|
37
|
+
readonly codec?: NeutralUploadChunkCodec;
|
|
38
|
+
readonly create_sha256: () => LosslessBlobSha256;
|
|
39
|
+
}
|
|
40
|
+
export declare const NEUTRAL_UPLOAD_RAW_CHUNK_BYTES: number;
|
|
41
|
+
export declare const NEUTRAL_UPLOAD_MAX_ENCODED_CHUNK_BYTES: number;
|
|
42
|
+
export declare const NEUTRAL_UPLOAD_MAX_OBJECT_BYTES: number;
|
|
43
|
+
export declare const NEUTRAL_UPLOAD_MAX_CHUNKS = 128;
|
|
44
|
+
export declare const NEUTRAL_UPLOAD_MAX_OBJECTS = 64;
|
|
45
|
+
export declare const NEUTRAL_UPLOAD_MAX_PLAN_BYTES: number;
|
|
46
|
+
/** 每个 raw chunk 独立压缩,禁止跨 chunk gzip 状态。descriptor 仅在全部 writer 成功后返回。 */
|
|
47
|
+
export declare function encodeNeutralUploadLogicalObject(input: NeutralUploadLogicalObjectInput, writer: NeutralUploadChunkWriter, options: EncodeNeutralUploadOptions): Promise<NeutralUploadLogicalObjectRefV2>;
|
|
48
|
+
export declare function assertNeutralUploadPlanLimits(objects: readonly NeutralUploadLogicalObjectRefV2[]): void;
|