@untangle-code/scanner 0.0.0-stage → 0.1.1
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 +661 -0
- package/NOTICE +90 -0
- package/README.md +303 -2
- package/THIRD_PARTY_LICENSES.md +24 -0
- package/dist/binary-classifier.d.ts +45 -0
- package/dist/binary-classifier.d.ts.map +1 -0
- package/dist/binary-classifier.js +296 -0
- package/dist/binary-classifier.js.map +1 -0
- package/dist/concurrency.d.ts +8 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +64 -0
- package/dist/concurrency.js.map +1 -0
- package/dist/discovery.d.ts +9 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +254 -0
- package/dist/discovery.js.map +1 -0
- package/dist/gitignore-pattern.d.ts +35 -0
- package/dist/gitignore-pattern.d.ts.map +1 -0
- package/dist/gitignore-pattern.js +155 -0
- package/dist/gitignore-pattern.js.map +1 -0
- package/dist/hashing.d.ts +14 -0
- package/dist/hashing.d.ts.map +1 -0
- package/dist/hashing.js +32 -0
- package/dist/hashing.js.map +1 -0
- package/dist/ignore-engine.d.ts +27 -0
- package/dist/ignore-engine.d.ts.map +1 -0
- package/dist/ignore-engine.js +130 -0
- package/dist/ignore-engine.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/language-classifier.d.ts +19 -0
- package/dist/language-classifier.d.ts.map +1 -0
- package/dist/language-classifier.js +114 -0
- package/dist/language-classifier.js.map +1 -0
- package/dist/node-fs/index.d.ts +10 -0
- package/dist/node-fs/index.d.ts.map +1 -0
- package/dist/node-fs/index.js +10 -0
- package/dist/node-fs/index.js.map +1 -0
- package/dist/node-fs/node-file-system.d.ts +19 -0
- package/dist/node-fs/node-file-system.d.ts.map +1 -0
- package/dist/node-fs/node-file-system.js +104 -0
- package/dist/node-fs/node-file-system.js.map +1 -0
- package/dist/node-fs/node-streaming-hasher.d.ts +4 -0
- package/dist/node-fs/node-streaming-hasher.d.ts.map +1 -0
- package/dist/node-fs/node-streaming-hasher.js +33 -0
- package/dist/node-fs/node-streaming-hasher.js.map +1 -0
- package/dist/port-options.d.ts +8 -0
- package/dist/port-options.d.ts.map +1 -0
- package/dist/port-options.js +4 -0
- package/dist/port-options.js.map +1 -0
- package/dist/semaphore.d.ts +18 -0
- package/dist/semaphore.d.ts.map +1 -0
- package/dist/semaphore.js +42 -0
- package/dist/semaphore.js.map +1 -0
- package/dist/types.d.ts +102 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +63 -3
- package/src/binary-classifier.ts +365 -0
- package/src/concurrency.ts +78 -0
- package/src/discovery.ts +362 -0
- package/src/gitignore-pattern.ts +170 -0
- package/src/hashing.ts +37 -0
- package/src/ignore-engine.ts +157 -0
- package/src/index.ts +48 -0
- package/src/language-classifier.ts +137 -0
- package/src/node-fs/index.ts +9 -0
- package/src/node-fs/node-file-system.ts +137 -0
- package/src/node-fs/node-streaming-hasher.ts +35 -0
- package/src/port-options.ts +10 -0
- package/src/semaphore.ts +45 -0
- package/src/types.ts +121 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary / encoding / minified-file classification from a content sample
|
|
3
|
+
* (spec.md §6.1: "binäre Dateien erkennen"; WP-05 order: "Nullbytes, Anteil
|
|
4
|
+
* nicht druckbarer Bytes, BOM-/Encoding-Erkennung" + minified heuristics).
|
|
5
|
+
*
|
|
6
|
+
* Pure functions over bytes already in memory — no I/O.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_MINIFIED_RULE, isMinifiedShape } from "@untangle-code/domain";
|
|
9
|
+
import type { TextFormatKind } from "@untangle-code/domain";
|
|
10
|
+
|
|
11
|
+
import type { BinaryClassification, TextEncodingGuess } from "./types.js";
|
|
12
|
+
|
|
13
|
+
interface BomSpec {
|
|
14
|
+
bytes: readonly number[];
|
|
15
|
+
encoding: TextEncodingGuess;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Longest match first (utf16le/be are 2-byte prefixes of nothing else here,
|
|
19
|
+
// but ordering is kept explicit and defensive).
|
|
20
|
+
//
|
|
21
|
+
// NOTE (checked per WP defect audit, not fixed — out of spec.md/architecture.md
|
|
22
|
+
// scope): UTF-32 is not a recognized `TextEncodingGuess` and has no BOM_SPECS
|
|
23
|
+
// entry. A UTF-32LE-BOM'd sample (FF FE 00 00 …) therefore still matches the
|
|
24
|
+
// 2-byte utf16le prefix above and is (mis-)tagged "utf16le" with a 2-byte BOM
|
|
25
|
+
// length. `classifyUtf16Body` below decodes the remaining bytes as UTF-16LE
|
|
26
|
+
// in that case, which — because every UTF-32LE code unit contributes two
|
|
27
|
+
// extra 0x00 bytes — reliably decodes to text containing genuine embedded
|
|
28
|
+
// NUL characters, so the sample still ends up classified `isBinary: true`.
|
|
29
|
+
// That is the pre-existing, undocumented-until-now behavior for UTF-32
|
|
30
|
+
// input: not a regression introduced by this fix, but also not a real fix —
|
|
31
|
+
// neither spec.md nor architecture.md specify UTF-32 support, so adding a
|
|
32
|
+
// dedicated "utf32le"/"utf32be" encoding is a separate, out-of-scope
|
|
33
|
+
// decision rather than something to guess at here.
|
|
34
|
+
const BOM_SPECS: readonly BomSpec[] = [
|
|
35
|
+
{ bytes: [0xef, 0xbb, 0xbf], encoding: "utf8" },
|
|
36
|
+
{ bytes: [0xff, 0xfe], encoding: "utf16le" },
|
|
37
|
+
{ bytes: [0xfe, 0xff], encoding: "utf16be" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function detectBom(sample: Uint8Array): {
|
|
41
|
+
hasBom: boolean;
|
|
42
|
+
encoding: TextEncodingGuess;
|
|
43
|
+
length: number;
|
|
44
|
+
} {
|
|
45
|
+
for (const spec of BOM_SPECS) {
|
|
46
|
+
if (sample.length < spec.bytes.length) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let matches = true;
|
|
50
|
+
for (let i = 0; i < spec.bytes.length; i += 1) {
|
|
51
|
+
if (sample[i] !== spec.bytes[i]) {
|
|
52
|
+
matches = false;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (matches) {
|
|
57
|
+
return { hasBom: true, encoding: spec.encoding, length: spec.bytes.length };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { hasBom: false, encoding: "unknown", length: 0 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Control bytes that indicate non-text content; tab/LF/CR are excluded (they're common in text). */
|
|
64
|
+
function isNonPrintableControlByte(byte: number): boolean {
|
|
65
|
+
return (
|
|
66
|
+
byte <= 0x08 ||
|
|
67
|
+
byte === 0x0b ||
|
|
68
|
+
byte === 0x0c ||
|
|
69
|
+
(byte >= 0x0e && byte <= 0x1f) ||
|
|
70
|
+
byte === 0x7f
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classifies the body of a sample confirmed (by BOM or by the BOM-less
|
|
76
|
+
* heuristic in `detectUtf16WithoutBom`) to be UTF-16 (LE or BE).
|
|
77
|
+
*
|
|
78
|
+
* The raw "a NUL byte means binary" rule from `classifyBinarySample` cannot
|
|
79
|
+
* be applied to UTF-16 bytes directly: every Basic-Latin code unit (i.e.
|
|
80
|
+
* every plain ASCII character) carries a 0x00 byte as one half of its
|
|
81
|
+
* 16-bit unit, so a legitimate UTF-16 text file is close to 50% NUL bytes
|
|
82
|
+
* by construction. Decoding first and inspecting actual *characters*
|
|
83
|
+
* avoids that false positive while still catching real NUL characters and
|
|
84
|
+
* a high ratio of real control characters.
|
|
85
|
+
*/
|
|
86
|
+
function classifyUtf16Body(
|
|
87
|
+
body: Uint8Array,
|
|
88
|
+
encoding: "utf16le" | "utf16be",
|
|
89
|
+
threshold: number,
|
|
90
|
+
hasBom: boolean,
|
|
91
|
+
): BinaryClassification {
|
|
92
|
+
// Non-fatal decode: a byte sequence that only coincidentally starts with a
|
|
93
|
+
// UTF-16 BOM (rare, but the classifier must stay robust for arbitrary
|
|
94
|
+
// binary input) must not throw here — invalid sequences become U+FFFD
|
|
95
|
+
// instead, which is handled like any other non-control character below.
|
|
96
|
+
const text = new TextDecoder(encoding === "utf16le" ? "utf-16le" : "utf-16be", {
|
|
97
|
+
fatal: false,
|
|
98
|
+
}).decode(body);
|
|
99
|
+
|
|
100
|
+
let hasNullByte = false;
|
|
101
|
+
let nonPrintable = 0;
|
|
102
|
+
let unitCount = 0;
|
|
103
|
+
for (const char of text) {
|
|
104
|
+
unitCount += 1;
|
|
105
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
106
|
+
if (codePoint === 0) {
|
|
107
|
+
hasNullByte = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (codePoint < 0x100 && isNonPrintableControlByte(codePoint)) {
|
|
111
|
+
nonPrintable += 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const nonPrintableRatio = unitCount === 0 ? 0 : nonPrintable / unitCount;
|
|
116
|
+
const isBinary = hasNullByte || nonPrintableRatio > threshold;
|
|
117
|
+
|
|
118
|
+
return { isBinary, hasNullByte, nonPrintableRatio, encoding, hasBom };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- BOM-less UTF-16 detection (defect fix, spec.md E4) --------------------
|
|
122
|
+
//
|
|
123
|
+
// A UTF-16 file with no BOM is legal (spec.md/architecture.md do not require
|
|
124
|
+
// one, and plenty of real tooling emits BOM-less UTF-16). Without a BOM,
|
|
125
|
+
// `detectBom` reports `hasBom:false` and — before this fix — the sample fell
|
|
126
|
+
// straight into the raw byte-level loop below, which sees every other byte
|
|
127
|
+
// as 0x00 (the high or low half of each Basic-Latin code unit) and
|
|
128
|
+
// classifies the file as binary. That is a false NEGATIVE (real source text
|
|
129
|
+
// silently vanishes from the index), which is worse than the false-POSITIVE
|
|
130
|
+
// risk of a speculative heuristic, so this is a deliberate, narrow, and
|
|
131
|
+
// documented exception to "don't guess encodings beyond spec": see
|
|
132
|
+
// README.md "Entscheidung 12".
|
|
133
|
+
//
|
|
134
|
+
// Detection requires BOTH of the following (either alone is too weak):
|
|
135
|
+
//
|
|
136
|
+
// 1. Pattern: over the sample, one specific byte parity (even bytes for
|
|
137
|
+
// UTF-16BE, odd bytes for UTF-16LE) is zero at a very high, regular
|
|
138
|
+
// rate (>= UTF16_HEURISTIC_DOMINANT_NULL_RATIO), while the *opposite*
|
|
139
|
+
// parity is zero only rarely (<= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO).
|
|
140
|
+
// A real binary file's NUL bytes are not reliably tied to byte parity
|
|
141
|
+
// like that; this is the "regelmäßig alternierende Nullbytes"
|
|
142
|
+
// signature of Basic-Latin text encoded as fixed-width 16-bit units.
|
|
143
|
+
// 2. Validation: decoding the sample under the *candidate* encoding and
|
|
144
|
+
// counting actual code units, at least
|
|
145
|
+
// UTF16_HEURISTIC_MIN_PRINTABLE_RATIO of them must be printable
|
|
146
|
+
// (excluding NUL, other control characters, and the U+FFFD decode-
|
|
147
|
+
// error marker). This is the same "decode and inspect real
|
|
148
|
+
// characters" principle `classifyUtf16Body` already applies to
|
|
149
|
+
// BOM-confirmed UTF-16 — it is what rejects a real binary blob whose
|
|
150
|
+
// bytes happen to satisfy the parity pattern by chance.
|
|
151
|
+
//
|
|
152
|
+
// Both gates apply only once the sample reaches UTF16_HEURISTIC_MIN_SAMPLE_BYTES
|
|
153
|
+
// (32 bytes = 16 code units) — below that, parity ratios are too noisy to
|
|
154
|
+
// trust one way or the other, so short samples fall through to the existing
|
|
155
|
+
// (conservative: binary-by-default) raw byte-level classification. This is
|
|
156
|
+
// the documented "sehr kurze Probe" edge case; see the co-located test.
|
|
157
|
+
const UTF16_HEURISTIC_MIN_SAMPLE_BYTES = 32;
|
|
158
|
+
const UTF16_HEURISTIC_DOMINANT_NULL_RATIO = 0.6;
|
|
159
|
+
const UTF16_HEURISTIC_OPPOSITE_NULL_RATIO = 0.1;
|
|
160
|
+
const UTF16_HEURISTIC_MIN_PRINTABLE_RATIO = 0.9;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Attempts to identify BOM-less UTF-16 text in `sample` via the two-gate
|
|
164
|
+
* heuristic documented above. Returns the detected encoding, or `null` when
|
|
165
|
+
* the sample is too short, does not show the alternating-NUL pattern, or
|
|
166
|
+
* fails the printability validation (including: genuinely random/binary
|
|
167
|
+
* data that happens to satisfy the parity check alone).
|
|
168
|
+
*/
|
|
169
|
+
function detectUtf16WithoutBom(sample: Uint8Array): "utf16le" | "utf16be" | null {
|
|
170
|
+
const pairCount = Math.floor(sample.length / 2);
|
|
171
|
+
if (sample.length < UTF16_HEURISTIC_MIN_SAMPLE_BYTES || pairCount === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let nullAtEven = 0; // high byte of each pair is 0x00 -> UTF-16BE ASCII signal
|
|
176
|
+
let nullAtOdd = 0; // low byte of each pair is 0x00 -> UTF-16LE ASCII signal
|
|
177
|
+
const bodyLength = pairCount * 2;
|
|
178
|
+
for (let i = 0; i < bodyLength; i += 2) {
|
|
179
|
+
if (sample[i] === 0) {
|
|
180
|
+
nullAtEven += 1;
|
|
181
|
+
}
|
|
182
|
+
if (sample[i + 1] === 0) {
|
|
183
|
+
nullAtOdd += 1;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const evenRatio = nullAtEven / pairCount;
|
|
188
|
+
const oddRatio = nullAtOdd / pairCount;
|
|
189
|
+
|
|
190
|
+
let candidate: "utf16le" | "utf16be" | null = null;
|
|
191
|
+
if (
|
|
192
|
+
oddRatio >= UTF16_HEURISTIC_DOMINANT_NULL_RATIO &&
|
|
193
|
+
evenRatio <= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO
|
|
194
|
+
) {
|
|
195
|
+
candidate = "utf16le";
|
|
196
|
+
} else if (
|
|
197
|
+
evenRatio >= UTF16_HEURISTIC_DOMINANT_NULL_RATIO &&
|
|
198
|
+
oddRatio <= UTF16_HEURISTIC_OPPOSITE_NULL_RATIO
|
|
199
|
+
) {
|
|
200
|
+
candidate = "utf16be";
|
|
201
|
+
}
|
|
202
|
+
if (candidate === null) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Gate 2: decode under the candidate encoding and require the result to be
|
|
207
|
+
// overwhelmingly printable — this is what keeps real binary data (which
|
|
208
|
+
// can coincidentally satisfy the byte-parity check above, e.g. a run of
|
|
209
|
+
// small little-endian integers) from being misread as text.
|
|
210
|
+
const body = sample.subarray(0, bodyLength);
|
|
211
|
+
const text = new TextDecoder(candidate === "utf16le" ? "utf-16le" : "utf-16be", {
|
|
212
|
+
fatal: false,
|
|
213
|
+
}).decode(body);
|
|
214
|
+
|
|
215
|
+
let printable = 0;
|
|
216
|
+
let total = 0;
|
|
217
|
+
for (const char of text) {
|
|
218
|
+
total += 1;
|
|
219
|
+
const codePoint = char.codePointAt(0) ?? 0;
|
|
220
|
+
if (codePoint === 0) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (codePoint === 0xfffd) {
|
|
224
|
+
// TextDecoder's replacement character: an invalid code unit sequence
|
|
225
|
+
// under this candidate encoding, not evidence of real text.
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (codePoint < 0x100 && isNonPrintableControlByte(codePoint)) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
printable += 1;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (total === 0 || printable / total < UTF16_HEURISTIC_MIN_PRINTABLE_RATIO) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return candidate;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Classifies a content sample as binary or text.
|
|
243
|
+
*
|
|
244
|
+
* A sample is binary when it contains a NUL byte or when the ratio of
|
|
245
|
+
* non-printable control bytes exceeds `nonPrintableThreshold` (default
|
|
246
|
+
* 0.3). Bytes in the 0x80–0xFF range are NOT counted as non-printable —
|
|
247
|
+
* valid UTF-8 text uses them for non-ASCII characters, and flagging them
|
|
248
|
+
* would misclassify ordinary internationalized source/comments as binary.
|
|
249
|
+
*
|
|
250
|
+
* When the sample carries a UTF-16 BOM, the body is decoded per the
|
|
251
|
+
* confirmed encoding and classified over actual characters instead
|
|
252
|
+
* (`classifyUtf16Body`) — see that function's doc comment for why the raw
|
|
253
|
+
* byte-level NUL rule does not apply there.
|
|
254
|
+
*
|
|
255
|
+
* Absent a BOM, `detectUtf16WithoutBom` still checks for a conservative,
|
|
256
|
+
* two-gate BOM-less UTF-16 signature (regular alternating NUL bytes at one
|
|
257
|
+
* parity, validated by decoding and requiring the result be overwhelmingly
|
|
258
|
+
* printable) before falling back to the raw byte-level rule — see that
|
|
259
|
+
* function's doc comment for the documented thresholds and rationale
|
|
260
|
+
* (spec.md E4: a false negative here silently drops real source text from
|
|
261
|
+
* the index).
|
|
262
|
+
*/
|
|
263
|
+
export function classifyBinarySample(
|
|
264
|
+
sample: Uint8Array,
|
|
265
|
+
options?: { nonPrintableThreshold?: number },
|
|
266
|
+
): BinaryClassification {
|
|
267
|
+
const threshold = options?.nonPrintableThreshold ?? 0.3;
|
|
268
|
+
const bom = detectBom(sample);
|
|
269
|
+
const body = bom.hasBom ? sample.subarray(bom.length) : sample;
|
|
270
|
+
|
|
271
|
+
if (bom.hasBom && (bom.encoding === "utf16le" || bom.encoding === "utf16be")) {
|
|
272
|
+
return classifyUtf16Body(body, bom.encoding, threshold, true);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!bom.hasBom) {
|
|
276
|
+
const bomlessUtf16 = detectUtf16WithoutBom(sample);
|
|
277
|
+
if (bomlessUtf16 !== null) {
|
|
278
|
+
return classifyUtf16Body(sample, bomlessUtf16, threshold, false);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let hasNullByte = false;
|
|
283
|
+
let nonPrintable = 0;
|
|
284
|
+
let highBit = false;
|
|
285
|
+
for (const byte of body) {
|
|
286
|
+
if (byte === 0) {
|
|
287
|
+
hasNullByte = true;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (isNonPrintableControlByte(byte)) {
|
|
291
|
+
nonPrintable += 1;
|
|
292
|
+
} else if (byte >= 0x80) {
|
|
293
|
+
highBit = true;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const nonPrintableRatio = body.length === 0 ? 0 : nonPrintable / body.length;
|
|
298
|
+
const isBinary = hasNullByte || nonPrintableRatio > threshold;
|
|
299
|
+
|
|
300
|
+
const encoding: TextEncodingGuess = bom.hasBom
|
|
301
|
+
? bom.encoding
|
|
302
|
+
: isBinary
|
|
303
|
+
? "unknown"
|
|
304
|
+
: highBit
|
|
305
|
+
? "utf8"
|
|
306
|
+
: "ascii";
|
|
307
|
+
|
|
308
|
+
return { isBinary, hasNullByte, nonPrintableRatio, encoding, hasBom: bom.hasBom };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface MinifiedHeuristics {
|
|
312
|
+
/**
|
|
313
|
+
* A single line at or above this byte length signals minification. Default 500.
|
|
314
|
+
* Does NOT apply to prose (`textFormat === "prose"`): a long Markdown table
|
|
315
|
+
* row is ordinary prose, not minification.
|
|
316
|
+
*/
|
|
317
|
+
maxLineLengthThreshold?: number;
|
|
318
|
+
/** Average bytes/line at or above this signals minification (low line-to-byte ratio). Default 300. */
|
|
319
|
+
averageBytesPerLineThreshold?: number;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Heuristic minified-file detection ("sehr lange Zeilen, geringes
|
|
324
|
+
* Zeilen-zu-Byte-Verhaeltnis") from a raw content sample. Operates on
|
|
325
|
+
* newline bytes (0x0A) directly rather than decoding, so it is safe to run
|
|
326
|
+
* on the same sample used for binary classification.
|
|
327
|
+
*/
|
|
328
|
+
export function detectMinified(
|
|
329
|
+
sample: Uint8Array,
|
|
330
|
+
heuristics?: MinifiedHeuristics,
|
|
331
|
+
textFormat?: TextFormatKind | null,
|
|
332
|
+
): boolean {
|
|
333
|
+
if (sample.length === 0) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
const maxLineLengthThreshold =
|
|
337
|
+
heuristics?.maxLineLengthThreshold ?? DEFAULT_MINIFIED_RULE.maxLineLengthThreshold;
|
|
338
|
+
const averageBytesPerLineThreshold =
|
|
339
|
+
heuristics?.averageBytesPerLineThreshold ?? DEFAULT_MINIFIED_RULE.averageLineLengthThreshold;
|
|
340
|
+
|
|
341
|
+
let lineBreaks = 0;
|
|
342
|
+
let longestLine = 0;
|
|
343
|
+
let currentLine = 0;
|
|
344
|
+
for (const byte of sample) {
|
|
345
|
+
if (byte === 0x0a) {
|
|
346
|
+
lineBreaks += 1;
|
|
347
|
+
longestLine = Math.max(longestLine, currentLine);
|
|
348
|
+
currentLine = 0;
|
|
349
|
+
} else {
|
|
350
|
+
currentLine += 1;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
longestLine = Math.max(longestLine, currentLine);
|
|
354
|
+
|
|
355
|
+
const averageBytesPerLine = sample.length / Math.max(lineBreaks, 1);
|
|
356
|
+
// Die Entscheidung selbst liegt in `@untangle-code/domain`, damit Scanner und
|
|
357
|
+
// Chunker dieselbe Frage nicht mit verschiedenen Schwellen beantworten. Hier
|
|
358
|
+
// wird nur GEMESSEN — auf Bytes, weil dieselbe Stichprobe schon fuer die
|
|
359
|
+
// Binaerklassifikation gelesen wurde.
|
|
360
|
+
return isMinifiedShape(
|
|
361
|
+
{ maxLineLength: longestLine, averageLineLength: averageBytesPerLine },
|
|
362
|
+
{ textFormat: textFormat ?? null },
|
|
363
|
+
{ maxLineLengthThreshold, averageLineLengthThreshold: averageBytesPerLineThreshold },
|
|
364
|
+
);
|
|
365
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded-concurrency mapping over an (async) iterable, yielding results as
|
|
3
|
+
* soon as they complete (not necessarily in source order). Backs the
|
|
4
|
+
* per-file processing stage of discovery (architecture.md §13.1/§13.2).
|
|
5
|
+
*/
|
|
6
|
+
import { Semaphore } from "./semaphore.js";
|
|
7
|
+
|
|
8
|
+
interface Settled<R> {
|
|
9
|
+
id: number;
|
|
10
|
+
value: R;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Applies `worker` to every item of `source` with at most `concurrency` in
|
|
15
|
+
* flight at once, yielding each result as it settles. Checks `signal` before
|
|
16
|
+
* pulling each new item and before yielding each result, throwing (via
|
|
17
|
+
* `AbortSignal.throwIfAborted`) as soon as an abort is observed.
|
|
18
|
+
*/
|
|
19
|
+
export async function* mapConcurrent<T, R>(
|
|
20
|
+
source: AsyncIterable<T> | Iterable<T>,
|
|
21
|
+
concurrency: number,
|
|
22
|
+
worker: (item: T) => Promise<R>,
|
|
23
|
+
signal?: AbortSignal,
|
|
24
|
+
): AsyncGenerator<R, void, void> {
|
|
25
|
+
const semaphore = new Semaphore(concurrency);
|
|
26
|
+
const iterator: AsyncIterator<T> | Iterator<T> =
|
|
27
|
+
Symbol.asyncIterator in source
|
|
28
|
+
? (source as AsyncIterable<T>)[Symbol.asyncIterator]()
|
|
29
|
+
: (source as Iterable<T>)[Symbol.iterator]();
|
|
30
|
+
|
|
31
|
+
const inFlight = new Map<number, Promise<Settled<R>>>();
|
|
32
|
+
let nextId = 0;
|
|
33
|
+
let sourceDone = false;
|
|
34
|
+
|
|
35
|
+
async function pullOne(): Promise<void> {
|
|
36
|
+
if (sourceDone) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const release = await semaphore.acquire();
|
|
40
|
+
signal?.throwIfAborted();
|
|
41
|
+
const step = await iterator.next();
|
|
42
|
+
if (step.done) {
|
|
43
|
+
sourceDone = true;
|
|
44
|
+
release();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const id = nextId;
|
|
48
|
+
nextId += 1;
|
|
49
|
+
const task = worker(step.value)
|
|
50
|
+
.then((value) => ({ id, value }))
|
|
51
|
+
.finally(release);
|
|
52
|
+
inFlight.set(id, task);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
signal?.throwIfAborted();
|
|
57
|
+
const starters: Array<Promise<void>> = [];
|
|
58
|
+
for (let i = 0; i < concurrency; i += 1) {
|
|
59
|
+
starters.push(pullOne());
|
|
60
|
+
}
|
|
61
|
+
await Promise.all(starters);
|
|
62
|
+
|
|
63
|
+
while (inFlight.size > 0) {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
const settled = await Promise.race(inFlight.values());
|
|
66
|
+
inFlight.delete(settled.id);
|
|
67
|
+
if (!sourceDone) {
|
|
68
|
+
await pullOne();
|
|
69
|
+
}
|
|
70
|
+
signal?.throwIfAborted();
|
|
71
|
+
yield settled.value;
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
if (typeof (iterator as AsyncIterator<T>).return === "function") {
|
|
75
|
+
await (iterator as AsyncIterator<T>).return?.();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|