add yEnc operations

This commit is contained in:
skywalker 2026-06-06 19:27:02 +08:00
parent d735496641
commit 346dff9a2d
7 changed files with 403 additions and 0 deletions

View File

@ -52,6 +52,8 @@
"Escape Smart Characters", "Escape Smart Characters",
"To Quoted Printable", "To Quoted Printable",
"From Quoted Printable", "From Quoted Printable",
"To yEnc",
"From yEnc",
"To Punycode", "To Punycode",
"From Punycode", "From Punycode",
"AMF Encode", "AMF Encode",

200
src/core/lib/YEnc.mjs Normal file
View File

@ -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";
}

View File

@ -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;

View File

@ -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;

View File

@ -197,6 +197,7 @@ import "./tests/JSONtoYAML.mjs";
// Cannot test operations that use the File type yet // Cannot test operations that use the File type yet
// import "./tests/SplitColourChannels.mjs"; // import "./tests/SplitColourChannels.mjs";
import "./tests/YARA.mjs"; import "./tests/YARA.mjs";
import "./tests/YEnc.mjs";
import "./tests/ParseCSR.mjs"; import "./tests/ParseCSR.mjs";
import "./tests/XXTEA.mjs"; import "./tests/XXTEA.mjs";

View File

@ -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", name: "Magic Chain: Hex -> Hexdump -> Base64",
input: "MDAwMDAwMDAgIDM3IDM0IDIwIDM2IDM1IDIwIDM3IDMzIDIwIDM3IDM0IDIwIDMyIDMwIDIwIDM3ICB8NzQgNjUgNzMgNzQgMjAgN3wKMDAwMDAwMTAgIDMzIDIwIDM3IDM0IDIwIDM3IDMyIDIwIDM2IDM5IDIwIDM2IDY1IDIwIDM2IDM3ICB8MyA3NCA3MiA2OSA2ZSA2N3w=", input: "MDAwMDAwMDAgIDM3IDM0IDIwIDM2IDM1IDIwIDM3IDMzIDIwIDM3IDM0IDIwIDMyIDMwIDIwIDM3ICB8NzQgNjUgNzMgNzQgMjAgN3wKMDAwMDAwMTAgIDMzIDIwIDM3IDM0IDIwIDM3IDMyIDIwIDM2IDM5IDIwIDM2IDY1IDIwIDM2IDM3ICB8MyA3NCA3MiA2OSA2ZSA2N3w=",

View File

@ -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: []
}
]
}
]);