add yEnc operations
This commit is contained in:
parent
d735496641
commit
346dff9a2d
@ -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
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
|
// 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";
|
||||||
|
|
||||||
|
|||||||
@ -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=",
|
||||||
|
|||||||
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