@link-foundation/sensitive-data-sanitizer 0.19.3 → 0.21.0

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -2
  3. package/bin/sensitive-data-sanitizer.js +50 -28
  4. package/docs/API.md +355 -16
  5. package/docs/COVERAGE.md +19 -9
  6. package/docs/RULES.md +218 -0
  7. package/examples/second-pass-placeholders.mjs +28 -0
  8. package/package.json +12 -3
  9. package/src/allow-list.js +93 -0
  10. package/src/confidence.js +24 -9
  11. package/src/context.js +287 -40
  12. package/src/credential-spans.js +180 -0
  13. package/src/credentials-entry.d.ts +23 -0
  14. package/src/credentials-entry.js +62 -0
  15. package/src/credentials.js +51 -0
  16. package/src/crypto-addresses.js +74 -0
  17. package/src/detection.js +87 -41
  18. package/src/document-person-cues.js +1 -1
  19. package/src/encoded.js +137 -51
  20. package/src/encrypt.js +95 -0
  21. package/src/engines.js +33 -10
  22. package/src/entities.js +4 -3
  23. package/src/exempt.js +72 -0
  24. package/src/fake-memory.js +32 -0
  25. package/src/fake.js +33 -29
  26. package/src/features.js +29 -0
  27. package/src/files.js +57 -12
  28. package/src/gitleaks.js +82 -27
  29. package/src/html-tags.js +8 -0
  30. package/src/identifier-detector.js +36 -0
  31. package/src/identity-types.js +63 -0
  32. package/src/identity.js +3 -47
  33. package/src/index.d.ts +157 -3
  34. package/src/index.js +13 -0
  35. package/src/known.js +33 -15
  36. package/src/national-checks.js +209 -0
  37. package/src/national.js +310 -92
  38. package/src/pattern-detector.js +219 -0
  39. package/src/personal-context.js +3 -2
  40. package/src/personal-evidence.js +233 -0
  41. package/src/personal-features.js +39 -0
  42. package/src/personal.js +66 -10
  43. package/src/placeholder-types.js +154 -4
  44. package/src/placeholders.js +127 -45
  45. package/src/projection.js +78 -26
  46. package/src/proper-names.js +48 -5
  47. package/src/prose-words.js +120 -0
  48. package/src/record-worker.js +1 -0
  49. package/src/rule-catalog.js +426 -0
  50. package/src/rules.js +8 -0
  51. package/src/sanitizer.js +133 -23
  52. package/src/sarif.js +127 -0
  53. package/src/stream-worker.js +1 -0
  54. package/src/strict-attributes.js +48 -4
  55. package/src/strict.js +1 -1
  56. package/src/structured.js +2 -0
  57. package/src/token-checksum.js +49 -0
  58. package/src/transforms.js +146 -33
  59. package/src/vendor/betterleaks/LICENSE +21 -0
  60. package/src/vendor/betterleaks/provenance.json +11 -0
  61. package/src/vendor/betterleaks/rules-0.json +463 -0
  62. package/src/vendor/betterleaks/rules-1.json +454 -0
  63. package/src/vendor/betterleaks/rules-2.json +461 -0
  64. package/src/vendor/betterleaks/rules-3.json +455 -0
  65. package/src/vendor/betterleaks/rules-4.json +483 -0
  66. package/src/vendor/betterleaks/rules-5.json +35 -0
  67. package/src/vendor/entities/aws.json +1 -1
  68. package/src/vendor/entities/azure.json +6 -2
  69. package/src/vendor/entities/google.json +2 -1
  70. package/src/vocabulary.js +27 -1
  71. package/src/worker-options.js +1 -0
