CyberChef/src/web/waiters/WorkerWaiter.mjs

889 lines
29 KiB
JavaScript

/**
* @author n1474335 [n1474335@gmail.com]
* @author j433866 [j433866@gmail.com]
* @copyright Crown Copyright 2019
* @license Apache-2.0
*/
import ChefWorker from "worker-loader?inline=no-fallback!../../core/ChefWorker.js";
import DishWorker from "worker-loader?inline=no-fallback!../workers/DishWorker.mjs";
import { debounce } from "../../core/Utils.mjs";
/**
* Waiter to handle conversations with the ChefWorker
*/
class WorkerWaiter {
/**
* WorkerWaiter constructor
*
* @param {App} app - The main view object for CyberChef
* @param {Manager} manager - The CyberChef event manager
*/
constructor(app, manager) {
this.app = app;
this.manager = manager;
this.loaded = false;
this.chefWorkers = [];
this.inputs = [];
this.inputNums = [];
this.totalOutputs = 0;
this.loadingOutputs = 0;
this.bakeId = 0;
this.callbacks = {};
this.callbackID = 0;
this.maxWorkers = 1;
if (navigator.hardwareConcurrency !== undefined &&
navigator.hardwareConcurrency > 1) {
this.maxWorkers = navigator.hardwareConcurrency - 1;
}
// Store dishWorker action (getDishAs or getDishTitle)
this.dishWorker = {
worker: null,
currentAction: ""
};
this.dishWorkerQueue = [];
}
/**
* Terminates any existing ChefWorkers and sets up a new worker
*/
setupChefWorker() {
for (let i = this.chefWorkers.length - 1; i >= 0; i--) {
this.removeChefWorker(this.chefWorkers[i]);
}
this.addChefWorker();
this.setupDishWorker();
}
/**
* Sets up a DishWorker to be used for performing Dish operations
*/
setupDishWorker() {
if (this.dishWorker.worker !== null) {
this.dishWorker.worker.terminate();
this.dishWorker.currentAction = "";
}
log.debug("Adding new DishWorker");
this.dishWorker.worker = new DishWorker();
this.dishWorker.worker.addEventListener("message", this.handleDishMessage.bind(this));
this.dishWorker.worker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
if (this.dishWorkerQueue.length > 0) {
this.postDishMessage(this.dishWorkerQueue.splice(0, 1)[0]);
}
}
/**
* Adds a new ChefWorker
*
* @returns {number} The index of the created worker
*/
addChefWorker() {
if (this.chefWorkers.length === this.maxWorkers) {
// Can't create any more workers
return -1;
}
log.debug(`Adding new ChefWorker (${this.chefWorkers.length + 1}/${this.maxWorkers})`);
// Create a new ChefWorker and send it the docURL
const newWorker = new ChefWorker();
newWorker.addEventListener("message", this.handleChefMessage.bind(this));
newWorker.postMessage({
action: "setLogPrefix",
data: "ChefWorker"
});
newWorker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
let docURL = document.location.href.split(/[#?]/)[0];
const index = docURL.lastIndexOf("/");
if (index > 0) {
docURL = docURL.substring(0, index);
}
newWorker.postMessage({"action": "docURL", "data": docURL});
// Store the worker, whether or not it's active, and the inputNum as an object
const newWorkerObj = {
worker: newWorker,
active: false,
inputNum: -1
};
this.chefWorkers.push(newWorkerObj);
return this.chefWorkers.indexOf(newWorkerObj);
}
/**
* Gets an inactive ChefWorker to be used for baking
*
* @param {boolean} [setActive=true] - If true, set the worker status to active
* @returns {number} - The index of the ChefWorker
*/
getInactiveChefWorker(setActive=true) {
for (let i = 0; i < this.chefWorkers.length; i++) {
if (!this.chefWorkers[i].active) {
this.chefWorkers[i].active = setActive;
return i;
}
}
return -1;
}
/**
* Removes a ChefWorker
*
* @param {Object} workerObj
*/
removeChefWorker(workerObj) {
const index = this.chefWorkers.indexOf(workerObj);
if (index === -1) {
return;
}
if (this.chefWorkers.length > 1 || this.chefWorkers[index].active) {
log.debug(`Removing ChefWorker at index ${index}`);
this.chefWorkers[index].worker.terminate();
this.chefWorkers.splice(index, 1);
}
// There should always be a ChefWorker loaded
if (this.chefWorkers.length === 0) {
this.addChefWorker();
}
}
/**
* Finds and returns the object for the ChefWorker of a given inputNum
*
* @param {number} inputNum
*/
getChefWorker(inputNum) {
for (let i = 0; i < this.chefWorkers.length; i++) {
if (this.chefWorkers[i].inputNum === inputNum) {
return this.chefWorkers[i];
}
}
}
/**
* Handler for messages sent back by the ChefWorkers
*
* @param {MessageEvent} e
*/
handleChefMessage(e) {
const r = e.data;
let inputNum = 0;
log.debug(`Receiving '${r.action}' from ChefWorker.`);
if (Object.prototype.hasOwnProperty.call(r.data, "inputNum")) {
inputNum = r.data.inputNum;
}
const currentWorker = this.getChefWorker(inputNum);
switch (r.action) {
case "bakeComplete":
log.debug(`Bake ${inputNum} complete.`);
this.manager.timing.recordTime("bakeComplete", inputNum);
this.manager.timing.recordTime("bakeDuration", inputNum, r.data.duration);
if (r.data.error) {
this.app.handleError(r.data.error);
this.manager.output.updateOutputError(r.data.error, inputNum, r.data.progress);
} else {
this.updateOutput(r.data, r.data.inputNum, r.data.bakeId, r.data.progress);
}
this.app.progress = r.data.progress;
if (r.data.progress === this.recipeConfig.length) {
this.step = false;
}
this.workerFinished(currentWorker);
break;
case "bakeError":
this.app.handleError(r.data.error);
this.manager.output.updateOutputError(r.data.error, inputNum, r.data.progress);
this.app.progress = r.data.progress;
this.workerFinished(currentWorker);
break;
case "dishReturned":
this.callbacks[r.data.id](r.data);
break;
case "silentBakeComplete":
break;
case "workerLoaded":
this.app.workerLoaded = true;
log.debug("ChefWorker loaded");
if (!this.loaded) {
this.app.loaded();
this.loaded = true;
} else {
this.bakeNextInput(this.getInactiveChefWorker(false));
}
break;
case "statusMessage":
this.manager.output.updateOutputMessage(r.data.message, r.data.inputNum, true);
break;
case "progressMessage":
this.manager.output.updateOutputProgress(r.data.progress, r.data.total, r.data.inputNum);
break;
case "optionUpdate":
log.debug(`Setting ${r.data.option} to ${r.data.value}`);
this.app.options[r.data.option] = r.data.value;
break;
case "setRegisters":
this.manager.recipe.setRegisters(r.data.opIndex, r.data.numPrevRegisters, r.data.registers);
break;
case "highlightsCalculated":
this.manager.highlighter.displayHighlights(r.data.pos, r.data.direction);
break;
default:
log.error("Unrecognised message from ChefWorker", e);
break;
}
}
/**
* Update the value of an output
*
* @param {Object} data
* @param {number} inputNum
* @param {number} bakeId
* @param {number} progress
*/
updateOutput(data, inputNum, bakeId, progress) {
this.manager.output.updateOutputBakeId(bakeId, inputNum);
if (progress === this.recipeConfig.length) {
progress = false;
}
this.manager.output.updateOutputProgress(progress, this.recipeConfig.length, inputNum);
this.manager.output.updateOutputValue(data, inputNum, false);
if (progress !== false) {
this.manager.output.updateOutputStatus("error", inputNum);
if (inputNum === this.manager.tabs.getActiveTab("input")) {
this.manager.recipe.updateBreakpointIndicator(progress);
}
} else {
this.manager.output.updateOutputStatus("baked", inputNum);
}
}
/**
* Updates the UI to show if baking is in progress or not.
*
* @param {boolean} bakingStatus
*/
setBakingStatus(bakingStatus) {
this.app.baking = bakingStatus;
debounce(this.manager.controls.toggleBakeButtonFunction, 20, "toggleBakeButton", this, [bakingStatus ? "cancel" : "bake"])();
if (bakingStatus) this.manager.output.hideMagicButton();
}
/**
* Get the progress of the ChefWorkers
*/
getBakeProgress() {
const pendingInputs = this.inputNums.length + this.loadingOutputs + this.inputs.length;
let bakingInputs = 0;
for (let i = 0; i < this.chefWorkers.length; i++) {
if (this.chefWorkers[i].active) {
bakingInputs++;
}
}
const total = this.totalOutputs;
const bakedInputs = total - pendingInputs - bakingInputs;
return {
total: total,
pending: pendingInputs,
baking: bakingInputs,
baked: bakedInputs
};
}
/**
* Cancels the current bake making it possible to autobake again
*/
cancelBakeForAutoBake() {
if (this.totalOutputs > 1) {
this.cancelBake();
} else {
// In this case the UI changes can be skipped
for (let i = this.chefWorkers.length - 1; i >= 0; i--) {
if (this.chefWorkers[i].active) {
this.removeChefWorker(this.chefWorkers[i]);
}
}
this.inputs = [];
this.inputNums = [];
this.totalOutputs = 0;
this.loadingOutputs = 0;
}
}
/**
* Cancels the current bake by terminating and removing all ChefWorkers
*
* @param {boolean} [silent=false] - If true, don't set the output
* @param {boolean} [killAll=false] - If true, kills all chefWorkers regardless of status
*/
cancelBake(silent=false, killAll=false) {
const deactiveOutputs = new Set();
for (let i = this.chefWorkers.length - 1; i >= 0; i--) {
if (this.chefWorkers[i].active || killAll) {
const inputNum = this.chefWorkers[i].inputNum;
this.removeChefWorker(this.chefWorkers[i]);
deactiveOutputs.add(inputNum);
}
}
this.setBakingStatus(false);
this.inputs.forEach(input => {
deactiveOutputs.add(input.inputNum);
});
this.inputNums.forEach(inputNum => {
deactiveOutputs.add(inputNum);
});
deactiveOutputs.forEach(num => {
this.manager.output.updateOutputStatus("inactive", num);
});
const tabList = this.manager.tabs.getTabList("output");
tabList.forEach(tab => {
this.manager.tabs.getTabItem(tab, "output").style.background = "";
});
this.inputs = [];
this.inputNums = [];
this.totalOutputs = 0;
this.loadingOutputs = 0;
if (!silent) this.manager.output.set(this.manager.tabs.getActiveTab("output"));
}
/**
* Handle a worker completing baking
*
* @param {object} workerObj - Object containing the worker information
* @param {ChefWorker} workerObj.worker - The actual worker object
* @param {number} workerObj.inputNum - The inputNum of the input being baked by the worker
* @param {boolean} workerObj.active - If true, the worker is currently baking an input
*/
workerFinished(workerObj) {
const workerIdx = this.chefWorkers.indexOf(workerObj);
this.chefWorkers[workerIdx].active = false;
if (this.inputs.length > 0) {
this.bakeNextInput(workerIdx);
} else if (this.inputNums.length === 0 && this.loadingOutputs === 0) {
// The ChefWorker is no longer needed
log.debug("No more inputs to bake.");
const progress = this.getBakeProgress();
if (progress.total === progress.baked) {
this.bakingComplete();
}
}
}
/**
* Handler for completed bakes
*/
bakingComplete() {
this.setBakingStatus(false);
let duration = Date.now() - this.bakeStartTime;
duration = duration.toLocaleString() + "ms";
const progress = this.getBakeProgress();
if (progress.total > 1) {
let width = progress.total.toLocaleString().length;
if (duration.length > width) {
width = duration.length;
}
width = width < 2 ? 2 : width;
const totalStr = progress.total.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const durationStr = duration.padStart(width, " ").replace(/ /g, "&nbsp;");
const inputNums = Object.keys(this.manager.output.outputs);
let avgTime = 0,
numOutputs = 0;
for (let i = 0; i < inputNums.length; i++) {
const output = this.manager.output.outputs[inputNums[i]];
if (output.status === "baked") {
numOutputs++;
avgTime += output.data.duration;
}
}
avgTime = Math.round(avgTime / numOutputs).toLocaleString() + "ms";
avgTime = avgTime.padStart(width, " ").replace(/ /g, "&nbsp;");
const msg = `total: ${totalStr}<br>time: ${durationStr}<br>average: ${avgTime}`;
const bakeInfo = document.getElementById("bake-info");
bakeInfo.innerHTML = msg;
bakeInfo.style.display = "";
} else {
document.getElementById("bake-info").style.display = "none";
}
document.getElementById("bake").style.background = "";
this.totalOutputs = 0; // Reset for next time
log.debug("--- Bake complete ---");
}
/**
* Bakes the next input and tells the inputWorker to load the next input
*
* @param {number} workerIdx - The index of the worker to bake with
*/
bakeNextInput(workerIdx) {
if (this.inputs.length === 0) return;
if (workerIdx === -1) return;
if (!this.chefWorkers[workerIdx]) return;
this.chefWorkers[workerIdx].active = true;
const nextInput = this.inputs.splice(0, 1)[0];
if (typeof nextInput.inputNum === "string") nextInput.inputNum = parseInt(nextInput.inputNum, 10);
log.debug(`Baking input ${nextInput.inputNum}.`);
this.manager.output.updateOutputMessage(`Baking input ${nextInput.inputNum}...`, nextInput.inputNum, false);
this.manager.output.updateOutputStatus("baking", nextInput.inputNum);
this.chefWorkers[workerIdx].inputNum = nextInput.inputNum;
const input = nextInput.input,
recipeConfig = this.recipeConfig;
if (this.step) {
// Remove all breakpoints from the recipe up to progress
if (nextInput.progress !== false) {
for (let i = 0; i < nextInput.progress; i++) {
if ("breakpoint" in recipeConfig[i]) {
delete recipeConfig[i].breakpoint;
}
}
}
// Set a breakpoint at the next operation so we stop baking there
if (recipeConfig[this.app.progress]) recipeConfig[this.app.progress].breakpoint = true;
}
let transferable;
if (input instanceof ArrayBuffer || ArrayBuffer.isView(input)) {
transferable = [input];
}
this.manager.timing.recordTime("chefWorkerTasked", nextInput.inputNum);
this.chefWorkers[workerIdx].worker.postMessage({
action: "bake",
data: {
input: input,
recipeConfig: recipeConfig,
options: this.options,
inputNum: nextInput.inputNum,
bakeId: this.bakeId
}
}, transferable);
if (this.inputNums.length > 0) {
this.manager.input.inputWorker.postMessage({
action: "bakeNext",
data: {
inputNum: this.inputNums.splice(0, 1)[0],
bakeId: this.bakeId
}
});
this.loadingOutputs++;
}
}
/**
* Bakes the current input using the current recipe.
*
* @param {Object[]} recipeConfig
* @param {Object} options
* @param {number} progress
* @param {boolean} step
*/
bake(recipeConfig, options, progress, step) {
this.setBakingStatus(true);
this.manager.recipe.updateBreakpointIndicator(false);
this.bakeStartTime = Date.now();
this.bakeId++;
this.recipeConfig = recipeConfig;
this.options = options;
this.progress = progress;
this.step = step;
this.displayProgress();
}
/**
* Queues an input ready to be baked
*
* @param {object} inputData
* @param {string | ArrayBuffer} inputData.input
* @param {number} inputData.inputNum
* @param {number} inputData.bakeId
*/
queueInput(inputData) {
this.loadingOutputs--;
if (this.app.baking && inputData.bakeId === this.bakeId) {
this.inputs.push(inputData);
this.bakeNextInput(this.getInactiveChefWorker(true));
}
}
/**
* Handles if an error is thrown by QueueInput
*
* @param {object} inputData
* @param {number} inputData.inputNum
* @param {number} inputData.bakeId
*/
queueInputError(inputData) {
this.loadingOutputs--;
if (this.app.baking && inputData.bakeId === this.bakeId) {
this.manager.output.updateOutputError("Error queueing the input for a bake.", inputData.inputNum, 0);
if (this.inputNums.length === 0) return;
// Load the next input
this.manager.input.inputWorker.postMessage({
action: "bakeNext",
data: {
inputNum: this.inputNums.splice(0, 1)[0],
bakeId: this.bakeId
}
});
this.loadingOutputs++;
}
}
/**
* Queues a list of inputNums to be baked by ChefWorkers, and begins baking
*
* @param {object} inputData
* @param {number[]} inputData.nums - The inputNums to be queued for baking
* @param {boolean} inputData.step - If true, only execute the next operation in the recipe
* @param {number} inputData.progress - The current progress through the recipe. Used when stepping
*/
async bakeInputs(inputData) {
log.debug(`Baking input list [${inputData.nums.join(",")}]`);
return await new Promise(resolve => {
if (this.app.baking) return;
const inputNums = inputData.nums.filter(n => n > 0);
const step = inputData.step;
// Use cancelBake to clear out the inputs
this.cancelBake(true, false);
this.inputNums = inputNums;
this.totalOutputs = inputNums.length;
this.app.progress = inputData.progress;
let inactiveWorkers = 0;
for (let i = 0; i < this.chefWorkers.length; i++) {
if (!this.chefWorkers[i].active) {
inactiveWorkers++;
}
}
for (let i = 0; i < inputNums.length - inactiveWorkers; i++) {
if (this.addChefWorker() === -1) break;
}
this.app.bake(step);
for (let i = 0; i < this.inputNums.length; i++) {
this.manager.output.updateOutputMessage(`Input ${inputNums[i]} has not been baked yet.`, inputNums[i], false);
this.manager.output.updateOutputStatus("pending", inputNums[i]);
}
let numBakes = this.chefWorkers.length;
if (this.inputNums.length < numBakes) {
numBakes = this.inputNums.length;
}
for (let i = 0; i < numBakes; i++) {
this.manager.timing.recordTime("trigger", this.inputNums[0]);
this.manager.input.inputWorker.postMessage({
action: "bakeNext",
data: {
inputNum: this.inputNums.splice(0, 1)[0],
bakeId: this.bakeId
}
});
this.loadingOutputs++;
}
if (numBakes === 0) this.bakingComplete();
});
}
/**
* Asks the ChefWorker to run a silent bake, forcing the browser to load and cache all the relevant
* JavaScript code needed to do a real bake.
*
* @param {Object[]} [recipeConfig]
*/
silentBake(recipeConfig) {
// If there aren't any active ChefWorkers, try to add one
let workerId = this.getInactiveChefWorker();
if (workerId === -1) {
workerId = this.addChefWorker();
}
if (workerId === -1) return;
this.chefWorkers[workerId].worker.postMessage({
action: "silentBake",
data: {
recipeConfig: recipeConfig
}
});
}
/**
* Handler for messages sent back from DishWorker
*
* @param {MessageEvent} e
*/
handleDishMessage(e) {
const r = e.data;
log.debug(`Receiving '${r.action}' from DishWorker`);
switch (r.action) {
case "dishReturned":
this.dishWorker.currentAction = "";
this.callbacks[r.data.id](r.data);
if (this.dishWorkerQueue.length > 0) {
this.postDishMessage(this.dishWorkerQueue.splice(0, 1)[0]);
}
break;
default:
log.error("Unrecognised message from DishWorker", e);
break;
}
}
/**
* Asks the DishWorker to return the dish as the specified type
*
* @param {Dish} dish
* @param {string} type
* @param {Function} callback
*/
getDishAs(dish, type, callback) {
const id = this.callbackID++;
this.callbacks[id] = callback;
if (this.dishWorker.worker === null) this.setupDishWorker();
this.postDishMessage({
action: "getDishAs",
data: {
dish: dish,
type: type,
id: id
}
});
}
/**
* Asks the DishWorker to get the title of the dish
*
* @param {Dish} dish
* @param {number} maxLength
* @param {Function} callback
* @returns {string}
*/
getDishTitle(dish, maxLength, callback) {
const id = this.callbackID++;
this.callbacks[id] = callback;
if (this.dishWorker.worker === null) this.setupDishWorker();
this.postDishMessage({
action: "getDishTitle",
data: {
dish: dish,
maxLength: maxLength,
id: id
}
});
}
/**
* Asks the DishWorker to translate a buffer into a specific character encoding
*
* @param {ArrayBuffer} buffer
* @param {number} encoding
* @param {Function} callback
* @returns {string}
*/
bufferToStr(buffer, encoding, callback) {
const id = this.callbackID++;
this.callbacks[id] = callback;
if (this.dishWorker.worker === null) this.setupDishWorker();
this.postDishMessage({
action: "bufferToStr",
data: {
buffer: buffer,
encoding: encoding,
id: id
}
});
}
/**
* Queues a message to be sent to the dishWorker
*
* @param {object} message
* @param {string} message.action
* @param {object} message.data
* @param {Dish} message.data.dish
* @param {number} message.data.id
*/
queueDishMessage(message) {
if (message.action === "getDishAs") {
this.dishWorkerQueue = [message].concat(this.dishWorkerQueue);
} else {
this.dishWorkerQueue.push(message);
}
}
/**
* Sends a message to the DishWorker
*
* @param {object} message
* @param {string} message.action
* @param {object} message.data
*/
postDishMessage(message) {
if (this.dishWorker.currentAction !== "") {
this.queueDishMessage(message);
} else {
this.dishWorker.currentAction = message.action;
this.dishWorker.worker.postMessage(message);
}
}
/**
* Sets the console log level in the workers.
*/
setLogLevel() {
this.chefWorkers.forEach(cw => {
cw.worker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
});
if (!this.dishWorker.worker) return;
this.dishWorker.worker.postMessage({
action: "setLogLevel",
data: log.getLevel()
});
}
/**
* Display the bake progress in the output bar and bake button
*/
displayProgress() {
const progress = this.getBakeProgress();
if (progress.total === progress.baked) return;
const percentComplete = ((progress.pending + progress.baking) / progress.total) * 100;
const bakeButton = document.getElementById("bake");
if (this.app.baking) {
if (percentComplete < 100) {
bakeButton.style.background = `linear-gradient(to left, #fea79a ${percentComplete}%, #f44336 ${percentComplete}%)`;
} else {
bakeButton.style.background = "";
}
} else {
// not baking
bakeButton.style.background = "";
}
const bakeInfo = document.getElementById("bake-info");
if (progress.total > 1) {
let width = progress.total.toLocaleString().length;
width = width < 2 ? 2 : width;
const totalStr = progress.total.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const bakedStr = progress.baked.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const pendingStr = progress.pending.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
const bakingStr = progress.baking.toLocaleString().padStart(width, " ").replace(/ /g, "&nbsp;");
let msg = "total: " + totalStr;
msg += "<br>baked: " + bakedStr;
if (progress.pending > 0) {
msg += "<br>pending: " + pendingStr;
} else if (progress.baking > 0) {
msg += "<br>baking: " + bakingStr;
}
bakeInfo.innerHTML = msg;
bakeInfo.style.display = "";
} else {
bakeInfo.style.display = "none";
}
if (progress.total !== progress.baked) {
setTimeout(function() {
this.displayProgress();
}.bind(this), 100);
}
}
/**
* Asks the ChefWorker to calculate highlight offsets if possible.
*
* @param {Object[]} recipeConfig
* @param {string} direction
* @param {Object[]} pos - The position object for the highlight.
* @param {number} pos.start - The start offset.
* @param {number} pos.end - The end offset.
*/
highlight(recipeConfig, direction, pos) {
let workerIdx = this.getInactiveChefWorker(false);
if (workerIdx === -1) {
workerIdx = this.addChefWorker();
}
if (workerIdx === -1) return;
this.chefWorkers[workerIdx].worker.postMessage({
action: "highlight",
data: {
recipeConfig: recipeConfig,
direction: direction,
pos: pos
}
});
}
}
export default WorkerWaiter;