@plasius/asset-contracts 0.4.6 → 0.6.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.
@@ -0,0 +1,198 @@
1
+ import type { ResourcePackageManifest } from "@plasius/gpu-model-core";
2
+ import { computeSha256 } from "@plasius/gpu-shader";
3
+ import {
4
+ createAssetManifest, createModelAssetManifest,
5
+ type AssetManifest, type ModelAssetManifestInput,
6
+ } from "./index.js";
7
+
8
+ /** Fixed ceilings for the opt-in, in-memory model package qualification boundary. */
9
+ export const MODEL_RESOURCE_MANIFEST_LIMITS = Object.freeze({
10
+ files: 16_384, fileBytes: 64 * 1024 * 1024, totalBytes: 128 * 1024 * 1024,
11
+ metadataValues: 262_144, metadataDepth: 16, timeoutMs: 60_000,
12
+ });
13
+
14
+ /** Exported file bytes and their independently supplied canonical MIME declaration. */
15
+ export interface ModelResourceFileInput {
16
+ readonly path: string;
17
+ readonly bytes: Uint8Array;
18
+ readonly contentType: string;
19
+ }
20
+
21
+ /** Complete exported inventory; embedded resources remain inside their owning file. */
22
+ export interface ModelResourceManifestQualificationInput {
23
+ readonly manifest: AssetManifest;
24
+ readonly packageManifest: ResourcePackageManifest;
25
+ readonly files: readonly ModelResourceFileInput[];
26
+ readonly signal?: AbortSignal;
27
+ readonly timeoutMs?: number;
28
+ }
29
+
30
+ /** Value-free failure, suitable for callers' bounded diagnostic projections. */
31
+ export class ModelResourceManifestError extends TypeError {
32
+ constructor(public readonly code: "invalid-input" | "limit-exceeded" | "path-conflict" | "inventory-mismatch" | "content-mismatch" | "aborted" | "deadline-exceeded") {
33
+ super(`Invalid model resource manifest: ${code}`);
34
+ this.name = "ModelResourceManifestError";
35
+ }
36
+ }
37
+ function fail(code: ModelResourceManifestError["code"] = "invalid-input"): never {
38
+ throw new ModelResourceManifestError(code);
39
+ }
40
+ function record(value: unknown): Record<string, unknown> {
41
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) fail();
42
+ const output: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
43
+ const keys = Reflect.ownKeys(value);
44
+ if (keys.length > MODEL_RESOURCE_MANIFEST_LIMITS.files) fail("limit-exceeded");
45
+ for (const key of keys) {
46
+ if (typeof key !== "string" || ["__proto__", "constructor", "prototype"].includes(key)) fail();
47
+ if (key.length > 128) fail("limit-exceeded");
48
+ const property = Object.getOwnPropertyDescriptor(value, key)!;
49
+ if (!("value" in property) || !property.enumerable) fail();
50
+ output[key] = property.value;
51
+ }
52
+ return output;
53
+ }
54
+ function list(value: unknown): unknown[] {
55
+ if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail();
56
+ if (value.length > MODEL_RESOURCE_MANIFEST_LIMITS.files) fail("limit-exceeded");
57
+ if (Reflect.ownKeys(value).length !== value.length + 1) fail();
58
+ const output: unknown[] = [];
59
+ for (let i = 0; i < value.length; i++) {
60
+ const property = Object.getOwnPropertyDescriptor(value, String(i));
61
+ if (!property || !("value" in property) || !property.enumerable) fail();
62
+ output.push(property.value);
63
+ }
64
+ return output;
65
+ }
66
+ function snapshot(value: unknown, budget: { remaining: number }, depth = 0): unknown {
67
+ if (--budget.remaining < 0 || depth > MODEL_RESOURCE_MANIFEST_LIMITS.metadataDepth) fail("limit-exceeded");
68
+ if (value === null || value === undefined || typeof value === "boolean") return value;
69
+ if (typeof value === "string") {
70
+ if (value.length > 2048) fail("limit-exceeded");
71
+ return value;
72
+ }
73
+ if (typeof value === "number") {
74
+ if (!Number.isFinite(value)) fail();
75
+ return value;
76
+ }
77
+ if (Array.isArray(value)) return Object.freeze(list(value).map((item) => snapshot(item, budget, depth + 1)));
78
+ const fields = record(value);
79
+ for (const key of Object.keys(fields)) fields[key] = snapshot(fields[key], budget, depth + 1);
80
+ return Object.freeze(fields);
81
+ }
82
+ function path(value: unknown): string {
83
+ if (typeof value !== "string" || value.length > 1024 || !value.length) fail();
84
+ for (const part of value.split("/")) {
85
+ if (part.length > 255 || !/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/u.test(part)
86
+ || part.endsWith(".") || /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\.|$)/iu.test(part)) fail();
87
+ }
88
+ return value;
89
+ }
90
+ function contentType(value: unknown): string {
91
+ if (typeof value !== "string" || value.length > 128
92
+ || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:; charset=utf-8)?$/u.test(value)) fail();
93
+ return value;
94
+ }
95
+ function uniquePaths(paths: readonly string[]): void {
96
+ const seen = new Set<string>();
97
+ for (const value of paths) {
98
+ const normalized = path(value).toLowerCase();
99
+ if (seen.has(normalized)) fail("path-conflict");
100
+ seen.add(normalized);
101
+ }
102
+ for (const value of seen) {
103
+ for (let offset = value.indexOf("/"); offset !== -1; offset = value.indexOf("/", offset + 1)) {
104
+ if (seen.has(value.slice(0, offset))) fail("path-conflict");
105
+ }
106
+ }
107
+ }
108
+
109
+ const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype);
110
+ const byteLengthGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "byteLength")!.get!;
111
+ const bufferGetter = Object.getOwnPropertyDescriptor(typedArrayPrototype, "buffer")!.get!;
112
+
113
+ /**
114
+ * Binds an exported model inventory to exact file bytes without parsing formats,
115
+ * fetching resources or changing legacy factories. Callers own remote rollout,
116
+ * content inspection and durable storage. The result is evidence at call time,
117
+ * not a promotion attestation. All inputs are copied before the first digest.
118
+ */
119
+ export async function qualifyModelResourceManifest(input: ModelResourceManifestQualificationInput): Promise<AssetManifest> {
120
+ try { return await qualify(input); }
121
+ catch (error) {
122
+ if (error instanceof ModelResourceManifestError) throw error;
123
+ return fail();
124
+ }
125
+ }
126
+
127
+ async function qualify(input: ModelResourceManifestQualificationInput): Promise<AssetManifest> {
128
+ const started = performance.now();
129
+ const root = record(input);
130
+ if (Object.keys(root).some((key) => !["manifest", "packageManifest", "files", "signal", "timeoutMs"].includes(key))) fail();
131
+ const timeout = root.timeoutMs ?? 10_000;
132
+ if (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout < 1 || timeout > MODEL_RESOURCE_MANIFEST_LIMITS.timeoutMs) fail();
133
+ const signal = root.signal;
134
+ if (signal !== undefined && !(signal instanceof AbortSignal)) fail();
135
+ const check = (): void => {
136
+ if (signal?.aborted) fail("aborted");
137
+ if (performance.now() - started >= timeout) fail("deadline-exceeded");
138
+ };
139
+ check();
140
+ const budget = { remaining: MODEL_RESOURCE_MANIFEST_LIMITS.metadataValues };
141
+ const rawManifest = snapshot(root.manifest, budget) as AssetManifest;
142
+ const inventory = record(snapshot(root.packageManifest, budget));
143
+ if (Object.keys(inventory).some((key) => !["format", "entries"].includes(key))
144
+ || !["zip", "tar", "directory-manifest", "inline"].includes(inventory.format as string)) fail();
145
+ let manifest: AssetManifest;
146
+ try {
147
+ manifest = rawManifest.assetKind === "model"
148
+ ? createModelAssetManifest(rawManifest as ModelAssetManifestInput)
149
+ : createAssetManifest(rawManifest);
150
+ } catch { return fail(); }
151
+ uniquePaths(manifest.files.map((file) => file.path));
152
+ if (!manifest.files.some((file) => file.path === path(manifest.entrypoint))) fail("inventory-mismatch");
153
+ const descriptors = new Map(manifest.files.map((file) => [file.path, file]));
154
+ let total = 0;
155
+ for (const file of manifest.files) {
156
+ if (!Number.isSafeInteger(file.byteLength)) fail();
157
+ if (file.byteLength > MODEL_RESOURCE_MANIFEST_LIMITS.fileBytes) fail("limit-exceeded");
158
+ total += file.byteLength;
159
+ if (total > MODEL_RESOURCE_MANIFEST_LIMITS.totalBytes) fail("limit-exceeded");
160
+ contentType(file.contentType);
161
+ }
162
+ const entries = list(inventory.entries).map(record);
163
+ uniquePaths(entries.map((entry) => path(entry.path)));
164
+ const ids = new Set<string>();
165
+ for (const entry of entries) {
166
+ if (Object.keys(entry).some((key) => !["id", "path", "role", "contentType"].includes(key))
167
+ || typeof entry.id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(entry.id)
168
+ || ids.has(entry.id) || !["mesh", "material", "texture", "animation", "auxiliary"].includes(entry.role as string)) fail();
169
+ ids.add(entry.id);
170
+ const descriptor = descriptors.get(entry.path as string);
171
+ if (!descriptor) fail("inventory-mismatch");
172
+ if (entry.contentType !== undefined && contentType(entry.contentType) !== descriptor.contentType) fail("content-mismatch");
173
+ }
174
+ if (entries.length !== descriptors.size) fail("inventory-mismatch");
175
+ const payloads = list(root.files).map((value) => {
176
+ const file = record(value);
177
+ if (Object.keys(file).length !== 3 || Object.keys(file).some((key) => !["path", "bytes", "contentType"].includes(key))) fail();
178
+ const filePath = path(file.path);
179
+ if (!(file.bytes instanceof Uint8Array) || Object.getPrototypeOf(file.bytes) !== Uint8Array.prototype
180
+ || !(bufferGetter.call(file.bytes) instanceof ArrayBuffer)) fail();
181
+ const descriptor = descriptors.get(filePath);
182
+ if (!descriptor) fail("inventory-mismatch");
183
+ if (byteLengthGetter.call(file.bytes) !== descriptor.byteLength || contentType(file.contentType) !== descriptor.contentType) fail("content-mismatch");
184
+ return { path: filePath, bytes: file.bytes, descriptor };
185
+ });
186
+ uniquePaths(payloads.map((file) => file.path));
187
+ if (payloads.length !== descriptors.size) fail("inventory-mismatch");
188
+ // Descriptor budgets and actual lengths were checked before allocating copies.
189
+ const copies = payloads.map((file) => ({ ...file, bytes: new Uint8Array(file.bytes) }));
190
+ for (const file of copies) {
191
+ check();
192
+ let hash: string;
193
+ try { hash = await computeSha256(file.bytes); } catch { return fail("content-mismatch"); }
194
+ check();
195
+ if (hash !== file.descriptor.sha256) fail("content-mismatch");
196
+ }
197
+ return Object.freeze({ ...manifest, files: Object.freeze([...manifest.files].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) });
198
+ }