@monochromatic-dev/module-logger 0.1.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.
- package/CHANGELOG.md +11 -0
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSES/LGPL-3.0-or-later.txt +165 -0
- package/README.md +404 -0
- package/dist/final/neutral/index.d.mts +673 -0
- package/dist/final/neutral/index.mjs +3 -0
- package/dist/final/neutral/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/dist/final/node/index.d.mts +673 -0
- package/dist/final/node/index.mjs +3 -0
- package/dist/final/node/rolldown-runtime-5duEfhBv.mjs +1 -0
- package/package.json +43 -0
- package/src/create-logger.ts +494 -0
- package/src/create-logger.unit.test.ts +752 -0
- package/src/error-format.ts +43 -0
- package/src/index.ts +35 -0
- package/src/logger.ts +67 -0
- package/src/logger.unit.test.ts +190 -0
- package/src/sink/console-control-chars.ts +140 -0
- package/src/sink/console-control-chars.unit.test.ts +206 -0
- package/src/sink/console.ts +531 -0
- package/src/sink/console.unit.test.ts +542 -0
- package/src/sink/file.ts +297 -0
- package/src/sink/file.unit.test.ts +202 -0
- package/src/sink/index.ts +11 -0
- package/src/sink/indexed-db-util.ts +96 -0
- package/src/sink/indexed-db.browser.test.ts +184 -0
- package/src/sink/indexed-db.ts +324 -0
- package/src/sink/indexed-db.unit.test.ts +80 -0
- package/src/sink/local-storage-key.ts +176 -0
- package/src/sink/local-storage-key.unit.test.ts +106 -0
- package/src/sink/local-storage-quota.ts +60 -0
- package/src/sink/local-storage-quota.unit.test.ts +98 -0
- package/src/sink/local-storage-store.ts +368 -0
- package/src/sink/local-storage-store.unit.test.ts +329 -0
- package/src/sink/local-storage.browser.test.ts +125 -0
- package/src/sink/local-storage.ts +182 -0
- package/src/sink/local-storage.unit.test.ts +218 -0
- package/src/sink/noop.ts +46 -0
- package/src/sink/noop.unit.test.ts +47 -0
- package/src/sink/opfs.browser.test.ts +84 -0
- package/src/sink/opfs.ts +212 -0
- package/src/sink/opfs.unit.test.ts +81 -0
- package/src/sink/record-buffer.ts +230 -0
- package/src/sink/record-buffer.unit.test.ts +288 -0
- package/src/sink/session-storage-quota.ts +57 -0
- package/src/sink/session-storage-quota.unit.test.ts +98 -0
- package/src/sink/session-storage-store.ts +178 -0
- package/src/sink/session-storage.browser.test.ts +137 -0
- package/src/sink/session-storage.ts +128 -0
- package/src/sink/session-storage.unit.test.ts +527 -0
- package/src/sink/web-storage-quota-error.ts +43 -0
- package/src/sink/web-storage-quota-error.unit.test.ts +55 -0
- package/src/sink/web-storage-runtime.ts +49 -0
- package/src/startup.unit.test.ts +232 -0
- package/src/tagged.ts +74 -0
- package/src/tagged.unit.test.ts +211 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal logger error reporting helpers.
|
|
3
|
+
*
|
|
4
|
+
* Logger internals cannot report failures through the logger itself without
|
|
5
|
+
* risking recursion, so these helpers format caught values and write directly
|
|
6
|
+
* to the host console.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { caughtValueText, } from '@monochromatic-dev/module-caught-value/ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reports a logger-internal caught value without going back through logger
|
|
15
|
+
* sinks, formatting it via {@link caughtValueText}.
|
|
16
|
+
*
|
|
17
|
+
* @param context - Human-readable operation that caught the value.
|
|
18
|
+
*
|
|
19
|
+
* @param error - Caught value to include in the diagnostic.
|
|
20
|
+
*
|
|
21
|
+
* @mutates error - `caughtValueText` may invoke string-conversion hooks.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* reportLoggerInternalError({
|
|
26
|
+
* context: 'console sink verify failed',
|
|
27
|
+
* error: new Error('blocked'),
|
|
28
|
+
* });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function reportLoggerInternalError(
|
|
32
|
+
{
|
|
33
|
+
context,
|
|
34
|
+
error,
|
|
35
|
+
}: {
|
|
36
|
+
readonly context: string;
|
|
37
|
+
readonly error: unknown;
|
|
38
|
+
},
|
|
39
|
+
): void {
|
|
40
|
+
console.warn(
|
|
41
|
+
`logger internal error: ${context}: ${caughtValueText(error,)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createLogger,
|
|
3
|
+
DEFAULT_FLUSH_DEADLINE_MS,
|
|
4
|
+
} from './create-logger.ts';
|
|
5
|
+
export {
|
|
6
|
+
initPromise,
|
|
7
|
+
logger,
|
|
8
|
+
} from './logger.ts';
|
|
9
|
+
export * as sinks from './sink/index.ts';
|
|
10
|
+
export { tagged, } from './tagged.ts';
|
|
11
|
+
|
|
12
|
+
//region Internal seams
|
|
13
|
+
// Underscore-prefixed re-exports let unit tests exercise internal modules
|
|
14
|
+
// through the built artifact (the `require-eventual-artifact` rule) without
|
|
15
|
+
// widening the documented API; they are not part of the public contract.
|
|
16
|
+
export { neutralizeControlCharacters as _neutralizeControlCharacters, } from './sink/console-control-chars.ts';
|
|
17
|
+
export {
|
|
18
|
+
buildLogKey as _buildLogKey,
|
|
19
|
+
compareLogKeys as _compareLogKeys,
|
|
20
|
+
parseLogKey as _parseLogKey,
|
|
21
|
+
} from './sink/local-storage-key.ts';
|
|
22
|
+
export { detectLocalStorageQuotaChars as _detectLocalStorageQuotaChars, } from './sink/local-storage-quota.ts';
|
|
23
|
+
export { createLocalStorageStore as _createLocalStorageStore, } from './sink/local-storage-store.ts';
|
|
24
|
+
export { createRecordBuffer as _createRecordBuffer, } from './sink/record-buffer.ts';
|
|
25
|
+
export { detectSessionStorageQuotaChars as _detectSessionStorageQuotaChars, } from './sink/session-storage-quota.ts';
|
|
26
|
+
export { isQuotaExceededError as _isQuotaExceededError, } from './sink/web-storage-quota-error.ts';
|
|
27
|
+
//endregion Internal seams
|
|
28
|
+
export type {
|
|
29
|
+
Level,
|
|
30
|
+
Logger,
|
|
31
|
+
LogRecord,
|
|
32
|
+
Sink,
|
|
33
|
+
SinkFlush,
|
|
34
|
+
Verify,
|
|
35
|
+
} from './types.ts';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createLogger, } from './create-logger.ts';
|
|
2
|
+
import { createConsoleSink, } from './sink/console.ts';
|
|
3
|
+
import { createFileSink, } from './sink/file.ts';
|
|
4
|
+
import { createIndexedDbSink, } from './sink/indexed-db.ts';
|
|
5
|
+
import { createLocalStorageSink, } from './sink/local-storage.ts';
|
|
6
|
+
import { createSessionStorageSink, } from './sink/session-storage.ts';
|
|
7
|
+
import type {
|
|
8
|
+
Logger,
|
|
9
|
+
Sink,
|
|
10
|
+
} from './types.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Default sink backends to attempt, in priority order. Each runtime keeps
|
|
14
|
+
* only the sinks whose `verify` confirms its backend: {@link createConsoleSink}
|
|
15
|
+
* everywhere, {@link createIndexedDbSink} in browsers,
|
|
16
|
+
* {@link createSessionStorageSink} wherever web storage round-trips (browsers,
|
|
17
|
+
* Node 22+, Deno), {@link createLocalStorageSink} wherever `localStorage`
|
|
18
|
+
* round-trips (browsers, Deno, Node launched with `--localstorage-file`),
|
|
19
|
+
* {@link createFileSink} under Node. The noop sink is intentionally absent:
|
|
20
|
+
* the console sink verifies wherever `console` and `queueMicrotask` exist,
|
|
21
|
+
* so the default logger has a backend in every supported runtime, and a
|
|
22
|
+
* custom `createLogger` whose sinks all fail verification surfaces the
|
|
23
|
+
* "No logging backends available" error instead of silently discarding.
|
|
24
|
+
* The OPFS sink is exported
|
|
25
|
+
* but no longer a default: its stream stages writes until a close that a
|
|
26
|
+
* crash never performs, so IndexedDB holds the persistent-browser slot; see
|
|
27
|
+
* `DECISIONS.md`.
|
|
28
|
+
*/
|
|
29
|
+
const defaultSinks: readonly Sink[] = [
|
|
30
|
+
createConsoleSink(),
|
|
31
|
+
createIndexedDbSink(),
|
|
32
|
+
createSessionStorageSink(),
|
|
33
|
+
createLocalStorageSink(),
|
|
34
|
+
createFileSink(),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default multi-sink logger plus its eager readiness promise, built by
|
|
39
|
+
* applying {@link createLogger} to {@link defaultSinks}.
|
|
40
|
+
*/
|
|
41
|
+
const {
|
|
42
|
+
initPromise: defaultInitPromise,
|
|
43
|
+
logger: defaultLogger,
|
|
44
|
+
} = createLogger({ sinks: defaultSinks, },);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Eager readiness promise. Consumers do not need to await this before logging;
|
|
48
|
+
* {@link Logger.flush} awaits it internally, and startup records replay to
|
|
49
|
+
* async sinks as they become available.
|
|
50
|
+
*/
|
|
51
|
+
export const initPromise: Promise<void> = defaultInitPromise;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Multi-sink logger that writes to all available backends.
|
|
55
|
+
* Startup records replay to async sinks that verify after the log call.
|
|
56
|
+
* Log calls throw only when initialization proves no backend is available,
|
|
57
|
+
* which the console sink prevents in every supported runtime.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { logger, } from '\@monochromatic-dev/module-logger/logger';
|
|
62
|
+
*
|
|
63
|
+
* logger.error('unexpected shutdown',);
|
|
64
|
+
* await logger.flush();
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export const logger: Logger = defaultLogger;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import {
|
|
7
|
+
logger,
|
|
8
|
+
} from '@monochromatic-dev/module-logger';
|
|
9
|
+
|
|
10
|
+
await describe({
|
|
11
|
+
name: logger.constructor.name,
|
|
12
|
+
children: [
|
|
13
|
+
it({
|
|
14
|
+
name: 'logger has all six log level methods',
|
|
15
|
+
fn: async () => {
|
|
16
|
+
expect(typeof logger.trace,).toBe('function',);
|
|
17
|
+
expect(typeof logger.debug,).toBe('function',);
|
|
18
|
+
expect(typeof logger.info,).toBe('function',);
|
|
19
|
+
expect(typeof logger.warn,).toBe('function',);
|
|
20
|
+
expect(typeof logger.error,).toBe('function',);
|
|
21
|
+
expect(typeof logger.fatal,).toBe('function',);
|
|
22
|
+
},
|
|
23
|
+
},),
|
|
24
|
+
|
|
25
|
+
it({
|
|
26
|
+
name: 'trace method accepts string message',
|
|
27
|
+
fn: async () => {
|
|
28
|
+
expect(() => {
|
|
29
|
+
logger.trace('test trace message',);
|
|
30
|
+
},)
|
|
31
|
+
.not
|
|
32
|
+
.toThrow();
|
|
33
|
+
},
|
|
34
|
+
},),
|
|
35
|
+
|
|
36
|
+
it({
|
|
37
|
+
name: 'debug method accepts string message',
|
|
38
|
+
fn: async () => {
|
|
39
|
+
expect(() => {
|
|
40
|
+
logger.debug('test debug message',);
|
|
41
|
+
},)
|
|
42
|
+
.not
|
|
43
|
+
.toThrow();
|
|
44
|
+
},
|
|
45
|
+
},),
|
|
46
|
+
|
|
47
|
+
it({
|
|
48
|
+
name: 'info method accepts string message',
|
|
49
|
+
fn: async () => {
|
|
50
|
+
expect(() => {
|
|
51
|
+
logger.info('test info message',);
|
|
52
|
+
},)
|
|
53
|
+
.not
|
|
54
|
+
.toThrow();
|
|
55
|
+
},
|
|
56
|
+
},),
|
|
57
|
+
|
|
58
|
+
it({
|
|
59
|
+
name: 'warn method accepts string message',
|
|
60
|
+
fn: async () => {
|
|
61
|
+
expect(() => {
|
|
62
|
+
logger.warn('test warn message',);
|
|
63
|
+
},)
|
|
64
|
+
.not
|
|
65
|
+
.toThrow();
|
|
66
|
+
},
|
|
67
|
+
},),
|
|
68
|
+
|
|
69
|
+
it({
|
|
70
|
+
name: 'error method accepts string message',
|
|
71
|
+
fn: async () => {
|
|
72
|
+
expect(() => {
|
|
73
|
+
logger.error('test error message',);
|
|
74
|
+
},)
|
|
75
|
+
.not
|
|
76
|
+
.toThrow();
|
|
77
|
+
},
|
|
78
|
+
},),
|
|
79
|
+
|
|
80
|
+
it({
|
|
81
|
+
name: 'fatal method accepts string message',
|
|
82
|
+
fn: async () => {
|
|
83
|
+
expect(() => {
|
|
84
|
+
logger.fatal('test fatal message',);
|
|
85
|
+
},)
|
|
86
|
+
.not
|
|
87
|
+
.toThrow();
|
|
88
|
+
},
|
|
89
|
+
},),
|
|
90
|
+
|
|
91
|
+
it({
|
|
92
|
+
name: 'logs with empty string message',
|
|
93
|
+
fn: async () => {
|
|
94
|
+
expect(() => {
|
|
95
|
+
logger.info('',);
|
|
96
|
+
},)
|
|
97
|
+
.not
|
|
98
|
+
.toThrow();
|
|
99
|
+
},
|
|
100
|
+
},),
|
|
101
|
+
|
|
102
|
+
it({
|
|
103
|
+
name: 'logs with unicode message',
|
|
104
|
+
fn: async () => {
|
|
105
|
+
expect(() => {
|
|
106
|
+
logger.info('Hello 世界 🌍',);
|
|
107
|
+
},)
|
|
108
|
+
.not
|
|
109
|
+
.toThrow();
|
|
110
|
+
},
|
|
111
|
+
},),
|
|
112
|
+
|
|
113
|
+
it({
|
|
114
|
+
name: 'logs with multiline message',
|
|
115
|
+
fn: async () => {
|
|
116
|
+
expect(() => {
|
|
117
|
+
logger.info('line1\nline2\nline3',);
|
|
118
|
+
},)
|
|
119
|
+
.not
|
|
120
|
+
.toThrow();
|
|
121
|
+
},
|
|
122
|
+
},),
|
|
123
|
+
|
|
124
|
+
it({
|
|
125
|
+
name: 'logs with special characters',
|
|
126
|
+
fn: async () => {
|
|
127
|
+
expect(() => {
|
|
128
|
+
logger.info('Special: <script>alert("xss")</script>',);
|
|
129
|
+
},)
|
|
130
|
+
.not
|
|
131
|
+
.toThrow();
|
|
132
|
+
},
|
|
133
|
+
},),
|
|
134
|
+
|
|
135
|
+
it({
|
|
136
|
+
name: 'logs with JSON-like content',
|
|
137
|
+
fn: async () => {
|
|
138
|
+
expect(() => {
|
|
139
|
+
logger.info('{"key": "value", "count": 42}',);
|
|
140
|
+
},)
|
|
141
|
+
.not
|
|
142
|
+
.toThrow();
|
|
143
|
+
},
|
|
144
|
+
},),
|
|
145
|
+
|
|
146
|
+
it({
|
|
147
|
+
name: 'handles rapid successive logs',
|
|
148
|
+
fn: async () => {
|
|
149
|
+
expect(() => {
|
|
150
|
+
const RAPID_LOG_COUNT = 100;
|
|
151
|
+
for (let logIndex = 0; logIndex < RAPID_LOG_COUNT; logIndex++)
|
|
152
|
+
logger.debug(`rapid log ${logIndex}`,);
|
|
153
|
+
},)
|
|
154
|
+
.not
|
|
155
|
+
.toThrow();
|
|
156
|
+
},
|
|
157
|
+
},),
|
|
158
|
+
|
|
159
|
+
it({
|
|
160
|
+
name: 'flush is a callable method returning a promise',
|
|
161
|
+
fn: async () => {
|
|
162
|
+
expect(typeof logger.flush,)
|
|
163
|
+
.toBe('function',);
|
|
164
|
+
const result = logger.flush();
|
|
165
|
+
expect(result instanceof Promise,)
|
|
166
|
+
.toBe(true,);
|
|
167
|
+
await result;
|
|
168
|
+
},
|
|
169
|
+
},),
|
|
170
|
+
|
|
171
|
+
it({
|
|
172
|
+
name: 'flush drains buffered console records',
|
|
173
|
+
fn: async () => {
|
|
174
|
+
// The console sink batches on microtasks; after a sync burst
|
|
175
|
+
// of logs, flush should resolve after the console has actually
|
|
176
|
+
// received the records. We cannot easily assert the count here
|
|
177
|
+
// without re-spying (the default logger owns its sinks), so this
|
|
178
|
+
// test only verifies that flush resolves after at least one
|
|
179
|
+
// microtask tick rather than returning an already-settled promise
|
|
180
|
+
// that misses the flush.
|
|
181
|
+
logger.info('pre-flush 1',);
|
|
182
|
+
logger.info('pre-flush 2',);
|
|
183
|
+
logger.info('pre-flush 3',);
|
|
184
|
+
await expect(logger.flush(),)
|
|
185
|
+
.resolves
|
|
186
|
+
.toBeUndefined();
|
|
187
|
+
},
|
|
188
|
+
},),
|
|
189
|
+
],
|
|
190
|
+
},);
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console-bound text crosses a syntax boundary: a terminal interprets C0 and
|
|
3
|
+
* C1 control characters as commands (clear screen, set title, move cursor,
|
|
4
|
+
* write clipboard). Log messages can carry attacker-influenced text, so the
|
|
5
|
+
* console sink neutralizes every control character except newline and tab
|
|
6
|
+
* before the text reaches `console.*` or `process.stderr`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* First code unit above the C0 control range; everything below it except
|
|
13
|
+
* newline and tab is neutralized.
|
|
14
|
+
*/
|
|
15
|
+
const C0_CONTROL_LIMIT = 0x20;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Newline stays literal: multi-line messages (stack traces) are a core use.
|
|
19
|
+
*/
|
|
20
|
+
const NEWLINE_CODE_UNIT = 0x0A;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tab stays literal: indentation in multi-line messages is harmless.
|
|
24
|
+
*/
|
|
25
|
+
const TAB_CODE_UNIT = 0x09;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* DEL sits alone above the printable ASCII range and is a control character.
|
|
29
|
+
*/
|
|
30
|
+
const DELETE_CODE_UNIT = 0x7F;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* First code unit of the C1 control range (8-bit CSI, OSC, and friends).
|
|
34
|
+
*/
|
|
35
|
+
const C1_CONTROL_START = 0x80;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Last code unit of the C1 control range.
|
|
39
|
+
*/
|
|
40
|
+
const C1_CONTROL_END = 0x9F;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Radix for the hexadecimal digits inside a `\uXXXX` escape.
|
|
44
|
+
*/
|
|
45
|
+
const HEX_RADIX = 16;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Digit count of a `\uXXXX` escape, zero-padded on the left.
|
|
49
|
+
*/
|
|
50
|
+
const UNICODE_ESCAPE_WIDTH = 4;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reports whether one UTF-16 code unit is a control character the console
|
|
54
|
+
* sink must neutralize.
|
|
55
|
+
*
|
|
56
|
+
* @param codeUnit - UTF-16 code unit read from the message.
|
|
57
|
+
*
|
|
58
|
+
* @returns Whether the code unit is a C0 control other than newline and tab,
|
|
59
|
+
* DEL, or a C1 control.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* isNeutralizedControl(0x1B); // true (ESC)
|
|
64
|
+
* isNeutralizedControl(0x0A); // false (newline stays)
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function isNeutralizedControl(codeUnit: number,): boolean {
|
|
68
|
+
if (codeUnit < C0_CONTROL_LIMIT)
|
|
69
|
+
return (codeUnit !== NEWLINE_CODE_UNIT) && (codeUnit !== TAB_CODE_UNIT);
|
|
70
|
+
|
|
71
|
+
if (codeUnit === DELETE_CODE_UNIT)
|
|
72
|
+
return true;
|
|
73
|
+
|
|
74
|
+
return (codeUnit >= C1_CONTROL_START) && (codeUnit <= C1_CONTROL_END);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Renders one code unit as a `\uXXXX` escape with uppercase hex digits (the
|
|
79
|
+
* repository's escape-case convention) so the attempted control stays
|
|
80
|
+
* visible for forensics instead of vanishing.
|
|
81
|
+
*
|
|
82
|
+
* @param codeUnit - UTF-16 code unit to escape.
|
|
83
|
+
*
|
|
84
|
+
* @returns Six-character escape such as `\u001B`.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* escapeCodeUnit(0x1B); // '\\u001B'
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
function escapeCodeUnit(codeUnit: number,): string {
|
|
92
|
+
return `\\u${
|
|
93
|
+
codeUnit
|
|
94
|
+
.toString(HEX_RADIX,)
|
|
95
|
+
.toUpperCase()
|
|
96
|
+
.padStart(
|
|
97
|
+
UNICODE_ESCAPE_WIDTH,
|
|
98
|
+
'0',
|
|
99
|
+
)
|
|
100
|
+
}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Neutralizes terminal control characters in console-bound text. One linear
|
|
105
|
+
* pass over the code points: each neutralized control becomes a `\uXXXX`
|
|
106
|
+
* escape, everything else is copied through, and newline and tab pass
|
|
107
|
+
* untouched. Well-formed and malformed escape sequences get no
|
|
108
|
+
* special treatment because the introducer byte itself is neutralized, so a
|
|
109
|
+
* trailing lone ESC, an unterminated OSC, and a nested ESC all lose their
|
|
110
|
+
* teeth the same way.
|
|
111
|
+
*
|
|
112
|
+
* @param text - Message text destined for `console.*` or `process.stderr`.
|
|
113
|
+
*
|
|
114
|
+
* @returns Text with every neutralized control rendered as `\uXXXX`.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* neutralizeControlCharacters('title:\u001B]0;x\u0007 ok\n\tnext');
|
|
119
|
+
* // => 'title:\\u001B]0;x\\u0007 ok\n\tnext'
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function neutralizeControlCharacters(text: string,): string {
|
|
123
|
+
/**
|
|
124
|
+
* Output pieces in input order: each code point either verbatim or as its
|
|
125
|
+
* escape, joined once at the end so no per-character string rebuild occurs.
|
|
126
|
+
*/
|
|
127
|
+
const pieces: string[] = [];
|
|
128
|
+
for (const character of text) {
|
|
129
|
+
/**
|
|
130
|
+
* Leading code unit of this iteration element. String iteration walks
|
|
131
|
+
* code points, so a surrogate pair arrives as one two-unit string whose
|
|
132
|
+
* lead surrogate is never a control, and a lone surrogate passes the
|
|
133
|
+
* same way.
|
|
134
|
+
*/
|
|
135
|
+
// oxlint-disable-next-line unicorn/prefer-code-point -- Classifier reads the lead code unit on purpose; controls below U+00A0 never sit inside a surrogate pair, so code-point decoding adds nothing.
|
|
136
|
+
const codeUnit = character.charCodeAt(0,);
|
|
137
|
+
pieces.push(isNeutralizedControl(codeUnit,) ? escapeCodeUnit(codeUnit,) : character,);
|
|
138
|
+
}
|
|
139
|
+
return pieces.join('',);
|
|
140
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
expect,
|
|
4
|
+
it,
|
|
5
|
+
} from '@monochromatic-dev/module-test/ts';
|
|
6
|
+
import {
|
|
7
|
+
_neutralizeControlCharacters as neutralizeControlCharacters,
|
|
8
|
+
} from '@monochromatic-dev/module-logger';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Adversarial inputs at the terminal boundary paired with the exact output
|
|
12
|
+
* the neutralizer must produce. Each case names the attack or the malformed
|
|
13
|
+
* shape it pins.
|
|
14
|
+
*/
|
|
15
|
+
const BOUNDARY_CASES: readonly {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly input: string;
|
|
18
|
+
readonly expected: string;
|
|
19
|
+
}[] = [
|
|
20
|
+
{
|
|
21
|
+
name: 'OSC title-set sequence',
|
|
22
|
+
input: 'title:\u001B]0;PWNED\u0007 ok',
|
|
23
|
+
expected: 'title:\\u001B]0;PWNED\\u0007 ok',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'CSI clear-screen sequence',
|
|
27
|
+
input: 'clear:\u001B[2J',
|
|
28
|
+
expected: 'clear:\\u001B[2J',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'SGR color sequence (no allowlist)',
|
|
32
|
+
input: '\u001B[31mred\u001B[0m',
|
|
33
|
+
expected: '\\u001B[31mred\\u001B[0m',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'trailing lone ESC',
|
|
37
|
+
input: 'tail\u001B',
|
|
38
|
+
expected: 'tail\\u001B',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'ESC [ with no final byte',
|
|
42
|
+
input: 'open\u001B[',
|
|
43
|
+
expected: 'open\\u001B[',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'unterminated OSC',
|
|
47
|
+
input: '\u001B]2;never closed',
|
|
48
|
+
expected: '\\u001B]2;never closed',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'nested ESC inside a sequence',
|
|
52
|
+
input: '\u001B[\u001B[2J',
|
|
53
|
+
expected: '\\u001B[\\u001B[2J',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: '8-bit C1 CSI',
|
|
57
|
+
input: 'c1:\u009B2J',
|
|
58
|
+
expected: 'c1:\\u009B2J',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'DEL',
|
|
62
|
+
input: 'del:\u007F',
|
|
63
|
+
expected: 'del:\\u007F',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'NUL',
|
|
67
|
+
input: 'nul:\u0000end',
|
|
68
|
+
expected: 'nul:\\u0000end',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'carriage return (line overwrite)',
|
|
72
|
+
input: 'real\rfake',
|
|
73
|
+
expected: 'real\\u000Dfake',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: 'backspace',
|
|
77
|
+
input: 'ab\bc',
|
|
78
|
+
expected: 'ab\\u0008c',
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Inputs the neutralizer must return unchanged.
|
|
84
|
+
*/
|
|
85
|
+
const PASSTHROUGH_CASES: readonly {
|
|
86
|
+
readonly name: string;
|
|
87
|
+
readonly input: string;
|
|
88
|
+
}[] = [
|
|
89
|
+
{
|
|
90
|
+
name: 'plain ASCII',
|
|
91
|
+
input: 'server started on port 3000',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'empty string',
|
|
95
|
+
input: '',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
name: 'newline and tab',
|
|
99
|
+
input: 'line one\n\tindented two\n',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'non-ASCII text and emoji',
|
|
103
|
+
input: 'héllo wörld 🚀 日本語',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'lone surrogate',
|
|
107
|
+
input: 'x\uD83Dy',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'format specifiers',
|
|
111
|
+
input: '%s %d %j %%',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'code point just above the C1 range',
|
|
115
|
+
input: 'nbsp:\u00A0end',
|
|
116
|
+
},
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One past the last C1 code unit; the exhaustive sweep covers every code unit
|
|
121
|
+
* below it.
|
|
122
|
+
*/
|
|
123
|
+
const CONTROL_SWEEP_LENGTH = 0xA0;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Reports whether an output code unit is still a control the boundary forbids.
|
|
127
|
+
*
|
|
128
|
+
* @param codeUnit - UTF-16 code unit read from neutralizer output.
|
|
129
|
+
*
|
|
130
|
+
* @returns Whether the code unit should have been neutralized.
|
|
131
|
+
*/
|
|
132
|
+
function isForbiddenControl(codeUnit: number,): boolean {
|
|
133
|
+
if (codeUnit < 0x20)
|
|
134
|
+
return (codeUnit !== 0x0A) && (codeUnit !== 0x09);
|
|
135
|
+
if (codeUnit === 0x7F)
|
|
136
|
+
return true;
|
|
137
|
+
return (codeUnit >= 0x80) && (codeUnit <= 0x9F);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await describe({
|
|
141
|
+
name: neutralizeControlCharacters.name,
|
|
142
|
+
children: [
|
|
143
|
+
//region Neutralized controls
|
|
144
|
+
|
|
145
|
+
...BOUNDARY_CASES.map(function mapBoundaryCase(boundaryCase,) {
|
|
146
|
+
return it({
|
|
147
|
+
name: `neutralizes ${boundaryCase.name}`,
|
|
148
|
+
fn: async () => {
|
|
149
|
+
expect(neutralizeControlCharacters(boundaryCase.input,),)
|
|
150
|
+
.toBe(boundaryCase.expected,);
|
|
151
|
+
},
|
|
152
|
+
},);
|
|
153
|
+
},),
|
|
154
|
+
|
|
155
|
+
it({
|
|
156
|
+
name: 'output never contains a neutralized control after the pass',
|
|
157
|
+
fn: async () => {
|
|
158
|
+
/**
|
|
159
|
+
* Every code unit from NUL through U+009F, in one string.
|
|
160
|
+
*/
|
|
161
|
+
const allControls = Array.from(
|
|
162
|
+
{ length: CONTROL_SWEEP_LENGTH, },
|
|
163
|
+
function toChar(
|
|
164
|
+
_unused,
|
|
165
|
+
index,
|
|
166
|
+
) {
|
|
167
|
+
return String.fromCodePoint(index,);
|
|
168
|
+
},
|
|
169
|
+
)
|
|
170
|
+
.join('',);
|
|
171
|
+
/**
|
|
172
|
+
* Neutralized sweep; only newline and tab may survive as controls.
|
|
173
|
+
*/
|
|
174
|
+
const output = neutralizeControlCharacters(allControls,);
|
|
175
|
+
/**
|
|
176
|
+
* Output code units that are still forbidden controls; must stay empty.
|
|
177
|
+
*/
|
|
178
|
+
const leaked: number[] = [];
|
|
179
|
+
for (const character of output) {
|
|
180
|
+
// oxlint-disable-next-line unicorn/prefer-code-point -- The sweep classifies lead code units below U+00A0, which never sit inside a surrogate pair.
|
|
181
|
+
const codeUnit = character.charCodeAt(0,);
|
|
182
|
+
if (isForbiddenControl(codeUnit,))
|
|
183
|
+
leaked.push(codeUnit,);
|
|
184
|
+
}
|
|
185
|
+
expect(leaked,)
|
|
186
|
+
.toEqual([],);
|
|
187
|
+
},
|
|
188
|
+
},),
|
|
189
|
+
|
|
190
|
+
//endregion Neutralized controls
|
|
191
|
+
|
|
192
|
+
//region Passthrough
|
|
193
|
+
|
|
194
|
+
...PASSTHROUGH_CASES.map(function mapPassthroughCase(passthroughCase,) {
|
|
195
|
+
return it({
|
|
196
|
+
name: `leaves ${passthroughCase.name} untouched`,
|
|
197
|
+
fn: async () => {
|
|
198
|
+
expect(neutralizeControlCharacters(passthroughCase.input,),)
|
|
199
|
+
.toBe(passthroughCase.input,);
|
|
200
|
+
},
|
|
201
|
+
},);
|
|
202
|
+
},),
|
|
203
|
+
|
|
204
|
+
//endregion Passthrough
|
|
205
|
+
],
|
|
206
|
+
},);
|