@moewolf/moe-icons-cli 0.0.10 → 0.0.14
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/README.md +27 -19
- package/dist/cli.js +124 -146
- package/dist/config-package/generated/SOURCE.json +3 -3
- package/dist/config-package/generated/moeicons-config.schema.json +1 -1
- package/dist/core/bitmap-shard-budget.d.ts +2 -2
- package/dist/core/bitmap-shard-budget.js +1 -1
- package/dist/core/bootstrap.js +5 -3
- package/dist/core/candidate-version-pointer.d.ts +13 -0
- package/dist/core/candidate-version-pointer.js +37 -0
- package/dist/core/config-package-plan.d.ts +25 -0
- package/dist/core/config-package-plan.js +156 -0
- package/dist/core/doctor.js +4 -0
- package/dist/core/free-download.js +34 -4
- package/dist/core/free-small-package-download.d.ts +67 -0
- package/dist/core/free-small-package-download.js +344 -0
- package/dist/core/generate.js +49 -33
- package/dist/core/install.d.ts +8 -2
- package/dist/core/install.js +70 -0
- package/dist/core/library-update.d.ts +6 -1
- package/dist/core/library-update.js +54 -0
- package/dist/core/manage.d.ts +4 -0
- package/dist/core/manage.js +4 -0
- package/dist/core/metadata-sync.js +119 -1
- package/dist/core/pro-download.js +19 -2
- package/dist/core/pro-install.d.ts +10 -4
- package/dist/core/pro-install.js +47 -0
- package/dist/core/pro-resources.d.ts +23 -0
- package/dist/core/pro-resources.js +51 -0
- package/dist/core/pro-selected-download.js +12 -2
- package/dist/core/pro-small-package-download.d.ts +61 -0
- package/dist/core/pro-small-package-download.js +301 -0
- package/dist/core/release-descriptor.js +6 -2
- package/dist/core/selected-resources.js +2 -2
- package/dist/core/small-pack/PIN.json +11 -0
- package/dist/core/small-pack/candidate-set-v1.schema.json +187 -0
- package/dist/core/small-pack/candidate-set.cjs +286 -0
- package/dist/core/small-pack/candidate-set.d.cts +12 -0
- package/dist/core/small-pack/tar-read.cjs +220 -0
- package/dist/core/small-pack/tar-read.d.cts +2 -0
- package/dist/core/small-package-install.d.ts +40 -0
- package/dist/core/small-package-install.js +252 -0
- package/dist/core/small-package-selection.d.ts +27 -0
- package/dist/core/small-package-selection.js +114 -0
- package/dist/core/update-policy.d.ts +1 -0
- package/dist/core/update-policy.js +10 -3
- package/dist/mcp/services.js +1 -1
- package/dist/project/config.js +1 -1
- package/dist/project/install-metadata.d.ts +2 -1
- package/dist/project/install-metadata.js +6 -3
- package/dist/project/install.d.ts +11 -2
- package/dist/project/install.js +104 -12
- package/dist/project/package-install-metadata.d.ts +31 -0
- package/dist/project/package-install-metadata.js +99 -0
- package/dist/project/tar-gz.d.ts +4 -8
- package/dist/project/tar-gz.js +6 -10
- package/package.json +11 -11
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// S01 contract: the root is a small reference snapshot; resource bytes live in
|
|
4
|
+
// independently addressed packages and are fetched only by selected consumers.
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
|
|
7
|
+
const MAX_ROOT_BYTES = 1 * 1024 ** 2;
|
|
8
|
+
const MAX_PAGE_BYTES = 8 * 1024 ** 2;
|
|
9
|
+
const MAX_PACKAGE_BYTES = 128 * 1024 ** 2;
|
|
10
|
+
const MAX_CODE_PACKAGE_BYTES = 32 * 1024 ** 2;
|
|
11
|
+
const MAX_PACKAGES = 20_000;
|
|
12
|
+
// Keep a tier candidate within the downstream website projection's existing
|
|
13
|
+
// selected-package byte ceiling. This is a protocol fail-closed bound, not an
|
|
14
|
+
// approved Hosted capacity budget.
|
|
15
|
+
const MAX_CANDIDATE_BYTES = 4 * 1024 ** 3;
|
|
16
|
+
const SHA256 = /^[a-f0-9]{64}$/;
|
|
17
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:alpha|beta)(?:\.(0|[1-9]\d*))?)?$/;
|
|
18
|
+
const COMMIT = /^[a-f0-9]{40}$/;
|
|
19
|
+
const TARGETS = new Set(['react', 'vue', 'vanilla']);
|
|
20
|
+
const KINDS = new Set(['bitmap', 'svg', 'code', 'shared', 'metadata', 'website']);
|
|
21
|
+
|
|
22
|
+
function fail(message) {
|
|
23
|
+
throw new Error(`candidate-set: ${message}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function canonical(value) {
|
|
27
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
28
|
+
if (value && typeof value === 'object') {
|
|
29
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function canonicalBytes(value) {
|
|
35
|
+
return Buffer.from(`${JSON.stringify(canonical(value))}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sha256(bytes) {
|
|
39
|
+
return crypto.createHash('sha256').update(bytes).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function record(value, field) {
|
|
43
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) fail(`${field} must be an object`);
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function exactKeys(value, required, optional, field) {
|
|
48
|
+
const allowed = new Set([...required, ...optional]);
|
|
49
|
+
for (const key of required) if (!Object.hasOwn(value, key)) fail(`${field}.${key} is required`);
|
|
50
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) fail(`${field}.${key} is not allowed`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function positiveInteger(value, field, maximum) {
|
|
54
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) fail(`${field} is outside its byte/count budget`);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function validatePageRef(value, index, tier) {
|
|
59
|
+
const field = `packagePages[${index}]`;
|
|
60
|
+
record(value, field);
|
|
61
|
+
exactKeys(value, ['filename', 'sha256', 'sizeBytes', 'packageCount'], [], field);
|
|
62
|
+
const expectedName = `package-page-${tier}-${String(index + 1).padStart(4, '0')}.json`;
|
|
63
|
+
if (value.filename !== expectedName) fail(`${field}.filename must be ${expectedName}`);
|
|
64
|
+
if (!SHA256.test(value.sha256 || '')) fail(`${field}.sha256 must be lowercase SHA-256`);
|
|
65
|
+
positiveInteger(value.sizeBytes, `${field}.sizeBytes`, MAX_PAGE_BYTES);
|
|
66
|
+
positiveInteger(value.packageCount, `${field}.packageCount`, MAX_PACKAGES);
|
|
67
|
+
return { filename: value.filename, sha256: value.sha256, sizeBytes: value.sizeBytes, packageCount: value.packageCount };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseCandidateSet(input) {
|
|
71
|
+
const bytes = Buffer.from(input);
|
|
72
|
+
if (bytes.length > MAX_ROOT_BYTES) fail('root descriptor exceeds 1 MiB');
|
|
73
|
+
let raw;
|
|
74
|
+
try { raw = JSON.parse(bytes.toString('utf8')); } catch { fail('root descriptor is not valid JSON'); }
|
|
75
|
+
record(raw, 'root');
|
|
76
|
+
if (!bytes.equals(canonicalBytes(raw))) fail('root descriptor is not canonical JSON');
|
|
77
|
+
exactKeys(raw, ['schemaVersion', 'fullVersion', 'tier', 'sourceCommit', 'generatorCommit', 'packagePageCount', 'packagePages', 'packageSetSha256'], [], 'root');
|
|
78
|
+
if (raw.schemaVersion !== 1) fail('schemaVersion must be 1');
|
|
79
|
+
if (!VERSION.test(raw.fullVersion || '')) fail('fullVersion is invalid');
|
|
80
|
+
if (raw.tier !== 'free' && raw.tier !== 'pro') fail('tier must be free or pro');
|
|
81
|
+
if (!COMMIT.test(raw.sourceCommit || '') || !COMMIT.test(raw.generatorCommit || '')) fail('source/generator commit must be 40-char lowercase SHA-1');
|
|
82
|
+
if (!Array.isArray(raw.packagePages) || raw.packagePages.length < 1 || raw.packagePages.length > 128) fail('packagePages count is outside 1..128');
|
|
83
|
+
if (raw.packagePageCount !== raw.packagePages.length) fail('packagePageCount mismatch');
|
|
84
|
+
const pages = raw.packagePages.map((page, index) => validatePageRef(page, index, raw.tier));
|
|
85
|
+
if (!SHA256.test(raw.packageSetSha256 || '')) fail('packageSetSha256 must be lowercase SHA-256');
|
|
86
|
+
const projection = {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
fullVersion: raw.fullVersion,
|
|
89
|
+
tier: raw.tier,
|
|
90
|
+
sourceCommit: raw.sourceCommit,
|
|
91
|
+
generatorCommit: raw.generatorCommit,
|
|
92
|
+
packagePageCount: pages.length,
|
|
93
|
+
packagePages: pages,
|
|
94
|
+
};
|
|
95
|
+
if (sha256(canonicalBytes(projection)) !== raw.packageSetSha256) fail('packageSetSha256 mismatch');
|
|
96
|
+
return { ...projection, packageSetSha256: raw.packageSetSha256 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createCandidateSet({ fullVersion, tier, sourceCommit, generatorCommit, packagePages }) {
|
|
100
|
+
if (!Array.isArray(packagePages) || packagePages.length < 1 || packagePages.length > 128) fail('packagePages count is outside 1..128');
|
|
101
|
+
if (tier !== 'free' && tier !== 'pro') fail('tier must be free or pro');
|
|
102
|
+
const projection = {
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
fullVersion,
|
|
105
|
+
tier,
|
|
106
|
+
sourceCommit,
|
|
107
|
+
generatorCommit,
|
|
108
|
+
packagePageCount: packagePages.length,
|
|
109
|
+
packagePages,
|
|
110
|
+
};
|
|
111
|
+
const bytes = canonicalBytes(projection);
|
|
112
|
+
if (bytes.length > MAX_ROOT_BYTES) fail('root descriptor exceeds 1 MiB');
|
|
113
|
+
return canonicalBytes({ ...projection, packageSetSha256: sha256(bytes) });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function expectedResourceKey(item) {
|
|
117
|
+
const { kind, tier, styleGroupId, target, variant } = item;
|
|
118
|
+
if (kind === 'bitmap') return `bitmap/${tier}/${styleGroupId}/${variant.width}x${variant.height}/${variant.format}`;
|
|
119
|
+
if (kind === 'svg') return `svg/${tier}/${styleGroupId}`;
|
|
120
|
+
if (kind === 'code') return `code/${tier}/${target}/${styleGroupId}`;
|
|
121
|
+
if (kind === 'shared') return `shared/${tier}/${target}`;
|
|
122
|
+
if (kind === 'metadata') return `metadata/${tier}/${styleGroupId || 'common'}`;
|
|
123
|
+
return `website/${tier}/${styleGroupId}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validatePackage(item, index, fullVersion) {
|
|
127
|
+
const field = `packages[${index}]`;
|
|
128
|
+
record(item, field);
|
|
129
|
+
exactKeys(item,
|
|
130
|
+
['packageSha256', 'filename', 'kind', 'tier', 'resourceKey', 'fullVersion', 'compressedBytes', 'expandedBytes', 'fileCount', 'dependencies'],
|
|
131
|
+
['styleGroupId', 'target', 'variant', 'inputSha256'], field);
|
|
132
|
+
if (item.inputSha256 !== undefined && !SHA256.test(item.inputSha256)) fail(`${field}.inputSha256 must be lowercase SHA-256`);
|
|
133
|
+
if (!KINDS.has(item.kind)) fail(`${field}.kind is not supported`);
|
|
134
|
+
if (item.tier !== 'free' && item.tier !== 'pro') fail(`${field}.tier must be free or pro`);
|
|
135
|
+
if (!SHA256.test(item.packageSha256 || '')) fail(`${field}.packageSha256 must be lowercase SHA-256`);
|
|
136
|
+
if (item.filename !== `packages/${item.packageSha256}.tgz`) fail(`${field}.filename must be content-addressed`);
|
|
137
|
+
positiveInteger(item.compressedBytes, `${field}.compressedBytes`, MAX_PACKAGE_BYTES);
|
|
138
|
+
positiveInteger(item.expandedBytes, `${field}.expandedBytes`, MAX_PACKAGE_BYTES);
|
|
139
|
+
if (!Number.isSafeInteger(item.fileCount) || item.fileCount < 1 || item.fileCount > 100_000) fail(`${field}.fileCount is invalid`);
|
|
140
|
+
if (!Array.isArray(item.dependencies) || item.dependencies.some((sha) => !SHA256.test(sha)) || new Set(item.dependencies).size !== item.dependencies.length || item.dependencies.includes(item.packageSha256) || item.dependencies.some((sha, position) => position > 0 && item.dependencies[position - 1].localeCompare(sha, 'en') >= 0)) fail(`${field}.dependencies is invalid`);
|
|
141
|
+
if (item.kind === 'bitmap') {
|
|
142
|
+
if (!/^moe-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.styleGroupId || '')) fail(`${field}.styleGroupId is invalid`);
|
|
143
|
+
record(item.variant, `${field}.variant`);
|
|
144
|
+
exactKeys(item.variant, ['width', 'height', 'format'], [], `${field}.variant`);
|
|
145
|
+
if (item.target !== undefined) fail(`${field}.target is not valid for bitmap`);
|
|
146
|
+
if (!Number.isSafeInteger(item.variant.width) || item.variant.width < 1 || item.variant.width > 4096 || item.variant.height !== item.variant.width) fail(`${field}.variant must be a square bitmap size`);
|
|
147
|
+
if (item.variant.format !== 'png' && item.variant.format !== 'webp') fail(`${field}.variant.format is unsupported`);
|
|
148
|
+
} else if (item.kind === 'shared') {
|
|
149
|
+
if (!TARGETS.has(item.target)) fail(`${field}.target is invalid`);
|
|
150
|
+
if (item.styleGroupId !== undefined || item.variant !== undefined) fail(`${field} shared package cannot be group/variant scoped`);
|
|
151
|
+
} else {
|
|
152
|
+
if (!/^moe-[a-z0-9]+(?:-[a-z0-9]+)*$/.test(item.styleGroupId || '')) fail(`${field}.styleGroupId is required`);
|
|
153
|
+
if (item.kind === 'code' && !TARGETS.has(item.target)) fail(`${field}.target is invalid`);
|
|
154
|
+
if (item.kind !== 'code' && item.target !== undefined) fail(`${field}.target is not valid for ${item.kind}`);
|
|
155
|
+
if (item.variant !== undefined) fail(`${field}.variant is only valid for bitmap`);
|
|
156
|
+
}
|
|
157
|
+
const byteBudget = item.kind === 'metadata' ? MAX_PAGE_BYTES : ['svg', 'code', 'shared', 'website'].includes(item.kind) ? MAX_CODE_PACKAGE_BYTES : MAX_PACKAGE_BYTES;
|
|
158
|
+
if (item.compressedBytes > byteBudget || item.expandedBytes > byteBudget) fail(`${field} exceeds the ${item.kind} package budget`);
|
|
159
|
+
if (item.resourceKey !== expectedResourceKey(item)) fail(`${field}.resourceKey does not match its scope`);
|
|
160
|
+
if (item.fullVersion !== fullVersion) fail(`${field}.fullVersion mismatch`);
|
|
161
|
+
return item;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parsePackagePage(input, expectedRef, expectedVersion, expectedTier) {
|
|
165
|
+
const bytes = Buffer.from(input);
|
|
166
|
+
if (bytes.length > MAX_PAGE_BYTES) fail('package page exceeds 8 MiB');
|
|
167
|
+
if (expectedRef) {
|
|
168
|
+
if (bytes.length !== expectedRef.sizeBytes || sha256(bytes) !== expectedRef.sha256) fail('package page size/digest mismatch');
|
|
169
|
+
}
|
|
170
|
+
let raw;
|
|
171
|
+
try { raw = JSON.parse(bytes.toString('utf8')); } catch { fail('package page is not valid JSON'); }
|
|
172
|
+
record(raw, 'page');
|
|
173
|
+
if (!bytes.equals(canonicalBytes(raw))) fail('package page is not canonical JSON');
|
|
174
|
+
exactKeys(raw, ['schemaVersion', 'fullVersion', 'tier', 'packages'], [], 'page');
|
|
175
|
+
if (raw.schemaVersion !== 1 || raw.fullVersion !== expectedVersion || (raw.tier !== 'free' && raw.tier !== 'pro') || (expectedTier && raw.tier !== expectedTier) || !Array.isArray(raw.packages)) fail('package page identity/schema is invalid');
|
|
176
|
+
if (raw.packages.length < 1 || raw.packages.length > MAX_PACKAGES) fail('package count is outside budget');
|
|
177
|
+
const packages = raw.packages.map((item, index) => validatePackage(item, index, expectedVersion));
|
|
178
|
+
if (packages.some((item) => item.tier !== raw.tier)) fail('package page contains a package from another tier');
|
|
179
|
+
const keys = packages.map((item) => item.resourceKey);
|
|
180
|
+
if (new Set(keys).size !== keys.length) fail('duplicate resourceKey in package page');
|
|
181
|
+
if (new Set(packages.map((item) => item.packageSha256)).size !== packages.length) fail('duplicate package digest in package page');
|
|
182
|
+
if (keys.some((key, index) => index > 0 && keys[index - 1].localeCompare(key, 'en') >= 0)) fail('packages must be sorted by unique resourceKey');
|
|
183
|
+
if (expectedRef && expectedRef.packageCount !== packages.length) fail('packageCount mismatch');
|
|
184
|
+
return { schemaVersion: 1, fullVersion: raw.fullVersion, tier: raw.tier, packages };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function verifyPackagePayload(row, input) {
|
|
188
|
+
const bytes = Buffer.from(input);
|
|
189
|
+
if (bytes.length !== row.compressedBytes) fail(`package ${row.resourceKey} byte count mismatch`);
|
|
190
|
+
if (sha256(bytes) !== row.packageSha256) fail(`package ${row.resourceKey} SHA-256 mismatch`);
|
|
191
|
+
return bytes;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validatePackagePages(candidateSet, pageBytesByName) {
|
|
195
|
+
const set = typeof candidateSet === 'object' && !Buffer.isBuffer(candidateSet)
|
|
196
|
+
? parseCandidateSet(canonicalBytes(candidateSet))
|
|
197
|
+
: parseCandidateSet(candidateSet);
|
|
198
|
+
const all = [];
|
|
199
|
+
let compressedBytes = 0;
|
|
200
|
+
let expandedBytes = 0;
|
|
201
|
+
for (const ref of set.packagePages) {
|
|
202
|
+
const bytes = pageBytesByName instanceof Map ? pageBytesByName.get(ref.filename) : pageBytesByName?.[ref.filename];
|
|
203
|
+
if (!bytes) fail(`missing package page ${ref.filename}`);
|
|
204
|
+
const page = parsePackagePage(bytes, ref, set.fullVersion, set.tier).packages;
|
|
205
|
+
if (all.length + page.length > MAX_PACKAGES) fail(`candidate package count exceeds ${MAX_PACKAGES}`);
|
|
206
|
+
for (const item of page) {
|
|
207
|
+
compressedBytes += item.compressedBytes;
|
|
208
|
+
expandedBytes += item.expandedBytes;
|
|
209
|
+
if (compressedBytes > MAX_CANDIDATE_BYTES) fail(`candidate compressed package bytes exceed ${MAX_CANDIDATE_BYTES}`);
|
|
210
|
+
if (expandedBytes > MAX_CANDIDATE_BYTES) fail(`candidate expanded package bytes exceed ${MAX_CANDIDATE_BYTES}`);
|
|
211
|
+
}
|
|
212
|
+
all.push(...page);
|
|
213
|
+
}
|
|
214
|
+
if (new Set(all.map((item) => item.packageSha256)).size !== all.length) {
|
|
215
|
+
fail('duplicate package digest across candidate set');
|
|
216
|
+
}
|
|
217
|
+
const packageIds = new Set(all.map((item) => item.packageSha256));
|
|
218
|
+
const keys = new Set();
|
|
219
|
+
for (const item of all) {
|
|
220
|
+
if (keys.has(item.resourceKey)) fail(`duplicate resourceKey across pages: ${item.resourceKey}`);
|
|
221
|
+
keys.add(item.resourceKey);
|
|
222
|
+
for (const dependency of item.dependencies) if (!packageIds.has(dependency)) fail(`missing dependency package ${dependency}`);
|
|
223
|
+
}
|
|
224
|
+
return all;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildCandidateSet({ fullVersion, tier, sourceCommit, generatorCommit, packages }) {
|
|
228
|
+
if (!Array.isArray(packages) || packages.length < 1 || packages.length > MAX_PACKAGES) fail(`package count is outside 1..${MAX_PACKAGES}`);
|
|
229
|
+
if (tier !== 'free' && tier !== 'pro') fail('tier must be free or pro');
|
|
230
|
+
if (packages.some((item) => item.tier !== tier)) fail('candidate-set packages must belong to exactly one tier');
|
|
231
|
+
const sorted = packages.map((item, index) => validatePackage(item, index, fullVersion))
|
|
232
|
+
.sort((a, b) => a.resourceKey.localeCompare(b.resourceKey, 'en'));
|
|
233
|
+
let compressedBytes = 0;
|
|
234
|
+
let expandedBytes = 0;
|
|
235
|
+
for (const item of sorted) {
|
|
236
|
+
compressedBytes += item.compressedBytes;
|
|
237
|
+
expandedBytes += item.expandedBytes;
|
|
238
|
+
if (compressedBytes > MAX_CANDIDATE_BYTES) fail(`candidate compressed package bytes exceed ${MAX_CANDIDATE_BYTES}`);
|
|
239
|
+
if (expandedBytes > MAX_CANDIDATE_BYTES) fail(`candidate expanded package bytes exceed ${MAX_CANDIDATE_BYTES}`);
|
|
240
|
+
}
|
|
241
|
+
const keys = sorted.map((item) => item.resourceKey);
|
|
242
|
+
if (new Set(keys).size !== keys.length) fail('duplicate resourceKey across candidate set');
|
|
243
|
+
const pageBytesByName = new Map();
|
|
244
|
+
const refs = [];
|
|
245
|
+
let page = [];
|
|
246
|
+
function flush() {
|
|
247
|
+
if (!page.length) return;
|
|
248
|
+
const index = refs.length + 1;
|
|
249
|
+
const filename = `package-page-${tier}-${String(index).padStart(4, '0')}.json`;
|
|
250
|
+
const bytes = canonicalBytes({ schemaVersion: 1, fullVersion, tier, packages: page });
|
|
251
|
+
if (bytes.length > MAX_PAGE_BYTES) fail(`package page ${index} exceeds 8 MiB`);
|
|
252
|
+
refs.push({ filename, sha256: sha256(bytes), sizeBytes: bytes.length, packageCount: page.length });
|
|
253
|
+
pageBytesByName.set(filename, bytes);
|
|
254
|
+
page = [];
|
|
255
|
+
}
|
|
256
|
+
for (const item of sorted) {
|
|
257
|
+
const tentative = canonicalBytes({ schemaVersion: 1, fullVersion, tier, packages: [...page, item] });
|
|
258
|
+
if (tentative.length > MAX_PAGE_BYTES && page.length) flush();
|
|
259
|
+
page.push(item);
|
|
260
|
+
if (canonicalBytes({ schemaVersion: 1, fullVersion, tier, packages: page }).length > MAX_PAGE_BYTES) fail(`package ${item.resourceKey} cannot fit within one metadata page`);
|
|
261
|
+
}
|
|
262
|
+
flush();
|
|
263
|
+
if (refs.length > 128) fail('package page count exceeds 128');
|
|
264
|
+
const rootBytes = createCandidateSet({ fullVersion, tier, sourceCommit, generatorCommit, packagePages: refs });
|
|
265
|
+
const candidateSet = parseCandidateSet(rootBytes);
|
|
266
|
+
validatePackagePages(candidateSet, pageBytesByName);
|
|
267
|
+
return { candidateSet, rootBytes, pageBytesByName };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
MAX_ROOT_BYTES,
|
|
272
|
+
MAX_PAGE_BYTES,
|
|
273
|
+
MAX_PACKAGE_BYTES,
|
|
274
|
+
MAX_CODE_PACKAGE_BYTES,
|
|
275
|
+
MAX_PACKAGES,
|
|
276
|
+
MAX_CANDIDATE_BYTES,
|
|
277
|
+
canonicalBytes,
|
|
278
|
+
sha256,
|
|
279
|
+
parseCandidateSet,
|
|
280
|
+
parsePackagePage,
|
|
281
|
+
verifyPackagePayload,
|
|
282
|
+
validatePackagePages,
|
|
283
|
+
createCandidateSet,
|
|
284
|
+
buildCandidateSet,
|
|
285
|
+
expectedResourceKey,
|
|
286
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface PackageRow {
|
|
2
|
+
packageSha256: string; filename: string; kind: 'bitmap' | 'svg' | 'code' | 'shared' | 'metadata' | 'website'; tier: 'free' | 'pro'; resourceKey: string; fullVersion: string;
|
|
3
|
+
compressedBytes: number; expandedBytes: number; fileCount: number; dependencies: string[]; styleGroupId?: string; target?: string; variant?: { width: number; height: number; format: string }; inputSha256?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface PageRef { filename: string; sha256: string; sizeBytes: number; packageCount: number }
|
|
6
|
+
export interface CandidateRoot { schemaVersion: number; fullVersion: string; tier: 'free' | 'pro'; sourceCommit: string; generatorCommit: string; packagePageCount: number; packagePages: PageRef[]; packageSetSha256: string }
|
|
7
|
+
export function canonicalBytes(value: unknown): Buffer;
|
|
8
|
+
export function sha256(bytes: Uint8Array): string;
|
|
9
|
+
export function parseCandidateSet(bytes: Uint8Array): CandidateRoot;
|
|
10
|
+
export function validatePackagePages(root: CandidateRoot, pages: Map<string, Uint8Array>): PackageRow[];
|
|
11
|
+
export function verifyPackagePayload(row: PackageRow, bytes: Uint8Array): Buffer;
|
|
12
|
+
export function buildCandidateSet(input: { fullVersion: string; tier: 'free' | 'pro'; sourceCommit: string; generatorCommit: string; packages: PackageRow[] }): { candidateSet: CandidateRoot; rootBytes: Buffer; pageBytesByName: Map<string, Buffer> };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PATCH-24-C: strict structured tar reader.
|
|
5
|
+
*
|
|
6
|
+
* Validates header checksum, strict octal/base-256 size, payload truncation and
|
|
7
|
+
* padding, the end-of-archive marker with no non-zero trailing bytes, PAX record
|
|
8
|
+
* lengths/scopes and GNU long names. Returns entries with the raw path plus the
|
|
9
|
+
* file bytes so callers never have to hand the same archive to a second parser
|
|
10
|
+
* (e.g. system tar) that might interpret it differently.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const BLOCK = 512;
|
|
14
|
+
const DEFAULT_MAX_ENTRIES = 200000;
|
|
15
|
+
// CI FLOW RULE: parse one bounded resource package per invocation, not a
|
|
16
|
+
// concatenated tier archive.
|
|
17
|
+
const DEFAULT_MAX_EXPANDED_BYTES = 128 * 1024 * 1024;
|
|
18
|
+
const MAX_METADATA_BYTES = 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
function readString(buffer, offset, length) {
|
|
21
|
+
const slice = buffer.subarray(offset, offset + length);
|
|
22
|
+
const end = slice.indexOf(0);
|
|
23
|
+
return (end >= 0 ? slice.subarray(0, end) : slice).toString('utf8');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function roundUp(size) {
|
|
27
|
+
return Math.ceil(size / BLOCK) * BLOCK;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isZeroBlock(buffer, offset) {
|
|
31
|
+
for (let index = offset; index < offset + BLOCK; index += 1) {
|
|
32
|
+
if (buffer[index] !== 0) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseOctal(field, label) {
|
|
38
|
+
const text = field.toString('latin1').replace(/\0.*$/, '').trim();
|
|
39
|
+
if (text === '') return 0;
|
|
40
|
+
if (!/^[0-7]+$/.test(text)) throw new Error(`tar ${label} is not valid octal: ${JSON.stringify(text)}`);
|
|
41
|
+
const value = Number.parseInt(text, 8);
|
|
42
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`tar ${label} is out of range`);
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** GNU base-256 encoding when the high bit of the first byte is set. */
|
|
47
|
+
function parseSize(field) {
|
|
48
|
+
if (field[0] & 0x80) {
|
|
49
|
+
let value = 0n;
|
|
50
|
+
for (const byte of field) value = (value << 8n) | BigInt(byte & 0x7f);
|
|
51
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('tar size overflows safe integer');
|
|
52
|
+
return Number(value);
|
|
53
|
+
}
|
|
54
|
+
return parseOctal(field, 'size');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseChecksum(field) {
|
|
58
|
+
const text = field.toString('latin1').replace(/\0.*$/, '').trim();
|
|
59
|
+
if (!/^[0-7]+$/.test(text)) throw new Error(`tar checksum is not valid octal: ${JSON.stringify(text)}`);
|
|
60
|
+
return Number.parseInt(text, 8);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function computeChecksums(header) {
|
|
64
|
+
let unsigned = 0;
|
|
65
|
+
let signed = 0;
|
|
66
|
+
for (let index = 0; index < BLOCK; index += 1) {
|
|
67
|
+
const byte = index >= 148 && index < 156 ? 0x20 : header[index];
|
|
68
|
+
unsigned += byte;
|
|
69
|
+
signed += byte < 0x80 ? byte : byte - 0x100;
|
|
70
|
+
}
|
|
71
|
+
return { unsigned, signed };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function verifyChecksum(header, offset) {
|
|
75
|
+
const stored = parseChecksum(header.subarray(148, 156));
|
|
76
|
+
const { unsigned, signed } = computeChecksums(header);
|
|
77
|
+
if (stored !== unsigned && stored !== signed) {
|
|
78
|
+
throw new Error(`tar checksum mismatch at block ${offset / BLOCK}: stored ${stored}, computed ${unsigned}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parsePax(data) {
|
|
83
|
+
const record = {};
|
|
84
|
+
let cursor = 0;
|
|
85
|
+
while (cursor < data.length) {
|
|
86
|
+
const space = data.indexOf(0x20, cursor);
|
|
87
|
+
if (space < 0) throw new Error('malformed pax record: missing length separator');
|
|
88
|
+
const lengthText = data.subarray(cursor, space).toString('latin1');
|
|
89
|
+
if (!/^\d+$/.test(lengthText)) throw new Error('malformed pax record: non-numeric length');
|
|
90
|
+
const length = Number.parseInt(lengthText, 10);
|
|
91
|
+
if (length <= 0 || cursor + length > data.length) throw new Error('malformed pax record: length overruns payload');
|
|
92
|
+
const raw = data.subarray(space + 1, cursor + length);
|
|
93
|
+
if (raw.length === 0 || raw[raw.length - 1] !== 0x0a) throw new Error('malformed pax record: missing newline');
|
|
94
|
+
// Lengths are UTF-8 byte counts; decode only the value bytes.
|
|
95
|
+
const body = raw.subarray(0, raw.length - 1).toString('utf8');
|
|
96
|
+
const equals = body.indexOf('=');
|
|
97
|
+
if (equals < 0) throw new Error('malformed pax record: missing "="');
|
|
98
|
+
record[body.slice(0, equals)] = body.slice(equals + 1);
|
|
99
|
+
cursor += length;
|
|
100
|
+
}
|
|
101
|
+
if (cursor !== data.length) throw new Error('malformed pax record: trailing bytes');
|
|
102
|
+
return record;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {Buffer} buffer full tar archive bytes
|
|
107
|
+
* @param {{maxEntries?:number,maxExpandedBytes?:number}} [options]
|
|
108
|
+
*/
|
|
109
|
+
function readTarEntries(buffer, options = {}) {
|
|
110
|
+
const maxEntries = options.maxEntries || DEFAULT_MAX_ENTRIES;
|
|
111
|
+
const maxExpandedBytes = options.maxExpandedBytes || DEFAULT_MAX_EXPANDED_BYTES;
|
|
112
|
+
const entries = [];
|
|
113
|
+
let offset = 0;
|
|
114
|
+
let pendingLongName = null;
|
|
115
|
+
let pendingPax = null;
|
|
116
|
+
let globalPax = {};
|
|
117
|
+
let expandedBytes = 0;
|
|
118
|
+
let metadataCount = 0;
|
|
119
|
+
let terminated = false;
|
|
120
|
+
|
|
121
|
+
while (offset < buffer.length) {
|
|
122
|
+
if (offset + BLOCK > buffer.length) throw new Error(`tar truncated header at offset ${offset}`);
|
|
123
|
+
if (isZeroBlock(buffer, offset)) {
|
|
124
|
+
// POSIX requires two consecutive 512-byte zero blocks; anything after the
|
|
125
|
+
// two blocks must be zero record padding.
|
|
126
|
+
const second = offset + BLOCK;
|
|
127
|
+
if (second + BLOCK > buffer.length || !isZeroBlock(buffer, second)) {
|
|
128
|
+
throw new Error('tar is missing the two-block end-of-archive marker');
|
|
129
|
+
}
|
|
130
|
+
for (let index = second; index < buffer.length; index += 1) {
|
|
131
|
+
if (buffer[index] !== 0) throw new Error('tar has trailing data after the end-of-archive marker');
|
|
132
|
+
}
|
|
133
|
+
terminated = true;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
const header = buffer.subarray(offset, offset + BLOCK);
|
|
137
|
+
verifyChecksum(header, offset);
|
|
138
|
+
|
|
139
|
+
const name = readString(header, 0, 100);
|
|
140
|
+
const type = String.fromCharCode(header[156]);
|
|
141
|
+
const linkname = readString(header, 157, 100);
|
|
142
|
+
const prefix = readString(header, 345, 155);
|
|
143
|
+
const size = parseSize(header.subarray(124, 136));
|
|
144
|
+
const dataStart = offset + BLOCK;
|
|
145
|
+
const paddedSize = roundUp(size);
|
|
146
|
+
if (dataStart + paddedSize > buffer.length) {
|
|
147
|
+
throw new Error(`tar payload truncated for ${name || '<unnamed>'} (size ${size})`);
|
|
148
|
+
}
|
|
149
|
+
if (size < 0) throw new Error('tar negative size');
|
|
150
|
+
expandedBytes += size;
|
|
151
|
+
if (expandedBytes > maxExpandedBytes) throw new Error('tar expanded size exceeds limit');
|
|
152
|
+
const data = buffer.subarray(dataStart, dataStart + size);
|
|
153
|
+
// Padding bytes must be zero.
|
|
154
|
+
for (let index = dataStart + size; index < dataStart + paddedSize; index += 1) {
|
|
155
|
+
if (buffer[index] !== 0) throw new Error(`tar non-zero padding for ${name || '<unnamed>'}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let filePath = prefix ? `${prefix}/${name}` : name;
|
|
159
|
+
if (pendingLongName !== null) filePath = pendingLongName;
|
|
160
|
+
const pax = pendingPax ? { ...globalPax, ...pendingPax } : globalPax;
|
|
161
|
+
if (pax.path) filePath = pax.path;
|
|
162
|
+
const entryLink = pax.linkpath || linkname;
|
|
163
|
+
|
|
164
|
+
const isMetadata = type === 'L' || type === 'x' || type === 'g';
|
|
165
|
+
if (isMetadata) {
|
|
166
|
+
// Metadata headers count toward the entry budget and are size-capped.
|
|
167
|
+
metadataCount += 1;
|
|
168
|
+
if (entries.length + metadataCount > maxEntries) throw new Error('tar entry count exceeds limit');
|
|
169
|
+
if (size > MAX_METADATA_BYTES) throw new Error('tar metadata entry exceeds size limit');
|
|
170
|
+
}
|
|
171
|
+
if (type === 'L') {
|
|
172
|
+
pendingLongName = data.toString('utf8').replace(/\0+$/, '');
|
|
173
|
+
pendingPax = null;
|
|
174
|
+
offset = dataStart + paddedSize;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (type === 'x' || type === 'g') {
|
|
178
|
+
const parsed = parsePax(data);
|
|
179
|
+
if (type === 'x') pendingPax = parsed;
|
|
180
|
+
else globalPax = parsed;
|
|
181
|
+
offset = dataStart + paddedSize;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
pendingLongName = null;
|
|
185
|
+
pendingPax = null;
|
|
186
|
+
|
|
187
|
+
if (entries.length >= maxEntries) throw new Error('tar entry count exceeds limit');
|
|
188
|
+
|
|
189
|
+
let kind;
|
|
190
|
+
if (type === '5') kind = 'dir';
|
|
191
|
+
else if (type === '0' || type === '\0' || type === '\u0000') kind = 'file';
|
|
192
|
+
else if (type === '2') kind = 'symlink';
|
|
193
|
+
else if (type === '1') kind = 'hardlink';
|
|
194
|
+
else if (type === '6') kind = 'fifo';
|
|
195
|
+
else kind = `other:${type}`;
|
|
196
|
+
|
|
197
|
+
if (!isMetadata && (!filePath || filePath.length === 0)) throw new Error('tar entry has an empty path');
|
|
198
|
+
|
|
199
|
+
entries.push({
|
|
200
|
+
path: kind === 'dir' ? filePath.replace(/\/$/, '') : filePath,
|
|
201
|
+
type: kind,
|
|
202
|
+
linkname: entryLink || null,
|
|
203
|
+
size,
|
|
204
|
+
data: kind === 'file' ? data : null,
|
|
205
|
+
});
|
|
206
|
+
offset = dataStart + paddedSize;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!terminated) throw new Error('tar is missing the end-of-archive marker');
|
|
210
|
+
return entries;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = {
|
|
214
|
+
BLOCK,
|
|
215
|
+
DEFAULT_MAX_ENTRIES,
|
|
216
|
+
DEFAULT_MAX_EXPANDED_BYTES,
|
|
217
|
+
readTarEntries,
|
|
218
|
+
parsePax,
|
|
219
|
+
parseOctal,
|
|
220
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type TransactionalFs } from "../project/install.js";
|
|
2
|
+
import { type MoeiconsConfigFile } from "../project/config.js";
|
|
3
|
+
import type { Target } from "../commands/parser.js";
|
|
4
|
+
export interface SmallPackageInstallResult {
|
|
5
|
+
readonly artifactVersion: string;
|
|
6
|
+
readonly candidateRootSha256: string;
|
|
7
|
+
readonly packageSetSha256: string;
|
|
8
|
+
readonly catalogSha256: string;
|
|
9
|
+
readonly metadataSha256: string;
|
|
10
|
+
readonly artifactBytes: number;
|
|
11
|
+
readonly files: readonly string[];
|
|
12
|
+
readonly packageCount: number;
|
|
13
|
+
readonly planItems: number;
|
|
14
|
+
readonly selectedFiles: number;
|
|
15
|
+
readonly networkBytes: number;
|
|
16
|
+
readonly cacheHit: boolean;
|
|
17
|
+
readonly cacheHits: number;
|
|
18
|
+
readonly metadataCacheHits: number;
|
|
19
|
+
readonly transferRetryCount: number;
|
|
20
|
+
readonly target: Target;
|
|
21
|
+
readonly targetSha256: string;
|
|
22
|
+
readonly targetFileCount: number;
|
|
23
|
+
readonly targetByteCount: number;
|
|
24
|
+
readonly fallbacks: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
export declare function runSmallPackageInstall(options: {
|
|
27
|
+
readonly projectRoot: string;
|
|
28
|
+
readonly fullVersion?: string;
|
|
29
|
+
readonly expectedPackageSetSha256?: string;
|
|
30
|
+
readonly proAccessToken?: string;
|
|
31
|
+
readonly apiBaseUrl?: string;
|
|
32
|
+
readonly config: MoeiconsConfigFile;
|
|
33
|
+
readonly plannedConfigDocument: string;
|
|
34
|
+
readonly fs: TransactionalFs;
|
|
35
|
+
readonly cacheDir: string;
|
|
36
|
+
readonly now: () => Date;
|
|
37
|
+
readonly signal?: AbortSignal;
|
|
38
|
+
readonly fetchFn?: typeof fetch;
|
|
39
|
+
readonly fixtureOrigin?: string;
|
|
40
|
+
}): Promise<SmallPackageInstallResult>;
|