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",
"To Quoted Printable",
"From Quoted Printable",
"To yEnc",
"From yEnc",
"To Punycode",
"From Punycode",
"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
// import "./tests/SplitColourChannels.mjs";
import "./tests/YARA.mjs";
import "./tests/YEnc.mjs";
import "./tests/ParseCSR.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",
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: []
}
]
}
]);