@moewolf/moe-icons-cli 0.0.2 → 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.
@@ -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.2",
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": "70e69a67e0cb555afbf081eb98b18f09c1fd0db70c95ab69a9ebb90fdd38c991"
77
+ "payloadHash": "abc4b8255136c07072b0c1b34bc2c960407997d8ce783515cbd51c439faaa820"
78
78
  }