diff --git a/src/core/Ingredient.mjs b/src/core/Ingredient.mjs
index 1c0f0cc374..57c3efa6f5 100644
--- a/src/core/Ingredient.mjs
+++ b/src/core/Ingredient.mjs
@@ -86,6 +86,9 @@ class Ingredient {
if (this.type === "option" && Array.isArray(checkVal)) {
checkVal = checkVal[this.defaultIndex ?? 0];
}
+ if (this.type === "argSelector" && Array.isArray(checkVal)) {
+ checkVal = checkVal[this.defaultIndex ?? 0]?.name || "";
+ }
// 1. check if empty
let isEmpty = false;
@@ -99,8 +102,10 @@ class Ingredient {
let isAllowedOptionEmpty = false;
if (this.type === "option" && Array.isArray(this.defaultValue)) {
isAllowedOptionEmpty = this.defaultValue.includes("");
+ } else if (this.type === "argSelector" && Array.isArray(this.defaultValue)) {
+ isAllowedOptionEmpty = this.defaultValue.some(opt => opt.name === "");
}
- if (this.allowEmpty === false || (this.type === "option" && !isAllowedOptionEmpty)) {
+ if (this.allowEmpty === false || ((this.type === "option" || this.type === "argSelector") && !isAllowedOptionEmpty)) {
throw new OperationError(`${this.name} cannot be empty.`);
}
return true;
@@ -150,6 +155,23 @@ class Ingredient {
}
}
+ // 5. argSelector checks
+ if (this.type === "argSelector") {
+ if (Array.isArray(this.defaultValue)) {
+ const permittedOptions = this.defaultValue
+ .map(opt => opt.name)
+ .filter(optName => {
+ if (typeof optName !== "string") return false;
+ return !optName.match(/^\[\/?[a-z0-9 -()^]+\]$/i);
+ });
+ const valStr = (checkVal !== null && checkVal !== undefined) ? String(checkVal).toLowerCase() : "";
+ const matchedOption = permittedOptions.find(opt => opt.toLowerCase() === valStr);
+ if (!matchedOption) {
+ throw new OperationError(`${this.name} must be one of the following: ${permittedOptions.join(", ")}.`);
+ }
+ }
+ }
+
return true;
}
diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json
index 4f4dedc2df..6ad3adb776 100644
--- a/src/core/config/Categories.json
+++ b/src/core/config/Categories.json
@@ -246,6 +246,7 @@
"Divide",
"MOD",
"Extended GCD",
+ "Modular Exponentiation",
"Modular Inverse",
"Mean",
"Median",
diff --git a/src/core/operations/A1Z26CipherDecode.mjs b/src/core/operations/A1Z26CipherDecode.mjs
index cc9aafbf76..4cbe605da7 100644
--- a/src/core/operations/A1Z26CipherDecode.mjs
+++ b/src/core/operations/A1Z26CipherDecode.mjs
@@ -35,32 +35,32 @@ class A1Z26CipherDecode extends Operation {
];
this.checks = [
{
- pattern: "^\\s*([12]?[0-9] )+[12]?[0-9]\\s*$",
+ pattern: "^\\s*((?:0?[1-9]|1[0-9]|2[0-6]) )+(?:0?[1-9]|1[0-9]|2[0-6])\\s*$",
flags: "",
args: ["Space"]
},
{
- pattern: "^\\s*([12]?[0-9],)+[12]?[0-9]\\s*$",
+ pattern: "^\\s*((?:0?[1-9]|1[0-9]|2[0-6]),)+(?:0?[1-9]|1[0-9]|2[0-6])\\s*$",
flags: "",
args: ["Comma"]
},
{
- pattern: "^\\s*([12]?[0-9];)+[12]?[0-9]\\s*$",
+ pattern: "^\\s*((?:0?[1-9]|1[0-9]|2[0-6]);)+(?:0?[1-9]|1[0-9]|2[0-6])\\s*$",
flags: "",
args: ["Semi-colon"]
},
{
- pattern: "^\\s*([12]?[0-9]:)+[12]?[0-9]\\s*$",
+ pattern: "^\\s*((?:0?[1-9]|1[0-9]|2[0-6]):)+(?:0?[1-9]|1[0-9]|2[0-6])\\s*$",
flags: "",
args: ["Colon"]
},
{
- pattern: "^\\s*([12]?[0-9]\\n)+[12]?[0-9]\\s*$",
+ pattern: "^\\s*((?:0?[1-9]|1[0-9]|2[0-6])\\n)+(?:0?[1-9]|1[0-9]|2[0-6])\\s*$",
flags: "",
args: ["Line feed"]
},
{
- pattern: "^\\s*([12]?[0-9]\\r\\n)+[12]?[0-9]\\s*$",
+ pattern: "^\\s*((?:0?[1-9]|1[0-9]|2[0-6])\\r\\n)+(?:0?[1-9]|1[0-9]|2[0-6])\\s*$",
flags: "",
args: ["CRLF"]
}
diff --git a/src/core/operations/AutomatedValidationTestOp.mjs b/src/core/operations/AutomatedValidationTestOp.mjs
index 92f803ae45..52af20c2ce 100644
--- a/src/core/operations/AutomatedValidationTestOp.mjs
+++ b/src/core/operations/AutomatedValidationTestOp.mjs
@@ -66,6 +66,23 @@ class AutomatedValidationTestOp extends Operation {
"type": "option",
"value": ["[Group 1]", "Option 1", "Option 2", "[/Group 1]", "[Group 2]", "Option 3", "[/Group 2]"],
"allowEmpty": false
+ },
+ {
+ "name": "Arg Selector Ingredient",
+ "type": "argSelector",
+ "value": [
+ {
+ name: "Option 1",
+ on: [0],
+ off: [1]
+ },
+ {
+ name: "Option 2",
+ on: [1],
+ off: [0]
+ }
+ ],
+ "allowEmpty": false
}
];
}
diff --git a/src/core/operations/ModularExponentiation.mjs b/src/core/operations/ModularExponentiation.mjs
new file mode 100644
index 0000000000..6a08a97dda
--- /dev/null
+++ b/src/core/operations/ModularExponentiation.mjs
@@ -0,0 +1,111 @@
+/**
+ * @author p-leriche [philip.leriche@cantab.net]
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+
+import Operation from "../Operation.mjs";
+import OperationError from "../errors/OperationError.mjs";
+import { parseBigInt, modPow } from "../lib/BigIntUtils.mjs";
+
+/* ---------- operation class ---------- */
+
+/**
+ * Modular Exponentiation operation
+ */
+class ModularExponentiation extends Operation {
+
+ /**
+ * ModularExponentiation constructor
+ */
+ constructor() {
+ super();
+
+ this.name = "Modular Exponentiation";
+ this.module = "Crypto";
+ this.description = "Performs modular exponentiation, as used in Diffie-Hellman and RSA.
" +
+ "Computes Base ^ Exponent mod Modulus.
" +
+ "Input handling: If either Base or Exponent is left blank, " +
+ "its value is taken from the Input field.";
+ this.infoURL = "https://wikipedia.org/wiki/Modular_exponentiation";
+ this.inputType = "string";
+ this.outputType = "string";
+ this.args = [
+ {
+ name: "Base",
+ type: "string",
+ value: ""
+ },
+ {
+ name: "Modulus",
+ type: "string",
+ value: "1"
+ },
+ {
+ name: "Exponent",
+ type: "string",
+ value: ""
+ }
+ ];
+ }
+
+ /**
+ * @param {string} input
+ * @param {Object[]} args
+ * @returns {string}
+ */
+ run(input, args) {
+ const [baseStr, modStr, expStr] = args;
+
+ // Trim everything so "" and " " count as empty
+ const baseParam = baseStr?.trim();
+ const expParam = expStr?.trim();
+ const modParam = modStr?.trim();
+ const inputVal = input?.trim();
+
+ const mod = modParam;
+ if (!mod) {
+ throw new OperationError("Modulus must be defined");
+ }
+
+ // Base *or* Exponent (but not both) are taken from the Input
+ // if their boxes are empty.
+ let base, exp;
+ if (baseParam && expParam) {
+ // Case 1: base and exponent both given as parameters
+ base = baseParam;
+ exp = expParam;
+ } else if (!baseParam && expParam) {
+ // Case 2: base missing - take from input
+ base = inputVal;
+ exp = expParam;
+ if (!base) {
+ throw new OperationError("Base must be defined");
+ }
+ } else if (baseParam && !expParam) {
+ // Case 3: exponent missing - take from input
+ base = baseParam;
+ exp = inputVal;
+ if (!exp) {
+ throw new OperationError("Exponent must be defined");
+ }
+ } else if (!inputVal) {
+ // Case 4: base and exponent both missing
+ throw new OperationError("Base and Exponent must be defined");
+ } else throw new OperationError("Ambiguous input: specify either Base or Exponent when using Input");
+
+ // Parse numbers
+ const baseBI = parseBigInt(base, "Base");
+ const expBI = parseBigInt(exp, "Exponent");
+ const modBI = parseBigInt(mod, "Modulus");
+
+ // Check for invalid modulus (parseBigInt eliminates negatives)
+ if (modBI === 0n) {
+ throw new OperationError("Modulus must be greater than zero");
+ }
+
+ return modPow(baseBI, expBI, modBI).toString();
+ }
+}
+
+export default ModularExponentiation;
diff --git a/src/core/operations/ParseQRCode.mjs b/src/core/operations/ParseQRCode.mjs
index 89eaddee98..addc1f17d2 100644
--- a/src/core/operations/ParseQRCode.mjs
+++ b/src/core/operations/ParseQRCode.mjs
@@ -33,15 +33,11 @@ class ParseQRCode extends Operation {
value: false,
},
];
- this.checks = [
- {
- pattern:
- "^(?:\\xff\\xd8\\xff|\\x89\\x50\\x4e\\x47|\\x47\\x49\\x46|.{8}\\x57\\x45\\x42\\x50|\\x42\\x4d)",
- flags: "",
- args: [false],
- useful: true,
- },
- ];
+ // No Magic checks: detecting a QR code in arbitrary image data requires
+ // actually attempting to parse one, which is expensive and produces
+ // spurious "Could not read a QR code from the image" log messages for
+ // any image input via Magic. Users can add Parse QR Code manually when
+ // they know the image contains a QR code. See issue #2610.
}
/**
diff --git a/src/node/api.mjs b/src/node/api.mjs
index 83163d37f0..a48ed3a972 100644
--- a/src/node/api.mjs
+++ b/src/node/api.mjs
@@ -77,6 +77,10 @@ function transformArgs(opArgsList, newArgs) {
return !Array.isArray(arg.value) ? arg.value : arg.value[arg.defaultIndex ?? 0];
}
+ if (arg.type === "argSelector") {
+ return !Array.isArray(arg.value) ? arg.value : (arg.value[arg.defaultIndex ?? 0]?.name || "");
+ }
+
if (arg.type === "editableOption") {
return typeof arg.value === "string" ? arg.value : arg.value[arg.defaultIndex ?? 0].value;
}
diff --git a/tests/node/index.mjs b/tests/node/index.mjs
index e53ca7b21e..454479b3b9 100644
--- a/tests/node/index.mjs
+++ b/tests/node/index.mjs
@@ -27,6 +27,7 @@ import "./tests/Categories.mjs";
import "./tests/ToHTMLEntity.mjs";
import "./tests/lib/BigIntUtils.mjs";
import "./tests/lib/ChartsProtocolPrototypePollution.mjs";
+import "./tests/ParseQRCode.mjs";
const testStatus = {
allTestsPassing: true,
diff --git a/tests/node/tests/ParseQRCode.mjs b/tests/node/tests/ParseQRCode.mjs
new file mode 100644
index 0000000000..c063946a01
--- /dev/null
+++ b/tests/node/tests/ParseQRCode.mjs
@@ -0,0 +1,35 @@
+/**
+ * ParseQRCode API tests.
+ *
+ * @author Sanjays2402
+ * @copyright Crown Copyright 2026
+ * @license Apache-2.0
+ */
+import TestRegister from "../../lib/TestRegister.mjs";
+import OperationConfig from "../../../src/core/config/OperationConfig.json" with { type: "json" };
+import it from "../assertionHandler.mjs";
+import assert from "assert";
+
+TestRegister.addApiTests([
+ /*
+ * Regression test for #2610.
+ *
+ * Parse QR Code used to declare a `checks` regex that matched any JPEG,
+ * PNG, GIF, WEBP or BMP magic bytes. Magic aggregates every operation
+ * with a `checks` property, so any image input ran through a full QR
+ * parse attempt, which in turn emitted a "Could not read a QR code from
+ * the image" warning to the browser console for every image. There is
+ * no cheap way to detect a QR code without attempting a full parse, so
+ * Parse QR Code must not participate in Magic; users can add it
+ * manually when they know an image contains a QR code.
+ */
+ it("Parse QR Code: must not participate in Magic (#2610)", () => {
+ const op = OperationConfig["Parse QR Code"];
+ assert(op, "Parse QR Code operation is missing from OperationConfig");
+ assert(
+ !op.checks || op.checks.length === 0,
+ "Parse QR Code must not declare `checks`; otherwise Magic will run a " +
+ "QR parse on every image and spam the console (see issue #2610)."
+ );
+ }),
+]);
diff --git a/tests/operations/tests/AutomatedValidation.mjs b/tests/operations/tests/AutomatedValidation.mjs
index f55efa017c..6190e2d24f 100644
--- a/tests/operations/tests/AutomatedValidation.mjs
+++ b/tests/operations/tests/AutomatedValidation.mjs
@@ -150,5 +150,38 @@ TestRegister.addTests([
args: [5, 1.5, "hello", "", { "option": "Option A", "string": "test" }, ""]
}
]
+ },
+ {
+ name: "Automated Validation: Valid Arg Selector value",
+ input: "test",
+ expectedOutput: "Success",
+ recipeConfig: [
+ {
+ op: "Automated Validation Test Op",
+ args: [5, 1.5, "hello", "", { "option": "Option A", "string": "test" }, "Option 1", "Option 2"]
+ }
+ ]
+ },
+ {
+ name: "Automated Validation: Invalid Arg Selector value",
+ input: "test",
+ expectedOutput: "Arg Selector Ingredient must be one of the following: Option 1, Option 2.",
+ recipeConfig: [
+ {
+ op: "Automated Validation Test Op",
+ args: [5, 1.5, "hello", "", { "option": "Option A", "string": "test" }, "Option 1", "Option 3"]
+ }
+ ]
+ },
+ {
+ name: "Automated Validation: Arg Selector value empty (invalid)",
+ input: "test",
+ expectedOutput: "Arg Selector Ingredient cannot be empty.",
+ recipeConfig: [
+ {
+ op: "Automated Validation Test Op",
+ args: [5, 1.5, "hello", "", { "option": "Option A", "string": "test" }, "Option 1", ""]
+ }
+ ]
}
]);
diff --git a/tests/operations/tests/Magic.mjs b/tests/operations/tests/Magic.mjs
index 90401dc19c..59486454a8 100644
--- a/tests/operations/tests/Magic.mjs
+++ b/tests/operations/tests/Magic.mjs
@@ -152,5 +152,49 @@ TestRegister.addTests([
args: [1, false, false]
}
]
+ },
+ {
+ name: "Magic: invalid zero A1Z26 values do not match",
+ input: "0,0",
+ unexpectedMatch: /A1Z26 Cipher Decode/,
+ recipeConfig: [
+ {
+ op: "Magic",
+ args: [0, false, false]
+ }
+ ]
+ },
+ {
+ name: "Magic: invalid high A1Z26 values do not match",
+ input: "27,1",
+ unexpectedMatch: /A1Z26 Cipher Decode/,
+ recipeConfig: [
+ {
+ op: "Magic",
+ args: [0, false, false]
+ }
+ ]
+ },
+ {
+ name: "Magic: valid leading-zero A1Z26 values match",
+ input: "26,01",
+ expectedMatch: /A1Z26 Cipher Decode/,
+ recipeConfig: [
+ {
+ op: "Magic",
+ args: [0, false, false]
+ }
+ ]
+ },
+ {
+ name: "Magic: valid leading-zero A1Z26 values match with two digits",
+ input: "09,10",
+ expectedMatch: /A1Z26 Cipher Decode/,
+ recipeConfig: [
+ {
+ op: "Magic",
+ args: [0, false, false]
+ }
+ ]
}
]);
diff --git a/tests/operations/tests/ModularExponentiation.mjs b/tests/operations/tests/ModularExponentiation.mjs
new file mode 100644
index 0000000000..4912530f07
--- /dev/null
+++ b/tests/operations/tests/ModularExponentiation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Modular Exponentiation tests.
+ *
+ * @author p-leriche [philip.leriche@cantab.net]
+ *
+ * @copyright Crown Copyright 2025
+ * @license Apache-2.0
+ */
+import TestRegister from "../../lib/TestRegister.mjs";
+
+TestRegister.addTests([
+ {
+ name: "Modular Exponentiation: basic example (2^10 mod 1000)",
+ input: "",
+ expectedOutput: "24",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["2", "1000", "10"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: small values (3^5 mod 7)",
+ input: "",
+ expectedOutput: "5",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["3", "7", "5"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: exponent zero",
+ input: "",
+ expectedOutput: "1",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["999", "100", "0"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: base one",
+ input: "",
+ expectedOutput: "1",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["1", "1000", "999"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: hexadecimal input",
+ input: "",
+ expectedOutput: "256",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["0x10", "1000", "0x2"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: using input field for base",
+ input: "5",
+ expectedOutput: "6",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["", "7", "3"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: using input field for exponent",
+ input: "4",
+ expectedOutput: "5",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["2", "11", ""],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: RSA-like example (small)",
+ input: "",
+ expectedOutput: "561",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["123", "1000", "456"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: large base and exponent",
+ input: "",
+ expectedOutput: "560583526",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["123456789", "1000000007", "65537"],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: crypto-sized numbers (RSA-2048 simulation)",
+ input: "",
+ expectedOutput: "1",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: [
+ "12345678901234567890123456789012345678901234567890",
+ "99999999999999999999999999999999999999999999999999",
+ "0"
+ ],
+ },
+ ],
+ },
+ {
+ name: "Modular Exponentiation: Fermat's Little Theorem (a^(p-1) mod p = 1)",
+ input: "",
+ expectedOutput: "1",
+ recipeConfig: [
+ {
+ op: "Modular Exponentiation",
+ args: ["3", "11", "10"],
+ },
+ ],
+ },
+]);