package/src/encrypt.js ADDED
@@ -0,0 +1,95 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ createHmac,
5
+ hkdfSync,
6
+ } from 'node:crypto';
7
+ import { failure } from './detection.js';
8
+
9
+ // Deterministic authenticated encryption (synthetic IV), comparable to
10
+ // Google's CryptoDeterministicConfig (AES-SIV) and Presidio's encrypt
11
+ // operator. Equal values of one type produce equal tokens, so the output
12
+ // still supports joins and counts, and decrypt() recovers the value. The
13
+ // derived IV is an HMAC of the type and value, so it never repeats for
14
+ // different plaintexts under one key.
15
+ const ivBytes = 12,
16
+ tagBytes = 16;
17
+ const token = /\[ENCRYPTED:([A-Z][A-Z0-9_]{0,63}):([A-Za-z0-9_-]{38,10000})\]/g;
18
+ const keys = new Map();
19
+
20
+ function derived(key) {
21
+ if (typeof key !== 'string' || Buffer.byteLength(key) < 16) {
22
+ throw failure('ERR_CONFIG');
23
+ }
24
+ let entry = keys.get(key);
25
+ if (!entry) {
26
+ const material = Buffer.from(
27
+ hkdfSync(
28
+ 'sha256',
29
+ key,
30
+ 'sensitive-data-sanitizer',
31
+ 'deterministic-encryption-v1',
32
+ 64
33
+ )
34
+ );
35
+ entry = { cipher: material.subarray(0, 32), mac: material.subarray(32) };
36
+ if (keys.size >= 16) {
37
+ keys.clear();
38
+ }
39
+ keys.set(key, entry);
40
+ }
41
+ return entry;
42
+ }
43
+
44
+ export function encryptValue(value, type, key) {
45
+ const { cipher, mac } = derived(key);
46
+ const iv = createHmac('sha256', mac)
47
+ .update(`${type}\0${value}`)
48
+ .digest()
49
+ .subarray(0, ivBytes);
50
+ const engine = createCipheriv('aes-256-gcm', cipher, iv);
51
+ engine.setAAD(Buffer.from(type));
52
+ const body = Buffer.concat([engine.update(value, 'utf8'), engine.final()]);
53
+ const payload = Buffer.concat([iv, body, engine.getAuthTag()]);
54
+ return `[ENCRYPTED:${type}:${payload.toString('base64url')}]`;
55
+ }
56
+
57
+ function decryptToken(type, encoded, key) {
58
+ const { cipher } = derived(key);
59
+ const payload = Buffer.from(encoded, 'base64url');
60
+ if (payload.length < ivBytes + tagBytes) {
61
+ throw failure('ERR_MAP');
62
+ }
63
+ try {
64
+ const engine = createDecipheriv(
65
+ 'aes-256-gcm',
66
+ cipher,
67
+ payload.subarray(0, ivBytes)
68
+ );
69
+ engine.setAAD(Buffer.from(type));
70
+ engine.setAuthTag(payload.subarray(-tagBytes));
71
+ return Buffer.concat([
72
+ engine.update(payload.subarray(ivBytes, -tagBytes)),
73
+ engine.final(),
74
+ ]).toString('utf8');
75
+ } catch {
76
+ throw failure('ERR_MAP');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Restores `[ENCRYPTED:TYPE:…]` tokens produced by `{mode: 'encrypt', key}`.
82
+ * A token that fails authentication under `key` throws ERR_MAP; nothing is
83
+ * partially restored. With `json: true` restored values are JSON-escaped for
84
+ * tokens inside JSON string literals.
85
+ */
86
+ export function decrypt(text, key, { json = false } = {}) {
87
+ if (typeof text !== 'string') {
88
+ throw failure('ERR_INPUT');
89
+ }
90
+ derived(key);
91
+ return text.replace(token, (_, type, encoded) => {
92
+ const value = decryptToken(type, encoded, key);
93
+ return json ? JSON.stringify(value).slice(1, -1) : value;
94
+ });
95
+ }
package/src/engines.js CHANGED
@@ -18,6 +18,7 @@ import { projectText } from './projection.js';
18
18
  import { decodedRuns } from './encoded.js';
19
19
  import { hasCustomTransforms } from './transforms.js';
20
20
  import { builtInEnabled } from './policy.js';
21
+ import { exemptFindingsAsync } from './exempt.js';
21
22
  import {
22
23
  initializePlaceholders,
23
24
  placeholderKnown,
@@ -220,10 +221,7 @@ async function analyzeStructured(
220
221
  );
221
222
  }
222
223
 
223
- export function createSanitizer(options = {}) {
224
- options = applyProfile(options);
225
- validateOptions(options);
226
- options = initializePlaceholders(options);
224
+ function configuredEngines(options) {
227
225
  for (const detector of options.detectors ?? []) {
228
226
  if (!detector || typeof detector.detect !== 'function') {
229
227
  throw failure('ERR_CONFIG');
@@ -234,6 +232,14 @@ export function createSanitizer(options = {}) {
234
232
  if (!options.knownOnly) {
235
233
  engines.push(...(options.detectors ?? []));
236
234
  }
235
+ return engines;
236
+ }
237
+
238
+ export function createSanitizer(options = {}) {
239
+ options = applyProfile(options);
240
+ validateOptions(options);
241
+ options = initializePlaceholders(options);
242
+ const engines = configuredEngines(options);
237
243
  let prepared;
238
244
  async function analyze(
239
245
  text,
@@ -266,6 +272,7 @@ export function createSanitizer(options = {}) {
266
272
  ...options,
267
273
  structured: undefined,
268
274
  detectors: [],
275
+ exempt: undefined,
269
276
  findings,
270
277
  })
271
278
  : reviewFindings(text, [...nativeFindings, ...findings], options);
@@ -274,15 +281,31 @@ export function createSanitizer(options = {}) {
274
281
  : detected;
275
282
  }
276
283
 
284
+ // The caller's `exempt` hook screens the findings of the text it sees,
285
+ // including the verification passes over the published output.
286
+ async function screened(
287
+ text,
288
+ initial,
289
+ plain,
290
+ scopedOptions = options,
291
+ nativeFindings
292
+ ) {
293
+ return exemptFindingsAsync(
294
+ text,
295
+ await analyze(text, initial, plain, scopedOptions, nativeFindings),
296
+ scopedOptions.exempt
297
+ );
298
+ }
299
+
277
300
  const sanitizer = {
278
- inspect: guarded(analyze),
301
+ inspect: guarded(screened),
279
302
  sanitize: guarded(sanitizeText, () => discardPlaceholderState(options)),
280
303
  sanitizeJsonl: (text) =>
281
304
  createSanitizer({ ...options, structured: 'jsonl' }).sanitize(text),
282
305
  };
283
306
  configurations.set(sanitizer, options);
284
307
  streamPreparers.set(sanitizer, async (text, nativeFindings) => {
285
- const findings = await analyze(
308
+ const findings = await screened(
286
309
  text,
287
310
  nativeFindings === undefined ? (options.findings ?? []) : [],
288
311
  false,
@@ -305,7 +328,7 @@ export function createSanitizer(options = {}) {
305
328
  const findings =
306
329
  depth === 0 && prepared?.text === text
307
330
  ? prepared.findings
308
- : await analyze(text, options.findings ?? [], plain, options);
331
+ : await screened(text, options.findings ?? [], plain, options);
309
332
  if (depth === 0) {
310
333
  prepared = undefined;
311
334
  }
@@ -329,7 +352,7 @@ export function createSanitizer(options = {}) {
329
352
  recursiveOptions
330
353
  )
331
354
  );
332
- if (await customResidual(result.text, options, plain, analyze)) {
355
+ if (await customResidual(result.text, options, plain, screened)) {
333
356
  throw failure('ERR_RESIDUAL');
334
357
  }
335
358
  const sanitized = fullRedaction
@@ -351,7 +374,7 @@ export function createSanitizer(options = {}) {
351
374
  ).text;
352
375
  // Verification at the publication boundary runs every enabled engine.
353
376
  if (
354
- (await analyze(sanitized, [], plain, options)).some((f) =>
377
+ (await screened(sanitized, [], plain, options)).some((f) =>
355
378
  actionable(f, options)
356
379
  )
357
380
  ) {
@@ -360,7 +383,7 @@ export function createSanitizer(options = {}) {
360
383
  );
361
384
  }
362
385
  verifyStructuredResult(result.text, options, plain);
363
- if (await placeholderResidual(result.text, options, plain, analyze)) {
386
+ if (await placeholderResidual(result.text, options, plain, screened)) {
364
387
  throw failure('ERR_RESIDUAL');
365
388
  }
366
389
  return { ...result, findings: auditFaked(findings, options, result) };
package/src/entities.js CHANGED
@@ -3,6 +3,7 @@ import azure from './vendor/entities/azure.json' with { type: 'json' };
3
3
  import aws from './vendor/entities/aws.json' with { type: 'json' };
4
4
  import { escapePattern, collectMatches } from './detection.js';
5
5
  import { numericNoise, epochNumber } from './confidence.js';
6
+ import { cryptoAddress } from './crypto-addresses.js';
6
7
  import { valueRange, personalValueAllowed } from './context.js';
7
8
  export const entityCatalogs = { google, azure, aws };
8
9
  const canonical = (name) =>
@@ -161,13 +162,13 @@ const formatRules = [
161
162
  ],
162
163
  [
163
164
  'SPAIN_DNI_NUMBER',
164
- /\b\d{8}[A-Z]\b/g,
165
+ /\b\d{8}-?[A-Z]\b/g,
165
166
  (v) => 'TRWAGMYFPDXBNJZSQVHLCKE'[Number(v.slice(0, 8)) % 23] === v.at(-1),
166
167
  ],
167
168
  [
168
169
  'CRYPTO_WALLET',
169
- /\b(?:0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[ac-hj-np-z02-9]{25,62})\b/g,
170
- () => true,
170
+ /\b(?:0x[a-fA-F0-9]{40}|[13][a-km-zA-HJ-NP-Z1-9]{25,34}|bc1[ac-hj-np-z02-9]{25,62}|BC1[AC-HJ-NP-Z02-9]{25,62})\b/g,
171
+ cryptoAddress,
171
172
  ],
172
173
  ['US_HEALTHCARE_NPI', /\b[12]\d{9}\b/g, npi],
173
174
  ];
package/src/exempt.js ADDED
@@ -0,0 +1,72 @@
1
+ import { failure } from './detection.js';
2
+
3
+ // `exempt(finding, text) => boolean` keeps findings the caller's policy
4
+ // knows are not sensitive (a configuration flag, a counter, a value another
5
+ // tool already masked). It runs after detection and before transformation.
6
+ // The hook decides per span: verification of the published text consults it
7
+ // again for each finding in the output, so the same value in another place
8
+ // is still caught unless the hook keeps that span too.
9
+ // Values the caller declared (`knownSecrets`, `knownPersonal`,
10
+ // `confirmedPersonal`) and structured `JSON_VALUE` denials are never offered.
11
+ const declared = new Set([
12
+ 'known-secret',
13
+ 'known-personal',
14
+ 'confirmed-personal',
15
+ ]);
16
+ const offered = (finding) =>
17
+ !declared.has(finding.rule) && finding.type !== 'JSON_VALUE';
18
+
19
+ function view(text, finding) {
20
+ return Object.freeze({
21
+ ...finding,
22
+ value: text.slice(finding.start, finding.end),
23
+ });
24
+ }
25
+
26
+ export function validateExempt(exempt) {
27
+ if (exempt !== undefined && typeof exempt !== 'function') {
28
+ throw failure('ERR_CONFIG');
29
+ }
30
+ }
31
+
32
+ function decision(kept) {
33
+ if (typeof kept !== 'boolean') {
34
+ // `undefined` from a forgotten return must not silently keep a secret.
35
+ throw failure('ERR_CONFIG');
36
+ }
37
+ return kept;
38
+ }
39
+
40
+ export function exemptFindings(text, findings, exempt) {
41
+ if (!exempt) {
42
+ return findings;
43
+ }
44
+ return findings.filter((finding) => {
45
+ if (!offered(finding)) {
46
+ return true;
47
+ }
48
+ const kept = exempt(view(text, finding), text);
49
+ if (typeof kept?.then === 'function') {
50
+ // An async hook needs the async engine (`createSanitizer`).
51
+ kept.then(undefined, () => {});
52
+ throw failure('ERR_CONFIG');
53
+ }
54
+ return !decision(kept);
55
+ });
56
+ }
57
+
58
+ export async function exemptFindingsAsync(text, findings, exempt) {
59
+ if (!exempt) {
60
+ return findings;
61
+ }
62
+ const retained = [];
63
+ for (const finding of findings) {
64
+ if (
65
+ !offered(finding) ||
66
+ !decision(await exempt(view(text, finding), text))
67
+ ) {
68
+ retained.push(finding);
69
+ }
70
+ }
71
+ return retained;
72
+ }
@@ -0,0 +1,32 @@
1
+ import { failure } from './detection.js';
2
+
3
+ // Values that `mode: 'fake'` generated, per transformation object, so that a
4
+ // later pass recognizes them as fictional. Kept apart from fake.js so that the
5
+ // credentials entry can read the (empty) memory without loading faker.
6
+ const generated = new WeakMap();
7
+ export function generatedValues(options) {
8
+ const transforms = [
9
+ options.transformation,
10
+ ...Object.values(options.transformations ?? {}),
11
+ ];
12
+ return transforms.flatMap((t) =>
13
+ t?.mode === 'fake'
14
+ ? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
15
+ value,
16
+ credential,
17
+ }))
18
+ : []
19
+ );
20
+ }
21
+ export function remember(value, transform, credential) {
22
+ let values = generated.get(transform);
23
+ if (!values) {
24
+ values = new Map();
25
+ generated.set(transform, values);
26
+ }
27
+ if (values.size >= 100000 && !values.has(value)) {
28
+ throw failure('ERR_LIMIT');
29
+ }
30
+ values.set(value, credential);
31
+ return value;
32
+ }
package/src/fake.js CHANGED
@@ -4,35 +4,11 @@ import { parsePhoneNumberFromString } from 'libphonenumber-js/max';
4
4
  import { mrzCheck } from './identity.js';
5
5
  import { fakeName } from './fake-names.js';
6
6
  import * as checks from './national-checks.js';
7
- import { REDACTED, failure } from './detection.js';
7
+ import { REDACTED } from './detection.js';
8
+ import { remember } from './fake-memory.js';
9
+
10
+ export { generatedValues } from './fake-memory.js';
8
11
 
9
- const generated = new WeakMap();
10
- export function generatedValues(options) {
11
- const transforms = [
12
- options.transformation,
13
- ...Object.values(options.transformations ?? {}),
14
- ];
15
- return transforms.flatMap((t) =>
16
- t?.mode === 'fake'
17
- ? [...(generated.get(t) ?? [])].map(([value, credential]) => ({
18
- value,
19
- credential,
20
- }))
21
- : []
22
- );
23
- }
24
- function remember(value, transform, credential) {
25
- let values = generated.get(transform);
26
- if (!values) {
27
- values = new Map();
28
- generated.set(transform, values);
29
- }
30
- if (values.size >= 100000 && !values.has(value)) {
31
- throw failure('ERR_LIMIT');
32
- }
33
- values.set(value, credential);
34
- return value;
35
- }
36
12
  function digest(value, key, purpose = 'value') {
37
13
  return createHmac('sha256', key).update(`${purpose}\0${value}`).digest();
38
14
  }
@@ -116,6 +92,18 @@ const validators = {
116
92
  SPAIN_DNI_NUMBER: (v) =>
117
93
  'TRWAGMYFPDXBNJZSQVHLCKE'[Number(v.slice(0, 8)) % 23] === v.at(-1),
118
94
  BRAZIL_CPF_NUMBER: cpf,
95
+ ABA_ROUTING_NUMBER: checks.abaRouting,
96
+ ES_NIE: checks.spanishNie,
97
+ IT_VAT_CODE: checks.shortLuhn,
98
+ AU_ACN: checks.acn,
99
+ IN_AADHAAR: checks.aadhaar,
100
+ TH_TNIN: checks.thaiId,
101
+ MEDICAL_LICENSE: checks.dea,
102
+ IMEI: checks.shortLuhn,
103
+ DE_TAX_ID: checks.germanTaxId,
104
+ AE_EMIRATES_ID: checks.emiratesId,
105
+ SA_NATIONAL_ID: checks.saudiId,
106
+ IL_ID_NUMBER: checks.israeliId,
119
107
  };
120
108
  function cpf(d) {
121
109
  for (const n of [9, 10]) {
@@ -226,9 +214,24 @@ function idShape(compact, candidate, type) {
226
214
  candidate =
227
215
  candidate.slice(0, 10) + compact.slice(10, 12) + candidate.slice(12);
228
216
  }
229
- if (type === 'SG_NRIC_FIN') {
217
+ if (
218
+ [
219
+ 'SG_NRIC_FIN',
220
+ 'ES_NIE',
221
+ 'IN_AADHAAR',
222
+ 'TH_TNIN',
223
+ 'DE_TAX_ID',
224
+ 'SA_NATIONAL_ID',
225
+ ].includes(type)
226
+ ) {
230
227
  candidate = compact[0] + candidate.slice(1);
231
228
  }
229
+ if (['ABA_ROUTING_NUMBER', 'MEDICAL_LICENSE'].includes(type)) {
230
+ candidate = compact.slice(0, 2) + candidate.slice(2);
231
+ }
232
+ if (type === 'AE_EMIRATES_ID') {
233
+ candidate = compact.slice(0, 7) + candidate.slice(7);
234
+ }
232
235
  if (type === 'CREDIT_CARD') {
233
236
  candidate = compact.slice(0, 6) + candidate.slice(6);
234
237
  }
@@ -255,6 +258,7 @@ function idChecks(compact, candidate, type) {
255
258
  'SG_NRIC_FIN',
256
259
  'IT_FISCAL_CODE',
257
260
  'SPAIN_DNI_NUMBER',
261
+ 'ES_NIE',
258
262
  ].includes(type);
259
263
  const count =
260
264
  (type === 'RU_INN' && compact.length === 12) ||
@@ -0,0 +1,29 @@
1
+ // Personal-data features that the main entry registers through
2
+ // `personal-features.js`. The `./credentials` entry never imports that module,
3
+ // so it does not load the personal detectors, faker or libphonenumber
4
+ // (issue 137). This module must stay free of imports.
5
+ const registered = {};
6
+
7
+ export function registerFeatures(entries) {
8
+ Object.assign(registered, entries);
9
+ }
10
+
11
+ /** Runs the registered implementation, or returns `fallback` without one. */
12
+ export function withFeature(name, fallback, ...args) {
13
+ const value = registered[name];
14
+ return value ? value(...args) : fallback;
15
+ }
16
+
17
+ /** The registered implementation; fails closed when it is unavailable. */
18
+ export function feature(name) {
19
+ const value = registered[name];
20
+ if (!value) {
21
+ throw Object.assign(
22
+ new Error(
23
+ 'This option needs the personal-data modules of the main entry.'
24
+ ),
25
+ { code: 'ERR_CONFIG' }
26
+ );
27
+ }
28
+ return value;
29
+ }
package/src/files.js CHANGED
@@ -4,31 +4,76 @@ import { dirname, join, resolve } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { TextDecoder } from 'node:util';
6
6
  import { failure } from './detection.js';
7
- import { decodeLogBytes } from './log-bytes.js';
7
+ import { decodeLogBytes, encodeLogText } from './log-bytes.js';
8
8
 
9
- export function decodeText(
9
+ // UTF-16 text (Windows logs, PowerShell transcripts) is recognised only by its
10
+ // byte order mark; BOM-less UTF-16 remains binary input.
11
+ export function textEncoding(bytes) {
12
+ if (bytes.length >= 2 && bytes.length % 2 === 0) {
13
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) {
14
+ return 'utf-16le';
15
+ }
16
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) {
17
+ return 'utf-16be';
18
+ }
19
+ }
20
+ return 'utf-8';
21
+ }
22
+
23
+ function decodeWith(encoding, bytes) {
24
+ try {
25
+ return new TextDecoder(encoding, { fatal: true, ignoreBOM: true }).decode(
26
+ bytes
27
+ );
28
+ } catch {
29
+ throw failure('ERR_ENCODING');
30
+ }
31
+ }
32
+
33
+ // Returns the decoded text and the encoding needed to write it back.
34
+ export function decodeTextInput(
10
35
  bytes,
11
36
  { lossless = false, allowBinary = false } = {}
12
37
  ) {
13
38
  if (typeof allowBinary !== 'boolean') {
14
39
  throw failure('ERR_CONFIG');
15
40
  }
41
+ const encoding = textEncoding(bytes);
42
+ if (encoding !== 'utf-8') {
43
+ return { text: decodeWith(encoding, bytes), encoding };
44
+ }
16
45
  if (!allowBinary && bytes.includes(0)) {
17
46
  throw failure('ERR_BINARY');
18
47
  }
19
- if (lossless) {
20
- return decodeLogBytes(bytes);
21
- }
22
- try {
23
- return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
24
- bytes
25
- );
26
- } catch {
27
- throw failure('ERR_ENCODING');
48
+ return {
49
+ text: lossless ? decodeLogBytes(bytes) : decodeWith('utf-8', bytes),
50
+ encoding,
51
+ };
52
+ }
53
+
54
+ export function decodeText(bytes, options) {
55
+ return decodeTextInput(bytes, options).text;
56
+ }
57
+
58
+ // Encode output in the input encoding; UTF-16 keeps its byte order mark
59
+ // because the decoded text retains it.
60
+ export function encodeTextOutput(text, encoding = 'utf-8') {
61
+ if (encoding === 'utf-8') {
62
+ return encodeLogText(text);
28
63
  }
64
+ const bytes = Buffer.from(text, 'utf16le');
65
+ return encoding === 'utf-16be' ? bytes.swap16() : bytes;
29
66
  }
30
67
 
31
68
  export async function readTextFile(path, maxBytes = 10 * 1024 * 1024, options) {
69
+ return (await readTextInput(path, maxBytes, options)).text;
70
+ }
71
+
72
+ export async function readTextInput(
73
+ path,
74
+ maxBytes = 10 * 1024 * 1024,
75
+ options
76
+ ) {
32
77
  let handle;
33
78
  try {
34
79
  const info = await lstat(path);
@@ -52,7 +97,7 @@ export async function readTextFile(path, maxBytes = 10 * 1024 * 1024, options) {
52
97
  if (read > maxBytes || read > opened.size) {
53
98
  throw failure('ERR_LIMIT');
54
99
  }
55
- return decodeText(bytes.subarray(0, read), options);
100
+ return decodeTextInput(bytes.subarray(0, read), options);
56
101
  } catch (error) {
57
102
  if (error.code?.startsWith('ERR_')) {
58
103
  throw error;