@zhuoyuezs/ml-platform 0.2.0 → 0.2.2-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,254 +1,324 @@
1
1
  "use strict";
2
-
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const fs = require("fs");
4
4
  const os = require("os");
5
5
  const path = require("path");
6
6
  const zlib = require("zlib");
7
7
  const { expandHome } = require("./config");
8
-
9
8
  class PlatformApiClient {
10
- constructor(baseUrl, timeoutSeconds = 30) {
11
- this.baseUrl = String(baseUrl).trim().replace(/\/+$/, "");
12
- if (!/^https?:\/\//.test(this.baseUrl)) throw new Error("server profile api_url must start with http:// or https://");
13
- if (!(timeoutSeconds > 0)) throw new Error("request timeout must be positive");
14
- this.timeoutMs = timeoutSeconds * 1000;
15
- }
16
-
17
- url(endpoint, params = {}) {
18
- const url = new URL(endpoint.replace(/^\//, ""), `${this.baseUrl}/`);
19
- for (const [key, value] of Object.entries(params)) {
20
- if (value !== null && value !== undefined) url.searchParams.set(key, typeof value === "boolean" ? String(value) : value);
9
+ constructor(baseUrl, timeoutSeconds = 30) {
10
+ this.baseUrl = String(baseUrl).trim().replace(/\/+$/, "");
11
+ if (!/^https?:\/\//.test(this.baseUrl))
12
+ throw new Error("server profile api_url must start with http:// or https://");
13
+ if (!(timeoutSeconds > 0))
14
+ throw new Error("request timeout must be positive");
15
+ this.timeoutMs = timeoutSeconds * 1000;
21
16
  }
22
- return url;
23
- }
24
-
25
- async request(method, endpoint, { params, payload, body, contentType } = {}) {
26
- const controller = new AbortController();
27
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
28
- const headers = { Accept: "application/json" };
29
- let requestBody = body;
30
- if (payload !== undefined) {
31
- headers["Content-Type"] = "application/json";
32
- requestBody = JSON.stringify(payload);
33
- } else if (contentType) headers["Content-Type"] = contentType;
34
- let response;
35
- try { response = await fetch(this.url(endpoint, params), { method, headers, body: requestBody, signal: controller.signal }); }
36
- catch (error) { throw new Error(`platform API request failed: ${method} ${this.url(endpoint, params)}: ${error.message}`); }
37
- finally { clearTimeout(timer); }
38
- const text = await response.text();
39
- let result;
40
- try { result = JSON.parse(text); } catch (_) { throw new Error("platform API returned invalid JSON"); }
41
- if (!response.ok) throw new Error(`platform API request failed: ${method} ${response.url}: HTTP ${response.status}: ${result?.detail ?? response.statusText}`);
42
- if (!result || typeof result !== "object") throw new Error("platform API returned an unsupported JSON response");
43
- return result;
44
- }
45
-
46
- get(endpoint, params) { return this.request("GET", endpoint, { params }); }
47
- post(endpoint, payload, params) { return this.request("POST", endpoint, { payload, params }); }
48
- put(endpoint, payload, params) { return this.request("PUT", endpoint, { payload, params }); }
49
- delete(endpoint, params) { return this.request("DELETE", endpoint, { params }); }
50
- uploadOperatorPackage(project, name, version, packagePath) {
51
- const resolved = path.resolve(expandHome(packagePath));
52
- if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new Error(`operator package not found: ${resolved}`);
53
- return this.request("POST", `/operator-packages/${encodeURIComponent(project)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
54
- params: { filename: path.basename(resolved) }, body: fs.readFileSync(resolved), contentType: "application/octet-stream",
55
- });
56
- }
57
-
58
- async downloadDatasetArtifact(project, datasetId, manifestHash, outputDir, force = false) {
59
- const target = path.resolve(expandHome(outputDir));
60
- if (fs.existsSync(target) && fs.readdirSync(target).length) {
61
- if (!force) throw new Error(`download output directory is not empty: ${target}`);
62
- fs.rmSync(target, { recursive: true, force: true });
63
- }
64
- fs.mkdirSync(target, { recursive: true });
65
- const controller = new AbortController();
66
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
67
- const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-platform-artifact-download-"));
68
- const temporary = path.join(temporaryDir, "artifact.zip");
69
- let response;
70
- try {
71
- response = await fetch(this.url(`/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/download`), { headers: { Accept: "application/zip" }, signal: controller.signal });
72
- if (!response.ok) {
73
- let detail = response.statusText;
74
- try { detail = JSON.parse(await abortable(response.text(), controller.signal))?.detail ?? detail; } catch (_) { /* use status text */ }
75
- throw new Error(`artifact download failed: HTTP ${response.status}: ${detail}`);
76
- }
77
- const total = contentLength(response);
78
- let lastReported = -1;
79
- const report = (received, complete = false) => {
80
- if ((complete && received !== lastReported) || received === 0 || received - lastReported >= 1024 * 1024) {
81
- reportDownloadProgress(received, total);
82
- lastReported = received;
83
- }
84
- };
85
- report(0);
86
- await writeResponseBody(response, temporary, controller.signal, report);
87
- const files = await extractArchive(temporary, target);
88
- return { dataset_id: datasetId, manifest_hash: manifestHash, output_dir: target, file_count: files.length, files };
89
- } catch (error) {
90
- fs.rmSync(target, { recursive: true, force: true });
91
- if (error instanceof Error && error.message.startsWith("artifact download failed:")) throw error;
92
- throw new Error(`artifact download failed: ${error.message}`);
93
- } finally {
94
- clearTimeout(timer);
95
- fs.rmSync(temporaryDir, { recursive: true, force: true });
17
+ url(endpoint, params = {}) {
18
+ const url = new URL(endpoint.replace(/^\//, ""), `${this.baseUrl}/`);
19
+ for (const [key, value] of Object.entries(params)) {
20
+ if (value !== null && value !== undefined)
21
+ url.searchParams.set(key, typeof value === "boolean" ? String(value) : value);
22
+ }
23
+ return url;
96
24
  }
97
- }
98
-
99
- async downloadDatasetArtifactFiles(project, datasetId, manifestHash, relativePaths, outputDir, force = false) {
100
- const files = [...new Set((relativePaths || []).map((value) => String(value)))];
101
- if (!files.length) throw new Error("at least one artifact file is required");
102
- for (const relative of files) {
103
- const normalized = relative.replace(/\\/g, "/");
104
- const parts = normalized.split("/");
105
- if (!normalized || normalized.startsWith("/") || normalized.endsWith("/") || parts.includes("..")) {
106
- throw new Error(`artifact path escapes output directory: ${JSON.stringify(relative)}`);
107
- }
25
+ async request(method, endpoint, { params, payload, body, contentType } = {}) {
26
+ const controller = new AbortController();
27
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
28
+ const headers = { Accept: "application/json" };
29
+ let requestBody = body;
30
+ if (payload !== undefined) {
31
+ headers["Content-Type"] = "application/json";
32
+ requestBody = JSON.stringify(payload);
33
+ }
34
+ else if (contentType)
35
+ headers["Content-Type"] = contentType;
36
+ let response;
37
+ try {
38
+ response = await fetch(this.url(endpoint, params), { method, headers, body: requestBody, signal: controller.signal });
39
+ }
40
+ catch (error) {
41
+ throw new Error(`platform API request failed: ${method} ${this.url(endpoint, params)}: ${error.message}`);
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ const text = await response.text();
47
+ let result;
48
+ try {
49
+ result = JSON.parse(text);
50
+ }
51
+ catch (_) {
52
+ throw new Error("platform API returned invalid JSON");
53
+ }
54
+ if (!response.ok)
55
+ throw new Error(`platform API request failed: ${method} ${response.url}: HTTP ${response.status}: ${result?.detail ?? response.statusText}`);
56
+ if (!result || typeof result !== "object")
57
+ throw new Error("platform API returned an unsupported JSON response");
58
+ return result;
108
59
  }
109
- const targetRoot = path.resolve(expandHome(outputDir));
110
- if (fs.existsSync(targetRoot) && fs.readdirSync(targetRoot).length) {
111
- if (!force) throw new Error(`download output directory is not empty: ${targetRoot}`);
112
- fs.rmSync(targetRoot, { recursive: true, force: true });
60
+ get(endpoint, params) { return this.request("GET", endpoint, { params }); }
61
+ post(endpoint, payload, params) { return this.request("POST", endpoint, { payload, params }); }
62
+ put(endpoint, payload, params) { return this.request("PUT", endpoint, { payload, params }); }
63
+ delete(endpoint, params) { return this.request("DELETE", endpoint, { params }); }
64
+ uploadOperatorPackage(project, name, version, packagePath) {
65
+ const resolved = path.resolve(expandHome(packagePath));
66
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile())
67
+ throw new Error(`operator package not found: ${resolved}`);
68
+ return this.request("POST", `/operator-packages/${encodeURIComponent(project)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, {
69
+ params: { filename: path.basename(resolved) }, body: fs.readFileSync(resolved), contentType: "application/octet-stream",
70
+ });
113
71
  }
114
- fs.mkdirSync(targetRoot, { recursive: true });
115
- try {
116
- for (const relative of files) {
117
- const normalized = relative.replace(/\\/g, "/");
118
- const parts = normalized.split("/");
119
- const destination = path.resolve(targetRoot, ...parts);
120
- if (!normalized || normalized.startsWith("/") || normalized.endsWith("/")
121
- || parts.includes("..") || destination !== targetRoot && !destination.startsWith(`${targetRoot}${path.sep}`)) {
122
- throw new Error(`artifact path escapes output directory: ${JSON.stringify(relative)}`);
123
- }
124
- fs.mkdirSync(path.dirname(destination), { recursive: true });
125
- const endpoint = `/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/files/${parts.map(encodeURIComponent).join("/")}`;
72
+ async downloadDatasetArtifact(project, datasetId, manifestHash, outputDir, force = false) {
73
+ const target = path.resolve(expandHome(outputDir));
74
+ if (fs.existsSync(target) && fs.readdirSync(target).length) {
75
+ if (!force)
76
+ throw new Error(`download output directory is not empty: ${target}`);
77
+ fs.rmSync(target, { recursive: true, force: true });
78
+ }
79
+ fs.mkdirSync(target, { recursive: true });
126
80
  const controller = new AbortController();
127
81
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
82
+ const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-platform-artifact-download-"));
83
+ const temporary = path.join(temporaryDir, "artifact.zip");
84
+ let response;
85
+ try {
86
+ response = await fetch(this.url(`/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/download`), { headers: { Accept: "application/zip" }, signal: controller.signal });
87
+ if (!response.ok) {
88
+ let detail = response.statusText;
89
+ try {
90
+ detail = JSON.parse(await abortable(response.text(), controller.signal))?.detail ?? detail;
91
+ }
92
+ catch (_) { /* use status text */ }
93
+ throw new Error(`artifact download failed: HTTP ${response.status}: ${detail}`);
94
+ }
95
+ const total = contentLength(response);
96
+ let lastReported = -1;
97
+ const report = (received, complete = false) => {
98
+ if ((complete && received !== lastReported) || received === 0 || received - lastReported >= 1024 * 1024) {
99
+ reportDownloadProgress(received, total);
100
+ lastReported = received;
101
+ }
102
+ };
103
+ report(0);
104
+ await writeResponseBody(response, temporary, controller.signal, report);
105
+ const files = await extractArchive(temporary, target);
106
+ return { dataset_id: datasetId, manifest_hash: manifestHash, output_dir: target, file_count: files.length, files };
107
+ }
108
+ catch (error) {
109
+ fs.rmSync(target, { recursive: true, force: true });
110
+ if (error instanceof Error && error.message.startsWith("artifact download failed:"))
111
+ throw error;
112
+ throw new Error(`artifact download failed: ${error.message}`);
113
+ }
114
+ finally {
115
+ clearTimeout(timer);
116
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
117
+ }
118
+ }
119
+ async downloadDatasetArtifactFiles(project, datasetId, manifestHash, relativePaths, outputDir, force = false) {
120
+ const files = [...new Set((relativePaths || []).map((value) => String(value)))];
121
+ if (!files.length)
122
+ throw new Error("at least one artifact file is required");
123
+ for (const relative of files) {
124
+ const normalized = relative.replace(/\\/g, "/");
125
+ const parts = normalized.split("/");
126
+ if (!normalized || normalized.startsWith("/") || normalized.endsWith("/") || parts.includes("..")) {
127
+ throw new Error(`artifact path escapes output directory: ${JSON.stringify(relative)}`);
128
+ }
129
+ }
130
+ const targetRoot = path.resolve(expandHome(outputDir));
131
+ if (fs.existsSync(targetRoot) && fs.readdirSync(targetRoot).length) {
132
+ if (!force)
133
+ throw new Error(`download output directory is not empty: ${targetRoot}`);
134
+ fs.rmSync(targetRoot, { recursive: true, force: true });
135
+ }
136
+ fs.mkdirSync(targetRoot, { recursive: true });
128
137
  try {
129
- const response = await fetch(this.url(endpoint), { headers: { Accept: "application/octet-stream" }, signal: controller.signal });
130
- if (!response.ok) {
131
- let detail = response.statusText;
132
- try { detail = JSON.parse(await response.text())?.detail ?? detail; } catch (_) { /* status text */ }
133
- throw new Error(`artifact file download failed: HTTP ${response.status}: ${detail}`);
134
- }
135
- fs.writeFileSync(destination, Buffer.from(await response.arrayBuffer()), { flag: "wx" });
136
- } finally { clearTimeout(timer); }
137
- }
138
- } catch (error) {
139
- fs.rmSync(targetRoot, { recursive: true, force: true });
140
- throw error;
138
+ for (const relative of files) {
139
+ const normalized = relative.replace(/\\/g, "/");
140
+ const parts = normalized.split("/");
141
+ const destination = path.resolve(targetRoot, ...parts);
142
+ if (!normalized || normalized.startsWith("/") || normalized.endsWith("/")
143
+ || parts.includes("..") || destination !== targetRoot && !destination.startsWith(`${targetRoot}${path.sep}`)) {
144
+ throw new Error(`artifact path escapes output directory: ${JSON.stringify(relative)}`);
145
+ }
146
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
147
+ const endpoint = `/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/files/${parts.map(encodeURIComponent).join("/")}`;
148
+ const controller = new AbortController();
149
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
150
+ try {
151
+ const response = await fetch(this.url(endpoint), { headers: { Accept: "application/octet-stream" }, signal: controller.signal });
152
+ if (!response.ok) {
153
+ let detail = response.statusText;
154
+ try {
155
+ detail = JSON.parse(await response.text())?.detail ?? detail;
156
+ }
157
+ catch (_) { /* status text */ }
158
+ throw new Error(`artifact file download failed: HTTP ${response.status}: ${detail}`);
159
+ }
160
+ fs.writeFileSync(destination, Buffer.from(await response.arrayBuffer()), { flag: "wx" });
161
+ }
162
+ finally {
163
+ clearTimeout(timer);
164
+ }
165
+ }
166
+ }
167
+ catch (error) {
168
+ fs.rmSync(targetRoot, { recursive: true, force: true });
169
+ throw error;
170
+ }
171
+ return { project, dataset_id: datasetId, manifest_hash: manifestHash, output_dir: targetRoot, file_count: files.length, files: files.sort() };
141
172
  }
142
- return { project, dataset_id: datasetId, manifest_hash: manifestHash, output_dir: targetRoot, file_count: files.length, files: files.sort() };
143
- }
144
173
  }
145
-
146
174
  function contentLength(response) {
147
- const raw = response.headers?.get?.("content-length") ?? response.headers?.["content-length"];
148
- const value = Number(raw);
149
- return Number.isFinite(value) && value >= 0 ? value : null;
175
+ const raw = response.headers?.get?.("content-length") ?? response.headers?.["content-length"];
176
+ const value = Number(raw);
177
+ return Number.isFinite(value) && value >= 0 ? value : null;
150
178
  }
151
-
152
179
  function reportDownloadProgress(received, total) {
153
- const suffix = total == null ? "" : `/${total}`;
154
- process.stderr.write(`artifact download: received ${received}${suffix} bytes\n`);
180
+ const suffix = total == null ? "" : `/${total}`;
181
+ process.stderr.write(`artifact download: received ${received}${suffix} bytes\n`);
155
182
  }
156
-
157
183
  function abortable(value, signal) {
158
- if (signal.aborted) return Promise.reject(new Error("This operation was aborted"));
159
- return new Promise((resolve, reject) => {
160
- const onAbort = () => { cleanup(); reject(new Error("This operation was aborted")); };
161
- const cleanup = () => signal.removeEventListener("abort", onAbort);
162
- signal.addEventListener("abort", onAbort, { once: true });
163
- Promise.resolve(value).then((result) => { cleanup(); resolve(result); }, (error) => { cleanup(); reject(error); });
164
- });
184
+ if (signal.aborted)
185
+ return Promise.reject(new Error("This operation was aborted"));
186
+ return new Promise((resolve, reject) => {
187
+ const onAbort = () => { cleanup(); reject(new Error("This operation was aborted")); };
188
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
189
+ signal.addEventListener("abort", onAbort, { once: true });
190
+ Promise.resolve(value).then((result) => { cleanup(); resolve(result); }, (error) => { cleanup(); reject(error); });
191
+ });
165
192
  }
166
-
167
193
  async function writeResponseBody(response, outputPath, signal, onProgress) {
168
- const handle = fs.openSync(outputPath, "w");
169
- let received = 0;
170
- try {
171
- const write = (value) => {
172
- const chunk = Buffer.from(value);
173
- fs.writeSync(handle, chunk);
174
- received += chunk.length;
175
- onProgress(received);
176
- };
177
- if (response.body?.getReader) {
178
- const reader = response.body.getReader();
179
- while (true) {
180
- const item = await abortable(reader.read(), signal);
181
- if (item.done) break;
182
- write(item.value);
183
- }
184
- } else if (response.body?.[Symbol.asyncIterator]) {
185
- const iterator = response.body[Symbol.asyncIterator]();
186
- while (true) {
187
- const item = await abortable(iterator.next(), signal);
188
- if (item.done) break;
189
- write(item.value);
190
- }
191
- } else if (typeof response.arrayBuffer === "function") {
192
- write(await abortable(response.arrayBuffer(), signal));
193
- } else {
194
- throw new Error("artifact download response has no readable body");
194
+ const handle = fs.openSync(outputPath, "w");
195
+ let received = 0;
196
+ try {
197
+ const write = (value) => {
198
+ const chunk = Buffer.from(value);
199
+ fs.writeSync(handle, chunk);
200
+ received += chunk.length;
201
+ onProgress(received);
202
+ };
203
+ if (response.body?.getReader) {
204
+ const reader = response.body.getReader();
205
+ while (true) {
206
+ const item = await abortable(reader.read(), signal);
207
+ if (item.done)
208
+ break;
209
+ write(item.value);
210
+ }
211
+ }
212
+ else if (response.body?.[Symbol.asyncIterator]) {
213
+ const iterator = response.body[Symbol.asyncIterator]();
214
+ while (true) {
215
+ const item = await abortable(iterator.next(), signal);
216
+ if (item.done)
217
+ break;
218
+ write(item.value);
219
+ }
220
+ }
221
+ else if (typeof response.arrayBuffer === "function") {
222
+ write(await abortable(response.arrayBuffer(), signal));
223
+ }
224
+ else {
225
+ throw new Error("artifact download response has no readable body");
226
+ }
227
+ onProgress(received, true);
228
+ }
229
+ finally {
230
+ fs.closeSync(handle);
195
231
  }
196
- onProgress(received, true);
197
- } finally {
198
- fs.closeSync(handle);
199
- }
200
232
  }
201
-
202
233
  async function extractArchive(archivePath, outputDir) {
203
- const archive = fs.readFileSync(archivePath);
204
- const end = findEndOfCentralDirectory(archive);
205
- const entryCount = archive.readUInt16LE(end + 10);
206
- const centralSize = archive.readUInt32LE(end + 12);
207
- const centralOffset = archive.readUInt32LE(end + 16);
208
- if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) throw new Error("artifact archive ZIP64 format is unsupported");
209
- if (centralOffset + centralSize > end) throw new Error("artifact archive central directory is invalid");
210
- const files = [];
211
- const names = new Set();
212
- let cursor = centralOffset;
213
- for (let index = 0; index < entryCount; index += 1) {
214
- if (cursor + 46 > archive.length || archive.readUInt32LE(cursor) !== 0x02014b50) throw new Error("artifact archive central directory is invalid");
215
- const flags = archive.readUInt16LE(cursor + 8); const method = archive.readUInt16LE(cursor + 10);
216
- const crc = archive.readUInt32LE(cursor + 16); const compressedSize = archive.readUInt32LE(cursor + 20); const size = archive.readUInt32LE(cursor + 24);
217
- const nameLength = archive.readUInt16LE(cursor + 28); const extraLength = archive.readUInt16LE(cursor + 30); const commentLength = archive.readUInt16LE(cursor + 32);
218
- const attributes = archive.readUInt32LE(cursor + 38); const localOffset = archive.readUInt32LE(cursor + 42);
219
- const next = cursor + 46 + nameLength + extraLength + commentLength;
220
- if (next > archive.length || compressedSize === 0xffffffff || size === 0xffffffff || localOffset === 0xffffffff) throw new Error("artifact archive entry metadata is invalid");
221
- if (flags & 1) throw new Error("artifact archive contains an encrypted entry");
222
- if (![0, 8].includes(method)) throw new Error(`artifact archive compression method is unsupported: ${method}`);
223
- const rawNameBytes = archive.subarray(cursor + 46, cursor + 46 + nameLength);
224
- const rawName = decodeZipFilename(rawNameBytes, flags);
225
- const name = rawName.replace(/\\/g, "/"); const parts = name.split("/"); const mode = (attributes >>> 16) & 0xffff;
226
- if (!name || name.startsWith("/") || /^[A-Za-z]:/.test(name) || parts.includes("..")) throw new Error(`artifact archive contains unsafe path: ${JSON.stringify(rawName)}`);
227
- if ((mode & 0o170000) === 0o120000) throw new Error(`artifact archive contains a symbolic link: ${JSON.stringify(rawName)}`);
228
- if (names.has(name)) throw new Error(`artifact archive contains duplicate path: ${JSON.stringify(rawName)}`); names.add(name);
229
- const targetRoot = path.resolve(outputDir); const target = path.resolve(targetRoot, ...parts);
230
- if (target !== targetRoot && !target.startsWith(`${targetRoot}${path.sep}`)) throw new Error(`artifact archive path escapes output directory: ${JSON.stringify(rawName)}`);
231
- if (name.endsWith("/")) { fs.mkdirSync(target, { recursive: true }); cursor = next; continue; }
232
- if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== 0x04034b50) throw new Error("artifact archive local entry is invalid");
233
- const localNameLength = archive.readUInt16LE(localOffset + 26); const localExtraLength = archive.readUInt16LE(localOffset + 28); const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
234
- if (dataOffset + compressedSize > archive.length) throw new Error("artifact archive entry data is truncated");
235
- const compressed = archive.subarray(dataOffset, dataOffset + compressedSize); const content = method === 0 ? compressed : zlib.inflateRawSync(compressed);
236
- if (content.length !== size || crc32(content) !== crc) throw new Error(`artifact archive entry integrity check failed: ${JSON.stringify(rawName)}`);
237
- fs.mkdirSync(path.dirname(target), { recursive: true }); fs.writeFileSync(target, content, { flag: "wx" }); files.push(name); cursor = next;
238
- }
239
- if (cursor !== centralOffset + centralSize) throw new Error("artifact archive central directory size is invalid");
240
- return files.sort();
234
+ const archive = fs.readFileSync(archivePath);
235
+ const end = findEndOfCentralDirectory(archive);
236
+ const entryCount = archive.readUInt16LE(end + 10);
237
+ const centralSize = archive.readUInt32LE(end + 12);
238
+ const centralOffset = archive.readUInt32LE(end + 16);
239
+ if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff)
240
+ throw new Error("artifact archive ZIP64 format is unsupported");
241
+ if (centralOffset + centralSize > end)
242
+ throw new Error("artifact archive central directory is invalid");
243
+ const files = [];
244
+ const names = new Set();
245
+ let cursor = centralOffset;
246
+ for (let index = 0; index < entryCount; index += 1) {
247
+ if (cursor + 46 > archive.length || archive.readUInt32LE(cursor) !== 0x02014b50)
248
+ throw new Error("artifact archive central directory is invalid");
249
+ const flags = archive.readUInt16LE(cursor + 8);
250
+ const method = archive.readUInt16LE(cursor + 10);
251
+ const crc = archive.readUInt32LE(cursor + 16);
252
+ const compressedSize = archive.readUInt32LE(cursor + 20);
253
+ const size = archive.readUInt32LE(cursor + 24);
254
+ const nameLength = archive.readUInt16LE(cursor + 28);
255
+ const extraLength = archive.readUInt16LE(cursor + 30);
256
+ const commentLength = archive.readUInt16LE(cursor + 32);
257
+ const attributes = archive.readUInt32LE(cursor + 38);
258
+ const localOffset = archive.readUInt32LE(cursor + 42);
259
+ const next = cursor + 46 + nameLength + extraLength + commentLength;
260
+ if (next > archive.length || compressedSize === 0xffffffff || size === 0xffffffff || localOffset === 0xffffffff)
261
+ throw new Error("artifact archive entry metadata is invalid");
262
+ if (flags & 1)
263
+ throw new Error("artifact archive contains an encrypted entry");
264
+ if (![0, 8].includes(method))
265
+ throw new Error(`artifact archive compression method is unsupported: ${method}`);
266
+ const rawNameBytes = archive.subarray(cursor + 46, cursor + 46 + nameLength);
267
+ const rawName = decodeZipFilename(rawNameBytes, flags);
268
+ const name = rawName.replace(/\\/g, "/");
269
+ const parts = name.split("/");
270
+ const mode = (attributes >>> 16) & 0xffff;
271
+ if (!name || name.startsWith("/") || /^[A-Za-z]:/.test(name) || parts.includes(".."))
272
+ throw new Error(`artifact archive contains unsafe path: ${JSON.stringify(rawName)}`);
273
+ if ((mode & 0o170000) === 0o120000)
274
+ throw new Error(`artifact archive contains a symbolic link: ${JSON.stringify(rawName)}`);
275
+ if (names.has(name))
276
+ throw new Error(`artifact archive contains duplicate path: ${JSON.stringify(rawName)}`);
277
+ names.add(name);
278
+ const targetRoot = path.resolve(outputDir);
279
+ const target = path.resolve(targetRoot, ...parts);
280
+ if (target !== targetRoot && !target.startsWith(`${targetRoot}${path.sep}`))
281
+ throw new Error(`artifact archive path escapes output directory: ${JSON.stringify(rawName)}`);
282
+ if (name.endsWith("/")) {
283
+ fs.mkdirSync(target, { recursive: true });
284
+ cursor = next;
285
+ continue;
286
+ }
287
+ if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== 0x04034b50)
288
+ throw new Error("artifact archive local entry is invalid");
289
+ const localNameLength = archive.readUInt16LE(localOffset + 26);
290
+ const localExtraLength = archive.readUInt16LE(localOffset + 28);
291
+ const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
292
+ if (dataOffset + compressedSize > archive.length)
293
+ throw new Error("artifact archive entry data is truncated");
294
+ const compressed = archive.subarray(dataOffset, dataOffset + compressedSize);
295
+ const content = method === 0 ? compressed : zlib.inflateRawSync(compressed);
296
+ if (content.length !== size || crc32(content) !== crc)
297
+ throw new Error(`artifact archive entry integrity check failed: ${JSON.stringify(rawName)}`);
298
+ fs.mkdirSync(path.dirname(target), { recursive: true });
299
+ fs.writeFileSync(target, content, { flag: "wx" });
300
+ files.push(name);
301
+ cursor = next;
302
+ }
303
+ if (cursor !== centralOffset + centralSize)
304
+ throw new Error("artifact archive central directory size is invalid");
305
+ return files.sort();
241
306
  }
242
-
243
307
  function decodeZipFilename(bytes, flags) {
244
- if (flags & 0x800) return bytes.toString("utf8");
245
- // ZIP producers commonly omit the UTF-8 flag for ASCII entry names. Keep
246
- // those names interoperable while refusing ambiguous non-ASCII encodings.
247
- if (bytes.some((value) => value > 0x7f)) throw new Error("artifact archive contains an unsupported non-UTF-8 filename");
248
- return bytes.toString("ascii");
308
+ if (flags & 0x800)
309
+ return bytes.toString("utf8");
310
+ // ZIP producers commonly omit the UTF-8 flag for ASCII entry names. Keep
311
+ // those names interoperable while refusing ambiguous non-ASCII encodings.
312
+ if (bytes.some((value) => value > 0x7f))
313
+ throw new Error("artifact archive contains an unsupported non-UTF-8 filename");
314
+ return bytes.toString("ascii");
249
315
  }
250
-
251
- function findEndOfCentralDirectory(archive) { const minimum = Math.max(0, archive.length - 65557); for (let offset = archive.length - 22; offset >= minimum; offset -= 1) if (archive.readUInt32LE(offset) === 0x06054b50 && offset + 22 + archive.readUInt16LE(offset + 20) === archive.length) return offset; throw new Error("artifact download is not a valid zip archive"); }
252
- function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc ^= byte; for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ 0xffffffff) >>> 0; }
253
-
316
+ function findEndOfCentralDirectory(archive) { const minimum = Math.max(0, archive.length - 65557); for (let offset = archive.length - 22; offset >= minimum; offset -= 1)
317
+ if (archive.readUInt32LE(offset) === 0x06054b50 && offset + 22 + archive.readUInt16LE(offset + 20) === archive.length)
318
+ return offset; throw new Error("artifact download is not a valid zip archive"); }
319
+ function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) {
320
+ crc ^= byte;
321
+ for (let bit = 0; bit < 8; bit += 1)
322
+ crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
323
+ } return (crc ^ 0xffffffff) >>> 0; }
254
324
  module.exports = { PlatformApiClient, extractArchive };