libbase64 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,9 @@ on:
2
2
  push:
3
3
  branches:
4
4
  - master
5
+ # Manual run: publish the version currently on master (used when a release was cut
6
+ # but the publish step failed)
7
+ workflow_dispatch:
5
8
 
6
9
  permissions:
7
10
  contents: write
@@ -15,23 +18,22 @@ jobs:
15
18
  steps:
16
19
  - uses: google-github-actions/release-please-action@v3
17
20
  id: release
21
+ if: ${{ github.event_name == 'push' }}
18
22
  with:
19
23
  release-type: node
20
24
  package-name: ${{vars.NPM_MODULE_NAME}}
21
25
  pull-request-title-pattern: 'chore${scope}: release ${version} [skip-ci]'
22
- # The logic below handles the npm publication:
26
+ # The logic below handles the npm publication through npm trusted publishing
27
+ # (OIDC, no token secret): it runs when a new release is created, or on a
28
+ # manual dispatch.
23
29
  - uses: actions/checkout@v4
24
- # these if statements ensure that a publication only occurs when
25
- # a new release is created:
26
- if: ${{ steps.release.outputs.release_created }}
30
+ if: ${{ steps.release.outputs.release_created || github.event_name == 'workflow_dispatch' }}
27
31
  - uses: actions/setup-node@v4
28
32
  with:
29
- node-version: 20
33
+ node-version: 24
30
34
  registry-url: 'https://registry.npmjs.org'
31
- if: ${{ steps.release.outputs.release_created }}
35
+ if: ${{ steps.release.outputs.release_created || github.event_name == 'workflow_dispatch' }}
32
36
  - run: npm ci
33
- if: ${{ steps.release.outputs.release_created }}
37
+ if: ${{ steps.release.outputs.release_created || github.event_name == 'workflow_dispatch' }}
34
38
  - run: npm publish --provenance --access public
