add yEnc operations
This commit is contained in:
parent
d735496641
commit
346dff9a2d
@ -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
200
src/core/lib/YEnc.mjs
Normal 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";
|
||||
}
|
||||
48
src/core/operations/FromYEnc.mjs
Normal file
48
src/core/operations/FromYEnc.mjs
Normal 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;
|
||||
52
src/core/operations/ToYEnc.mjs
Normal file
52
src/core/operations/ToYEnc.mjs
Normal 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;
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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=",
|
||||
|
||||
89
tests/operations/tests/YEnc.mjs
Normal file
89
tests/operations/tests/YEnc.mjs
Normal 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: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
Loading…
x
Reference in New Issue
Block a user