CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

29,1 KB · 417 linesDownload file
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const path = require("node:path");
6const { EventEmitter } = require("node:events");
7const { Worker } = require("node:worker_threads");
8const { BY_ID, getAlgorithms, normalizeAlgorithmRequests, typedError } = require("./hashAlgorithms");
10const MAX_SELECTIONS_PER_PLUGIN = 64;
11const MAX_FILES_PER_JOB = 10000;
12const MAX_MANIFEST_BYTES = 10 * 1024 * 1024;
13const DEFAULT_CHUNK_SIZE = 1024 * 1024;
14const TOKEN_TTL_MS = 30 * 60 * 1000;
15const OUTPUTS = new Set(["hex-lower", "hex-upper", "base64"]);
16const FORMATS = new Set(["gnu", "bsd", "sfv"]);
18function randomId(prefix) { return `${prefix}_${crypto.randomBytes(24).toString("base64url")}`; }
19function cleanName(value) { return String(value || "").replace(/[\r\n\0]/g, "_"); }
20function safeError(error) {
21 const code = String(error?.code || "HASH_INTERNAL_ERROR");
22 const known = new Set(["ENOENT", "EACCES", "EPERM", "HASH_CANCELLED", "HASH_FILE_CHANGED", "HASH_NOT_REGULAR_FILE", "HASH_INVALID_MANIFEST", "HASH_PATH_ESCAPE", "HASH_SYMLINK_REJECTED", "HASH_UNSUPPORTED_ALGORITHM"]);
23 return { code: known.has(code) ? code : "HASH_INTERNAL_ERROR", message: known.has(code) ? String(error?.message || "Hash operation failed.") : "An internal hashing error occurred." };
25function containsPath(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }
26function statIdentity(stat) { return { dev: String(stat.dev), ino: String(stat.ino), size: String(stat.size), mtimeNs: String(stat.mtimeNs) }; }
27function nowMs() { return Date.now(); }
29class LocalHashService extends EventEmitter {
30 constructor(options = {}) {
31 super();
32 this.selectFilesDialog = options.selectFilesDialog;
33 this.selectDirectoryDialog = options.selectDirectoryDialog;
34 this.selectManifestDialog = options.selectManifestDialog;
35 this.selectSaveDialog = options.selectSaveDialog;
36 this.writeClipboard = options.writeClipboard;
37 this.workerPath = options.workerPath || path.join(__dirname, "hashWorker.js");
38 this.Worker = options.Worker || Worker;
39 this.logger = options.logger;
40 this.chunkSize = Number(options.chunkSize || DEFAULT_CHUNK_SIZE);
41 this.selections = new Map();
42 this.jobs = new Map();
43 }
45 getAlgorithms() { return getAlgorithms(); }
47 async selectFiles(pluginId, payload = {}) {
48 const multiple = payload.multiple === true;
49 const result = await this.selectFilesDialog({ multiple });
50 if (result.canceled) return { canceled: true, selectionId: null, files: [] };
51 const sources = Array.isArray(result.paths) ? result.paths : [];
52 if (!sources.length || (!multiple && sources.length !== 1)) throw typedError("The file picker returned an invalid selection.", "HASH_INVALID_SELECTION");
53 const files = sources.map((source) => this._authorizeRegularFile(source));
54 const commonRoot = files.length > 1 ? this._commonDirectory(files.map((file) => path.dirname(file.path))) : path.dirname(files[0].path);
55 for (const file of files) file.relativePath = path.relative(commonRoot, file.path).split(path.sep).join("/");
56 const selection = this._storeSelection(pluginId, { type: "files", files });
57 return { canceled: false, selectionId: selection.id, files: files.map((file) => this._publicFile(file)) };
58 }
60 async selectDirectory(pluginId) {
61 const result = await this.selectDirectoryDialog();
62 if (result.canceled) return { canceled: true, selectionId: null };
63 const directory = this._authorizeDirectory(result.path);
64 const selection = this._storeSelection(pluginId, { type: "directory", directory });
65 return { canceled: false, selectionId: selection.id, name: path.basename(directory.path) || directory.path };
66 }
68 async selectManifest(pluginId) {
69 const result = await this.selectManifestDialog();
70 if (result.canceled) return { canceled: true, selectionId: null };
71 const file = this._authorizeRegularFile(result.path, MAX_MANIFEST_BYTES);
72 const selection = this._storeSelection(pluginId, { type: "manifest", files: [file] });
73 return { canceled: false, selectionId: selection.id, file: this._publicFile(file) };
74 }
76 async selectManifestDestination(pluginId, payload = {}) {
77 const suggestedName = cleanName(payload.suggestedName || "checksums.sha256");
78 const result = await this.selectSaveDialog({ suggestedName });
79 if (result.canceled) return { canceled: true, selectionId: null };
80 const destination = path.resolve(String(result.path || ""));
81 const parent = fs.realpathSync.native(path.dirname(destination));
82 const parentStat = fs.statSync(parent);
83 if (!parentStat.isDirectory()) throw typedError("Manifest destination directory is invalid.", "HASH_INVALID_DESTINATION");
84 let existing = null;
85 try { existing = fs.lstatSync(destination); } catch (error) { if (error.code !== "ENOENT") throw error; }
86 if (existing?.isSymbolicLink() || (existing && !existing.isFile())) throw typedError("Manifest destination must be a regular file and cannot be a symbolic link.", "HASH_SYMLINK_REJECTED");
87 const selection = this._storeSelection(pluginId, { type: "destination", destination, parent });
88 return { canceled: false, selectionId: selection.id, name: path.basename(destination) };
89 }
91 start(pluginId, payload = {}) {
92 const algorithms = normalizeAlgorithmRequests(payload.algorithms);
93 const output = OUTPUTS.has(payload.output) ? payload.output : "hex-lower";
94 const selection = this._selection(pluginId, payload.selectionId, ["files", "directory"]);
95 const files = selection.type === "files" ? selection.files : this._enumerateDirectory(selection.directory, payload.recursive === true);
96 return this._startJob(pluginId, { mode: "calculate", algorithms, output, files });
97 }
99 verify(pluginId, payload = {}) {
100 const algorithms = normalizeAlgorithmRequests([payload.algorithm]);
101 const selection = this._selection(pluginId, payload.selectionId, ["files"]);
102 if (selection.files.length !== 1) throw typedError("Verify requires exactly one selected file.", "HASH_INVALID_SELECTION");
103 const expected = this._normalizeExpected(payload.expected, algorithms[0], payload.expectedEncoding);
104 return this._startJob(pluginId, { mode: "verify", algorithms, output: "hex-lower", files: selection.files, expected });
105 }
107 compare(pluginId, payload = {}) {
108 const algorithms = normalizeAlgorithmRequests(payload.algorithms || ["sha256"]);
109 const left = this._selection(pluginId, payload.leftSelectionId, ["files"]);
110 const right = this._selection(pluginId, payload.rightSelectionId, ["files"]);
111 if (left.files.length !== 1 || right.files.length !== 1) throw typedError("Compare requires one file in each selection.", "HASH_INVALID_SELECTION");
112 return this._startJob(pluginId, { mode: "compare", algorithms, output: "hex-lower", files: [left.files[0], right.files[0]] });
113 }
115 generateManifest(pluginId, payload = {}) {
116 const algorithms = normalizeAlgorithmRequests([payload.algorithm]);
117 const algorithm = BY_ID.get(algorithms[0].id);
118 if (algorithm.xof || algorithm.keyed || algorithm.seeded) throw typedError("Checksum manifests require a fixed, unkeyed, unseeded algorithm.", "HASH_INVALID_MANIFEST");
119 const format = FORMATS.has(payload.format) ? payload.format : "gnu";
120 if (format === "sfv" && algorithms[0].id !== "crc32") throw typedError("SFV manifests require CRC-32/ISO-HDLC.", "HASH_INVALID_MANIFEST");
121 const source = this._selection(pluginId, payload.selectionId, ["files", "directory"]);
122 const destination = this._selection(pluginId, payload.destinationSelectionId, ["destination"]);
123 const files = source.type === "files" ? source.files : this._enumerateDirectory(source.directory, payload.recursive === true);
124 return this._startJob(pluginId, { mode: "generate-manifest", algorithms, output: "hex-lower", files, manifest: { format, destination: destination.destination } });
125 }
127 verifyManifest(pluginId, payload = {}) {
128 const manifestSelection = this._selection(pluginId, payload.manifestSelectionId, ["manifest"]);
129 const rootSelection = this._selection(pluginId, payload.rootSelectionId, ["directory"]);
130 const parsed = this._parseManifest(manifestSelection.files[0].path, payload.algorithmId);
131 const files = [];
132 for (const entry of parsed.entries) {
133 if (entry.invalid) {
134 files.push({ id: randomId("file"), name: path.basename(entry.filename), relativePath: entry.filename, size: 0, preflightError: { code: "HASH_INVALID_MANIFEST", message: "Invalid checksum manifest entry." }, algorithms: [{ id: entry.algorithmId }], expected: entry.expected });
135 continue;
136 }
137 try {
138 const relativePath = this._safeManifestPath(entry.filename);
139 const candidate = path.resolve(rootSelection.directory.path, ...relativePath.split("/"));
140 if (!containsPath(rootSelection.directory.path, candidate)) throw typedError("Manifest path escapes the selected directory.", "HASH_PATH_ESCAPE");
141 this._assertNoSymlinkComponents(rootSelection.directory.path, relativePath);
142 const file = this._authorizeRegularFile(candidate);
143 if (!containsPath(rootSelection.directory.path, file.path)) throw typedError("Manifest path resolves outside the selected directory.", "HASH_PATH_ESCAPE");
144 files.push({ ...file, relativePath, algorithms: [normalizeAlgorithmRequests([{ id: entry.algorithmId }])[0]], expected: entry.expected });
145 } catch (error) {
146 files.push({ id: randomId("file"), name: path.basename(entry.filename), relativePath: entry.filename, size: 0, preflightError: safeError(error), algorithms: [{ id: entry.algorithmId }], expected: entry.expected });
147 }
148 }
149 return this._startJob(pluginId, { mode: "verify-manifest", algorithms: parsed.algorithms, output: "hex-lower", files, manifest: { sourceName: manifestSelection.files[0].name } });
150 }
152 exportResults(pluginId, payload = {}) {
153 const job = this._job(pluginId, payload.jobId);
154 if (job.state !== "completed") throw typedError("Only completed hash results can be exported.", "HASH_JOB_NOT_COMPLETED");
155 const destination = this._selection(pluginId, payload.destinationSelectionId, ["destination"]);
156 const format = payload.format === "json" ? "json" : "text";
157 const serializable = job.results.map((result) => ({ file: result.file.relativePath, size: result.file.size, status: result.status, hashes: Object.fromEntries((result.hashes || []).map((hash) => [hash.id, hash.value])) }));
158 const text = format === "json" ? `${JSON.stringify({ generatedBy: "CH-J Server Manager Hash & Checksum", results: serializable }, null, 2)}\n` : `${serializable.flatMap((result) => (Object.entries(result.hashes).length ? Object.entries(result.hashes).map(([id, value]) => `${result.file}\t${result.size}\t${id}\t${value}\t${result.status}`) : [`${result.file}\t${result.size}\t\t\t${result.status}`])).join("\n")}\n`;
159 this._writeAuthorized(destination.destination, text);
160 return { ok: true, name: path.basename(destination.destination), format };
161 }
163 copyResult(pluginId, payload = {}) {
164 const job = this._job(pluginId, payload.jobId);
165 const lines = [];
166 for (const result of job.results) {
167 if (payload.fileId && result.file.fileId !== payload.fileId) continue;
168 for (const hash of result.hashes || []) {
169 if (payload.algorithmId && hash.id !== payload.algorithmId) continue;
170 lines.push(payload.compact === true ? hash.value : `${hash.value} ${result.file.relativePath} (${hash.id})`);
171 }
172 }
173 if (!lines.length) throw typedError("No matching completed result is available to copy.", "HASH_RESULT_NOT_FOUND");
174 this.writeClipboard(lines.join("\n"));
175 return { ok: true, count: lines.length };
176 }
178 status(pluginId, jobId) { return this._publicJob(this._job(pluginId, jobId)); }
180 async cancel(pluginId, jobId) {
181 const job = this._job(pluginId, jobId);
182 if (["completed", "failed", "cancelled"].includes(job.state)) return this._publicJob(job);
183 job.cancelled = true;
184 Atomics.store(job.cancelView, 0, 1);
185 const worker = job.worker;
186 job.worker = null;
187 if (worker) await worker.terminate();
188 job.state = "cancelled"; job.error = { code: "HASH_CANCELLED", message: "Hash job was cancelled." }; job.completedAt = nowMs();
189 this._emit(job);
190 return this._publicJob(job);
191 }
193 cleanupPlugin(pluginId) {
194 const id = String(pluginId || "");
195 this.selections.delete(id);
196 for (const job of [...this.jobs.values()]) {
197 if (job.pluginId !== id) continue;
198 if (["completed", "failed", "cancelled"].includes(job.state)) this.jobs.delete(job.id);
199 else void this.cancel(id, job.id).finally(() => this.jobs.delete(job.id));
200 }
201 }
203 _startJob(pluginId, source) {
204 if (!source.files.length) throw typedError("No regular files were selected.", "HASH_EMPTY_SELECTION");
205 if (source.files.length > MAX_FILES_PER_JOB) throw typedError(`A job can contain at most ${MAX_FILES_PER_JOB} files.`, "HASH_TOO_MANY_FILES");
206 const id = randomId("job"); const startedAt = nowMs(); const cancelBuffer = new SharedArrayBuffer(4); const cancelView = new Int32Array(cancelBuffer);
207 const job = { id, pluginId: String(pluginId), state: "queued", mode: source.mode, algorithms: source.algorithms, output: source.output, files: source.files, expected: source.expected, manifest: source.manifest, results: [], currentIndex: -1, bytes: 0, totalBytes: source.files.reduce((sum, file) => sum + Number(file.size || 0), 0), startedAt, completedAt: null, error: null, cancelled: false, cancelBuffer, cancelView, worker: null };
208 this.jobs.set(id, job); this._emit(job);
209 setImmediate(() => this._runJob(job).catch((error) => this._failJob(job, error)));
210 return this._publicJob(job);
211 }
213 async _runJob(job) {
214 job.state = "running"; this._emit(job);
215 let completedBytes = 0;
216 for (let index = 0; index < job.files.length; index += 1) {
217 if (job.cancelled) throw Object.assign(new Error("Hash job was cancelled."), { code: "HASH_CANCELLED" });
218 const file = job.files[index]; job.currentIndex = index; job.bytes = completedBytes; this._emit(job);
219 if (file.preflightError) { job.results.push({ file: this._publicFile(file), status: file.preflightError.code === "ENOENT" ? "MISSING" : "INVALID ENTRY", error: file.preflightError }); continue; }
220 try {
221 const algorithms = file.algorithms || job.algorithms;
222 const hashes = await this._hashFile(job, file, algorithms, completedBytes);
223 const result = { file: this._publicFile(file), status: "COMPLETED", hashes: hashes.map((hash) => this._formatResult(hash, job.output)) };
224 if (job.mode === "verify") { result.expected = job.expected.hex; result.actual = hashes[0].hex; result.status = crypto.timingSafeEqual(Buffer.from(result.expected, "hex"), Buffer.from(result.actual, "hex")) ? "MATCH" : "MISMATCH"; }
225 if (job.mode === "verify-manifest") { result.expected = file.expected; result.actual = hashes[0].hex; result.status = this._safeHexEqual(file.expected, hashes[0].hex) ? "MATCH" : "MISMATCH"; }
226 job.results.push(result);
227 } catch (error) {
228 if (error?.code === "HASH_CANCELLED") throw error;
229 job.results.push({ file: this._publicFile(file), status: "ERROR", error: safeError(error) });
230 }
231 completedBytes += Number(file.size || 0); job.bytes = completedBytes; this._emit(job);
232 }
233 if (job.mode === "compare") {
234 const complete = job.results.length === 2 && job.results.every((result) => result.status === "COMPLETED");
235 job.comparison = { digestBased: true, identical: complete && job.algorithms.every((algorithm) => job.results[0].hashes.find((hash) => hash.id === algorithm.id)?.hex === job.results[1].hashes.find((hash) => hash.id === algorithm.id)?.hex) };
236 }
237 if (job.mode === "generate-manifest") this._writeManifest(job);
238 job.state = "completed"; job.completedAt = nowMs(); job.currentIndex = -1; this._emit(job);
239 this.logger?.info("Local hash job completed.", { pluginId: job.pluginId, jobId: job.id, mode: job.mode, files: job.files.length, algorithms: job.algorithms.map((item) => item.id) });
240 }
242 _hashFile(job, file, algorithms, completedBytes) {
243 return new Promise((resolve, reject) => {
244 const worker = new this.Worker(this.workerPath, { workerData: { filePath: file.path, expected: file.identity, algorithms, chunkSize: this.chunkSize, cancelView: job.cancelView } });
245 job.worker = worker; let settled = false;
246 const finish = (callback, value) => { if (settled) return; settled = true; job.worker = null; callback(value); };
247 worker.on("message", (message) => {
248 if (message?.type === "progress") { job.bytes = completedBytes + Number(message.bytes || 0); this._emit(job); }
249 if (message?.type === "complete") finish(resolve, message.results);
250 if (message?.type === "error") finish(reject, Object.assign(new Error(message.message), { code: message.code }));
251 });
252 worker.once("error", (error) => finish(reject, error));
253 worker.once("exit", (code) => { if (!settled) finish(reject, Object.assign(new Error(job.cancelled ? "Hash job was cancelled." : `Hash worker stopped with code ${code}.`), { code: job.cancelled ? "HASH_CANCELLED" : "HASH_WORKER_EXIT" })); });
254 });
255 }
257 _failJob(job, error) {
258 if (job.state === "cancelled") return;
259 job.state = error?.code === "HASH_CANCELLED" ? "cancelled" : "failed"; job.error = safeError(error); job.completedAt = nowMs(); job.worker = null; this._emit(job);
260 this.logger?.warn("Local hash job failed.", { pluginId: job.pluginId, jobId: job.id, code: job.error.code, message: error?.message || String(error) });
261 }
263 _emit(job) { this.emit("progress", { pluginId: job.pluginId, job: this._publicJob(job) }); }
265 _publicJob(job) {
266 const elapsedMs = Math.max(0, (job.completedAt || nowMs()) - job.startedAt); const throughput = elapsedMs > 0 ? job.bytes / (elapsedMs / 1000) : 0; const remaining = Math.max(0, job.totalBytes - job.bytes);
267 return { jobId: job.id, mode: job.mode, state: job.state, algorithms: job.algorithms.map((item) => ({ ...item })), filesCompleted: job.results.length, filesTotal: job.files.length, currentFile: job.currentIndex >= 0 ? this._publicFile(job.files[job.currentIndex]) : null, bytes: job.bytes, totalBytes: job.totalBytes, percent: job.totalBytes ? Math.min(100, (job.bytes / job.totalBytes) * 100) : (job.state === "completed" ? 100 : 0), throughputBytesPerSecond: throughput, elapsedMs, etaMs: throughput > 0 ? (remaining / throughput) * 1000 : null, results: job.results.map((result) => ({ ...result, hashes: result.hashes?.map((hash) => ({ ...hash })) })), comparison: job.comparison ? { ...job.comparison } : null, manifest: job.manifestResult ? { ...job.manifestResult } : null, error: job.error ? { ...job.error } : null };
268 }
270 _publicFile(file) { return { fileId: file.id, name: file.name, relativePath: file.relativePath || file.name, size: Number(file.size || 0) }; }
271 _formatResult(result, output) { const bytes = Buffer.from(result.hex, "hex"); return { id: result.id, hex: result.hex, value: output === "base64" ? bytes.toString("base64") : (output === "hex-upper" ? result.hex.toUpperCase() : result.hex), encoding: output }; }
273 _authorizeRegularFile(source, maxBytes = Number.MAX_SAFE_INTEGER) {
274 const original = path.resolve(String(source || "")); const lstat = fs.lstatSync(original);
275 if (lstat.isSymbolicLink()) throw typedError("Symbolic links are not accepted as file selections.", "HASH_SYMLINK_REJECTED");
276 const canonical = fs.realpathSync.native(original); const stat = fs.statSync(canonical, { bigint: true });
277 if (!stat.isFile()) throw typedError("Only regular files can be selected.", "HASH_NOT_REGULAR_FILE");
278 if (stat.size > BigInt(maxBytes) || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) throw typedError("Selected file exceeds the supported size.", "HASH_FILE_TOO_LARGE");
279 return { id: randomId("file"), path: canonical, name: path.basename(canonical), size: Number(stat.size), identity: statIdentity(stat) };
280 }
282 _authorizeDirectory(source) {
283 const original = path.resolve(String(source || "")); const lstat = fs.lstatSync(original);
284 if (lstat.isSymbolicLink()) throw typedError("Symbolic links are not accepted as directory selections.", "HASH_SYMLINK_REJECTED");
285 const canonical = fs.realpathSync.native(original); const stat = fs.statSync(canonical);
286 if (!stat.isDirectory()) throw typedError("The selection is not a directory.", "HASH_INVALID_SELECTION");
287 return { path: canonical };
288 }
290 _enumerateDirectory(directory, recursive) {
291 const root = directory.path;
292 const files = []; const visit = (current, relativeRoot) => {
293 const currentLstat = fs.lstatSync(current);
294 if (currentLstat.isSymbolicLink()) return;
295 const canonical = fs.realpathSync.native(current);
296 if (!containsPath(root, canonical) || !fs.statSync(canonical).isDirectory()) throw typedError("Directory traversal left the selected root.", "HASH_PATH_ESCAPE");
297 const entries = fs.readdirSync(canonical, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
298 for (const entry of entries) {
299 if (files.length >= MAX_FILES_PER_JOB) throw typedError(`A job can contain at most ${MAX_FILES_PER_JOB} files.`, "HASH_TOO_MANY_FILES");
300 if (entry.isSymbolicLink()) continue;
301 const fullPath = path.join(canonical, entry.name); const relativePath = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
302 if (entry.isDirectory()) { if (recursive) visit(fullPath, relativePath); continue; }
303 if (!entry.isFile()) continue;
304 const file = this._authorizeRegularFile(fullPath);
305 if (!containsPath(root, file.path)) throw typedError("Directory traversal left the selected root.", "HASH_PATH_ESCAPE");
306 files.push({ ...file, relativePath });
307 }
308 };
309 visit(directory.path, ""); return files;
310 }
312 _commonDirectory(directories) {
313 let candidate = path.resolve(directories[0]);
314 while (!directories.every((directory) => containsPath(candidate, path.resolve(directory)))) {
315 const parent = path.dirname(candidate);
316 if (parent === candidate) return candidate;
317 candidate = parent;
318 }
319 return candidate;
320 }
322 _storeSelection(pluginId, source) {
323 const owner = String(pluginId || ""); let values = this.selections.get(owner);
324 if (!values) { values = new Map(); this.selections.set(owner, values); }
325 const cutoff = nowMs() - TOKEN_TTL_MS; for (const [id, item] of values) if (item.createdAt < cutoff) values.delete(id);
326 while (values.size >= MAX_SELECTIONS_PER_PLUGIN) values.delete(values.keys().next().value);
327 const selection = { ...source, id: randomId("selection"), createdAt: nowMs() }; values.set(selection.id, selection); return selection;
328 }
330 _selection(pluginId, selectionId, types) {
331 const selection = this.selections.get(String(pluginId || ""))?.get(String(selectionId || ""));
332 if (!selection || nowMs() - selection.createdAt > TOKEN_TTL_MS || !types.includes(selection.type)) throw typedError("Unknown, expired, or unauthorized selection token.", "HASH_INVALID_SELECTION_TOKEN");
333 return selection;
334 }
335 _job(pluginId, jobId) { const job = this.jobs.get(String(jobId || "")); if (!job || job.pluginId !== String(pluginId || "")) throw typedError("Unknown or unauthorized hash job.", "HASH_INVALID_JOB"); return job; }
337 _normalizeExpected(value, algorithm, encoding = "hex") {
338 const source = String(value || "").trim(); let bytes;
339 if (encoding === "base64") { if (!/^[A-Za-z0-9+/]*={0,2}$/.test(source)) throw typedError("Expected digest is not valid Base64.", "HASH_INVALID_EXPECTED"); bytes = Buffer.from(source, "base64"); }
340 else { if (!/^[0-9a-f]+$/i.test(source) || source.length % 2) throw typedError("Expected digest must be hexadecimal.", "HASH_INVALID_EXPECTED"); bytes = Buffer.from(source, "hex"); }
341 const requestBytes = algorithm.outputBytes || (BY_ID.get(algorithm.id).digestBits / 8);
342 if (bytes.length !== requestBytes) throw typedError(`Expected digest must be exactly ${requestBytes} bytes.`, "HASH_INVALID_EXPECTED");
343 return { hex: bytes.toString("hex") };
344 }
345 _safeHexEqual(left, right) { try { const a = Buffer.from(String(left), "hex"); const b = Buffer.from(String(right), "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b); } catch { return false; } }
347 _safeManifestPath(value) {
348 const source = String(value || "").replace(/\\/g, "/");
349 if (!source || source.includes("\0") || source.startsWith("/") || source.startsWith("//") || /^[A-Za-z]:\//.test(source)) throw typedError("Manifest contains an absolute or invalid path.", "HASH_PATH_ESCAPE");
350 const segments = source.split("/"); if (segments.some((segment) => !segment || segment === "." || segment === "..")) throw typedError("Manifest contains path traversal.", "HASH_PATH_ESCAPE");
351 return segments.join("/");
352 }
354 _assertNoSymlinkComponents(root, relativePath) {
355 let current = root;
356 for (const segment of relativePath.split("/")) { current = path.join(current, segment); const stat = fs.lstatSync(current); if (stat.isSymbolicLink()) throw typedError("Manifest path contains a symbolic link.", "HASH_SYMLINK_REJECTED"); }
357 }
359 _parseManifest(filePath, requestedAlgorithm) {
360 const text = fs.readFileSync(filePath, "utf8"); if (Buffer.byteLength(text) > MAX_MANIFEST_BYTES) throw typedError("Checksum manifest is too large.", "HASH_INVALID_MANIFEST");
361 const entries = []; let declared = null;
362 const bsdNames = new Map(getAlgorithms().filter((algorithm) => !algorithm.xof).map((algorithm) => [algorithm.name.replace(/[^A-Za-z0-9]/g, "").toUpperCase(), algorithm.id]));
363 const mapName = (value) => bsdNames.get(String(value).replace(/[^A-Za-z0-9]/g, "").toUpperCase()) || null;
364 for (const rawLine of text.replace(/^\uFEFF/, "").split(/\r?\n/)) {
365 if (!rawLine.trim()) continue;
366 const header = rawLine.match(/^#\s*Algorithm:\s*([a-z0-9-]+)\s*$/i); if (header) { declared = header[1].toLowerCase(); continue; }
367 if (rawLine.startsWith("#") || rawLine.startsWith(";")) continue;
368 let match = rawLine.match(/^(.+?) \((.*)\) = ([0-9a-fA-F]+)$/);
369 if (match) { const id = mapName(match[1]); if (!id) throw typedError(`Unsupported BSD manifest algorithm: ${match[1]}`, "HASH_INVALID_MANIFEST"); entries.push({ algorithmId: id, filename: match[2], expected: match[3].toLowerCase() }); continue; }
370 match = rawLine.match(/^([0-9a-fA-F]+) [ *](.+)$/);
371 if (match) { entries.push({ algorithmId: null, filename: match[2], expected: match[1].toLowerCase() }); continue; }
372 match = rawLine.match(/^(.+?)\s+([0-9a-fA-F]{8})$/);
373 if (match) { entries.push({ algorithmId: "crc32", filename: match[1], expected: match[2].toLowerCase() }); continue; }
374 entries.push({ algorithmId: null, filename: rawLine, expected: "", invalid: true });
375 }
376 if (!entries.length) throw typedError("Checksum manifest contains no entries.", "HASH_INVALID_MANIFEST");
377 const fallback = requestedAlgorithm || declared || this._algorithmFromExtension(filePath);
378 for (const entry of entries) {
379 if (entry.invalid) { entry.algorithmId = fallback || "sha256"; continue; }
380 entry.algorithmId ||= fallback;
381 if (!entry.algorithmId) {
382 const candidates = getAlgorithms().filter((algorithm) => algorithm.digestBits && algorithm.digestBits / 4 === entry.expected.length);
383 if (candidates.length !== 1) throw typedError("Manifest digest length is ambiguous; select the algorithm explicitly.", "HASH_AMBIGUOUS_ALGORITHM");
384 entry.algorithmId = candidates[0].id;
385 }
386 const algorithm = BY_ID.get(entry.algorithmId); if (!algorithm || algorithm.xof || !new RegExp(`^[0-9a-f]{${algorithm.digestBits / 4}}$`).test(entry.expected)) entry.invalid = true;
387 }
388 const algorithms = normalizeAlgorithmRequests([...new Set(entries.map((entry) => entry.algorithmId))]);
389 return { entries, algorithms };
390 }
392 _algorithmFromExtension(filePath) {
393 const extension = path.extname(filePath).slice(1).toLowerCase();
394 return ({ sha224: "sha224", sha256: "sha256", sha384: "sha384", sha512: "sha512", sha3: "sha3-256", blake3: "blake3", md5: "md5", sha1: "sha1", sfv: "crc32" })[extension] || null;
395 }
397 _writeManifest(job) {
398 const { format, destination } = job.manifest; const algorithm = BY_ID.get(job.algorithms[0].id); const lines = [];
399 if (format !== "sfv") lines.push(`# Algorithm: ${algorithm.id}`);
400 for (const result of job.results) {
401 if (result.status !== "COMPLETED") continue;
402 const filename = cleanName(result.file.relativePath).replace(/\\/g, "/"); const digest = result.hashes[0].hex;
403 if (format === "bsd") lines.push(`${algorithm.name} (${filename}) = ${digest}`);
404 else if (format === "sfv") lines.push(`${filename} ${digest.toUpperCase()}`);
405 else lines.push(`${digest} ${filename}`);
406 }
407 this._writeAuthorized(destination, `${lines.join("\n")}\n`);
408 job.manifestResult = { name: path.basename(destination), entries: lines.length - (format === "sfv" ? 0 : 1), format };
409 }
411 _writeAuthorized(destination, text) {
412 const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0); const fd = fs.openSync(destination, flags, 0o600);
413 try { fs.writeFileSync(fd, text, "utf8"); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
414 }
417module.exports = { DEFAULT_CHUNK_SIZE, LocalHashService, MAX_FILES_PER_JOB, MAX_MANIFEST_BYTES, containsPath, safeError };

SHA-256: a0291d0f22070709e00f041ddf74d75c508fd4d615e780eff3296e039ba8ae36

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0