35
- env:
36
- NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
37
- if: ${{ steps.release.outputs.release_created }}
39
+ if: ${{ steps.release.outputs.release_created || github.event_name == 'workflow_dispatch' }}
package/.ncurc.js CHANGED
@@ -1,7 +1,13 @@
1
1
  module.exports = {
2
2
  upgrade: true,
3
3
  reject: [
4
+ // 12 is esm only ("type": "module"), and grunt-mocha-test require()s it as a
5
+ // constructor - "Mocha is not a constructor". Node 24 hides this through
6
+ // require(esm); CI on Node 22 does not. Lift it when the runner drops grunt.
7
+ 'mocha',
4
8
  // 5x is esm only
5
- 'chai'
9
+ 'chai',
10
+ // api changes in newer eslint
11
+ 'grunt-eslint'
6
12
  ]
7
13
  };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.2](https://github.com/nodemailer/libbase64/compare/v1.3.1...v1.3.2) (2026-10-04)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **encoder:** wrap base64 lines without regular expressions, independent of chunk boundaries ([2c76a48](https://github.com/nodemailer/libbase64/commit/2c76a489f1444600b5645a44c933e159ac8e920f))
9
+
10
+ ## [1.3.1](https://github.com/nodemailer/libbase64/compare/v1.3.0...v1.3.1) (2026-09-28)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **decoder:** decode every padded segment instead of stopping at the first padding ([d9ba154](https://github.com/nodemailer/libbase64/commit/d9ba154ba13e45338936a55acec417dfb6ae2e52))
16
+
3
17
  ## [1.3.0](https://github.com/nodemailer/libbase64/compare/v1.2.1...v1.3.0) (2024-02-23)
4
18
 
5
19
 
package/lib/libbase64.js CHANGED
@@ -26,9 +26,71 @@ function encode(buffer) {
26
26
  */
27
27
  function decode(str) {
28
28
  str = str || '';
29
+
30
+ // Buffer.from() stops decoding at the first padding char, so input made of several
31
+ // padded segments (eg. every line padded on its own) is decoded segment by segment
32
+ if (typeof str === 'string') {
33
+ let padPos = str.indexOf('=');
34
+ if (padPos >= 0 && /[a-zA-Z0-9+/\-_]/.test(str.substr(padPos))) {
35
+ let parts = [];
36
+ for (let segment of str.split(/[=]+/)) {
37
+ if (segment) {
38
+ parts.push(Buffer.from(segment, 'base64'));
39
+ }
40
+ }
41
+ return Buffer.concat(parts);
42
+ }
43
+ }
44
+
29
45
  return Buffer.from(str, 'base64');
30
46
  }
31
47
 
48
+ /**
49
+ * Turns a line length option into a whole number of characters, the default for anything unusable
50
+ */
51
+ function normalizeLineLength(lineLength) {
52
+ lineLength = Math.floor(Number(lineLength));
53
+ return Number.isFinite(lineLength) && lineLength >= 1 ? lineLength : 76;
54
+ }
55
+
56
+ /**
57
+ * Splits the bytes of `src` into lines of `lineLength` bytes, each followed by a line break. With
58
+ * `final` set the last line, which may be shorter, gets no line break; otherwise only complete lines
59
+ * are taken and the rest is left for the caller
60
+ *
61
+ * @param {Buffer} src Bytes to wrap
62
+ * @param {Number} lineLength Line length
63
+ * @param {Boolean} final Whether `src` ends the output
64
+ * @returns {Object} `{ output, rest }`: the wrapped bytes and the number of trailing bytes not taken
65
+ */
66
+ function wrapBuffer(src, lineLength, final) {
67
+ let lines = Math.ceil(src.length / lineLength);
68
+ // the last line waits for more data unless this is the end: whether it gets a line break depends on
69
+ // whether anything follows it
70
+ let complete = Math.max(lines - 1, 0);
71
+ let rest = src.length - complete * lineLength;
72
+
73
+ let output = Buffer.allocUnsafe(complete * (lineLength + 2) + (final ? rest : 0));
74
+ let to = 0;
75
+ for (let from = 0; from < complete * lineLength; from += lineLength) {
76
+ src.copy(output, to, from, from + lineLength);
77
+ to += lineLength;
78
+ output[to++] = 0x0d;
79
+ output[to++] = 0x0a;
80
+ }
81
+ if (final) {
82
+ to += src.copy(output, to, complete * lineLength);
83
+ rest = 0;
84
+ }
85
+
86
+ if (to !== output.length) {
87
+ // never hand out bytes of the unfilled allocation
88
+ throw new Error('Unexpected wrapped length');
89
+ }
90
+
91
+ return { output, rest };
92
+ }
93
+
32
94
  /**
33
95
  * Adds soft line breaks to a base64 string
34
96
  *
@@ -38,12 +100,26 @@ function decode(str) {
38
100
  */
39
101
  function wrap(str, lineLength) {
40
102
  str = (str || '').toString();
41
- lineLength = lineLength || 76;
103
+ lineLength = normalizeLineLength(lineLength);
42
104
 
43
105
  if (str.length <= lineLength) {
44
106
  return str;
45
107
  }
46
108
 
109
+ // eslint-disable-next-line no-control-regex
110
+ if (/[^\u0000-\u00ff]|[\r\n]/.test(str) || str.trim() !== str) {
111
+ // not a plain base64 string, keep the line based behaviour for whatever this is
112
+ return legacyWrap(str, lineLength);
113
+ }
114
+
115
+ return wrapBuffer(Buffer.from(str, 'latin1'), lineLength, true).output.toString('latin1');
116
+ }
117
+
118
+ /**
119
+ * Line wrapping for input that is not a plain base64 string: line breaks already in the input end a
120
+ * line, and surrounding whitespace is trimmed
121
+ */
122
+ function legacyWrap(str, lineLength) {
47
123
  let result = [];
48
124
  let pos = 0;
49
125
  let chunkLength = lineLength * 1024;
@@ -62,9 +138,15 @@ function wrap(str, lineLength) {
62
138
  /**
63
139
  * Creates a transform stream for encoding data to base64 encoding
64
140
  *
141
+ * The output is the same as `wrap(encode(input), lineLength)` no matter how the input is split into
142
+ * chunks: every line but the last one ends with a line break, the last one does not
143
+ *
65
144
  * @constructor
66
145
  * @param {Object} options Stream options
67
146
  * @param {Number} [options.lineLength=76] Maximum lenght for lines, set to false to disable wrapping
147
+ * @param {Number} [options.skipStartBytes] Number of output bytes to drop from the start
148
+ * @param {Number} [options.limitOutputBytes] Maximum number of output bytes to emit
149
+ * @param {String} [options.startPadding] Characters to prepend to the first line before wrapping
68
150
  */
69
151
  class Encoder extends Transform {
70
152
  constructor(options) {
@@ -73,40 +155,42 @@ class Encoder extends Transform {
73
155
  this.options = options || {};
74
156
 
75
157
  if (this.options.lineLength !== false) {
76
- this.options.lineLength = Number(this.options.lineLength) || 76;
158
+ this.options.lineLength = normalizeLineLength(this.options.lineLength);
77
159
  }
78
160
 
79
161
  this.skipStartBytes = Number(this.options.skipStartBytes) || 0;
80
- this.limitOutbutBytes = Number(this.options.limitOutbutBytes) || 0;
162
+ // `limitOutbutBytes` is the name earlier versions read
163
+ this.limitOutputBytes = Number(this.options.limitOutputBytes || this.options.limitOutbutBytes) || 0;
81
164
 
82
- // startPadding can be used together with skipStartBytes
165
+ // encoded characters of the line that is not complete yet. startPadding can be used together
166
+ // with skipStartBytes
83
167
  this._curLine = this.options.startPadding || '';
84
- this._remainingBytes = false;
168
+ // input bytes that do not make up a complete base64 group yet
169
+ this._remainingBytes = null;
85
170
 
86
171
  this.inputBytes = 0;
87
172
  this.outputBytes = 0;
88
173
  }
89
174
 
90
- _writeChunk(chunk /*, isFinal */) {
175
+ _writeChunk(chunk) {
91
176
  if (this.skipStartBytes) {
92
177
  if (chunk.length <= this.skipStartBytes) {
93
178
  this.skipStartBytes -= chunk.length;
94
179
  return;
95
180
  }
96
181
 
97
- chunk = chunk.slice(this.skipStartBytes);
182
+ chunk = chunk.subarray(this.skipStartBytes);
98
183
  this.skipStartBytes = 0;
99
184
  }
100
185
 
101
- if (this.limitOutbutBytes) {
102
- if (this.outputBytes + chunk.length <= this.limitOutbutBytes) {
103
- // ignore, can use entire chunk
104
- } else if (this.outputBytes >= this.limitOutbutBytes) {
186
+ if (this.limitOutputBytes) {
187
+ if (this.outputBytes >= this.limitOutputBytes) {
105
188
  // chunks already processed
106
189
  return;
107
- } else {
190
+ }
191
+ if (this.outputBytes + chunk.length > this.limitOutputBytes) {
108
192
  // use partial chunk
109
- chunk = chunk.slice(0, this.limitOutbutBytes - this.outputBytes);
193
+ chunk = chunk.subarray(0, this.limitOutputBytes - this.outputBytes);
110
194
  }
111
195
  }
112
196
 
@@ -114,12 +198,26 @@ class Encoder extends Transform {
114
198
  this.push(chunk);
115
199
  }
116
200
 
117
- _getWrapped(str, isFinal) {
118
- str = wrap(str, this.options.lineLength);
119
- if (!isFinal && str.length === this.options.lineLength) {
120
- str += '\r\n';
201
+ /**
202
+ * Emits the encoded characters `b64` that follow the current line, keeping what can not be emitted
203
+ * yet as the new current line
204
+ */
205
+ _emit(b64, final) {
206
+ let src = Buffer.from(this._curLine + b64, 'latin1');
207
+ if (!src.length) {
208
+ return;
209
+ }
210
+
211
+ if (!this.options.lineLength) {
212
+ this._curLine = '';
213
+ return this._writeChunk(src);
214
+ }
215
+
216
+ let { output, rest } = wrapBuffer(src, this.options.lineLength, final);
217
+ this._curLine = rest ? src.toString('latin1', src.length - rest) : '';
218
+ if (output.length) {
219
+ this._writeChunk(output);
121
220
  }
122
- return str;
123
221
  }
124
222
 
125
223
  _transform(chunk, encoding, done) {
@@ -128,58 +226,28 @@ class Encoder extends Transform {
128
226
  }
129
227
 
130
228
  if (!chunk || !chunk.length) {
131
- return setImmediate(done);
229
+ return done();
132
230
  }
133
231
 
134
232
  this.inputBytes += chunk.length;
135
233
 
136
- if (this._remainingBytes && this._remainingBytes.length) {
234
+ if (this._remainingBytes) {
137
235
  chunk = Buffer.concat([this._remainingBytes, chunk], this._remainingBytes.length + chunk.length);
138
- this._remainingBytes = false;
139
- }
140
-
141
- if (chunk.length % 3) {
142
- this._remainingBytes = chunk.slice(chunk.length - (chunk.length % 3));
143
- chunk = chunk.slice(0, chunk.length - (chunk.length % 3));
144
- } else {
145
- this._remainingBytes = false;
146
- }
147
-
148
- let b64 = this._curLine + encode(chunk);
149
-
150
- if (this.options.lineLength) {
151
- b64 = this._getWrapped(b64);
152
-
153
- // remove last line as it is still most probably incomplete
154
- let lastLF = b64.lastIndexOf('\n');
155
- if (lastLF < 0) {
156
- this._curLine = b64;
157
- b64 = '';
158
- } else if (lastLF === b64.length - 1) {
159
- this._curLine = '';
160
- } else {
161
- this._curLine = b64.substr(lastLF + 1);
162
- b64 = b64.substr(0, lastLF + 1);
163
- }
236
+ this._remainingBytes = null;
164
237
  }
165
238
 
166
- if (b64) {
167
- this._writeChunk(Buffer.from(b64, 'ascii'), false);
239
+ let extra = chunk.length % 3;
240
+ if (extra) {
241
+ this._remainingBytes = chunk.subarray(chunk.length - extra);
242
+ chunk = chunk.subarray(0, chunk.length - extra);
168
243
  }
169
244
 
170
- setImmediate(done);
245
+ this._emit(encode(chunk), false);
246
+ done();
171
247
  }
172
248
 
173
249
  _flush(done) {
174
- if (this._remainingBytes && this._remainingBytes.length) {
175
- this._curLine += encode(this._remainingBytes);
176
- }
177
-
178
- if (this._curLine) {
179
- this._curLine = this._getWrapped(this._curLine, true);
180
- this._writeChunk(Buffer.from(this._curLine, 'ascii'), true);
181
- this._curLine = '';
182
- }
250
+ this._emit(this._remainingBytes ? encode(this._remainingBytes) : '', true);
183
251
  done();
184
252
  }
185
253
  }
@@ -215,6 +283,15 @@ class Decoder extends Transform {
215
283
  b64 = b64.replace(/[^a-zA-Z0-9+/=]/g, '');
216
284
  }
217
285
 
286
+ // everything up to the last padding char ends in complete segments, quartets are
287
+ // only counted from there on as a padding run restarts the quartet alignment
288
+ let padded = '';
289
+ let lastPad = b64.lastIndexOf('=');
290
+ if (lastPad >= 0) {
291
+ padded = b64.substr(0, lastPad + 1);
292
+ b64 = b64.substr(lastPad + 1);
293
+ }
294
+
218
295
  if (b64.length < 4) {
219
296
  this._curLine = b64;
220
297
  b64 = '';
@@ -223,6 +300,8 @@ class Decoder extends Transform {
223
300
  b64 = b64.substr(0, b64.length - this._curLine.length);
224
301
  }
225
302
 
303
+ b64 = padded + b64;
304
+
226
305
  if (b64) {
227
306
  let buf = decode(b64);
228
307
  this.outputBytes += buf.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libbase64",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "Encode and decode base64 encoded strings",
5
5
  "main": "lib/libbase64.js",
6
6
  "scripts": {
@@ -24,9 +24,9 @@
24
24
  "devDependencies": {
25
25
  "chai": "4.4.1",
26
26
  "eslint-config-nodemailer": "1.2.0",
27
- "eslint-config-prettier": "9.1.0",
28
- "grunt": "1.6.1",
29
- "grunt-cli": "1.4.3",
27
+ "eslint-config-prettier": "10.1.8",
28
+ "grunt": "1.6.3",
29
+ "grunt-cli": "1.5.0",
30
30
  "grunt-eslint": "24.3.0",
31
31
  "grunt-mocha-test": "0.13.3",
32
32
  "mocha": "10.3.0"