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"], + }, + ], + }, +]);