From 346dff9a2d275a3ea2c303a7c7e4a2cfd0fc8763 Mon Sep 17 00:00:00 2001 From: skywalker Date: Sat, 6 Jun 2026 19:27:02 +0800 Subject: [PATCH] add yEnc operations --- src/core/config/Categories.json | 2 + src/core/lib/YEnc.mjs | 200 +++++++++++++++++++++++++++++++ src/core/operations/FromYEnc.mjs | 48 ++++++++ src/core/operations/ToYEnc.mjs | 52 ++++++++ tests/operations/index.mjs | 1 + tests/operations/tests/Magic.mjs | 11 ++ tests/operations/tests/YEnc.mjs | 89 ++++++++++++++ 7 files changed, 403 insertions(+) create mode 100644 src/core/lib/YEnc.mjs create mode 100644 src/core/operations/FromYEnc.mjs create mode 100644 src/core/operations/ToYEnc.mjs create mode 100644 tests/operations/tests/YEnc.mjs diff --git a/src/core/config/Categories.json b/src/core/config/Categories.json index d3e7648a..35a1bd50 100644 --- a/src/core/config/Categories.json +++ b/src/core/config/Categories.json @@ -52,6 +52,8 @@ "Escape Smart Characters", "To Quoted Printable", "From Quoted Printable", + "To yEnc", + "From yEnc", "To Punycode", "From Punycode", "AMF Encode", diff --git a/src/core/lib/YEnc.mjs b/src/core/lib/YEnc.mjs new file mode 100644 index 00000000..c6202cf3 --- /dev/null +++ b/src/core/lib/YEnc.mjs @@ -0,0 +1,200 @@ +/** + * yEnc functions. + * + * @author skyswordw + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import OperationError from "../errors/OperationError.mjs"; + +const OFFSET = 42, + ESCAPE_OFFSET = 64, + ESCAPE_BYTE = 0x3d, + CRITICAL_BYTES = new Set([0x00, 0x0a, 0x0d, ESCAPE_BYTE]); + +/** + * Encode the input byte array as a single-part yEnc block. + * + * @param {ArrayBuffer|Uint8Array|byteArray} input + * @param {number} lineLength + * @param {string} filename + * @returns {string} + */ +export function toYEnc(input, lineLength=128, filename="file.bin") { + const data = input instanceof Uint8Array ? input : new Uint8Array(input), + name = sanitiseFilename(filename); + lineLength = validateLineLength(lineLength); + + const lines = []; + let line = "", + lineBytes = 0; + + for (let i = 0; i < data.length; i++) { + if (lineBytes >= lineLength) { + lines.push(line); + line = ""; + lineBytes = 0; + } + + line += encodeByte(data[i]); + lineBytes++; + } + + if (line.length > 0 || data.length === 0) { + lines.push(line); + } + + return [ + `=ybegin line=${lineLength} size=${data.length} name=${name}`, + ...lines, + `=yend size=${data.length}` + ].join("\r\n"); +} + +/** + * Decode a single yEnc block. + * + * @param {string} input + * @returns {byteArray} + */ +export function fromYEnc(input) { + const lines = input.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"), + headerIndex = lines.findIndex(line => line.startsWith("=ybegin ")); + + if (headerIndex < 0) { + throw new OperationError("Could not find yEnc header."); + } + + const header = parseHeader(lines[headerIndex]), + dataStart = lines[headerIndex + 1]?.startsWith("=ypart ") ? headerIndex + 2 : headerIndex + 1, + endIndex = lines.findIndex((line, index) => index >= dataStart && line.startsWith("=yend ")); + + if (endIndex < 0) { + throw new OperationError("Could not find yEnc trailer."); + } + + const trailer = parseTrailer(lines[endIndex]), + output = decodeData(lines.slice(dataStart, endIndex).join("")); + + if (!header.multipart && header.size !== output.length) { + throw new OperationError(`Decoded size ${output.length} does not match yEnc header size ${header.size}.`); + } + + if (trailer.size !== output.length) { + throw new OperationError(`Decoded size ${output.length} does not match yEnc trailer size ${trailer.size}.`); + } + + return output; +} + +/** + * @param {number} byte + * @returns {string} + */ +function encodeByte(byte) { + let encodedByte = (byte + OFFSET) & 0xff; + + if (CRITICAL_BYTES.has(encodedByte)) { + encodedByte = (encodedByte + ESCAPE_OFFSET) & 0xff; + return "=" + String.fromCharCode(encodedByte); + } + + return String.fromCharCode(encodedByte); +} + +/** + * @param {string} data + * @returns {byteArray} + */ +function decodeData(data) { + const output = []; + + for (let i = 0; i < data.length; i++) { + let encodedByte = data.charCodeAt(i) & 0xff; + + if (encodedByte === ESCAPE_BYTE) { + i++; + if (i >= data.length) { + throw new OperationError("Invalid yEnc escape sequence."); + } + encodedByte = ((data.charCodeAt(i) & 0xff) - ESCAPE_OFFSET) & 0xff; + } + + output.push((encodedByte - OFFSET) & 0xff); + } + + return output; +} + +/** + * @param {string} line + * @returns {{size: number, multipart: boolean}} + */ +function parseHeader(line) { + const lineSize = matchInteger(line, "line"), + size = matchInteger(line, "size"); + + if (lineSize < 1 || lineSize > 998) { + throw new OperationError("Invalid yEnc line length."); + } + + if (!/\sname=.+$/.test(line)) { + throw new OperationError("Invalid yEnc header: missing name."); + } + + return { + size, + multipart: /\spart=\d+\b/.test(line) + }; +} + +/** + * @param {string} line + * @returns {{size: number}} + */ +function parseTrailer(line) { + return { + size: matchInteger(line, "size") + }; +} + +/** + * @param {string} line + * @param {string} parameter + * @returns {number} + */ +function matchInteger(line, parameter) { + const match = line.match(new RegExp(`(?:^|\\s)${parameter}=(\\d+)(?:\\s|$)`)); + if (!match) { + throw new OperationError(`Invalid yEnc block: missing ${parameter}.`); + } + + const value = Number(match[1]); + if (!Number.isSafeInteger(value)) { + throw new OperationError(`Invalid yEnc ${parameter} value.`); + } + + return value; +} + +/** + * @param {number} lineLength + * @returns {number} + */ +function validateLineLength(lineLength) { + lineLength = Number(lineLength); + if (!Number.isInteger(lineLength) || lineLength < 1 || lineLength > 998) { + throw new OperationError("Line length must be an integer between 1 and 998."); + } + return lineLength; +} + +/** + * @param {string} filename + * @returns {string} + */ +function sanitiseFilename(filename) { + filename = (filename || "file.bin").replace(/[\r\n]/g, " ").trim(); + return filename || "file.bin"; +} diff --git a/src/core/operations/FromYEnc.mjs b/src/core/operations/FromYEnc.mjs new file mode 100644 index 00000000..3c6c9e03 --- /dev/null +++ b/src/core/operations/FromYEnc.mjs @@ -0,0 +1,48 @@ +/** + * @author skyswordw + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import { fromYEnc } from "../lib/YEnc.mjs"; + +/** + * From yEnc operation + */ +class FromYEnc extends Operation { + + /** + * FromYEnc constructor + */ + constructor() { + super(); + + this.name = "From yEnc"; + this.module = "Default"; + this.description = "Decodes yEnc data blocks back to their original bytes."; + this.infoURL = "http://www.yenc.org/yEnc1-formal1.txt"; + this.inputType = "string"; + this.outputType = "byteArray"; + this.args = []; + this.checks = [ + { + pattern: "(?:^|\\r?\\n)=ybegin\\s+.*\\bline=\\d+\\b.*\\bsize=\\d+\\b.*\\bname=.+\\r?\\n[\\s\\S]*\\r?\\n=yend\\s+.*\\bsize=\\d+\\b", + flags: "", + args: [] + } + ]; + } + + /** + * @param {string} input + * @param {Object[]} args + * @returns {byteArray} + */ + run(input, args) { + return fromYEnc(input); + } + +} + +export default FromYEnc; diff --git a/src/core/operations/ToYEnc.mjs b/src/core/operations/ToYEnc.mjs new file mode 100644 index 00000000..d048d36b --- /dev/null +++ b/src/core/operations/ToYEnc.mjs @@ -0,0 +1,52 @@ +/** + * @author skyswordw + * @copyright Crown Copyright 2026 + * @license Apache-2.0 + */ + +import Operation from "../Operation.mjs"; +import { toYEnc } from "../lib/YEnc.mjs"; + +/** + * To yEnc operation + */ +class ToYEnc extends Operation { + + /** + * ToYEnc constructor + */ + constructor() { + super(); + + this.name = "To yEnc"; + this.module = "Default"; + this.description = "Encodes data using yEnc, an 8-bit binary-to-text encoding commonly used on Usenet."; + this.infoURL = "http://www.yenc.org/yEnc1-formal1.txt"; + this.inputType = "ArrayBuffer"; + this.outputType = "string"; + this.args = [ + { + name: "Filename", + type: "string", + value: "file.bin" + }, + { + name: "Line length", + type: "number", + value: 128 + } + ]; + } + + /** + * @param {ArrayBuffer} input + * @param {Object[]} args + * @returns {string} + */ + run(input, args) { + return toYEnc(input, args[1], args[0]); + } + +} + +export default ToYEnc; diff --git a/tests/operations/index.mjs b/tests/operations/index.mjs index 44792560..5df55917 100644 --- a/tests/operations/index.mjs +++ b/tests/operations/index.mjs @@ -197,6 +197,7 @@ import "./tests/JSONtoYAML.mjs"; // Cannot test operations that use the File type yet // import "./tests/SplitColourChannels.mjs"; import "./tests/YARA.mjs"; +import "./tests/YEnc.mjs"; import "./tests/ParseCSR.mjs"; import "./tests/XXTEA.mjs"; diff --git a/tests/operations/tests/Magic.mjs b/tests/operations/tests/Magic.mjs index 90401dc1..d8f0a7d6 100644 --- a/tests/operations/tests/Magic.mjs +++ b/tests/operations/tests/Magic.mjs @@ -76,6 +76,17 @@ TestRegister.addTests([ } ], }, + { + name: "Magic: yEnc", + input: "before\r\n=ybegin line=128 size=3 name=test.bin\r\nklm\r\n=yend size=3\r\nafter", + expectedMatch: /#recipe=From_yEnc\(\)"/, + recipeConfig: [ + { + op: "Magic", + args: [1, false, false] + } + ], + }, { name: "Magic Chain: Hex -> Hexdump -> Base64", input: "MDAwMDAwMDAgIDM3IDM0IDIwIDM2IDM1IDIwIDM3IDMzIDIwIDM3IDM0IDIwIDMyIDMwIDIwIDM3ICB8NzQgNjUgNzMgNzQgMjAgN3wKMDAwMDAwMTAgIDMzIDIwIDM3IDM0IDIwIDM3IDMyIDIwIDM2IDM5IDIwIDM2IDY1IDIwIDM2IDM3ICB8MyA3NCA3MiA2OSA2ZSA2N3w=", diff --git a/tests/operations/tests/YEnc.mjs b/tests/operations/tests/YEnc.mjs new file mode 100644 index 00000000..62205c15 --- /dev/null +++ b/tests/operations/tests/YEnc.mjs @@ -0,0 +1,89 @@ +/** + * yEnc tests. + * + * @author skyswordw + * @copyright Crown Copyright 2026 + * @licence Apache-2.0 + */ +import TestRegister from "../../lib/TestRegister.mjs"; + +TestRegister.addTests([ + { + name: "To yEnc", + input: "ABC", + expectedOutput: "=ybegin line=128 size=3 name=test.bin\r\nklm\r\n=yend size=3", + recipeConfig: [ + { + op: "To yEnc", + args: ["test.bin", 128] + } + ] + }, + { + name: "From yEnc", + input: "=ybegin line=128 size=3 name=test.bin\r\nklm\r\n=yend size=3", + expectedOutput: "ABC", + recipeConfig: [ + { + op: "From yEnc", + args: [] + } + ] + }, + { + name: "To yEnc line length", + input: "ABCD", + expectedOutput: "=ybegin line=2 size=4 name=test.bin\r\nkl\r\nmn\r\n=yend size=4", + recipeConfig: [ + { + op: "To yEnc", + args: ["test.bin", 2] + } + ] + }, + { + name: "yEnc round trip with escaped byte", + input: "13", + expectedOutput: "13", + recipeConfig: [ + { + op: "From Hex", + args: ["None"] + }, + { + op: "To yEnc", + args: ["escaped.bin", 128] + }, + { + op: "From yEnc", + args: [] + }, + { + op: "To Hex", + args: ["None", 0] + } + ] + }, + { + name: "From yEnc ignores surrounding text", + input: "before\r\n=ybegin line=128 size=3 name=test.bin\r\nklm\r\n=yend size=3\r\nafter", + expectedOutput: "ABC", + recipeConfig: [ + { + op: "From yEnc", + args: [] + } + ] + }, + { + name: "From yEnc rejects size mismatch", + input: "=ybegin line=128 size=4 name=test.bin\r\nklm\r\n=yend size=3", + expectedOutput: "Decoded size 3 does not match yEnc header size 4.", + recipeConfig: [ + { + op: "From yEnc", + args: [] + } + ] + } +]);