Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const path = require("node:path");6
const { EventEmitter } = require("node:events");7
const { Worker } = require("node:worker_threads");8
const { BY_ID, getAlgorithms, normalizeAlgorithmRequests, typedError } = require("./hashAlgorithms");10
const MAX_SELECTIONS_PER_PLUGIN = 64;11
const MAX_FILES_PER_JOB = 10000;12
const MAX_MANIFEST_BYTES = 10 * 1024 * 1024;13
const DEFAULT_CHUNK_SIZE = 1024 * 1024;14
const TOKEN_TTL_MS = 30 * 60 * 1000;15
const OUTPUTS = new Set(["hex-lower", "hex-upper", "base64"]);16
const FORMATS = new Set(["gnu", "bsd", "sfv"]);18
function randomId(prefix) { return `${prefix}_${crypto.randomBytes(24).toString("base64url")}`; }19
function cleanName(value) { return String(value || "").replace(/[\r\n\0]/g, "_"); }20
function 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." };24
}25
function containsPath(root, candidate) { const relative = path.relative(root, candidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }26
function statIdentity(stat) { return { dev: String(stat.dev), ino: String(stat.ino), size: String(stat.size), mtimeNs: String(stat.mtimeNs) }; }27
function nowMs() { return Date.now(); }29
class 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
}415
}417
module.exports = { DEFAULT_CHUNK_SIZE, LocalHashService, MAX_FILES_PER_JOB, MAX_MANIFEST_BYTES, containsPath, safeError };SHA-256: a0291d0f22070709e00f041ddf74d75c508fd4d615e780eff3296e039ba8ae36
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0