mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-09-17 17:09:30 +07:00
67ce3558d6
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
141 lines
5.1 KiB
JavaScript
141 lines
5.1 KiB
JavaScript
// streams through a message body and calculates relaxed body hash
|
|
import { Transform } from 'node:stream';
|
|
import crypto from 'node:crypto';
|
|
const CHAR_CR = 0x0d;
|
|
const CHAR_LF = 0x0a;
|
|
const CHAR_SPACE = 0x20;
|
|
const CHAR_TAB = 0x09;
|
|
const CRLF = Buffer.from('\r\n');
|
|
// a run of empty lines is hashed from this buffer in slices
|
|
const EMPTY_LINES = Buffer.alloc(4096, CRLF);
|
|
/**
|
|
* Passes the message body through unchanged and hashes its relaxed
|
|
* canonicalization (RFC 6376 section 3.4.4) on the side: whitespace at the end
|
|
* of a line is dropped, runs of whitespace within a line become a single space,
|
|
* every line ends with CRLF, empty lines at the end of the body are ignored and
|
|
* a non-empty body always ends with CRLF. Bytes are canonicalized as they arrive,
|
|
* so a line of any length costs constant memory.
|
|
*/
|
|
export default class RelaxedBody extends Transform {
|
|
constructor(options) {
|
|
super();
|
|
options = options || {};
|
|
this.bodyHash = crypto.createHash(options.hashAlgo || 'sha256');
|
|
this.byteLength = 0;
|
|
this.debug = options.debug;
|
|
this._debugBody = options.debug ? [] : false;
|
|
this._lineHasContent = false;
|
|
this._pendingWsp = false;
|
|
this._pendingCr = false;
|
|
this._pendingEmptyLines = 0;
|
|
}
|
|
/** @internal */
|
|
_hashCanonical(data) {
|
|
if (!data.length) {
|
|
return;
|
|
}
|
|
this.bodyHash.update(data);
|
|
if (this._debugBody) {
|
|
this._debugBody.push(Buffer.from(data));
|
|
}
|
|
}
|
|
/** @internal */
|
|
_hashEmptyLines() {
|
|
while (this._pendingEmptyLines > 0) {
|
|
const count = Math.min(this._pendingEmptyLines, EMPTY_LINES.length / 2);
|
|
this._hashCanonical(EMPTY_LINES.subarray(0, count * 2));
|
|
this._pendingEmptyLines -= count;
|
|
}
|
|
}
|
|
/**
|
|
* Writes a content byte, with the space a pending run of whitespace collapses to,
|
|
* into the output buffer and returns the new write position. Kept a method rather
|
|
* than a closure so the write position stays a plain local in the byte loop
|
|
* @internal
|
|
*/
|
|
_emitContent(out, outPos, c) {
|
|
if (!this._lineHasContent) {
|
|
if (this._pendingEmptyLines) {
|
|
// the first content byte of a line is where the empty lines before it
|
|
// become part of the body, so hash what is in the buffer before them
|
|
this._hashCanonical(out.subarray(0, outPos));
|
|
outPos = 0;
|
|
this._hashEmptyLines();
|
|
}
|
|
this._lineHasContent = true;
|
|
}
|
|
if (this._pendingWsp) {
|
|
out[outPos++] = CHAR_SPACE;
|
|
this._pendingWsp = false;
|
|
}
|
|
out[outPos++] = c;
|
|
return outPos;
|
|
}
|
|
updateHash(chunk, final) {
|
|
// every byte contributes itself at most once, plus a CR for a bare LF
|
|
// and, once per chunk, a pending space and CR carried over from before
|
|
const out = Buffer.allocUnsafe(chunk.length * 2 + 2);
|
|
let outPos = 0;
|
|
for (let i = 0; i < chunk.length; i++) {
|
|
const c = chunk[i];
|
|
if (c === CHAR_LF) {
|
|
// end of line, a CR right before it and any trailing whitespace are dropped
|
|
if (this._lineHasContent) {
|
|
out[outPos++] = CHAR_CR;
|
|
out[outPos++] = CHAR_LF;
|
|
this._lineHasContent = false;
|
|
}
|
|
else {
|
|
this._pendingEmptyLines++;
|
|
}
|
|
this._pendingWsp = false;
|
|
this._pendingCr = false;
|
|
continue;
|
|
}
|
|
if (this._pendingCr) {
|
|
// not followed by LF, so the CR is content
|
|
outPos = this._emitContent(out, outPos, CHAR_CR);
|
|
this._pendingCr = false;
|
|
}
|
|
if (c === CHAR_CR) {
|
|
this._pendingCr = true;
|
|
}
|
|
else if (c === CHAR_SPACE || c === CHAR_TAB) {
|
|
this._pendingWsp = true;
|
|
}
|
|
else {
|
|
outPos = this._emitContent(out, outPos, c);
|
|
}
|
|
}
|
|
if (final && this._pendingCr) {
|
|
// a CR at the very end of the body is content
|
|
outPos = this._emitContent(out, outPos, CHAR_CR);
|
|
this._pendingCr = false;
|
|
}
|
|
this._hashCanonical(out.subarray(0, outPos));
|
|
}
|
|
/** @internal */
|
|
_transform(chunk, encoding, callback) {
|
|
if (!chunk || !chunk.length) {
|
|
return callback();
|
|
}
|
|
if (typeof chunk === 'string') {
|
|
chunk = Buffer.from(chunk, encoding);
|
|
}
|
|
this.updateHash(chunk);
|
|
this.byteLength += chunk.length;
|
|
this.push(chunk);
|
|
callback();
|
|
}
|
|
/** @internal */
|
|
_flush(callback) {
|
|
this.updateHash(Buffer.alloc(0), true);
|
|
if (this._lineHasContent) {
|
|
// the body does not end with a line break, add one
|
|
this._hashCanonical(CRLF);
|
|
}
|
|
this.emit('hash', this.bodyHash.digest('base64'), this.debug ? Buffer.concat(this._debugBody) : false);
|
|
callback();
|
|
}
|
|
}
|