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 test = require("node:test");4
const assert = require("node:assert/strict");5
const fs = require("node:fs");6
const os = require("node:os");7
const path = require("node:path");8
const vectors = require("./fixtures/hash-vectors.json");9
const { LocalHashService } = require("../src/main/hashing/localHashService");10
const { ALGORITHMS } = require("../src/main/hashing/hashAlgorithms");12
const SIP_KEY = "000102030405060708090a0b0c0d0e0f";13
const HIGHWAY_KEY = Array.from({ length: 32 }, (_unused, index) => index.toString(16).padStart(2, "0")).join("");15
function requests() {16
return ALGORITHMS.map((algorithm) => ({17
id: algorithm.id,18
...(algorithm.id === "siphash-2-4" ? { keyHex: SIP_KEY } : {}),19
...(algorithm.id.startsWith("highwayhash") ? { keyHex: HIGHWAY_KEY } : {})20
}));21
}23
function fixture(t, files) {24
const root = fs.mkdtempSync(path.join(os.tmpdir(), "chj-local-hash-"));25
t.after(() => fs.rmSync(root, { recursive: true, force: true }));26
const paths = {};27
for (const [name, value] of Object.entries(files)) { paths[name] = path.join(root, name); fs.mkdirSync(path.dirname(paths[name]), { recursive: true }); fs.writeFileSync(paths[name], value); }28
return { root, paths };29
}31
function serviceFor(filePaths, chunkSize = 1024 * 1024, extras = {}) {32
return new LocalHashService({33
chunkSize,34
selectFilesDialog: async () => ({ canceled: false, paths: filePaths }),35
selectDirectoryDialog: async () => ({ canceled: false, path: extras.directory || path.dirname(filePaths[0]) }),36
selectManifestDialog: async () => ({ canceled: false, path: extras.manifest || filePaths[0] }),37
selectSaveDialog: async () => ({ canceled: false, path: extras.destination || path.join(path.dirname(filePaths[0]), "output.txt") }),38
writeClipboard: extras.writeClipboard || (() => {})39
});40
}42
async function completed(service, pluginId, start) {43
let job = start;44
for (let attempt = 0; attempt < 3000 && ["queued", "running"].includes(job.state); attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 5)); job = service.status(pluginId, job.jobId); }45
assert.equal(job.state, "completed", JSON.stringify(job.error));46
return job;47
}49
async function hashFile(service, pluginId = "chj.hash-checksum") {50
const selection = await service.selectFiles(pluginId, { multiple: false });51
const job = await completed(service, pluginId, service.start(pluginId, { selectionId: selection.selectionId, algorithms: requests() }));52
assert.equal(job.results[0].status, "COMPLETED", JSON.stringify(job.results[0].error));53
return Object.fromEntries(job.results[0].hashes.map((hash) => [hash.id, hash.hex]));54
}56
test("algorithm registry contains every specified exact variant and portable metadata", () => {57
assert.equal(ALGORITHMS.length, 49);58
assert.deepEqual(new Set(ALGORITHMS.map((algorithm) => algorithm.id)), new Set(Object.keys(vectors.vectors.abc)));59
assert.equal(ALGORITHMS.find((item) => item.id === "crc16-ccitt-false").parameters, "poly=0x1021 init=0xffff refin=false refout=false xorout=0x0000");60
assert.deepEqual(ALGORITHMS.find((item) => item.id === "shake128").xof, { defaultBytes: 32, minBytes: 16, maxBytes: 1024 });61
assert.deepEqual(ALGORITHMS.find((item) => item.id === "shake256").xof, { defaultBytes: 64, minBytes: 16, maxBytes: 1024 });62
assert.deepEqual(ALGORITHMS.find((item) => item.id === "kangaroo-twelve").xof, { defaultBytes: 32, minBytes: 16, maxBytes: 1024 });63
for (const id of ["sha512-224", "sha3-224", "sha3-256", "sha3-384", "sha3-512", "shake128", "shake256", "blake2b-512", "blake2s-256", "kangaroo-twelve"]) assert.equal(ALGORITHMS.find((item) => item.id === id).backend, "noble-hashes", id);64
});66
for (const name of ["empty", "abc", "quick"]) {67
test(`all algorithms match pinned reference vectors for ${name}`, async (t) => {68
const { paths } = fixture(t, { vector: vectors.messages[name] });69
assert.deepEqual(await hashFile(serviceFor([paths.vector], 7)), vectors.vectors[name]);70
});71
}73
test("all algorithms are invariant across 1 B, 7 B, 64 B, 4 KiB, 64 KiB, and 1 MiB chunks", async (t) => {74
const data = Buffer.alloc(8193); for (let index = 0; index < data.length; index += 1) data[index] = (index * 131 + 17) & 0xff;75
const { paths } = fixture(t, { "stream.bin": data });76
const baseline = await hashFile(serviceFor([paths["stream.bin"]], 1024 * 1024));77
for (const chunkSize of [1, 7, 64, 4096, 65536, 1024 * 1024]) assert.deepEqual(await hashFile(serviceFor([paths["stream.bin"]], chunkSize)), baseline, `chunk size ${chunkSize}`);78
});80
test("selection tokens are opaque, plugin-owned, and arbitrary renderer paths are ignored", async (t) => {81
const { paths } = fixture(t, { "allowed.txt": "allowed", "secret.txt": "secret" });82
const service = serviceFor([paths["allowed.txt"]]); const selection = await service.selectFiles("owner", { multiple: false });83
assert.equal(JSON.stringify(selection).includes(paths["allowed.txt"]), false);84
assert.throws(() => service.start("attacker", { selectionId: selection.selectionId, algorithms: ["sha256"] }), { code: "HASH_INVALID_SELECTION_TOKEN" });85
assert.throws(() => service.start("owner", { selectionId: "missing", path: paths["secret.txt"], algorithms: ["sha256"] }), { code: "HASH_INVALID_SELECTION_TOKEN" });86
const job = await completed(service, "owner", service.start("owner", { selectionId: selection.selectionId, path: paths["secret.txt"], algorithms: ["sha256"] }));87
assert.equal(job.results[0].file.name, "allowed.txt");88
assert.throws(() => service.status("attacker", job.jobId), { code: "HASH_INVALID_JOB" });89
assert.throws(() => service.status("owner", "unknown-job"), { code: "HASH_INVALID_JOB" });90
});92
test("a file changed after selection is rejected by its stable file identity", async (t) => {93
const { paths } = fixture(t, { file: "abc" }); const service = serviceFor([paths.file]); const selection = await service.selectFiles("p");94
fs.writeFileSync(paths.file, "xyz"); fs.utimesSync(paths.file, new Date(), new Date(Date.now() + 2000));95
const job = await completed(service, "p", service.start("p", { selectionId: selection.selectionId, algorithms: ["sha256"] }));96
assert.equal(job.results[0].status, "ERROR"); assert.equal(job.results[0].error.code, "HASH_FILE_CHANGED");97
});99
test("verification reports MATCH and MISMATCH without accepting malformed expected hashes", async (t) => {100
const { paths } = fixture(t, { file: "abc" }); const service = serviceFor([paths.file]); const selection = await service.selectFiles("p");101
let job = await completed(service, "p", service.verify("p", { selectionId: selection.selectionId, algorithm: "sha256", expected: vectors.vectors.abc.sha256.toUpperCase() }));102
assert.equal(job.results[0].status, "MATCH");103
job = await completed(service, "p", service.verify("p", { selectionId: selection.selectionId, algorithm: "sha256", expected: "00".repeat(32) }));104
assert.equal(job.results[0].status, "MISMATCH");105
assert.throws(() => service.verify("p", { selectionId: selection.selectionId, algorithm: "sha256", expected: "not-a-hash" }), { code: "HASH_INVALID_EXPECTED" });106
});108
test("directory enumeration is bounded to the selected root and never follows symlinks", async (t) => {109
const { root, paths } = fixture(t, { "tree/a.txt": "a", "tree/sub/b.txt": "b", "outside.txt": "outside" });110
try { fs.symlinkSync(paths["outside.txt"], path.join(root, "tree", "escape.txt")); } catch (error) { if (process.platform !== "win32") throw error; }111
const service = serviceFor([paths["tree/a.txt"]], 64, { directory: path.join(root, "tree") }); const directory = await service.selectDirectory("p");112
const job = await completed(service, "p", service.start("p", { selectionId: directory.selectionId, algorithms: ["sha256"], recursive: true }));113
assert.deepEqual(job.results.map((result) => result.file.relativePath), ["a.txt", "sub/b.txt"]);114
});116
test("manifest generation and verification support GNU and reject traversal and symlink escapes", async (t) => {117
const { root, paths } = fixture(t, { "root/a file.txt": "abc", "root/sub/žluťoučký.txt": "unicode", "outside": "secret" });118
const manifest = path.join(root, "checksums.sha256"); const service = serviceFor([paths["root/a file.txt"], paths["root/sub/žluťoučký.txt"]], 7, { directory: path.join(root, "root"), destination: manifest, manifest });119
const source = await service.selectFiles("p", { multiple: true }); const destination = await service.selectManifestDestination("p", { suggestedName: "checksums.sha256" });120
await completed(service, "p", service.generateManifest("p", { selectionId: source.selectionId, destinationSelectionId: destination.selectionId, algorithm: "sha256", format: "gnu" }));121
const generated = fs.readFileSync(manifest, "utf8"); assert.match(generated, /# Algorithm: sha256/); assert.match(generated, /a file\.txt/); assert.match(generated, /žluťoučký\.txt/);122
const manifestToken = await service.selectManifest("p"); const rootToken = await service.selectDirectory("p");123
const verified = await completed(service, "p", service.verifyManifest("p", { manifestSelectionId: manifestToken.selectionId, rootSelectionId: rootToken.selectionId }));124
assert.deepEqual(verified.results.map((result) => result.status), ["MATCH", "MATCH"]);125
fs.writeFileSync(manifest, `${vectors.vectors.abc.sha256} ../outside\n`);126
const traversalToken = await service.selectManifest("p"); const traversal = await completed(service, "p", service.verifyManifest("p", { manifestSelectionId: traversalToken.selectionId, rootSelectionId: rootToken.selectionId, algorithmId: "sha256" }));127
assert.equal(traversal.results[0].status, "INVALID ENTRY"); assert.equal(traversal.results[0].error.code, "HASH_PATH_ESCAPE");128
});130
test("BSD and SFV manifests round-trip spaces and Unicode filenames", async (t) => {131
const { root, paths } = fixture(t, { "root/a file.txt": "abc", "root/žluťoučký.txt": "unicode" });132
for (const [format, algorithm, filename] of [["bsd", "fnv1a-64", "checksums.txt"], ["sfv", "crc32", "checksums.sfv"]]) {133
const destinationPath = path.join(root, filename);134
const service = serviceFor([paths["root/a file.txt"], paths["root/žluťoučký.txt"]], 7, { directory: path.join(root, "root"), destination: destinationPath, manifest: destinationPath });135
const source = await service.selectFiles("p", { multiple: true }); const destination = await service.selectManifestDestination("p", { suggestedName: filename });136
await completed(service, "p", service.generateManifest("p", { selectionId: source.selectionId, destinationSelectionId: destination.selectionId, algorithm, format }));137
const manifest = await service.selectManifest("p"); const selectedRoot = await service.selectDirectory("p");138
const verified = await completed(service, "p", service.verifyManifest("p", { manifestSelectionId: manifest.selectionId, rootSelectionId: selectedRoot.selectionId }));139
assert.deepEqual(verified.results.map((result) => result.status), ["MATCH", "MATCH"], format);140
}141
});143
test("manifest verification rejects absolute, drive, UNC, traversal, and symlink paths", async (t) => {144
const { root, paths } = fixture(t, { "root/safe.txt": "safe", "outside/secret.txt": "secret", manifest: "" });145
let symlinkCreated = false;146
try { fs.symlinkSync(path.join(root, "outside"), path.join(root, "root", "link"), "dir"); symlinkCreated = true; } catch (error) { if (process.platform !== "win32") throw error; }147
const digest = vectors.vectors.abc.sha256;148
const entries = ["../outside/secret.txt", "/etc/passwd", "C:\\Windows\\system.ini", "\\\\server\\share\\file", ...(symlinkCreated ? ["link/secret.txt"] : [])];149
fs.writeFileSync(paths.manifest, entries.map((entry) => `${digest} ${entry}`).join("\n"));150
const service = serviceFor([paths.manifest], 7, { directory: path.join(root, "root"), manifest: paths.manifest });151
const manifest = await service.selectManifest("p"); const selectedRoot = await service.selectDirectory("p");152
const job = await completed(service, "p", service.verifyManifest("p", { manifestSelectionId: manifest.selectionId, rootSelectionId: selectedRoot.selectionId, algorithmId: "sha256" }));153
assert.equal(job.results.length, entries.length); assert.ok(job.results.every((result) => result.status === "INVALID ENTRY"));154
assert.ok(job.results.slice(0, 4).every((result) => result.error.code === "HASH_PATH_ESCAPE"));155
if (symlinkCreated) assert.equal(job.results.at(-1).error.code, "HASH_SYMLINK_REJECTED");156
});158
test("cancel stops an active worker and cleanup removes plugin authorization", async (t) => {159
const { paths } = fixture(t, { large: Buffer.alloc(16 * 1024 * 1024, 0x5a) }); const service = serviceFor([paths.large], 4096); const selection = await service.selectFiles("p");160
const started = service.start("p", { selectionId: selection.selectionId, algorithms: requests() }); const cancelled = await service.cancel("p", started.jobId); assert.equal(cancelled.state, "cancelled");161
service.cleanupPlugin("p");162
assert.throws(() => service.start("p", { selectionId: selection.selectionId, algorithms: ["sha256"] }), { code: "HASH_INVALID_SELECTION_TOKEN" });163
assert.throws(() => service.status("p", started.jobId), { code: "HASH_INVALID_JOB" });165
const other = serviceFor([paths.large], 4096); const otherSelection = await other.selectFiles("p"); const active = other.start("p", { selectionId: otherSelection.selectionId, algorithms: requests() });166
other.cleanupPlugin("p"); await new Promise((resolve) => setTimeout(resolve, 20));167
assert.throws(() => other.status("p", active.jobId), { code: "HASH_INVALID_JOB" });168
});170
test("all 49 algorithms export uppercase HEX and Base64 without altering digest bytes", async (t) => {171
const { paths } = fixture(t, { file: "abc" });172
const service = serviceFor([paths.file], 7);173
const selection = await service.selectFiles("p");174
for (const output of ["hex-upper", "base64"]) {175
const job = await completed(service, "p", service.start("p", { selectionId: selection.selectionId, algorithms: requests(), output }));176
assert.equal(job.results[0].hashes.length, 49);177
for (const hash of job.results[0].hashes) {178
const reference = vectors.vectors.abc[hash.id];179
assert.equal(hash.hex, reference, hash.id);180
assert.equal(hash.value, output === "base64" ? Buffer.from(reference, "hex").toString("base64") : reference.toUpperCase(), hash.id);181
}182
}183
});185
test("comparison distinguishes matching and different files using all 49 algorithms", async (t) => {186
const { paths } = fixture(t, { a: "abc", b: "abc", c: "different" });187
const service = serviceFor([paths.a], 7);188
const left = await service.selectFiles("p");189
for (const [file, identical] of [[paths.b, true], [paths.c, false]]) {190
service.selectFilesDialog = async () => ({ canceled: false, paths: [file] });191
const right = await service.selectFiles("p");192
const job = await completed(service, "p", service.compare("p", { leftSelectionId: left.selectionId, rightSelectionId: right.selectionId, algorithms: requests() }));193
assert.equal(job.comparison.identical, identical);194
assert.equal(job.results.every((result) => result.status === "COMPLETED" && result.hashes.length === 49), true);195
}196
});SHA-256: 655abac6a9f2f9a31b45ad2e1d34b101b2017c1db65f8ef270cc07029f75c711
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0