@moewolf/moe-icons-cli 0.0.3 → 0.0.4

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/bin/moeicons.js CHANGED
@@ -9,6 +9,14 @@ if (!checked.ok) {
9
9
  process.exit(1);
10
10
  }
11
11
 
12
+ // Plain help/version do not need the generation, extraction or MCP modules.
13
+ const args = process.argv.slice(2);
14
+ if (args.length === 1 && ["--help", "-h", "help"].includes(args[0])) {
15
+ const { HELP_TEXT } = await import("../dist/commands/parser.js");
16
+ process.stdout.write(HELP_TEXT);
17
+ } else if (args.length === 1 && ["--version", "-v"].includes(args[0])) {
18
+ process.stdout.write(require("../package.json").version + "\n");
19
+ } else {
12
20
  const { main } = await import("../dist/cli.js");
13
21
 
14
22
  const runtime = {
@@ -29,3 +37,5 @@ main(process.argv.slice(2), runtime).then(
29
37
  process.exitCode = 5;
30
38
  },
31
39
  );
40
+
41
+ }
@@ -56,6 +56,7 @@ export type FreeDownloadSuccess = {
56
56
  readonly descriptor: ReleaseDescriptor;
57
57
  readonly descriptorSha256: string;
58
58
  readonly artifactBytes: Uint8Array;
59
+ readonly archiveFiles?: Readonly<Record<string, Uint8Array>>;
59
60
  readonly selected?: SelectedResourceDownload;
60
61
  readonly downloadNote?: string;
61
62
  readonly catalogJson: string;
@@ -6,7 +6,7 @@ import { validateConfigDocument, } from "../project/config.js";
6
6
  import { join } from "node:path";
7
7
  import { createHash } from "node:crypto";
8
8
  import { downloadArtifact, verifyArtifact } from "../project/install.js";
9
- import { decodeUtf8, extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
9
+ import { decodeUtf8, extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
10
10
  import { catalog as bundledCatalog } from "../catalog/catalog.js";
11
11
  import { cacheArtifact } from "./cache.js";
12
12
  import { extractAndVerifyMetadataArchive } from "../metadata/archive.js";
@@ -65,6 +65,8 @@ function mapDownloadError(code, message) {
65
65
  if (code === "HTTP_ERROR" && / 404$/.test(message)) {
66
66
  return { ok: false, reason: "not-found", message };
67
67
  }
68
+ if (code === "CANCELLED")
69
+ return { ok: false, reason: "cancelled", message };
68
70
  if (code === "NETWORK_ERROR")
69
71
  return { ok: false, reason: "network", message };
70
72
  return { ok: false, reason: "validation", message };
@@ -148,12 +150,14 @@ async function loadBytes(io, filename, limits, tag) {
148
150
  message: `download failed after ${MAX_DOWNLOAD_ATTEMPTS} attempts`,
149
151
  };
150
152
  }
151
- function catalogFromArchive(artifactBytes, catalogFilename, expectedSha) {
152
- const unpacked = extractTarGz(artifactBytes, {
153
+ async function catalogFromArchive(artifactBytes, catalogFilename, expectedSha, signal) {
154
+ const unpacked = await extractTarGzAsync(artifactBytes, {
153
155
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
154
156
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
155
- });
157
+ }, signal);
156
158
  if (unpacked.errors.length > 0) {
159
+ if (signal?.aborted)
160
+ return { ok: false, reason: "cancelled", message: "archive extraction cancelled" };
157
161
  return { ok: false, reason: "validation", message: unpacked.errors[0] ?? "extract failed" };
158
162
  }
159
163
  const catalogBytes = unpacked.files[catalogFilename] ?? unpacked.files[`./${catalogFilename}`];
@@ -168,7 +172,7 @@ function catalogFromArchive(artifactBytes, catalogFilename, expectedSha) {
168
172
  message: `catalog SHA-256 mismatch: expected ${expectedSha}, got ${actual}`,
169
173
  };
170
174
  }
171
- return { ok: true, json: decodeUtf8(catalogBytes) };
175
+ return { ok: true, json: decodeUtf8(catalogBytes), files: unpacked.files };
172
176
  }
173
177
  /**
174
178
  * Download the small metadata archive, verify the whole-archive SHA-256 plus
@@ -489,7 +493,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
489
493
  const cached = io.readFileSync(artifactCache);
490
494
  const verified = verifyArtifact(cached, descriptor.free.sha256);
491
495
  if (verified.ok) {
492
- const catalog = catalogFromArchive(cached, descriptor.catalog.filename, descriptor.catalog.sha256);
496
+ const catalog = await catalogFromArchive(cached, descriptor.catalog.filename, descriptor.catalog.sha256, io.signal);
493
497
  if (!catalog.ok)
494
498
  return catalog;
495
499
  const metadata = preflightMetadata
@@ -503,6 +507,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
503
507
  descriptorSha256,
504
508
  artifactBytes: cached,
505
509
  catalogJson: catalog.json,
510
+ archiveFiles: catalog.files,
506
511
  cacheHit: true,
507
512
  tag,
508
513
  metadataSha256: metadata.value.metadataSha256,
@@ -533,7 +538,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
533
538
  message: `free artifact SHA-256 mismatch: expected ${descriptor.free.sha256}, got ${verified.actual}`,
534
539
  };
535
540
  }
536
- const catalog = catalogFromArchive(artifact.bytes, descriptor.catalog.filename, descriptor.catalog.sha256);
541
+ const catalog = await catalogFromArchive(artifact.bytes, descriptor.catalog.filename, descriptor.catalog.sha256, io.signal);
537
542
  if (!catalog.ok)
538
543
  return catalog;
539
544
  const metadata = preflightMetadata
@@ -553,6 +558,7 @@ export async function downloadFreeRelease(io, sourceVersion, selection) {
553
558
  descriptorSha256,
554
559
  artifactBytes: artifact.bytes,
555
560
  catalogJson: catalog.json,
561
+ archiveFiles: catalog.files,
556
562
  cacheHit: false,
557
563
  tag,
558
564
  metadataSha256: metadata.value.metadataSha256,
@@ -46,13 +46,13 @@ export declare function loadInstalledCatalog(projectRoot: string, fs_: Pick<Tran
46
46
  * Vanilla (and bitmap themes on react/vue) need the full cached archive so
47
47
  * `assets/` is available — the installed package subtree alone is not enough.
48
48
  */
49
- export declare function loadArchiveFiles(projectRoot: string, env: Readonly<Record<string, string | undefined>>, fs_: Pick<TransactionalFsWithCopy, "readFileSync" | "existsSync" | "readdirSync">, injected?: Readonly<Record<string, Uint8Array>>): {
49
+ export declare function loadArchiveFiles(projectRoot: string, env: Readonly<Record<string, string | undefined>>, fs_: Pick<TransactionalFsWithCopy, "readFileSync" | "existsSync" | "readdirSync">, injected?: Readonly<Record<string, Uint8Array>>, signal?: AbortSignal): Promise<{
50
50
  readonly ok: true;
51
51
  readonly files: Readonly<Record<string, Uint8Array>>;
52
52
  } | {
53
53
  readonly ok: false;
54
54
  readonly reason: string;
55
- };
55
+ }>;
56
56
  export declare function runGenerateUseCase(context: CommandContext, fs_: TransactionalFsWithCopy, options?: {
57
57
  readonly noTailwind?: boolean;
58
58
  readonly target?: Target;
@@ -8,7 +8,7 @@ import { toProxyName } from "./icon-names.js";
8
8
  import { planGeneratedFiles } from "../generator/generate.js";
9
9
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
10
10
  import { isCliError } from "../errors/index.js";
11
- import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
11
+ import { extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
12
12
  import { artifactCachePath } from "./free-download.js";
13
13
  import { resolveThemes } from "../generator/theme-resolve.js";
14
14
  import { loadPinnedBitmapShardAssets } from "./bitmap-shard-resolver.js";
@@ -238,7 +238,7 @@ function mergePinnedBitmapShardAssets(archiveFiles, effectiveConfig, sourceCatal
238
238
  * Vanilla (and bitmap themes on react/vue) need the full cached archive so
239
239
  * `assets/` is available — the installed package subtree alone is not enough.
240
240
  */
241
- export function loadArchiveFiles(projectRoot, env, fs_, injected) {
241
+ export async function loadArchiveFiles(projectRoot, env, fs_, injected, signal) {
242
242
  if (injected)
243
243
  return { ok: true, files: injected };
244
244
  const fixtureTgz = env.MOEICONS_BITMAP_ARCHIVE;
@@ -249,10 +249,10 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
249
249
  return { ok: false, reason: "MOEICONS_BITMAP_ARCHIVE is only honored in a local-test context" };
250
250
  }
251
251
  if (fixtureTgz && fs_.existsSync(fixtureTgz)) {
252
- const unpacked = extractTarGz(readBinaryFile(fs_, fixtureTgz), {
252
+ const unpacked = await extractTarGzAsync(readBinaryFile(fs_, fixtureTgz), {
253
253
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
254
254
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
255
- });
255
+ }, signal);
256
256
  if (unpacked.errors.length > 0)
257
257
  return { ok: false, reason: unpacked.errors[0] ?? "invalid bitmap archive fixture" };
258
258
  return { ok: true, files: unpacked.files };
@@ -320,10 +320,10 @@ export function loadArchiveFiles(projectRoot, env, fs_, injected) {
320
320
  if (meta?.artifactVersion && meta.artifactSha256) {
321
321
  const cached = artifactCachePath(cacheDir, meta.artifactVersion, meta.artifactSha256);
322
322
  if (fs_.existsSync(cached)) {
323
- const unpacked = extractTarGz(readBinaryFile(fs_, cached), {
323
+ const unpacked = await extractTarGzAsync(readBinaryFile(fs_, cached), {
324
324
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
325
325
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
326
- });
326
+ }, signal);
327
327
  if (unpacked.errors.length > 0)
328
328
  return { ok: false, reason: unpacked.errors[0] ?? "invalid cached artifact" };
329
329
  return { ok: true, files: unpacked.files };
@@ -371,7 +371,7 @@ export async function runGenerateUseCase(context, fs_, options = {}) {
371
371
  }
372
372
  let archiveFiles = options.archiveFiles;
373
373
  if (needsArchiveFiles(effectiveConfig, sourceCatalog) && archiveFiles === undefined) {
374
- const loadedArchive = loadArchiveFiles(project.root, context.env, fs_, undefined);
374
+ const loadedArchive = await loadArchiveFiles(project.root, context.env, fs_, undefined, context.signal);
375
375
  if (!loadedArchive.ok) {
376
376
  return {
377
377
  ok: false,
@@ -6,7 +6,7 @@ import { CliError, isCliError } from "../errors/index.js";
6
6
  import { bundledSourceVersion, downloadFreeRelease } from "./free-download.js";
7
7
  import { serializeInstallMetadata, sha256Bytes } from "../project/install-metadata.js";
8
8
  import { withProjectLock } from "../project/project-lock.js";
9
- import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
9
+ import { configuredComponentFiles, selectTargetSubtreeAsync, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
10
10
  import { parseCatalog } from "../catalog/catalog.js";
11
11
  import { posix } from "node:path";
12
12
  /**
@@ -118,7 +118,9 @@ export async function runInstallUseCase(context, deps, options) {
118
118
  : undefined;
119
119
  const subtree = selectedTarget
120
120
  ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
121
- : selectTargetSubtree(downloaded.artifactBytes, downloaded.descriptor.free, target);
121
+ : downloaded.archiveFiles
122
+ ? selectTargetSubtreeFromFiles(downloaded.archiveFiles, downloaded.descriptor.free, target)
123
+ : await selectTargetSubtreeAsync(downloaded.artifactBytes, downloaded.descriptor.free, target, context.signal);
122
124
  if (!subtree.ok) {
123
125
  return subtree.reason === "checksum-mismatch"
124
126
  ? { ok: false, reason: "checksum-mismatch", message: subtree.message }
@@ -10,14 +10,14 @@ import { executeManagedReconcile } from "../project/install.js";
10
10
  import { parseInstallMetadata, serializeInstallMetadata, sha256Bytes, } from "../project/install-metadata.js";
11
11
  import { withProjectLock } from "../project/project-lock.js";
12
12
  import { ensureClassMergeDependencies, planTailwindIntegration } from "../project/tailwind.js";
13
- import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
13
+ import { extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
14
14
  import { artifactCachePath, downloadFreeRelease, metadataCachePath, } from "./free-download.js";
15
15
  import { downloadProArtifact } from "./pro-download.js";
16
16
  import { PRO_DOWNLOAD_HOSTS, resolveProDescriptorEndpoint } from "./pro-download.js";
17
17
  import { runAccessTokenUseCase } from "./auth.js";
18
18
  import { resolveBitmapTuples, resolveConfiguredBitmapShards } from "./bitmap-shard-resolver.js";
19
19
  import { mergeBitmapShardCacheManifest, readBitmapShardCacheManifest, writeBitmapShardCacheManifest, } from "./bitmap-shard-cache.js";
20
- import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
20
+ import { configuredComponentFiles, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
21
21
  export async function runLibraryUpdateUseCase(context, deps, expected) {
22
22
  const project = detectProject(context.cwd);
23
23
  if (!project)
@@ -31,6 +31,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
31
31
  assertDownloadSelection(bootstrap.config);
32
32
  let selected;
33
33
  let catalogJson;
34
+ let fullArchiveFiles;
34
35
  let archiveBytes;
35
36
  let artifactSha256;
36
37
  let catalogSha256;
@@ -58,6 +59,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
58
59
  selected = downloaded.selected;
59
60
  catalogJson = downloaded.catalogJson;
60
61
  archiveBytes = downloaded.artifactBytes;
62
+ fullArchiveFiles = downloaded.archiveFiles;
61
63
  artifactSha256 = downloaded.descriptor.free.sha256;
62
64
  catalogSha256 = downloaded.descriptor.catalog.sha256;
63
65
  manifestJson = downloaded.manifestJson;
@@ -74,6 +76,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
74
76
  selected = downloaded.selected;
75
77
  catalogJson = downloaded.catalogJson;
76
78
  archiveBytes = downloaded.artifactBytes;
79
+ fullArchiveFiles = downloaded.archiveFiles;
77
80
  artifactSha256 = downloaded.descriptor.sha256;
78
81
  catalogSha256 = downloaded.descriptor.catalogSha256;
79
82
  manifestJson = downloaded.manifestJson;
@@ -114,10 +117,10 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
114
117
  : undefined;
115
118
  const unpacked = selected
116
119
  ? { files: selected.files, errors: [] }
117
- : extractTarGz(archiveBytes, {
120
+ : fullArchiveFiles ? { files: fullArchiveFiles, errors: [] } : await extractTarGzAsync(archiveBytes, {
118
121
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
119
122
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
120
- });
123
+ }, context.signal);
121
124
  if (unpacked.errors.length)
122
125
  throw new CliError("VALIDATION_ERROR", unpacked.errors[0] ?? "invalid artifact");
123
126
  const target = loaded.config.target;
@@ -128,7 +131,7 @@ export async function runLibraryUpdateUseCase(context, deps, expected) {
128
131
  : undefined;
129
132
  const subtree = selectedTarget
130
133
  ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
131
- : selectTargetSubtree(archiveBytes, tierSource, target);
134
+ : selectTargetSubtreeFromFiles(unpacked.files, tierSource, target);
132
135
  if (!subtree.ok)
133
136
  throw new CliError("VALIDATION_ERROR", subtree.message);
134
137
  const archiveFiles = { ...unpacked.files };
@@ -83,6 +83,7 @@ export declare function downloadProArtifact(context: CommandContext, auth: AuthU
83
83
  }): Promise<{
84
84
  readonly descriptor: ProArtifactDescriptor;
85
85
  readonly artifactBytes: Uint8Array;
86
+ readonly archiveFiles?: Readonly<Record<string, Uint8Array>>;
86
87
  readonly catalogJson: string;
87
88
  readonly manifestJson: string;
88
89
  readonly manualMd: string;
@@ -3,7 +3,7 @@ import { downloadProSelected } from "./pro-selected-download.js";
3
3
  import { validateConfigDocument, } from "../project/config.js";
4
4
  import { parseCatalog } from "../catalog/catalog.js";
5
5
  import { CliError } from "../errors/index.js";
6
- import { extractTarGz, decodeUtf8, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
6
+ import { extractTarGzAsync, decodeUtf8, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES, } from "../project/tar-gz.js";
7
7
  import { sha256Bytes } from "../project/install-metadata.js";
8
8
  import { downloadSignedArtifact, parseSignedDescriptor, } from "./signed-artifact.js";
9
9
  import { runAccessTokenUseCase } from "./auth.js";
@@ -310,10 +310,12 @@ export async function downloadProArtifact(context, auth, expected, deps = {}) {
310
310
  size: descriptor.size,
311
311
  sha256: descriptor.sha256,
312
312
  }, downloadOptions);
313
- const extracted = extractTarGz(artifactBytes, {
313
+ const extracted = await extractTarGzAsync(artifactBytes, {
314
314
  maxEntries: ICON_ARCHIVE_MAX_ENTRIES,
315
315
  maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES,
316
- });
316
+ }, context.signal);
317
+ if (context.signal.aborted)
318
+ throw new CliError("CANCELLED", "archive extraction cancelled");
317
319
  if (extracted.errors.length > 0)
318
320
  throw new CliError("VALIDATION_ERROR", extracted.errors[0] ?? "invalid pro archive");
319
321
  const catalog = extracted.files[descriptor.catalogFilename] ??
@@ -323,6 +325,7 @@ export async function downloadProArtifact(context, auth, expected, deps = {}) {
323
325
  return {
324
326
  descriptor,
325
327
  artifactBytes,
328
+ archiveFiles: extracted.files,
326
329
  catalogJson: decodeUtf8(catalog),
327
330
  manifestJson: metadata.manifestJson,
328
331
  manualMd: metadata.manualMd,
@@ -7,7 +7,7 @@ import { withProjectLock } from "../project/project-lock.js";
7
7
  import { runAccessTokenUseCase } from "./auth.js";
8
8
  import { downloadProArtifact, PRO_DOWNLOAD_HOSTS, resolveProDescriptorEndpoint, } from "./pro-download.js";
9
9
  import { artifactCachePath, metadataCachePath } from "./free-download.js";
10
- import { configuredComponentFiles, selectTargetSubtree, computeSubtreeHash, } from "./target-subtree.js";
10
+ import { configuredComponentFiles, selectTargetSubtreeAsync, selectTargetSubtreeFromFiles, computeSubtreeHash, } from "./target-subtree.js";
11
11
  import { typesReexport } from "./install.js";
12
12
  import { CliError } from "../errors/index.js";
13
13
  import { catalog as bundledCatalog, parseCatalog } from "../catalog/catalog.js";
@@ -81,7 +81,9 @@ export async function runProInstallUseCase(context, deps, expected) {
81
81
  : undefined;
82
82
  const subtree = selectedTarget
83
83
  ? { ok: true, target, files: selectedTarget, ...computeSubtreeHash(selectedTarget) }
84
- : selectTargetSubtree(downloaded.artifactBytes, downloaded.descriptor, target);
84
+ : downloaded.archiveFiles
85
+ ? selectTargetSubtreeFromFiles(downloaded.archiveFiles, downloaded.descriptor, target)
86
+ : await selectTargetSubtreeAsync(downloaded.artifactBytes, downloaded.descriptor, target, context.signal);
85
87
  if (!subtree.ok) {
86
88
  throw new CliError("VALIDATION_ERROR", subtree.message);
87
89
  }
@@ -55,3 +55,6 @@ export declare function computeSubtreeHash(files: Readonly<Record<string, Uint8A
55
55
  * verification for backward compatibility but still reject an empty subtree.
56
56
  */
57
57
  export declare function selectTargetSubtree(archiveBytes: Uint8Array, source: TargetSubtreeSource, target: Target): TargetSubtreeResult;
58
+ export declare function selectTargetSubtreeAsync(archiveBytes: Uint8Array, source: TargetSubtreeSource, target: Target, signal?: AbortSignal): Promise<TargetSubtreeResult>;
59
+ /** Reuse the same verified archive rather than inflating it again per target. */
60
+ export declare function selectTargetSubtreeFromFiles(archiveFiles: Readonly<Record<string, Uint8Array>>, source: TargetSubtreeSource, target: Target): TargetSubtreeResult;
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { extractTarGz, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES } from "../project/tar-gz.js";
2
+ import { extractTarGz, extractTarGzAsync, ICON_ARCHIVE_MAX_ENTRIES, ICON_ARCHIVE_MAX_EXPANDED_BYTES } from "../project/tar-gz.js";
3
3
  import { posix } from "node:path";
4
4
  import { toProxyName } from "./icon-names.js";
5
5
  import { findCatalogStyleGroup } from "../catalog/catalog.js";
@@ -122,7 +122,17 @@ export function selectTargetSubtree(archiveBytes, source, target) {
122
122
  if (extracted.errors.length > 0) {
123
123
  return { ok: false, reason: "validation", message: extracted.errors[0] ?? "invalid archive" };
124
124
  }
125
- const files = subtreeFilesOf(extracted.files, target);
125
+ return selectTargetSubtreeFromFiles(extracted.files, source, target);
126
+ }
127
+ export async function selectTargetSubtreeAsync(archiveBytes, source, target, signal) {
128
+ const extracted = await extractTarGzAsync(archiveBytes, { maxEntries: ICON_ARCHIVE_MAX_ENTRIES, maxExpandedBytes: ICON_ARCHIVE_MAX_EXPANDED_BYTES }, signal);
129
+ if (extracted.errors.length)
130
+ return { ok: false, reason: "validation", message: extracted.errors[0] ?? "invalid archive" };
131
+ return selectTargetSubtreeFromFiles(extracted.files, source, target);
132
+ }
133
+ /** Reuse the same verified archive rather than inflating it again per target. */
134
+ export function selectTargetSubtreeFromFiles(archiveFiles, source, target) {
135
+ const files = subtreeFilesOf(archiveFiles, target);
126
136
  const computed = computeSubtreeHash(files);
127
137
  const modern = hasModernTargetContract(source);
128
138
  if (modern) {
@@ -109,13 +109,13 @@ function planViteReact(source, rel, specifier) {
109
109
  fixes: [],
110
110
  };
111
111
  }
112
- const { childStart, childEnd } = root;
112
+ const { childStart, childEnd, jsx } = root;
113
113
  const child = source.slice(childStart, childEnd);
114
114
  if (binding && new RegExp(`^\\s*<${escapeRegExp(binding)}(?:\\s|>)`).test(child)) {
115
115
  return { kind: "application", status: "ok", path: rel, candidates: [rel], evidence: ["render root is wrapped in the generated Provider"], fixes: [] };
116
116
  }
117
117
  const providerName = binding ?? "MoeiconsProvider";
118
- const after = source.slice(0, childStart) + `<${providerName}>${child}</${providerName}>` + source.slice(childEnd);
118
+ const after = source.slice(0, childStart) + `<${providerName}>${jsx ? child : `{${child}}`}</${providerName}>` + source.slice(childEnd);
119
119
  const imported = binding ? after : insertImport(after, `import { MoeiconsProvider } from "${specifier}";`, rel);
120
120
  const valid = parseSource(imported, rel);
121
121
  if (!valid.ok)
@@ -151,7 +151,7 @@ function findUniqueRender(ast) {
151
151
  node.arguments.length === 1) {
152
152
  const child = node.arguments[0];
153
153
  if (child && typeof child.start === "number" && typeof child.end === "number") {
154
- renders.push({ start: child.start, end: child.end });
154
+ renders.push({ start: child.start, end: child.end, jsx: child.type === "JSXElement" || child.type === "JSXFragment" });
155
155
  }
156
156
  }
157
157
  for (const value of Object.values(node)) {
@@ -168,7 +168,7 @@ function findUniqueRender(ast) {
168
168
  if (renders.length > 1)
169
169
  return "multiple";
170
170
  const only = renders[0];
171
- return only ? { childStart: only.start, childEnd: only.end } : undefined;
171
+ return only ? { childStart: only.start, childEnd: only.end, jsx: only.jsx } : undefined;
172
172
  }
173
173
  function insertImport(source, line, rel = "src/main.tsx") {
174
174
  if (source.includes(line))
@@ -0,0 +1,41 @@
1
+ export interface DownloadLimits {
2
+ readonly maxBytes: number;
3
+ readonly timeoutMs: number;
4
+ readonly maxRedirects: number;
5
+ readonly allowedHosts?: readonly string[];
6
+ readonly userAgent?: string;
7
+ readonly onProgress?: (event: {
8
+ readonly downloadedBytes: number;
9
+ readonly totalBytes?: number;
10
+ }) => void;
11
+ readonly allowHttpLoopback?: boolean;
12
+ }
13
+ export type DownloadResult = {
14
+ readonly ok: true;
15
+ readonly bytes: Uint8Array;
16
+ readonly finalUrl: string;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly code: string;
20
+ readonly message: string;
21
+ };
22
+ /**
23
+ * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
24
+ * timeout, byte limit. Abort and temporary-file cleanup are handled by the
25
+ * caller via the injected fetch/signal.
26
+ *
27
+ * Redirect policy: unlike the API descriptor handshake (which uses
28
+ * `redirect: "error"`), payload downloads intentionally follow a bounded number
29
+ * of redirects because code/metadata archives are served from CDNs that 3xx to
30
+ * a signed object host; the destination host is re-checked against the
31
+ * allowlist on every hop.
32
+ */
33
+ export declare function downloadArtifact(url: string, limits: DownloadLimits, deps?: {
34
+ fetchFn?: typeof fetch;
35
+ signal?: AbortSignal;
36
+ }): Promise<DownloadResult>;
37
+ /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
38
+ export declare function verifyArtifact(bytes: Uint8Array, expectedSha256: string): {
39
+ ok: boolean;
40
+ actual: string;
41
+ };
@@ -0,0 +1,162 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdtemp, open, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
7
+ * timeout, byte limit. Abort and temporary-file cleanup are handled by the
8
+ * caller via the injected fetch/signal.
9
+ *
10
+ * Redirect policy: unlike the API descriptor handshake (which uses
11
+ * `redirect: "error"`), payload downloads intentionally follow a bounded number
12
+ * of redirects because code/metadata archives are served from CDNs that 3xx to
13
+ * a signed object host; the destination host is re-checked against the
14
+ * allowlist on every hop.
15
+ */
16
+ export async function downloadArtifact(url, limits, deps = {}) {
17
+ if (deps.signal?.aborted)
18
+ return { ok: false, code: "CANCELLED", message: "download cancelled" };
19
+ const parsed = new URL(url);
20
+ const loopbackHttp = limits.allowHttpLoopback === true && parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1");
21
+ if (parsed.protocol !== "https:" && !loopbackHttp) {
22
+ return { ok: false, code: "NON_HTTPS", message: "artifact URLs must use https" };
23
+ }
24
+ if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.host)) {
25
+ return { ok: false, code: "HOST_NOT_ALLOWED", message: `host ${parsed.host} not in allowlist` };
26
+ }
27
+ const fetchFn = deps.fetchFn ?? globalThis.fetch;
28
+ const controller = new AbortController();
29
+ const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
30
+ const abortHandler = () => controller.abort();
31
+ deps.signal?.addEventListener("abort", abortHandler, { once: true });
32
+ let response;
33
+ let temporary;
34
+ let file;
35
+ let reader;
36
+ try {
37
+ let currentUrl = url;
38
+ let redirects = 0;
39
+ for (;;) {
40
+ response = await fetchFn(currentUrl, {
41
+ method: "GET",
42
+ redirect: "manual",
43
+ signal: controller.signal,
44
+ ...(limits.userAgent ? { headers: { "user-agent": limits.userAgent } } : {}),
45
+ });
46
+ if (response.status >= 300 && response.status < 400) {
47
+ await response.body?.cancel();
48
+ redirects += 1;
49
+ if (redirects > limits.maxRedirects) {
50
+ return { ok: false, code: "TOO_MANY_REDIRECTS", message: `exceeded ${limits.maxRedirects} redirects` };
51
+ }
52
+ const location = response.headers.get("location");
53
+ if (!location) {
54
+ return { ok: false, code: "REDIRECT_NO_LOCATION", message: "redirect without location header" };
55
+ }
56
+ currentUrl = new URL(location, currentUrl).toString();
57
+ const next = new URL(currentUrl);
58
+ const nextLoopback = limits.allowHttpLoopback === true && next.protocol === "http:" && (next.hostname === "127.0.0.1" || next.hostname === "localhost" || next.hostname === "::1");
59
+ if (next.protocol !== "https:" && !nextLoopback) {
60
+ return { ok: false, code: "NON_HTTPS", message: "redirect to non-https url" };
61
+ }
62
+ if (limits.allowedHosts && !limits.allowedHosts.includes(next.host)) {
63
+ return { ok: false, code: "HOST_NOT_ALLOWED", message: `redirect host ${next.host} not in allowlist` };
64
+ }
65
+ continue;
66
+ }
67
+ break;
68
+ }
69
+ if (!response || response.status >= 400) {
70
+ return { ok: false, code: "HTTP_ERROR", message: `request failed with ${response.status ?? "unknown"}` };
71
+ }
72
+ const rawLength = response.headers.get("content-length");
73
+ const contentLength = rawLength !== null && /^\d+$/.test(rawLength) ? Number(rawLength) : undefined;
74
+ if (contentLength !== undefined && contentLength > limits.maxBytes) {
75
+ return { ok: false, code: "TOO_LARGE", message: `content-length ${contentLength} exceeds limit` };
76
+ }
77
+ const chunks = [];
78
+ let downloadedBytes = 0;
79
+ if (response.body) {
80
+ reader = response.body.getReader();
81
+ for (;;) {
82
+ const chunk = await reader.read();
83
+ if (chunk.done)
84
+ break;
85
+ const value = chunk.value;
86
+ downloadedBytes += value.byteLength;
87
+ if (downloadedBytes > limits.maxBytes) {
88
+ await reader.cancel();
89
+ return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
90
+ }
91
+ if (!file && downloadedBytes > 8 * 1024 * 1024) {
92
+ temporary = await mkdtemp(join(tmpdir(), "moeicons-download-"));
93
+ file = await open(join(temporary, "bytes"), "wx", 0o600);
94
+ for (const previous of chunks)
95
+ await file.writeFile(previous);
96
+ chunks.length = 0;
97
+ }
98
+ if (file)
99
+ await file.writeFile(value);
100
+ else
101
+ chunks.push(value);
102
+ limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
103
+ }
104
+ }
105
+ else {
106
+ const buffer = await response.arrayBuffer();
107
+ downloadedBytes = buffer.byteLength;
108
+ if (downloadedBytes > limits.maxBytes)
109
+ return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
110
+ chunks.push(new Uint8Array(buffer));
111
+ limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
112
+ }
113
+ if (file) {
114
+ await file.close();
115
+ file = undefined;
116
+ const bytes = await readFile(join(temporary, "bytes"));
117
+ if (controller.signal.aborted)
118
+ return { ok: false, code: deps.signal?.aborted ? "CANCELLED" : "NETWORK_ERROR", message: "download aborted" };
119
+ return { ok: true, bytes, finalUrl: currentUrl };
120
+ }
121
+ const bytes = new Uint8Array(downloadedBytes);
122
+ let offset = 0;
123
+ for (const chunk of chunks) {
124
+ bytes.set(chunk, offset);
125
+ offset += chunk.byteLength;
126
+ }
127
+ return { ok: true, bytes, finalUrl: currentUrl };
128
+ }
129
+ catch (error) {
130
+ return {
131
+ ok: false,
132
+ code: controller.signal.aborted ? (deps.signal?.aborted ? "CANCELLED" : "NETWORK_ERROR") : "NETWORK_ERROR",
133
+ message: error instanceof Error ? error.message : String(error),
134
+ };
135
+ }
136
+ finally {
137
+ if (reader) {
138
+ try {
139
+ await reader.cancel();
140
+ }
141
+ catch { /* Stream may already be closed. */ }
142
+ reader.releaseLock();
143
+ }
144
+ else {
145
+ try {
146
+ await response?.body?.cancel();
147
+ }
148
+ catch { /* Redirect or error response. */ }
149
+ }
150
+ if (file)
151
+ await file.close().catch(() => undefined);
152
+ clearTimeout(timer);
153
+ deps.signal?.removeEventListener("abort", abortHandler);
154
+ if (temporary)
155
+ await rm(temporary, { recursive: true, force: true });
156
+ }
157
+ }
158
+ /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
159
+ export function verifyArtifact(bytes, expectedSha256) {
160
+ const actual = createHash("sha256").update(bytes).digest("hex");
161
+ return { ok: actual.toLowerCase() === expectedSha256.toLowerCase(), actual };
162
+ }
@@ -92,44 +92,5 @@ export declare function executeGeneratedFilesDir(files: readonly {
92
92
  }[], projectRoot: string, outputDir: string, fs_: TransactionalFsWithCopy): void;
93
93
  /** Atomically create a new file without replacing an existing user file. */
94
94
  export declare function createFileIfAbsent(target: string, content: string, fs_: TransactionalFs): boolean;
95
- export interface DownloadLimits {
96
- readonly maxBytes: number;
97
- readonly timeoutMs: number;
98
- readonly maxRedirects: number;
99
- readonly allowedHosts?: readonly string[];
100
- readonly userAgent?: string;
101
- readonly onProgress?: (event: {
102
- readonly downloadedBytes: number;
103
- readonly totalBytes?: number;
104
- }) => void;
105
- readonly allowHttpLoopback?: boolean;
106
- }
107
- export type DownloadResult = {
108
- readonly ok: true;
109
- readonly bytes: Uint8Array;
110
- readonly finalUrl: string;
111
- } | {
112
- readonly ok: false;
113
- readonly code: string;
114
- readonly message: string;
115
- };
116
- /**
117
- * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
118
- * timeout, byte limit. Abort and temporary-file cleanup are handled by the
119
- * caller via the injected fetch/signal.
120
- *
121
- * Redirect policy: unlike the API descriptor handshake (which uses
122
- * `redirect: "error"`), payload downloads intentionally follow a bounded number
123
- * of redirects because code/metadata archives are served from CDNs that 3xx to
124
- * a signed object host; the destination host is re-checked against the
125
- * allowlist on every hop.
126
- */
127
- export declare function downloadArtifact(url: string, limits: DownloadLimits, deps?: {
128
- fetchFn?: typeof fetch;
129
- signal?: AbortSignal;
130
- }): Promise<DownloadResult>;
131
- /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
132
- export declare function verifyArtifact(bytes: Uint8Array, expectedSha256: string): {
133
- ok: boolean;
134
- actual: string;
135
- };
95
+ export { downloadArtifact, verifyArtifact } from "./download.js";
96
+ export type { DownloadLimits, DownloadResult } from "./download.js";
@@ -630,120 +630,4 @@ export function createFileIfAbsent(target, content, fs_) {
630
630
  fs_.rmSync(staging, { force: true });
631
631
  }
632
632
  }
633
- /**
634
- * Download an artifact. HTTPS only, optional host allowlist, bounded redirects,
635
- * timeout, byte limit. Abort and temporary-file cleanup are handled by the
636
- * caller via the injected fetch/signal.
637
- *
638
- * Redirect policy: unlike the API descriptor handshake (which uses
639
- * `redirect: "error"`), payload downloads intentionally follow a bounded number
640
- * of redirects because code/metadata archives are served from CDNs that 3xx to
641
- * a signed object host; the destination host is re-checked against the
642
- * allowlist on every hop.
643
- */
644
- export async function downloadArtifact(url, limits, deps = {}) {
645
- const parsed = new URL(url);
646
- const loopbackHttp = limits.allowHttpLoopback === true && parsed.protocol === "http:" && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "::1");
647
- if (parsed.protocol !== "https:" && !loopbackHttp) {
648
- return { ok: false, code: "NON_HTTPS", message: "artifact URLs must use https" };
649
- }
650
- if (limits.allowedHosts && !limits.allowedHosts.includes(parsed.host)) {
651
- return { ok: false, code: "HOST_NOT_ALLOWED", message: `host ${parsed.host} not in allowlist` };
652
- }
653
- const fetchFn = deps.fetchFn ?? globalThis.fetch;
654
- const controller = new AbortController();
655
- const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
656
- const abortHandler = () => controller.abort();
657
- deps.signal?.addEventListener("abort", abortHandler, { once: true });
658
- try {
659
- let currentUrl = url;
660
- let redirects = 0;
661
- let response;
662
- for (;;) {
663
- response = await fetchFn(currentUrl, {
664
- method: "GET",
665
- redirect: "manual",
666
- signal: controller.signal,
667
- ...(limits.userAgent ? { headers: { "user-agent": limits.userAgent } } : {}),
668
- });
669
- if (response.status >= 300 && response.status < 400) {
670
- redirects += 1;
671
- if (redirects > limits.maxRedirects) {
672
- return { ok: false, code: "TOO_MANY_REDIRECTS", message: `exceeded ${limits.maxRedirects} redirects` };
673
- }
674
- const location = response.headers.get("location");
675
- if (!location) {
676
- return { ok: false, code: "REDIRECT_NO_LOCATION", message: "redirect without location header" };
677
- }
678
- currentUrl = new URL(location, currentUrl).toString();
679
- const next = new URL(currentUrl);
680
- const nextLoopback = limits.allowHttpLoopback === true && next.protocol === "http:" && (next.hostname === "127.0.0.1" || next.hostname === "localhost" || next.hostname === "::1");
681
- if (next.protocol !== "https:" && !nextLoopback) {
682
- return { ok: false, code: "NON_HTTPS", message: "redirect to non-https url" };
683
- }
684
- if (limits.allowedHosts && !limits.allowedHosts.includes(next.host)) {
685
- return { ok: false, code: "HOST_NOT_ALLOWED", message: `redirect host ${next.host} not in allowlist` };
686
- }
687
- continue;
688
- }
689
- break;
690
- }
691
- if (!response || response.status >= 400) {
692
- return { ok: false, code: "HTTP_ERROR", message: `request failed with ${response.status ?? "unknown"}` };
693
- }
694
- const rawLength = response.headers.get("content-length");
695
- const contentLength = rawLength !== null && /^\d+$/.test(rawLength) ? Number(rawLength) : undefined;
696
- if (contentLength !== undefined && contentLength > limits.maxBytes) {
697
- return { ok: false, code: "TOO_LARGE", message: `content-length ${contentLength} exceeds limit` };
698
- }
699
- const chunks = [];
700
- let downloadedBytes = 0;
701
- if (response.body) {
702
- const reader = response.body.getReader();
703
- for (;;) {
704
- const chunk = await reader.read();
705
- if (chunk.done)
706
- break;
707
- const value = chunk.value;
708
- downloadedBytes += value.byteLength;
709
- if (downloadedBytes > limits.maxBytes) {
710
- await reader.cancel();
711
- return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
712
- }
713
- chunks.push(value);
714
- limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
715
- }
716
- }
717
- else {
718
- const buffer = await response.arrayBuffer();
719
- downloadedBytes = buffer.byteLength;
720
- if (downloadedBytes > limits.maxBytes)
721
- return { ok: false, code: "TOO_LARGE", message: `body exceeds byte limit ${limits.maxBytes}` };
722
- chunks.push(new Uint8Array(buffer));
723
- limits.onProgress?.({ downloadedBytes, ...(contentLength !== undefined ? { totalBytes: contentLength } : {}) });
724
- }
725
- const bytes = new Uint8Array(downloadedBytes);
726
- let offset = 0;
727
- for (const chunk of chunks) {
728
- bytes.set(chunk, offset);
729
- offset += chunk.byteLength;
730
- }
731
- return { ok: true, bytes, finalUrl: currentUrl };
732
- }
733
- catch (error) {
734
- return {
735
- ok: false,
736
- code: "NETWORK_ERROR",
737
- message: error instanceof Error ? error.message : String(error),
738
- };
739
- }
740
- finally {
741
- clearTimeout(timer);
742
- deps.signal?.removeEventListener("abort", abortHandler);
743
- }
744
- }
745
- /** Verify a downloaded artifact against an expected SHA-256 (and optional signature). */
746
- export function verifyArtifact(bytes, expectedSha256) {
747
- const actual = createHash("sha256").update(bytes).digest("hex");
748
- return { ok: actual.toLowerCase() === expectedSha256.toLowerCase(), actual };
749
- }
633
+ export { downloadArtifact, verifyArtifact } from "./download.js";
@@ -15,15 +15,13 @@ export type ExtractedTarFiles = {
15
15
  readonly files: Record<string, Uint8Array>;
16
16
  readonly errors: string[];
17
17
  };
18
- /**
19
- * Gunzip + unpack tar. Rejects absolute paths, `..`, symlinks and oversized
20
- * archives. Path safety uses POSIX `/` separators (the tar format mandates `/`);
21
- * a Windows-style separator would appear literally inside a name and never be
22
- * treated as a path separator, so `..\evil` is inert (no traversal), which is
23
- * the documented known assumption.
24
- */
25
18
  export declare function extractTarGz(bytes: Uint8Array, limits: {
26
19
  maxEntries: number;
27
20
  maxExpandedBytes: number;
28
21
  }): ExtractedTarFiles;
22
+ /** Production path: yield between chunks; never materialise the complete tar. */
23
+ export declare function extractTarGzAsync(bytes: Uint8Array, limits: {
24
+ maxEntries: number;
25
+ maxExpandedBytes: number;
26
+ }, signal?: AbortSignal): Promise<ExtractedTarFiles>;
29
27
  export declare function decodeUtf8(bytes: Uint8Array): string;
@@ -1,4 +1,5 @@
1
- import { gzipSync, gunzipSync } from "node:zlib";
1
+ import { gzipSync, gunzipSync, createGunzip } from "node:zlib";
2
+ import { Readable } from "node:stream";
2
3
  const BLOCK = 512;
3
4
  /**
4
5
  * Combined release archives contain all four target trees. The current Pro
@@ -60,77 +61,172 @@ export function createTarGz(files) {
60
61
  return gzipSync(Buffer.concat(parts), { level: 9 });
61
62
  }
62
63
  function readOctal(buf, offset, length) {
63
- const raw = buf.subarray(offset, offset + length).toString("utf8").replace(/\0.*$/, "").trim();
64
- if (!raw)
65
- return 0;
66
- return Number.parseInt(raw, 8);
64
+ const raw = buf.subarray(offset, offset + length).toString("ascii").replace(/\0.*$/, "").trim();
65
+ if (!/^[0-7]+$/.test(raw))
66
+ throw new Error("invalid tar numeric field");
67
+ const value = Number.parseInt(raw, 8);
68
+ if (!Number.isSafeInteger(value) || value < 0)
69
+ throw new Error("invalid tar size");
70
+ return value;
67
71
  }
68
- /**
69
- * Gunzip + unpack tar. Rejects absolute paths, `..`, symlinks and oversized
70
- * archives. Path safety uses POSIX `/` separators (the tar format mandates `/`);
71
- * a Windows-style separator would appear literally inside a name and never be
72
- * treated as a path separator, so `..\evil` is inert (no traversal), which is
73
- * the documented known assumption.
74
- */
75
- export function extractTarGz(bytes, limits) {
76
- const files = {};
77
- const errors = [];
78
- let data;
79
- try {
80
- // Bound the decompressed stream itself so a zip bomb fails during gunzip,
81
- // before the tar bytes are materialised.
82
- data = gunzipSync(bytes, { maxOutputLength: limits.maxExpandedBytes });
72
+ /** One incremental parser used by both fixture and production extraction. */
73
+ class TarReader {
74
+ limits;
75
+ files = {};
76
+ errors = [];
77
+ header = Buffer.alloc(BLOCK);
78
+ headerBytes = 0;
79
+ body;
80
+ bodyBytes = 0;
81
+ padding = 0;
82
+ name = "";
83
+ longName;
84
+ type = 0;
85
+ total = 0;
86
+ entries = 0;
87
+ zeroBlocks = 0;
88
+ failed = false;
89
+ constructor(limits) {
90
+ this.limits = limits;
83
91
  }
84
- catch (error) {
85
- if (isOutputLimitError(error))
86
- return { files, errors: ["expanded size exceeds limit"] };
87
- return { files, errors: ["invalid gzip"] };
92
+ fail(message) { this.errors.push(message); this.failed = true; }
93
+ feed(bytes) {
94
+ if (this.failed)
95
+ return;
96
+ this.total += bytes.length;
97
+ if (this.total > this.limits.maxExpandedBytes)
98
+ return this.fail("expanded size exceeds limit");
99
+ let offset = 0;
100
+ while (offset < bytes.length && !this.failed) {
101
+ if (this.body) {
102
+ const count = Math.min(this.body.length - this.bodyBytes, bytes.length - offset);
103
+ this.body.set(bytes.subarray(offset, offset + count), this.bodyBytes);
104
+ offset += count;
105
+ this.bodyBytes += count;
106
+ if (this.bodyBytes !== this.body.length)
107
+ continue;
108
+ this.finishBody();
109
+ }
110
+ else if (this.padding) {
111
+ const count = Math.min(this.padding, bytes.length - offset);
112
+ offset += count;
113
+ this.padding -= count;
114
+ }
115
+ else {
116
+ const count = Math.min(BLOCK - this.headerBytes, bytes.length - offset);
117
+ this.header.set(bytes.subarray(offset, offset + count), this.headerBytes);
118
+ this.headerBytes += count;
119
+ offset += count;
120
+ if (this.headerBytes === BLOCK) {
121
+ this.headerBytes = 0;
122
+ this.beginBody();
123
+ }
124
+ }
125
+ }
88
126
  }
89
- let offset = 0;
90
- let entries = 0;
91
- let expanded = 0;
92
- while (offset + BLOCK <= data.byteLength) {
93
- const header = data.subarray(offset, offset + BLOCK);
94
- offset += BLOCK;
95
- if (header.every((byte) => byte === 0))
96
- break;
97
- const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "").replace(/^\.\//, "");
98
- const size = readOctal(header, 124, 12);
99
- const type = header[156];
100
- const padded = Math.ceil(size / BLOCK) * BLOCK;
101
- const content = data.subarray(offset, offset + size);
102
- offset += padded;
103
- if (type === 0x31 || type === 0x32) {
104
- // Hard-link (typeflag '1') and symlink (typeflag '2') entries are
105
- // rejected with an explicit error, never silently skipped.
106
- errors.push(`link entries are not allowed: ${name || "<unnamed>"}`);
107
- continue;
127
+ beginBody() {
128
+ const header = this.header;
129
+ if (header.every(byte => byte === 0)) {
130
+ this.zeroBlocks++;
131
+ return;
108
132
  }
109
- if (type !== 0 && type !== 0x30)
110
- continue;
111
- if (!name || name.endsWith("/"))
112
- continue;
113
- entries += 1;
114
- if (entries > limits.maxEntries) {
115
- errors.push(`too many entries (> ${limits.maxEntries})`);
116
- break;
133
+ if (this.zeroBlocks)
134
+ return this.fail("tar data after end marker");
135
+ try {
136
+ const expected = readOctal(header, 148, 8);
137
+ const copy = Buffer.from(header);
138
+ copy.fill(0x20, 148, 156);
139
+ if (checksumHeader(copy) !== expected)
140
+ return this.fail("invalid tar header checksum");
141
+ const size = readOctal(header, 124, 12);
142
+ if (size > this.limits.maxExpandedBytes)
143
+ return this.fail("expanded size exceeds limit");
144
+ this.entries++;
145
+ if (this.entries > this.limits.maxEntries)
146
+ return this.fail(`too many entries (> ${this.limits.maxEntries})`);
147
+ const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
148
+ const prefix = header.subarray(257, 263).toString("ascii") === "ustar\0"
149
+ ? header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "") : "";
150
+ this.name = this.longName ?? (prefix ? `${prefix}/${name}` : name);
151
+ this.longName = undefined;
152
+ this.name = this.name.replace(/^\.\//, "");
153
+ this.type = header[156] ?? 0;
154
+ this.padding = (BLOCK - size % BLOCK) % BLOCK;
155
+ this.bodyBytes = 0;
156
+ this.body = Buffer.alloc(size);
157
+ if (size === 0)
158
+ this.finishBody();
117
159
  }
118
- if (name.startsWith("/") || name.split("/").includes("..")) {
119
- errors.push(`unsafe path "${name}"`);
120
- continue;
160
+ catch (error) {
161
+ this.fail(error instanceof Error ? error.message : "invalid tar header");
121
162
  }
122
- if (name in files) {
123
- errors.push(`duplicate entry "${name}"`);
124
- continue;
163
+ }
164
+ finishBody() {
165
+ const body = this.body;
166
+ this.body = undefined;
167
+ if (this.type === 0x4c) { // GNU long names, emitted by the production packer.
168
+ this.longName = body.toString("utf8").replace(/\0.*$/, "");
169
+ return;
170
+ }
171
+ if (this.type === 0x31 || this.type === 0x32) {
172
+ this.errors.push(`link entries are not allowed: ${this.name}`);
173
+ return;
174
+ }
175
+ if (this.type === 0x35)
176
+ return;
177
+ if (this.type !== 0 && this.type !== 0x30)
178
+ return this.fail("unsupported tar entry type");
179
+ if (!this.name || this.name.startsWith("/") || this.name.split("/").includes("..")) {
180
+ this.errors.push(`unsafe path "${this.name}"`);
181
+ return;
125
182
  }
126
- expanded += content.byteLength;
127
- if (expanded > limits.maxExpandedBytes) {
128
- errors.push("expanded size exceeds limit");
129
- break;
183
+ if (this.name in this.files) {
184
+ this.errors.push(`duplicate entry "${this.name}"`);
185
+ return;
130
186
  }
131
- files[name] = new Uint8Array(content);
187
+ this.files[this.name] = body;
188
+ }
189
+ result() {
190
+ if (!this.failed && (this.body || this.padding || this.headerBytes || this.zeroBlocks < 2 || this.longName))
191
+ this.errors.push("truncated tar archive");
192
+ return { files: this.files, errors: this.errors };
193
+ }
194
+ }
195
+ export function extractTarGz(bytes, limits) {
196
+ const reader = new TarReader(limits);
197
+ try {
198
+ reader.feed(gunzipSync(bytes, { maxOutputLength: limits.maxExpandedBytes }));
199
+ }
200
+ catch (error) {
201
+ return { files: {}, errors: [isOutputLimitError(error) ? "expanded size exceeds limit" : "invalid gzip"] };
202
+ }
203
+ return reader.result();
204
+ }
205
+ /** Production path: yield between chunks; never materialise the complete tar. */
206
+ export async function extractTarGzAsync(bytes, limits, signal) {
207
+ const reader = new TarReader(limits);
208
+ const stream = Readable.from([bytes]).pipe(createGunzip());
209
+ const abort = () => stream.destroy(new Error("archive extraction cancelled"));
210
+ signal?.addEventListener("abort", abort, { once: true });
211
+ if (signal?.aborted)
212
+ abort();
213
+ try {
214
+ for await (const chunk of stream) {
215
+ reader.feed(chunk);
216
+ if (reader.errors.length) {
217
+ stream.destroy();
218
+ break;
219
+ }
220
+ }
221
+ return reader.result();
222
+ }
223
+ catch (error) {
224
+ return { files: {}, errors: [signal?.aborted ? "archive extraction cancelled" : "invalid gzip"] };
225
+ }
226
+ finally {
227
+ signal?.removeEventListener("abort", abort);
228
+ stream.destroy();
132
229
  }
133
- return { files, errors };
134
230
  }
135
231
  export function decodeUtf8(bytes) {
136
232
  return Buffer.from(bytes).toString("utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moewolf/moe-icons-cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Moeicons CLI: install React, Vue, Vanilla, or Assets icon targets, manage releases and login, and expose an MCP server.",
5
5
  "type": "module",
6
6
  "main": "./dist/cli.js",
@@ -74,5 +74,5 @@
74
74
  "fflate": "^0.8.2",
75
75
  "jsonc-parser": "^3.3.1"
76
76
  },
77
- "payloadHash": "b890d559f2625d622f87d9c628ec96b39da2e66a6c65408883d0e8a2b462db4e"
77
+ "payloadHash": "abc4b8255136c07072b0c1b34bc2c960407997d8ce783515cbd51c439faaa820"
78
78
  }