redac---n 0.0.0-stage → 0.0.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.
- package/LICENSE +21 -0
- package/README.md +323 -2
- package/THIRD_PARTY_NOTICES.md +59 -0
- package/dist/aho.d.ts +25 -0
- package/dist/aho.js +116 -0
- package/dist/base64.d.ts +10 -0
- package/dist/base64.js +29 -0
- package/dist/bip39-data.d.ts +2 -0
- package/dist/bip39-data.js +2 -0
- package/dist/cache.d.ts +14 -0
- package/dist/cache.js +40 -0
- package/dist/calls.d.ts +64 -0
- package/dist/calls.js +269 -0
- package/dist/charlm-data.d.ts +2 -0
- package/dist/charlm-data.js +3 -0
- package/dist/charlm.d.ts +7 -0
- package/dist/charlm.js +65 -0
- package/dist/checksums.d.ts +10 -0
- package/dist/checksums.js +98 -0
- package/dist/commands.d.ts +72 -0
- package/dist/commands.js +787 -0
- package/dist/context.d.ts +98 -0
- package/dist/context.js +469 -0
- package/dist/encoded.d.ts +11 -0
- package/dist/encoded.js +336 -0
- package/dist/environment.d.ts +10 -0
- package/dist/environment.js +62 -0
- package/dist/formats-data.d.ts +2 -0
- package/dist/formats-data.js +405 -0
- package/dist/formats.d.ts +5 -0
- package/dist/formats.js +470 -0
- package/dist/hmac.d.ts +3 -0
- package/dist/hmac.js +87 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +488 -0
- package/dist/json.d.ts +82 -0
- package/dist/json.js +392 -0
- package/dist/known.d.ts +34 -0
- package/dist/known.js +340 -0
- package/dist/lines.d.ts +28 -0
- package/dist/lines.js +69 -0
- package/dist/lookup.d.ts +5 -0
- package/dist/lookup.js +7 -0
- package/dist/model-data.d.ts +3 -0
- package/dist/model-data.js +4 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +19 -0
- package/dist/named.d.ts +20 -0
- package/dist/named.js +3257 -0
- package/dist/names.d.ts +106 -0
- package/dist/names.js +1517 -0
- package/dist/normalize.d.ts +25 -0
- package/dist/normalize.js +529 -0
- package/dist/pem.d.ts +32 -0
- package/dist/pem.js +269 -0
- package/dist/policy.d.ts +3 -0
- package/dist/policy.js +470 -0
- package/dist/public.d.ts +44 -0
- package/dist/public.js +362 -0
- package/dist/publickeys.d.ts +41 -0
- package/dist/publickeys.js +232 -0
- package/dist/random.d.ts +29 -0
- package/dist/random.js +165 -0
- package/dist/repeats.d.ts +21 -0
- package/dist/repeats.js +80 -0
- package/dist/rules.d.ts +42 -0
- package/dist/rules.js +58 -0
- package/dist/runs.d.ts +10 -0
- package/dist/runs.js +36 -0
- package/dist/secretfile.d.ts +52 -0
- package/dist/secretfile.js +722 -0
- package/dist/shell.d.ts +127 -0
- package/dist/shell.js +703 -0
- package/dist/spans.d.ts +61 -0
- package/dist/spans.js +287 -0
- package/dist/sql.d.ts +20 -0
- package/dist/sql.js +66 -0
- package/dist/stream.d.ts +46 -0
- package/dist/stream.js +150 -0
- package/dist/structure.d.ts +24 -0
- package/dist/structure.js +1625 -0
- package/dist/syntax.d.ts +156 -0
- package/dist/syntax.js +446 -0
- package/dist/transcript.d.ts +30 -0
- package/dist/transcript.js +713 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +1 -0
- package/dist/unnamed.d.ts +59 -0
- package/dist/unnamed.js +939 -0
- package/dist/urls.d.ts +70 -0
- package/dist/urls.js +303 -0
- package/dist/values.d.ts +178 -0
- package/dist/values.js +1621 -0
- package/dist/yaml.d.ts +40 -0
- package/dist/yaml.js +174 -0
- package/docs/creddata.svg +318 -0
- package/docs/guessing.md +139 -0
- package/docs/holdout.svg +85 -0
- package/docs/layers.md +80 -0
- package/docs/models.svg +81 -0
- package/docs/real-text.svg +112 -0
- package/package.json +57 -5
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Normalized {
|
|
2
|
+
text: string;
|
|
3
|
+
/** For each position of `text` (and one past its end), the position in the original. */
|
|
4
|
+
map: number[];
|
|
5
|
+
/** For each position of `text`, where its source in the original ends. */
|
|
6
|
+
ends: number[];
|
|
7
|
+
/**
|
|
8
|
+
* Positions of `text` where a separator was removed before them: a colour code (`AKIA…ESC[0mdefault`) or a line
|
|
9
|
+
* continuation. Two tokens meet there that the original kept apart. (Not where invisible characters were
|
|
10
|
+
* dropped from inside a token: `sk_…` is one token.)
|
|
11
|
+
*/
|
|
12
|
+
boundaries: number[];
|
|
13
|
+
}
|
|
14
|
+
/** A text with its HTML entities decoded as normalize decodes them: `<redacted>` is `<redacted>`. */
|
|
15
|
+
export declare function decodeEntities(text: string): string;
|
|
16
|
+
/** A `.pgpass` line, `host:port:database:user:password` (`*` is a wildcard in the first four). */
|
|
17
|
+
export declare const PGPASS: RegExp;
|
|
18
|
+
/** Whether a text has anything to normalize, cheaply. (A pass only has work after one that ran if its own gate already fires on the original.) */
|
|
19
|
+
export declare function needsNormalizing(text: string): boolean;
|
|
20
|
+
export declare function normalize(text: string): Normalized;
|
|
21
|
+
/** A text with a blank put in at each of `at` (sorted), and for each of its positions the position in the text it came from. */
|
|
22
|
+
export declare function withBlanks(text: string, at: number[]): {
|
|
23
|
+
text: string;
|
|
24
|
+
map: number[];
|
|
25
|
+
};
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output rewritten in ways that hide what it says from the detectors, undone before detection, in three passes:
|
|
3
|
+
*
|
|
4
|
+
* 1. What a terminal doesn't show: ANSI escape sequences (raw, or written as escapes in a JSON log), NULs (UTF-16
|
|
5
|
+
* read as UTF-8), BELs, invisible format characters.
|
|
6
|
+
* 2. What a tool puts in front of lines: a file viewer's line numbers (` 12→`, `cat -n`), `grep -n`'s
|
|
7
|
+
* `path:12:`, a diff hunk's markers.
|
|
8
|
+
* 3. What spells a character or a string another way: shell line continuations (`--password \` then the value on
|
|
9
|
+
* the next line), HTML entities (`"`, `#`), full-width punctuation and spaces of other widths, and a
|
|
10
|
+
* string literal split in pieces (`"xoxb-12ab" "34cd"`, `'sk-' + '…'`), joined.
|
|
11
|
+
*
|
|
12
|
+
* Each pass sees the lines the one before it leaves (a coloured diff's `ESC[32m+API_TOKEN=…` starts with its `+`;
|
|
13
|
+
* a literal split over numbered lines joins once the numbers are gone). Detection runs on the normalized text;
|
|
14
|
+
* `map` and `ends` turn its positions back into the original's, and `boundaries` says where something that
|
|
15
|
+
* separated two characters was removed.
|
|
16
|
+
*/
|
|
17
|
+
import { lookup } from "./lookup.js";
|
|
18
|
+
import { lineEnd, lineStart, lowerBound } from "./lines.js";
|
|
19
|
+
import { FILE_EXTENSION } from "./names.js";
|
|
20
|
+
/** The named entities that decode (the ASCII punctuation HTML names, and ` `), by lower-case name. */
|
|
21
|
+
const ENTITIES = lookup({
|
|
22
|
+
quot: '"',
|
|
23
|
+
amp: "&",
|
|
24
|
+
lt: "<",
|
|
25
|
+
gt: ">",
|
|
26
|
+
apos: "'",
|
|
27
|
+
nbsp: " ",
|
|
28
|
+
sol: "/",
|
|
29
|
+
colon: ":",
|
|
30
|
+
equals: "=",
|
|
31
|
+
num: "#",
|
|
32
|
+
excl: "!",
|
|
33
|
+
percnt: "%",
|
|
34
|
+
plus: "+",
|
|
35
|
+
commat: "@",
|
|
36
|
+
dollar: "$",
|
|
37
|
+
lowbar: "_",
|
|
38
|
+
period: ".",
|
|
39
|
+
comma: ",",
|
|
40
|
+
semi: ";",
|
|
41
|
+
quest: "?",
|
|
42
|
+
lpar: "(",
|
|
43
|
+
rpar: ")",
|
|
44
|
+
lsqb: "[",
|
|
45
|
+
lbrack: "[",
|
|
46
|
+
rsqb: "]",
|
|
47
|
+
rbrack: "]",
|
|
48
|
+
lcub: "{",
|
|
49
|
+
lbrace: "{",
|
|
50
|
+
rcub: "}",
|
|
51
|
+
rbrace: "}",
|
|
52
|
+
bsol: "\\",
|
|
53
|
+
verbar: "|",
|
|
54
|
+
vert: "|",
|
|
55
|
+
ast: "*",
|
|
56
|
+
midast: "*",
|
|
57
|
+
grave: "`",
|
|
58
|
+
hat: "^",
|
|
59
|
+
});
|
|
60
|
+
/** An entity that decodes: a number, or a name of ENTITIES. */
|
|
61
|
+
const ENTITY = new RegExp(String.raw `&(?:#\d{1,6}|#x[0-9a-f]{1,5}|${Object.keys(ENTITIES).join("|")});`, "i");
|
|
62
|
+
/** An entity's shape, its number (decimal or hex) or its name in a group: entityChar decodes it. */
|
|
63
|
+
const ENTITY_SHAPE = String.raw `&(?:#(\d{1,6})|#[xX]([0-9a-fA-F]{1,5})|([a-zA-Z]{2,8}));`;
|
|
64
|
+
const ENTITY_AT = new RegExp(`^${ENTITY_SHAPE}`);
|
|
65
|
+
/** The character an entity (ENTITY_SHAPE's match) stands for: one UTF-16 unit, and a character, not NUL nor half of a surrogate pair. */
|
|
66
|
+
function entityChar(m) {
|
|
67
|
+
const ch = m[1]
|
|
68
|
+
? String.fromCodePoint(Number(m[1]))
|
|
69
|
+
: m[2]
|
|
70
|
+
? String.fromCodePoint(parseInt(m[2], 16))
|
|
71
|
+
: ENTITIES[m[3].toLowerCase()];
|
|
72
|
+
return ch !== undefined && /^[^\0\ud800-\udfff]$/.test(ch) ? ch : undefined;
|
|
73
|
+
}
|
|
74
|
+
/** A text with its HTML entities decoded as normalize decodes them: `<redacted>` is `<redacted>`. */
|
|
75
|
+
export function decodeEntities(text) {
|
|
76
|
+
return text.replace(new RegExp(ENTITY_SHAPE, "g"), (...m) => entityChar(m) ?? m[0]);
|
|
77
|
+
}
|
|
78
|
+
/** An ANSI colour code written as an escape in a JSON log or a string literal: `\u001b[32m`, `\x1b[0m`, `\033[1;31m`, `\e[0m`. */
|
|
79
|
+
const ESCAPED_CSI = /\\{1,3}(?:u001[bB]|x1[bB]|033|e)\[[0-9;:]{0,20}[A-HJKSTfmsu]/;
|
|
80
|
+
const ESCAPED_CSI_AT = new RegExp(ESCAPED_CSI.source, "y");
|
|
81
|
+
/**
|
|
82
|
+
* A terminal's capture in caret notation, as `cat -v` and `less` print ESC: a colour or cursor code (`^[[32m`,
|
|
83
|
+
* `^[[0;1m`, `^[[K`), a charset's (`^[(B`) or an OSC's introducer (`^[]0;`). Only where one stands is `^[` an ESC: elsewhere
|
|
84
|
+
* it is a regex's `^[` (`^[\w-]+$`, `^[a-z]`), which no rewrite may touch. (Nor is any other two-byte sequence, `^[7`, read.)
|
|
85
|
+
*/
|
|
86
|
+
const CARET_ESCAPE = /\^\[(?:\[[0-9;]{0,20}[A-HJKSTfmsu]|\([B0]|\]\d{1,3};)/;
|
|
87
|
+
const CARET_ESCAPE_AT = new RegExp(CARET_ESCAPE.source, "y");
|
|
88
|
+
/** Lines carrying a tool's prefix: `grep -n`'s `path:12:` and `path-13-`, a diff hunk's markers. */
|
|
89
|
+
const PREFIXED = /(?:^|\n)[\w./@+-]{1,200}\.\w{1,10}[:-]\d{1,7}[:-]|(?:^|\n)@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/;
|
|
90
|
+
/**
|
|
91
|
+
* A line number a file viewer prints in front of each line: an agent's Read tool (` 12→`), `cat -n` (` 12\t`).
|
|
92
|
+
* Either is the viewer's on a file of one line too (` 1→Harbor#2291x`).
|
|
93
|
+
*/
|
|
94
|
+
const LINE_NUMBER = /^(?:[ \t]*\d{1,7}→| +\d{1,7}\t)/gm;
|
|
95
|
+
const HAS_LINE_NUMBER = new RegExp(LINE_NUMBER.source, "m");
|
|
96
|
+
/** A `.pgpass` line, `host:port:database:user:password` (`*` is a wildcard in the first four). */
|
|
97
|
+
export const PGPASS = /^[ \t]*[^:\s#][^:\s]*:(?:\d{1,5}|\*):[^:\s]+:[^:\s]+:([^\r\n]+?)\r?$/m;
|
|
98
|
+
/** `grep -n` and `rg -n`: `path:12:` before a match, `path-13-` before a context line. */
|
|
99
|
+
const GREP_PREFIX = /^([\w./@+-]{1,200}\.[\w]{1,10})[:-](\d{1,7})[:-]/gm;
|
|
100
|
+
/**
|
|
101
|
+
* Where one string literal ends and the next begins, when code builds one string from pieces: C and
|
|
102
|
+
* Python juxtapose them (`"ab" "cd"`), most languages add them (`"ab" + "cd"`, PHP `.`, SQL `||`, VB `&`),
|
|
103
|
+
* also across a line break when an operator joins them. The pieces must be token-like (no blanks), so prose in quotes
|
|
104
|
+
* stays apart, and single-quoted pieces need an operator (a CSP source list is `'self' 'nonce-…' 'sha256-…'`). A
|
|
105
|
+
* string starts with a piece of three characters or more; a separator after that may be one (`"SG." + a + "." + b`).
|
|
106
|
+
*/
|
|
107
|
+
const STRING_PIECE = String.raw `[!#-&(-~]{3,300}`;
|
|
108
|
+
const STRING_PART = String.raw `[!#-&(-~]{1,300}`;
|
|
109
|
+
const PIECE_JOINT = String.raw `(?:[ \t]*(?:[+.&]|\|\|)[ \t]*(?:\r?\n[ \t]*)?|[ \t]*\r?\n[ \t]*(?:[+.&]|\|\|)[ \t]*|[ \t]*\r?\n[ \t]*|[ \t]+)`;
|
|
110
|
+
// (A gate, looser than literalJoints, which decides: it admits single-quoted pieces side by side, for one. The
|
|
111
|
+
// second piece as short as literalJoints reads one: `"hunter" + "2" + "xyz"`.)
|
|
112
|
+
const JOINT = new RegExp(String.raw `(["'])${STRING_PIECE}\1${PIECE_JOINT}\1${STRING_PART}\1`);
|
|
113
|
+
/**
|
|
114
|
+
* What `cat -A`, `-e` and `-vET` print at a line's end: a `$`, after `^M` for a CRLF. A text marked so has them at the end of
|
|
115
|
+
* every line but its last (a file with no final newline), and at least two lines do.
|
|
116
|
+
*/
|
|
117
|
+
const LINE_END_MARK_TEST = /\$\n[^\n]*\$(?:\n|$)/;
|
|
118
|
+
/**
|
|
119
|
+
* Characters pass one drops or turns into a line break: NUL, BEL, ESC and the invisible format characters, the blank
|
|
120
|
+
* fillers some scripts draw as nothing (`\u3164`), and, written as two UTF-16 units, Unicode's tag characters and the
|
|
121
|
+
* supplementary variation selectors, which carry a hidden copy of ASCII text through a copy and paste.
|
|
122
|
+
*/
|
|
123
|
+
const INVISIBLE = /[\x00\x07\x1b\u00ad\u034f\u061c\u115f\u1160\u17b4\u17b5\u180b-\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\u3164\ufe00-\ufe0f\ufeff\uffa0]|\udb40[\udc00-\udc7f\udd00-\uddef]/;
|
|
124
|
+
const NEEDS_PASS_ONE = (text) => INVISIBLE.test(text) || ESCAPED_CSI.test(text) || CARET_ESCAPE.test(text);
|
|
125
|
+
const NEEDS_PASS_TWO = (text) => HAS_LINE_NUMBER.test(text) || PREFIXED.test(text) || LINE_END_MARK_TEST.test(text);
|
|
126
|
+
const NEEDS_PASS_THREE = (text) => INLINE_TAG_TEST.test(text) ||
|
|
127
|
+
/[\u00a0\u2000-\u200a\u202f\u3000\uff1a\uff1d]|\\\r?\n/.test(text) ||
|
|
128
|
+
ENTITY.test(text) ||
|
|
129
|
+
JOINT.test(text);
|
|
130
|
+
/** Whether a text has anything to normalize, cheaply. (A pass only has work after one that ran if its own gate already fires on the original.) */
|
|
131
|
+
export function needsNormalizing(text) {
|
|
132
|
+
return NEEDS_PASS_ONE(text) || NEEDS_PASS_TWO(text) || NEEDS_PASS_THREE(text);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* An inline formatting tag, with no attribute but a class, `<b>`, `</strong>`, `<code>`, a syntax highlighter's `<span
|
|
136
|
+
* class="hljs-string">`: what a page shows without it, so a label and its value read as one (`<b>Password:</b> X`,
|
|
137
|
+
* `Password: <code>X</code>`, `<span class="hljs-attr">password</span>: <span class="hljs-string">"X"</span>`).
|
|
138
|
+
*/
|
|
139
|
+
const INLINE_TAG_TEST = /<\/?(?:b|strong|em|i|u|code|tt|kbd|mark|samp|var|span)(?:[ \t]+class=(?:"[^"<>\n]{0,80}"|'[^'<>\n]{0,80}'))?>/i;
|
|
140
|
+
const INLINE_TAG = new RegExp(`^${INLINE_TAG_TEST.source}`, "i");
|
|
141
|
+
/** Builds one pass's output, recording for each character the span of the input it came from. */
|
|
142
|
+
class Rewrite {
|
|
143
|
+
parts = [];
|
|
144
|
+
map = [];
|
|
145
|
+
ends = [];
|
|
146
|
+
boundaries = [];
|
|
147
|
+
emit(ch, from, to) {
|
|
148
|
+
this.parts.push(ch);
|
|
149
|
+
this.map.push(from);
|
|
150
|
+
this.ends.push(to);
|
|
151
|
+
}
|
|
152
|
+
/** Something that separated what comes before from what comes after was dropped here. */
|
|
153
|
+
separate() {
|
|
154
|
+
if (this.boundaries.at(-1) !== this.map.length)
|
|
155
|
+
this.boundaries.push(this.map.length);
|
|
156
|
+
}
|
|
157
|
+
done(inputLength) {
|
|
158
|
+
this.map.push(inputLength);
|
|
159
|
+
return { text: this.parts.join(""), map: this.map, ends: this.ends, boundaries: this.boundaries };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Pass one: what a terminal doesn't show. */
|
|
163
|
+
function stripInvisible(text) {
|
|
164
|
+
const w = new Rewrite();
|
|
165
|
+
let i = 0;
|
|
166
|
+
// Inside an OSC (`ESC ]` or `^[]` begun, not yet ended by BEL, `^G`, a string terminator or the line).
|
|
167
|
+
let inOsc = false;
|
|
168
|
+
while (i < text.length) {
|
|
169
|
+
const c = text.charCodeAt(i);
|
|
170
|
+
// NULs that alternate with characters are UTF-16 read as UTF-8, and dropped; a NUL on its own
|
|
171
|
+
// separates records (`/proc/<pid>/environ`, `find -print0`), and becomes a line break.
|
|
172
|
+
if (c === 0) {
|
|
173
|
+
if (text.charCodeAt(i + 2) !== 0 && text.charCodeAt(i - 2) !== 0)
|
|
174
|
+
w.emit("\n", i, i + 1);
|
|
175
|
+
i++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// ESC, or its caret notation `^[` as `cat -v` and `less` show it: one grammar, escapeSequenceEnd's. (In caret
|
|
179
|
+
// notation, an introducer is read where it stands, `^[[32m`, and `^[\` only as the end of an OSC: `^[\w` is a regex's.)
|
|
180
|
+
const esc = c === 0x1b ? 1 : c === 94 && isCaretEscape(text, i, inOsc) ? 2 : 0;
|
|
181
|
+
if (esc) {
|
|
182
|
+
const end = escapeSequenceEnd(text, i, esc);
|
|
183
|
+
if (end > i) {
|
|
184
|
+
// (An OSC's terminator, BEL or `ESC \`, becomes a line break; see escapeSequenceEnd.)
|
|
185
|
+
if (text[i + esc] === "\\") {
|
|
186
|
+
w.emit("\n", i, end);
|
|
187
|
+
inOsc = false;
|
|
188
|
+
}
|
|
189
|
+
else
|
|
190
|
+
w.separate();
|
|
191
|
+
if (text[i + esc] === "]")
|
|
192
|
+
inOsc = true;
|
|
193
|
+
i = end;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// BEL in caret notation, `^G`, ending an OSC: a line break, as BEL is.
|
|
198
|
+
if (inOsc && c === 94 && text[i + 1] === "G") {
|
|
199
|
+
w.emit("\n", i, i + 2);
|
|
200
|
+
inOsc = false;
|
|
201
|
+
i += 2;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (c === 92) {
|
|
205
|
+
ESCAPED_CSI_AT.lastIndex = i;
|
|
206
|
+
const m = ESCAPED_CSI_AT.exec(text);
|
|
207
|
+
if (m) {
|
|
208
|
+
w.separate();
|
|
209
|
+
i += m[0].length;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Invisible format characters (zero-width spaces and joiners, the word joiner, a BOM, a soft hyphen) are dropped:
|
|
214
|
+
// placed between every character of a key, they hide it from every rule (`sk_l…`).
|
|
215
|
+
if (c > 0x7f && INVISIBLE.test(text[i])) {
|
|
216
|
+
i++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (c === 0xdb40 && INVISIBLE.test(text.slice(i, i + 2))) {
|
|
220
|
+
i += 2;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (c === 7) {
|
|
224
|
+
w.emit("\n", i, i + 1);
|
|
225
|
+
inOsc = false;
|
|
226
|
+
i++;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (c === 10)
|
|
230
|
+
inOsc = false;
|
|
231
|
+
w.emit(text[i], i, i + 1);
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
return w.done(text.length);
|
|
235
|
+
}
|
|
236
|
+
/** Whether the `^` at `i` begins an ESC in caret notation (see CARET_ESCAPE), or ends an OSC (`^[\`) when `inOsc`. */
|
|
237
|
+
function isCaretEscape(text, i, inOsc) {
|
|
238
|
+
CARET_ESCAPE_AT.lastIndex = i;
|
|
239
|
+
return CARET_ESCAPE_AT.test(text) || (inOsc && text.startsWith("^[\\", i));
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Where the escape sequence at `i` ends, or `i` if it isn't one, its ESC `esc` characters long (1, or 2 in caret
|
|
243
|
+
* notation: `^[`): CSI (`ESC [ … letter`), dropped whole; nF character-set designations (`ESC ( B`); two-byte
|
|
244
|
+
* sequences (`ESC 7`, `ESC =`, `ESC M`), among them an OSC's introducer `ESC ]` (only the introducer goes: the
|
|
245
|
+
* payload of `ESC ] … BEL`, a title or a hyperlink's URL, is scanned like any text) and its terminator `ESC \`.
|
|
246
|
+
*/
|
|
247
|
+
function escapeSequenceEnd(text, i, esc = 1) {
|
|
248
|
+
const next = text[i + esc] ?? "";
|
|
249
|
+
if (next === "[") {
|
|
250
|
+
let j = i + esc + 1;
|
|
251
|
+
// Parameters (`:` too, as in 256-colour `38:5:196`), intermediates, then the final byte.
|
|
252
|
+
while (j < text.length && j - i < 40 && /[0-9:;<=>?!]/.test(text[j]))
|
|
253
|
+
j++;
|
|
254
|
+
while (j < text.length && j - i < 44 && /[ -/]/.test(text[j]))
|
|
255
|
+
j++;
|
|
256
|
+
return j < text.length && /[@-~]/.test(text[j]) ? j + 1 : i;
|
|
257
|
+
}
|
|
258
|
+
if (/[ -/]/.test(next)) {
|
|
259
|
+
let j = i + esc;
|
|
260
|
+
while (j < text.length && j - i < esc + 3 && /[ -/]/.test(text[j]))
|
|
261
|
+
j++;
|
|
262
|
+
return /[0-~]/.test(text[j] ?? "") ? j + 1 : i;
|
|
263
|
+
}
|
|
264
|
+
return /[0-~]/.test(next) ? i + esc + 1 : i;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Line prefixes to drop, by where each starts: a file viewer's line numbers;
|
|
268
|
+
* `grep -n`'s on consecutively numbered lines of one file, or on a match on its own when its path has a directory
|
|
269
|
+
* in it (`/etc/redis/redis.conf:1043:requirepass …`; a bare `host.example.com:8443:` could be a host and a port);
|
|
270
|
+
* a unified diff's markers, walked by its hunk header's line counts.
|
|
271
|
+
*/
|
|
272
|
+
function linePrefixes(text) {
|
|
273
|
+
const drop = new Map();
|
|
274
|
+
for (const m of text.matchAll(LINE_NUMBER))
|
|
275
|
+
drop.set(m.index, m[0].length);
|
|
276
|
+
const grep = [];
|
|
277
|
+
for (const m of text.matchAll(GREP_PREFIX))
|
|
278
|
+
grep.push({ at: m.index, len: m[0].length, file: m[1], n: Number(m[2]), match: m[0].endsWith(":") });
|
|
279
|
+
for (let k = 0; k < grep.length; k++) {
|
|
280
|
+
const near = (j) => j >= 0 && j < grep.length && grep[j].file === grep[k].file && Math.abs(grep[j].n - grep[k].n) === 1;
|
|
281
|
+
// (A file's name: a path, or an extension. `db.example.com:5432:` on two lines of a `.pgpass` is a host and a port,
|
|
282
|
+
// and so is `db.example.sh:5432:` on a whole `.pgpass` line, whatever its top-level domain.)
|
|
283
|
+
if (!grep[k].file.includes("/") &&
|
|
284
|
+
((!FILE_EXTENSION.test(grep[k].file) && !/\.(?:env|properties|cnf|pem|key|conf)$/.test(grep[k].file)) ||
|
|
285
|
+
PGPASS.test(text.slice(grep[k].at, lineEnd(text, grep[k].at, 4096)))))
|
|
286
|
+
continue;
|
|
287
|
+
if (near(k - 1) || near(k + 1) || (grep[k].match && grep[k].file.includes("/")))
|
|
288
|
+
drop.set(grep[k].at, grep[k].len);
|
|
289
|
+
}
|
|
290
|
+
// After `@@ -a,b +c,d @@`: b old lines (context and `-`) and d new ones (context and `+`). A context line whose
|
|
291
|
+
// single space an editor stripped is empty; `` belongs to the line before. Read on each
|
|
292
|
+
// line's text after the prefix dropped above, so a viewer's numbered hunk is walked too (`5→-password=…`).
|
|
293
|
+
if (!text.includes("@@ -"))
|
|
294
|
+
return drop;
|
|
295
|
+
const hunk = /@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@[^\n]*\n/y;
|
|
296
|
+
for (let line = 0; line < text.length;) {
|
|
297
|
+
hunk.lastIndex = line + (drop.get(line) ?? 0);
|
|
298
|
+
const h = hunk.exec(text);
|
|
299
|
+
if (!h) {
|
|
300
|
+
const nl = text.indexOf("\n", line);
|
|
301
|
+
if (nl < 0)
|
|
302
|
+
break;
|
|
303
|
+
line = nl + 1;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
let old = Number(h[1] ?? 1);
|
|
307
|
+
let fresh = Number(h[2] ?? 1);
|
|
308
|
+
let at = hunk.lastIndex;
|
|
309
|
+
while (at < text.length && (old > 0 || fresh > 0)) {
|
|
310
|
+
const prefix = drop.get(at) ?? 0;
|
|
311
|
+
const c = text[at + prefix];
|
|
312
|
+
if (c === "+")
|
|
313
|
+
fresh--;
|
|
314
|
+
else if (c === "-")
|
|
315
|
+
old--;
|
|
316
|
+
else if (c === " " || c === "\n" || c === "\r") {
|
|
317
|
+
old--;
|
|
318
|
+
fresh--;
|
|
319
|
+
}
|
|
320
|
+
else if (c !== "\\")
|
|
321
|
+
break;
|
|
322
|
+
if (c === "+" || c === "-" || c === " ")
|
|
323
|
+
drop.set(at, prefix + 1);
|
|
324
|
+
const nl = text.indexOf("\n", at);
|
|
325
|
+
if (nl < 0) {
|
|
326
|
+
at = text.length;
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
at = nl + 1;
|
|
330
|
+
}
|
|
331
|
+
line = at;
|
|
332
|
+
}
|
|
333
|
+
return drop;
|
|
334
|
+
}
|
|
335
|
+
/** Where each line-end mark of a text shown by `cat -A` is, and its length (`^M$` is 3, `$` is 1); none if the text isn't marked so. */
|
|
336
|
+
function lineEndMarks(text) {
|
|
337
|
+
const lines = text.split("\n");
|
|
338
|
+
if (lines.length < 3 || !lines.slice(0, -1).every((line) => line.endsWith("$")))
|
|
339
|
+
return undefined;
|
|
340
|
+
const marks = new Map();
|
|
341
|
+
let at = 0;
|
|
342
|
+
for (const line of lines.slice(0, -1)) {
|
|
343
|
+
const length = line.endsWith("^M$") ? 3 : 1;
|
|
344
|
+
marks.set(at + line.length - length, length);
|
|
345
|
+
at += line.length + 1;
|
|
346
|
+
}
|
|
347
|
+
return marks;
|
|
348
|
+
}
|
|
349
|
+
/** Pass two: what a tool puts in front of lines, and after them. */
|
|
350
|
+
function stripLinePrefixes(text) {
|
|
351
|
+
const w = new Rewrite();
|
|
352
|
+
const prefixes = HAS_LINE_NUMBER.test(text) || PREFIXED.test(text) ? linePrefixes(text) : undefined;
|
|
353
|
+
const marks = LINE_END_MARK_TEST.test(text) ? lineEndMarks(text) : undefined;
|
|
354
|
+
for (let i = 0; i < text.length;) {
|
|
355
|
+
// (Keys are line starts: after `\n`, or `\r` and U+2028 too, as a multiline `^` matches.)
|
|
356
|
+
const skip = prefixes?.get(i) ?? marks?.get(i);
|
|
357
|
+
if (skip) {
|
|
358
|
+
i += skip;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
w.emit(text[i], i, i + 1);
|
|
362
|
+
i++;
|
|
363
|
+
}
|
|
364
|
+
return w.done(text.length);
|
|
365
|
+
}
|
|
366
|
+
/** For each joint, its start (the closing quote of a piece) and its end (after the next piece's opening quote). */
|
|
367
|
+
function literalJoints(text) {
|
|
368
|
+
const joints = new Map();
|
|
369
|
+
const piece = new RegExp(String.raw `(["'])(${STRING_PART})\1`, "g");
|
|
370
|
+
const sep = new RegExp(PIECE_JOINT, "y");
|
|
371
|
+
const doubleQuoted = new RegExp(`"${STRING_PART}"`, "y");
|
|
372
|
+
const singleQuoted = new RegExp(`'${STRING_PART}'`, "y");
|
|
373
|
+
let continues = -1; // where a piece joined to the one before starts
|
|
374
|
+
for (let m = piece.exec(text); m; m = piece.exec(text)) {
|
|
375
|
+
// (A piece that joins nothing may be the gap between two literals, `"a;"+"b"` read as `"+"`: the next search
|
|
376
|
+
// starts inside it, at its closing quote.)
|
|
377
|
+
piece.lastIndex = m.index + 1;
|
|
378
|
+
const chained = m.index === continues;
|
|
379
|
+
if (m[2].length < 3 && !chained)
|
|
380
|
+
continue;
|
|
381
|
+
const close = m.index + m[0].length - 1;
|
|
382
|
+
sep.lastIndex = close + 1;
|
|
383
|
+
const s = sep.exec(text);
|
|
384
|
+
if (!s)
|
|
385
|
+
continue;
|
|
386
|
+
// The next piece: in the same quotes, and token-like too (`"Hello" + " world"` is two strings).
|
|
387
|
+
const next = m[1] === '"' ? doubleQuoted : singleQuoted;
|
|
388
|
+
next.lastIndex = sep.lastIndex;
|
|
389
|
+
if (!next.test(text))
|
|
390
|
+
continue;
|
|
391
|
+
const start = lineStart(text, m.index);
|
|
392
|
+
const before = text.slice(start, m.index);
|
|
393
|
+
// (Or a line of its own after one: `parse(\n "postgres://u:" "pw" "@host"`, Black's wrapping.)
|
|
394
|
+
const above = /^[ \t]*$/.test(before) && start > 0 ? text.slice(lineStart(text, start - 1), start - 1) : "";
|
|
395
|
+
// Juxtaposition (blanks alone) is code only after `=`, `(`, `,`, `:`, `[` or `return`, in either quotes (Python and
|
|
396
|
+
// Ruby join single-quoted pieces too): shell, nginx and log lines put separate arguments and fields side by side.
|
|
397
|
+
// A format string starts no join, nor a CSP source list (`'self' 'nonce-…'`).
|
|
398
|
+
if (!/[+.&|]/.test(s[0]) && !chained) {
|
|
399
|
+
if (!/(?:[=(,:[]|\breturn)[ \t]*$/.test(before) && !/[=(,[][ \t]*\r?$/.test(above))
|
|
400
|
+
continue;
|
|
401
|
+
// (A command line with options passes its quoted words as separate arguments: `curl -u u:"$PW" "https://…"`.)
|
|
402
|
+
if (/(?:^|\s)--?[A-Za-z][\w-]*(?:[ \t=]|$)/.test(before))
|
|
403
|
+
continue;
|
|
404
|
+
if (/%[-+ #0-9.]*[sdvqf]|\{\d*\}/.test(m[2]))
|
|
405
|
+
continue;
|
|
406
|
+
if (m[1] === "'" && /content-security-policy|-src\b/i.test(text.slice(start, lineEnd(text, m.index))))
|
|
407
|
+
continue;
|
|
408
|
+
// Across a line break with no operator: inside parentheses (Python), or a C initializer after `=`.
|
|
409
|
+
if (/\n/.test(s[0]) && !/[(=][ \t]*$/.test(before) && !/[(=,][ \t]*\r?$/.test(above))
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
// The next piece is more of the string, not a name: `"…"="…"` lines (a .reg file, an INI) don't join.
|
|
413
|
+
if (/^[ \t]*[=:]/.test(text.slice(next.lastIndex, next.lastIndex + 20)))
|
|
414
|
+
continue;
|
|
415
|
+
joints.set(close, sep.lastIndex + 1);
|
|
416
|
+
continues = sep.lastIndex;
|
|
417
|
+
piece.lastIndex = sep.lastIndex; // the next piece starts a literal of its own
|
|
418
|
+
}
|
|
419
|
+
return joints;
|
|
420
|
+
}
|
|
421
|
+
/** Pass three: what spells a character or a string another way. */
|
|
422
|
+
function unspell(text) {
|
|
423
|
+
const w = new Rewrite();
|
|
424
|
+
const joints = JOINT.test(text) ? literalJoints(text) : undefined;
|
|
425
|
+
let i = 0;
|
|
426
|
+
while (i < text.length) {
|
|
427
|
+
const c = text.charCodeAt(i);
|
|
428
|
+
// Between two pieces of one string literal: the quotes and the operator vanish.
|
|
429
|
+
const joint = joints?.get(i);
|
|
430
|
+
if (joint !== undefined) {
|
|
431
|
+
i = joint;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
// A line continuation: the backslash and newline vanish, joining the lines as a shell, a C macro or a string
|
|
435
|
+
// does (`--password \`, `TOKEN=AbCd\` then `EfGh`). Not a Windows path's last backslash (`DATA_DIR=C:\data\`)
|
|
436
|
+
// nor a Markdown hard break (`**Host:** db\` then `**Password:** …`): a line that begins with no token there.
|
|
437
|
+
if (c === 92 && (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))) {
|
|
438
|
+
const next = text[i + 1] === "\r" ? i + 3 : i + 2;
|
|
439
|
+
const line = text.slice(lineStart(text, i), i);
|
|
440
|
+
const windowsPath = /(?:^|[\s=:"'])[A-Za-z]:\\[^\s]*$/.test(line);
|
|
441
|
+
const hardBreak = !/[ \t]$/.test(line) && !/^[\w"'$\s-]/.test(text[next] ?? "");
|
|
442
|
+
if (!windowsPath && !hardBreak) {
|
|
443
|
+
w.separate();
|
|
444
|
+
i = next;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (c === 60) {
|
|
449
|
+
const tag = INLINE_TAG.exec(text.slice(i, i + 100));
|
|
450
|
+
if (tag) {
|
|
451
|
+
w.separate();
|
|
452
|
+
i += tag[0].length;
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (c === 38) {
|
|
457
|
+
const m = ENTITY_AT.exec(text.slice(i, i + 12));
|
|
458
|
+
const ch = m ? entityChar(m) : undefined;
|
|
459
|
+
if (m && ch !== undefined) {
|
|
460
|
+
w.emit(ch, i, i + m[0].length);
|
|
461
|
+
i += m[0].length;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// A space of another width or one that doesn't break (`Password:\u00a0V`, ` `'s character, CJK's ideographic
|
|
466
|
+
// space): a blank, as ` ` decodes to.
|
|
467
|
+
if (c === 0xa0 || c === 0x202f || c === 0x3000 || (c >= 0x2000 && c <= 0x200a)) {
|
|
468
|
+
w.emit(" ", i, i + 1);
|
|
469
|
+
i++;
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
// Full-width colon and equals sign.
|
|
473
|
+
if (c === 0xff1a || c === 0xff1d) {
|
|
474
|
+
w.emit(c === 0xff1a ? ":" : "=", i, i + 1);
|
|
475
|
+
i++;
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
w.emit(text[i], i, i + 1);
|
|
479
|
+
i++;
|
|
480
|
+
}
|
|
481
|
+
return w.done(text.length);
|
|
482
|
+
}
|
|
483
|
+
/** Pass `b` run over pass `a`'s output, as one pass over `a`'s input. */
|
|
484
|
+
function compose(a, b) {
|
|
485
|
+
return {
|
|
486
|
+
text: b.text,
|
|
487
|
+
map: b.map.map((p) => a.map[p]),
|
|
488
|
+
// (A character's source ends where the last character of `a`'s output it came from ends.)
|
|
489
|
+
ends: b.ends.map((e) => a.ends[e - 1]),
|
|
490
|
+
// (`a`'s boundaries carried into `b`'s output: the first position of `b` at or after each.)
|
|
491
|
+
boundaries: [...new Set([...a.boundaries.map((p) => lowerBound(b.map, p)), ...b.boundaries])].sort((x, y) => x - y),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
export function normalize(text) {
|
|
495
|
+
let done;
|
|
496
|
+
for (const [needs, pass] of [
|
|
497
|
+
[NEEDS_PASS_ONE, stripInvisible],
|
|
498
|
+
[NEEDS_PASS_TWO, stripLinePrefixes],
|
|
499
|
+
[NEEDS_PASS_THREE, unspell],
|
|
500
|
+
]) {
|
|
501
|
+
const input = done?.text ?? text;
|
|
502
|
+
if (!needs(input))
|
|
503
|
+
continue;
|
|
504
|
+
const next = pass(input);
|
|
505
|
+
done = done ? compose(done, next) : next;
|
|
506
|
+
}
|
|
507
|
+
if (done)
|
|
508
|
+
return done;
|
|
509
|
+
const map = Array.from({ length: text.length + 1 }, (_, k) => k);
|
|
510
|
+
return { text, map, ends: map.slice(1), boundaries: [] };
|
|
511
|
+
}
|
|
512
|
+
/** A text with a blank put in at each of `at` (sorted), and for each of its positions the position in the text it came from. */
|
|
513
|
+
export function withBlanks(text, at) {
|
|
514
|
+
const parts = [];
|
|
515
|
+
const map = [];
|
|
516
|
+
let from = 0;
|
|
517
|
+
for (const b of at) {
|
|
518
|
+
parts.push(text.slice(from, b), " ");
|
|
519
|
+
for (let i = from; i < b; i++)
|
|
520
|
+
map.push(i);
|
|
521
|
+
map.push(b);
|
|
522
|
+
from = b;
|
|
523
|
+
}
|
|
524
|
+
parts.push(text.slice(from));
|
|
525
|
+
for (let i = from; i < text.length; i++)
|
|
526
|
+
map.push(i);
|
|
527
|
+
map.push(text.length);
|
|
528
|
+
return { text: parts.join(""), map };
|
|
529
|
+
}
|
package/dist/pem.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The BEGIN lines of a text, each with its label, in order. */
|
|
2
|
+
export declare function beginLines(text: string): Generator<{
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
label: string;
|
|
6
|
+
}>;
|
|
7
|
+
/** An armored block as pemBody reads it: its BEGIN line (`start`, `labelEnd`), its label, and its body. */
|
|
8
|
+
export interface ArmoredBlock extends PemBody {
|
|
9
|
+
start: number;
|
|
10
|
+
labelEnd: number;
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every armored block of a text, read once a text, in order. (A BEGIN line inside an earlier block's body is that
|
|
15
|
+
* body's text, not a block of its own.)
|
|
16
|
+
*/
|
|
17
|
+
export declare const armoredBlocks: (text: string) => ArmoredBlock[];
|
|
18
|
+
/** What a block's body is: where it ends (its END line's end, or its last material's), and how much material it has. */
|
|
19
|
+
export interface PemBody {
|
|
20
|
+
end: number;
|
|
21
|
+
material: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The material above an END line at `end` whose BEGIN was cut off: the lines of base64 up from it, each with the
|
|
25
|
+
* prefix and the endings pemBody reads (a line number, quotes, a `\n` escape), of any length (a key stored unwrapped),
|
|
26
|
+
* up to the first line that isn't one; that line's own trailing material too, where the cut fell inside it (`out=…`,
|
|
27
|
+
* `"private_key": "…`).
|
|
28
|
+
*/
|
|
29
|
+
export declare function pemTail(text: string, end: number): {
|
|
30
|
+
start: number;
|
|
31
|
+
material: number;
|
|
32
|
+
};
|