@adhisang/minecraft-modding-mcp 7.0.0-rc.2 → 7.0.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 +50 -3
- package/README.md +1 -1
- package/dist/artifact-identity.d.ts +130 -0
- package/dist/artifact-identity.js +142 -0
- package/dist/cache-registry.d.ts +16 -0
- package/dist/cache-registry.js +78 -10
- package/dist/config.js +2 -2
- package/dist/entry-tools/analyze-mod-service.d.ts +4 -4
- package/dist/entry-tools/analyze-symbol-service.d.ts +2 -2
- package/dist/entry-tools/entry-tool-schema.d.ts +2 -2
- package/dist/json-rpc-framing.d.ts +20 -0
- package/dist/json-rpc-framing.js +155 -23
- package/dist/mapping/loaders/tiny-maven.d.ts +9 -0
- package/dist/mapping/loaders/tiny-maven.js +10 -2
- package/dist/minecraft-explorer-service.js +2 -2
- package/dist/path-resolver.d.ts +13 -2
- package/dist/path-resolver.js +12 -1
- package/dist/repo-downloader.d.ts +19 -7
- package/dist/repo-downloader.js +123 -3
- package/dist/source/artifact-resolver.js +14 -6
- package/dist/source/class-source.js +56 -1
- package/dist/source-resolver.d.ts +0 -1
- package/dist/source-resolver.js +25 -94
- package/dist/stdio-supervisor.d.ts +150 -4
- package/dist/stdio-supervisor.js +710 -68
- package/dist/storage/db.js +5 -1
- package/dist/tool-schemas.d.ts +55 -55
- package/dist/types.d.ts +18 -1
- package/docs/tool-reference.md +24 -12
- package/package.json +1 -1
|
@@ -2,8 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
import type { CheckSymbolExistsOutput, FindMappingOutput, GetClassApiMatrixOutput, ResolveMethodMappingExactOutput, ResolveWorkspaceSymbolOutput, TraceSymbolLifecycleOutput } from "../source-service.js";
|
|
3
3
|
export declare const analyzeSymbolShape: {
|
|
4
4
|
task: z.ZodEnum<{
|
|
5
|
-
map: "map";
|
|
6
5
|
workspace: "workspace";
|
|
6
|
+
map: "map";
|
|
7
7
|
exists: "exists";
|
|
8
8
|
"exact-map": "exact-map";
|
|
9
9
|
lifecycle: "lifecycle";
|
|
@@ -72,8 +72,8 @@ export declare const analyzeSymbolShape: {
|
|
|
72
72
|
};
|
|
73
73
|
export declare const analyzeSymbolSchema: z.ZodObject<{
|
|
74
74
|
task: z.ZodEnum<{
|
|
75
|
-
map: "map";
|
|
76
75
|
workspace: "workspace";
|
|
76
|
+
map: "map";
|
|
77
77
|
exists: "exists";
|
|
78
78
|
"exact-map": "exact-map";
|
|
79
79
|
lifecycle: "lifecycle";
|
|
@@ -5,10 +5,10 @@ export declare const detailSchema: z.ZodEnum<{
|
|
|
5
5
|
full: "full";
|
|
6
6
|
}>;
|
|
7
7
|
export declare const includeGroupSchema: z.ZodEnum<{
|
|
8
|
-
entries: "entries";
|
|
9
8
|
workspace: "workspace";
|
|
10
|
-
|
|
9
|
+
entries: "entries";
|
|
11
10
|
source: "source";
|
|
11
|
+
candidates: "candidates";
|
|
12
12
|
warnings: "warnings";
|
|
13
13
|
registry: "registry";
|
|
14
14
|
preview: "preview";
|
|
@@ -78,6 +78,15 @@ export declare class JsonRpcFrameReader {
|
|
|
78
78
|
private awaitedBodyStart;
|
|
79
79
|
private idleTimer;
|
|
80
80
|
private fatal;
|
|
81
|
+
/**
|
|
82
|
+
* Set when an oversized, not-yet-terminated line/header-less run was just
|
|
83
|
+
* discarded with no newline in hand. The bytes that eventually complete
|
|
84
|
+
* that same logical line are not a delimiter the reader can trust as the
|
|
85
|
+
* start of a fresh frame, so every byte up to and including the next 0x0a —
|
|
86
|
+
* however many chunks it takes to arrive — is swallowed unread before
|
|
87
|
+
* normal parsing resumes. See `rejectOversizedIncompleteInput`.
|
|
88
|
+
*/
|
|
89
|
+
private discardingOversizedLine;
|
|
81
90
|
/**
|
|
82
91
|
* @param options.maxFrameBytes Largest accepted frame; defaults to
|
|
83
92
|
* {@link loadMaxFrameBytes}.
|
|
@@ -128,6 +137,17 @@ export declare class JsonRpcFrameReader {
|
|
|
128
137
|
* a caller that does not still gets a live pair rather than a stale one.
|
|
129
138
|
*/
|
|
130
139
|
private armIdleTimer;
|
|
140
|
+
/**
|
|
141
|
+
* Whether the buffer, despite `mode` still sticking at "content-length"
|
|
142
|
+
* from an earlier frame, actually opens a line-mode frame — the same probe
|
|
143
|
+
* `readContentLengthMessage` uses to detect the mid-stream switch back to
|
|
144
|
+
* line framing (a JSON object/array opener can never begin a Content-Length
|
|
145
|
+
* header block). Used to keep the header-size ceiling scoped to buffers
|
|
146
|
+
* still being accumulated as a header block, so it never judges a line
|
|
147
|
+
* frame's bytes as an oversized header just because the switch hasn't been
|
|
148
|
+
* recognized yet.
|
|
149
|
+
*/
|
|
150
|
+
private looksLikeLineFrame;
|
|
131
151
|
private canCompleteFrame;
|
|
132
152
|
private rejectOversizedIncompleteInput;
|
|
133
153
|
/**
|
package/dist/json-rpc-framing.js
CHANGED
|
@@ -16,23 +16,72 @@ const clearIdleTimerHandle = (handle) => {
|
|
|
16
16
|
clearNodeTimeout(handle);
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
-
* The
|
|
19
|
+
* The end of the header block: the FIRST EMPTY LINE in the buffer.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* One rule generates every terminator style instead of a list of literals to
|
|
22
|
+
* match. A header line ends at an LF, and a CR immediately before that LF
|
|
23
|
+
* belongs to the terminator rather than to the line; the block ends at the
|
|
24
|
+
* first line that holds nothing but its own terminator. `index` is the first
|
|
25
|
+
* byte of that terminating sequence — so `buffer.slice(0, index)` is exactly
|
|
26
|
+
* the header text — and `delimiterBytes` spans through the empty line's LF, so
|
|
27
|
+
* the body opens at `index + delimiterBytes`. The four shapes a peer can
|
|
28
|
+
* produce are consequences, not cases:
|
|
29
|
+
*
|
|
30
|
+
* "\r\n\r\n" → 4 bytes "\r\n\n" → 3 bytes
|
|
31
|
+
* "\n\r\n" → 3 bytes "\n\n" → 2 bytes
|
|
32
|
+
*
|
|
33
|
+
* Scanning forward from byte 0 is also what keeps a real terminator ahead of
|
|
34
|
+
* any byte sequence inside a body: preferring a CRLFCRLF found ANYWHERE over
|
|
35
|
+
* an earlier LFLF mis-framed every LF-framed peer whose JSON body happened to
|
|
36
|
+
* contain a raw `\r\n\r\n` — legal inter-token whitespace — because the header
|
|
37
|
+
* block was then cut at a boundary inside the body, losing that frame and the
|
|
38
|
+
* next. A single forward scan cannot reach the body before the block ends, so
|
|
39
|
+
* no body byte can outrank the terminator and no two readings compete.
|
|
40
|
+
*
|
|
41
|
+
* Consequence worth stating, because it is load-bearing for the caller: an
|
|
42
|
+
* EXTRA empty line after the terminator is body, not header. The body window
|
|
43
|
+
* then opens on that empty line, shifted by however many bytes that line's own
|
|
44
|
+
* terminator took — one for a bare LF, two for CRLF — bytes the peer's
|
|
45
|
+
* declared length did not count, so the window no longer covers the same span
|
|
46
|
+
* as the JSON value.
|
|
47
|
+
* All four extra-blank-line shapes behave alike here, which is the point: the
|
|
48
|
+
* reading does not depend on which terminator style the peer chose.
|
|
49
|
+
*
|
|
50
|
+
* What that shift COSTS is a separate question, and the answer is not always
|
|
51
|
+
* "the frame". The usual outcome is a window running off the end of the JSON,
|
|
52
|
+
* `readContentLengthMessage`'s body-parse failure and a framing-fatal — see
|
|
53
|
+
* {@link JsonRpcFramingFatalError}. But the window is only shifted, not
|
|
54
|
+
* mis-sized, so trailing whitespace INSIDE the declared length can absorb the
|
|
55
|
+
* shift exactly: a length that counts two trailing spaces, against two
|
|
56
|
+
* uncounted leading bytes, lands the window on `"\r\n" + <json>`, which
|
|
57
|
+
* `JSON.parse` accepts. Such a frame is delivered normally. The reader does
|
|
58
|
+
* not detect the extra blank line; it only ever sees where the bytes fall.
|
|
25
59
|
*/
|
|
26
60
|
function findHeaderBoundary(buffer) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
61
|
+
let searchFrom = 0;
|
|
62
|
+
while (true) {
|
|
63
|
+
const lineEnd = buffer.indexOf(0x0a, searchFrom);
|
|
64
|
+
if (lineEnd === -1) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
// The next line starts immediately after that LF. It is the empty line
|
|
68
|
+
// when the only bytes it holds are its own terminator: an optional CR and
|
|
69
|
+
// then an LF. An out-of-range read is `undefined`, which matches neither
|
|
70
|
+
// byte, so a truncated tail simply keeps the scan waiting for more input.
|
|
71
|
+
let cursor = lineEnd + 1;
|
|
72
|
+
if (buffer[cursor] === 0x0d) {
|
|
73
|
+
cursor += 1;
|
|
74
|
+
}
|
|
75
|
+
if (buffer[cursor] === 0x0a) {
|
|
76
|
+
// A CR in front of the FIRST LF terminates the preceding header line, so
|
|
77
|
+
// it is part of the delimiter, not of the headers. Guarding on
|
|
78
|
+
// `lineEnd > 0` keeps the lookbehind inside the buffer when the block is
|
|
79
|
+
// terminated at byte 0.
|
|
80
|
+
const index = lineEnd > 0 && buffer[lineEnd - 1] === 0x0d ? lineEnd - 1 : lineEnd;
|
|
81
|
+
return { index, delimiterBytes: cursor + 1 - index };
|
|
82
|
+
}
|
|
83
|
+
searchFrom = lineEnd + 1;
|
|
34
84
|
}
|
|
35
|
-
return undefined;
|
|
36
85
|
}
|
|
37
86
|
function parseJsonRpcMessage(json) {
|
|
38
87
|
return parseJSONRPCMessage(JSON.parse(json));
|
|
@@ -124,6 +173,15 @@ export class JsonRpcFrameReader {
|
|
|
124
173
|
awaitedBodyStart = -1;
|
|
125
174
|
idleTimer;
|
|
126
175
|
fatal = false;
|
|
176
|
+
/**
|
|
177
|
+
* Set when an oversized, not-yet-terminated line/header-less run was just
|
|
178
|
+
* discarded with no newline in hand. The bytes that eventually complete
|
|
179
|
+
* that same logical line are not a delimiter the reader can trust as the
|
|
180
|
+
* start of a fresh frame, so every byte up to and including the next 0x0a —
|
|
181
|
+
* however many chunks it takes to arrive — is swallowed unread before
|
|
182
|
+
* normal parsing resumes. See `rejectOversizedIncompleteInput`.
|
|
183
|
+
*/
|
|
184
|
+
discardingOversizedLine = false;
|
|
127
185
|
/**
|
|
128
186
|
* @param options.maxFrameBytes Largest accepted frame; defaults to
|
|
129
187
|
* {@link loadMaxFrameBytes}.
|
|
@@ -159,6 +217,7 @@ export class JsonRpcFrameReader {
|
|
|
159
217
|
this.awaitedFrameEnd = -1;
|
|
160
218
|
this.awaitedBodyStart = -1;
|
|
161
219
|
this.fatal = false;
|
|
220
|
+
this.discardingOversizedLine = false;
|
|
162
221
|
}
|
|
163
222
|
clear() {
|
|
164
223
|
this.clearIdleTimer();
|
|
@@ -169,6 +228,7 @@ export class JsonRpcFrameReader {
|
|
|
169
228
|
this.awaitedFrameEnd = -1;
|
|
170
229
|
this.awaitedBodyStart = -1;
|
|
171
230
|
this.fatal = false;
|
|
231
|
+
this.discardingOversizedLine = false;
|
|
172
232
|
}
|
|
173
233
|
processChunk(chunk, handlers) {
|
|
174
234
|
if (chunk.length === 0 || this.fatal) {
|
|
@@ -195,6 +255,24 @@ export class JsonRpcFrameReader {
|
|
|
195
255
|
this.pendingBytes = 0;
|
|
196
256
|
while (true) {
|
|
197
257
|
try {
|
|
258
|
+
if (this.discardingOversizedLine) {
|
|
259
|
+
// Swallow bytes up to and including the next newline WITHOUT
|
|
260
|
+
// interpreting them as a frame — they are the tail of the line just
|
|
261
|
+
// rejected as oversized, not a fresh start, even if they happen to
|
|
262
|
+
// look like a well-formed message on their own. Only once that
|
|
263
|
+
// terminator is found does this resynchronize on the byte position
|
|
264
|
+
// the peer itself delimited.
|
|
265
|
+
const newlineIndex = this.buffer.indexOf(0x0a);
|
|
266
|
+
if (newlineIndex === -1) {
|
|
267
|
+
// The discarded bytes carry no information, so there is nothing
|
|
268
|
+
// to hold onto while waiting for the terminator.
|
|
269
|
+
this.buffer = Buffer.alloc(0);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.buffer = this.buffer.subarray(newlineIndex + 1);
|
|
273
|
+
this.discardingOversizedLine = false;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
198
276
|
this.rejectOversizedIncompleteInput();
|
|
199
277
|
if (this.mode === "unknown") {
|
|
200
278
|
const detected = this.detectMode();
|
|
@@ -250,6 +328,7 @@ export class JsonRpcFrameReader {
|
|
|
250
328
|
this.buffer = Buffer.alloc(0);
|
|
251
329
|
this.pendingChunks = [];
|
|
252
330
|
this.pendingBytes = 0;
|
|
331
|
+
this.discardingOversizedLine = false;
|
|
253
332
|
handlers.onError(error);
|
|
254
333
|
return;
|
|
255
334
|
}
|
|
@@ -314,6 +393,28 @@ export class JsonRpcFrameReader {
|
|
|
314
393
|
handle = this.scheduleTimer(expire, this.incompleteFrameIdleMs);
|
|
315
394
|
this.idleTimer = handle;
|
|
316
395
|
}
|
|
396
|
+
/**
|
|
397
|
+
* Whether the buffer, despite `mode` still sticking at "content-length"
|
|
398
|
+
* from an earlier frame, actually opens a line-mode frame — the same probe
|
|
399
|
+
* `readContentLengthMessage` uses to detect the mid-stream switch back to
|
|
400
|
+
* line framing (a JSON object/array opener can never begin a Content-Length
|
|
401
|
+
* header block). Used to keep the header-size ceiling scoped to buffers
|
|
402
|
+
* still being accumulated as a header block, so it never judges a line
|
|
403
|
+
* frame's bytes as an oversized header just because the switch hasn't been
|
|
404
|
+
* recognized yet.
|
|
405
|
+
*/
|
|
406
|
+
looksLikeLineFrame() {
|
|
407
|
+
let probeIndex = 0;
|
|
408
|
+
while (probeIndex < this.buffer.length &&
|
|
409
|
+
(this.buffer[probeIndex] === 0x20 ||
|
|
410
|
+
this.buffer[probeIndex] === 0x09 ||
|
|
411
|
+
this.buffer[probeIndex] === 0x0d ||
|
|
412
|
+
this.buffer[probeIndex] === 0x0a)) {
|
|
413
|
+
probeIndex += 1;
|
|
414
|
+
}
|
|
415
|
+
return (probeIndex < this.buffer.length &&
|
|
416
|
+
(this.buffer[probeIndex] === 0x7b /* '{' */ || this.buffer[probeIndex] === 0x5b /* '[' */));
|
|
417
|
+
}
|
|
317
418
|
canCompleteFrame(chunk) {
|
|
318
419
|
const bufferedBytes = this.buffer.length + this.pendingBytes;
|
|
319
420
|
if (this.mode === "content-length" && this.awaitedFrameEnd >= 0) {
|
|
@@ -326,8 +427,17 @@ export class JsonRpcFrameReader {
|
|
|
326
427
|
return chunk.includes(0x0a) || bufferedBytes > this.maxFrameBytes;
|
|
327
428
|
}
|
|
328
429
|
rejectOversizedIncompleteInput() {
|
|
329
|
-
|
|
330
|
-
|
|
430
|
+
// Sticky "content-length" mode only means a header block is being
|
|
431
|
+
// accumulated when the buffer doesn't already look like a line frame; a
|
|
432
|
+
// JSON object/array opener here is the same mid-stream switch
|
|
433
|
+
// `readContentLengthMessage` recognizes, just not yet reached. The header
|
|
434
|
+
// ceiling below must be scoped to actual header accumulation, or a large
|
|
435
|
+
// line-mode frame arriving right after a Content-Length frame gets judged
|
|
436
|
+
// as an oversized header before the switch is detected.
|
|
437
|
+
const isLineFrameAfterContentLength = this.mode === "content-length" && this.looksLikeLineFrame();
|
|
438
|
+
const inHeaderAccumulation = this.mode === "content-length" && !isLineFrameAfterContentLength;
|
|
439
|
+
const headerBoundary = inHeaderAccumulation ? findHeaderBoundary(this.buffer) : undefined;
|
|
440
|
+
if (inHeaderAccumulation &&
|
|
331
441
|
!headerBoundary &&
|
|
332
442
|
this.buffer.length > MAX_CONTENT_LENGTH_HEADER_BYTES) {
|
|
333
443
|
// No header terminator anywhere in an over-limit header block: there is
|
|
@@ -340,15 +450,25 @@ export class JsonRpcFrameReader {
|
|
|
340
450
|
if (this.buffer.length <= this.maxFrameBytes) {
|
|
341
451
|
return;
|
|
342
452
|
}
|
|
343
|
-
if (
|
|
453
|
+
if (inHeaderAccumulation && headerBoundary) {
|
|
344
454
|
return;
|
|
345
455
|
}
|
|
346
|
-
if (
|
|
456
|
+
if (!inHeaderAccumulation && this.buffer.includes(0x0a)) {
|
|
347
457
|
return;
|
|
348
458
|
}
|
|
349
459
|
const observedBytes = this.buffer.length;
|
|
350
|
-
const description = this.mode === "line"
|
|
460
|
+
const description = this.mode === "line" || isLineFrameAfterContentLength
|
|
461
|
+
? "Line-delimited JSON-RPC frame"
|
|
462
|
+
: "Headerless JSON-RPC input";
|
|
351
463
|
this.buffer = Buffer.alloc(0);
|
|
464
|
+
if (!inHeaderAccumulation) {
|
|
465
|
+
// The oversized run has no newline anywhere in it yet (the check above
|
|
466
|
+
// would otherwise have returned): remember to swallow bytes through the
|
|
467
|
+
// eventual terminator — wherever it arrives — before resuming normal
|
|
468
|
+
// parsing, so the discarded line's own tail is never re-read as a fresh
|
|
469
|
+
// frame (see `discardingOversizedLine` in `drainChunk`).
|
|
470
|
+
this.discardingOversizedLine = true;
|
|
471
|
+
}
|
|
352
472
|
throw new Error(`${description} is ${observedBytes} bytes, exceeding the configured frame limit of ` +
|
|
353
473
|
`${this.maxFrameBytes} bytes.`);
|
|
354
474
|
}
|
|
@@ -435,11 +555,23 @@ export class JsonRpcFrameReader {
|
|
|
435
555
|
continue;
|
|
436
556
|
}
|
|
437
557
|
if (/^\s*content-length\s*:/i.test(line)) {
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
558
|
+
// `line` has already had its trailing \r stripped, so the terminator
|
|
559
|
+
// has to be written back. Always re-inject CRLF: choosing the
|
|
560
|
+
// separator from the next buffered byte raced byte-granular chunking
|
|
561
|
+
// (the peer's \r may not have arrived yet, so the boundary style would
|
|
562
|
+
// depend on read timing rather than on the peer), and this keeps the
|
|
563
|
+
// output independent of arrival.
|
|
564
|
+
//
|
|
565
|
+
// It does not cost the FRAMING: findHeaderBoundary sees an empty line
|
|
566
|
+
// next either way — "...\r\n" + "\r\n…" and "...\r\n" + "\n…" are both
|
|
567
|
+
// first-empty-line boundaries — so no boundary moves. It does cost one
|
|
568
|
+
// BYTE when the peer wrote a bare LF, and that byte is measured
|
|
569
|
+
// against MAX_CONTENT_LENGTH_HEADER_BYTES: an LF-framed header block of
|
|
570
|
+
// exactly 8192 bytes frames on its own, but re-injected here it
|
|
571
|
+
// measures 8193 and is rejected. Exactly one block size is affected —
|
|
572
|
+
// 8192, since anything larger was already over the ceiling — and only
|
|
573
|
+
// when it follows a line-delimited frame, which is why this is left as
|
|
574
|
+
// it stands rather than traded for the chunking race.
|
|
443
575
|
this.buffer = Buffer.concat([Buffer.from(`${line}\r\n`, "utf8"), this.buffer]);
|
|
444
576
|
this.mode = "content-length";
|
|
445
577
|
return undefined;
|
|
@@ -1,2 +1,11 @@
|
|
|
1
|
+
import type { DirectionIndex, PairKey } from "../internal-types.js";
|
|
1
2
|
import type { MappingLoaderDeps, MappingLoaderResult } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* `maxEntryBytes` reuses the same ceiling as nested-jar extraction
|
|
5
|
+
* ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
|
|
6
|
+
* download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
|
|
7
|
+
* decompressed in full before parsing, so an entry with a small compressed
|
|
8
|
+
* size and a huge inflated size (zip-bomb style) must still be bounded here.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseTinyFromJar(jarPath: string, maxEntryBytes?: number): Promise<Map<PairKey, DirectionIndex>>;
|
|
2
11
|
export declare function loadTinyPairsFromMaven(deps: MappingLoaderDeps, version: string): Promise<MappingLoaderResult>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { defaultDownloadPath, downloadToCache } from "../../repo-downloader.js";
|
|
2
|
+
import { loadMaxNestedJarEntryBytes } from "../../source/nested-jars.js";
|
|
2
3
|
import { collectMatchedJarEntriesAsUtf8 } from "../../source-jar-reader.js";
|
|
3
4
|
import { mergeDirectionIndexes } from "../parsers/symbol-records.js";
|
|
4
5
|
import { parseTinyMappingsInto } from "../parsers/tiny.js";
|
|
@@ -27,8 +28,15 @@ async function fetchYarnCoordinates(fetchFn, repoBase, version) {
|
|
|
27
28
|
return [version];
|
|
28
29
|
}
|
|
29
30
|
}
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `maxEntryBytes` reuses the same ceiling as nested-jar extraction
|
|
33
|
+
* ({@link loadMaxNestedJarEntryBytes}): the downloaded jar is itself
|
|
34
|
+
* download-size-capped, but a single `.tiny`/`.tinyv2` entry inside it is
|
|
35
|
+
* decompressed in full before parsing, so an entry with a small compressed
|
|
36
|
+
* size and a huge inflated size (zip-bomb style) must still be bounded here.
|
|
37
|
+
*/
|
|
38
|
+
export async function parseTinyFromJar(jarPath, maxEntryBytes = loadMaxNestedJarEntryBytes()) {
|
|
39
|
+
const tinyEntries = (await collectMatchedJarEntriesAsUtf8(jarPath, (entry) => entry.toLowerCase().endsWith(".tiny") || entry.toLowerCase().endsWith(".tinyv2"), { continueOnError: true, maxBytes: maxEntryBytes })).sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
32
40
|
// Parsed straight into the shared accumulator: a parse-then-merge loop would
|
|
33
41
|
// hold each entry's full index alongside the accumulated one. `ensurePairIndex`
|
|
34
42
|
// + `addLookupEntries` union into what is already there, matching what
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { createError, ERROR_CODES } from "./errors.js";
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
|
-
import {
|
|
4
|
+
import { normalizeJarPath, readJarStatStamp } from "./path-resolver.js";
|
|
5
5
|
import { createJarEntryReader } from "./source-jar-reader.js";
|
|
6
6
|
import { artifactIdNamesMinecraftRuntime } from "./source/artifact-resolver.js";
|
|
7
7
|
import { matchesMemberPattern } from "./source/member-pattern.js";
|
|
@@ -988,7 +988,7 @@ export class MinecraftExplorerService {
|
|
|
988
988
|
// With no derivable version the conservative "obfuscated" stands — which
|
|
989
989
|
// is also the right default for a dependency jar's own bytecode namespace.
|
|
990
990
|
mappingNamespace: minecraftVersion && isUnobfuscatedVersion(minecraftVersion) ? "mojang" : "obfuscated",
|
|
991
|
-
jarSignature:
|
|
991
|
+
jarSignature: readJarStatStamp(jarPath).sourceArtifactId,
|
|
992
992
|
generatedAt: new Date().toISOString()
|
|
993
993
|
};
|
|
994
994
|
}
|
package/dist/path-resolver.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { JarStatStamp } from "./types.js";
|
|
2
2
|
export interface ResolvedJarInfo {
|
|
3
3
|
originalPath: string;
|
|
4
4
|
resolvedPath: string;
|
|
@@ -9,7 +9,18 @@ export declare function buildJarSignature(stats: {
|
|
|
9
9
|
mtimeMs: number;
|
|
10
10
|
size: number;
|
|
11
11
|
}): string;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Read a jar's stat stamp: it stats the file, it does not read a byte of it.
|
|
14
|
+
*
|
|
15
|
+
* The name says stat because a stat is all this is. Artifact identity is
|
|
16
|
+
* derived and composed in `src/artifact-identity.ts`; nothing this returns is
|
|
17
|
+
* an artifactId, and nothing keys an artifact by it.
|
|
18
|
+
*
|
|
19
|
+
* Its one production caller is the `jarSignature` field in
|
|
20
|
+
* `src/minecraft-explorer-service.ts`, which publishes this stat-based value to
|
|
21
|
+
* callers of `get-project-context`.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readJarStatStamp(jarPath: string): JarStatStamp;
|
|
13
24
|
export declare function isSecureJarEntryPath(entryPath: string): boolean;
|
|
14
25
|
/**
|
|
15
26
|
* Validate and normalize a user-supplied jar path input.
|
package/dist/path-resolver.js
CHANGED
|
@@ -72,7 +72,18 @@ export function resolveJarPathWithSymlinkCheck(jarPath) {
|
|
|
72
72
|
export function buildJarSignature(stats) {
|
|
73
73
|
return `${Math.trunc(stats.mtimeMs)}:${stats.size}`;
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Read a jar's stat stamp: it stats the file, it does not read a byte of it.
|
|
77
|
+
*
|
|
78
|
+
* The name says stat because a stat is all this is. Artifact identity is
|
|
79
|
+
* derived and composed in `src/artifact-identity.ts`; nothing this returns is
|
|
80
|
+
* an artifactId, and nothing keys an artifact by it.
|
|
81
|
+
*
|
|
82
|
+
* Its one production caller is the `jarSignature` field in
|
|
83
|
+
* `src/minecraft-explorer-service.ts`, which publishes this stat-based value to
|
|
84
|
+
* callers of `get-project-context`.
|
|
85
|
+
*/
|
|
86
|
+
export function readJarStatStamp(jarPath) {
|
|
76
87
|
const resolvedPath = resolveJarPathWithSymlinkCheck(jarPath).resolvedPath;
|
|
77
88
|
const stats = statSync(resolvedPath);
|
|
78
89
|
return {
|
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* That claim is scoped to this entry point, not to the `downloads/` directory.
|
|
12
12
|
* Callers that reach for raw {@link downloadToCache} - the version service's
|
|
13
13
|
* Mojang jars, the mapping service's mapping archives - park files under the
|
|
14
|
-
* same root
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* bytes
|
|
20
|
-
* {@link digestFile} to get
|
|
14
|
+
* same root and write no sidecar: a Mojang artifact already carries an upstream
|
|
15
|
+
* SHA-1 contract. They are a different cache layer, not a migration this module
|
|
16
|
+
* is waiting on.
|
|
17
|
+
*
|
|
18
|
+
* Every artifactId `src/artifact-identity.ts` composes, in both of its id
|
|
19
|
+
* spaces, is built on a sha256 of the bytes of the file it names - see
|
|
20
|
+
* `contentSignature` there, which reuses {@link digestFile} to get it. That
|
|
21
|
+
* covers artifacts on local disk outside the cache entirely (`~/.m2`, the
|
|
22
|
+
* Gradle module cache) and a jar or a Minecraft version the caller names
|
|
23
|
+
* directly.
|
|
21
24
|
*/
|
|
22
25
|
export interface DownloadResult {
|
|
23
26
|
ok: boolean;
|
|
@@ -109,6 +112,15 @@ export interface CachedDownloadFailure {
|
|
|
109
112
|
* for a fresh download.
|
|
110
113
|
*/
|
|
111
114
|
export type CachedDownloadResult = CachedDownloadSuccess | CachedDownloadFailure;
|
|
115
|
+
/**
|
|
116
|
+
* Drop every remembered rejection.
|
|
117
|
+
*
|
|
118
|
+
* The records are process-local and expire on their own, so this exists for a
|
|
119
|
+
* caller that wants the next resolve to ask the repositories again without
|
|
120
|
+
* waiting out {@link REMEMBERED_REJECTION_TTL_MS} - a test isolating itself from
|
|
121
|
+
* the record another test left behind, above all.
|
|
122
|
+
*/
|
|
123
|
+
export declare function clearRememberedRejections(): void;
|
|
112
124
|
/** The persisted identity and freshness record for one cached download. */
|
|
113
125
|
export interface DownloadSidecar {
|
|
114
126
|
version: number;
|
package/dist/repo-downloader.js
CHANGED
|
@@ -40,6 +40,96 @@ const DEFINITIVE_REJECTION_STATUS_CODES = new Set([403, 404, 410]);
|
|
|
40
40
|
function isDefinitiveRejection(statusCode) {
|
|
41
41
|
return statusCode !== undefined && DEFINITIVE_REJECTION_STATUS_CODES.has(statusCode);
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* How long a definitive rejection is remembered, and how many such records are
|
|
45
|
+
* kept at once.
|
|
46
|
+
*
|
|
47
|
+
* Nothing about a FAILED download is recorded anywhere else: only bytes land in
|
|
48
|
+
* the download cache, so a coordinate whose sources jar does not exist re-issues
|
|
49
|
+
* the same request to every configured repository on every resolve. That is what
|
|
50
|
+
* makes a repeated `target`-addressed resolve expensive - the artifact index it
|
|
51
|
+
* built is reused, but the repository sweep that preceded it is paid again - and
|
|
52
|
+
* a caller whose first call timed out pays the identical sweep on its retry.
|
|
53
|
+
*
|
|
54
|
+
* Only {@link DEFINITIVE_REJECTION_STATUS_CODES} are eligible, because those are
|
|
55
|
+
* the statuses this module already treats as an answer about the artifact rather
|
|
56
|
+
* than about the repository's health; the doc comment there is the reasoning.
|
|
57
|
+
* A 5xx, a 429, a 401, a timeout and a thrown network error are all ineligible,
|
|
58
|
+
* so a passing fault costs one extra request on the next call rather than
|
|
59
|
+
* locking the artifact out of reach.
|
|
60
|
+
*
|
|
61
|
+
* The window bounds even the eligible ones. Records live in memory only, so the
|
|
62
|
+
* whole set dies with the process, and an artifact published after its rejection
|
|
63
|
+
* was recorded is reachable again once the record expires.
|
|
64
|
+
*/
|
|
65
|
+
const REMEMBERED_REJECTION_TTL_MS = 5 * 60_000;
|
|
66
|
+
const REMEMBERED_REJECTION_MAX_ENTRIES = 1024;
|
|
67
|
+
/**
|
|
68
|
+
* Definitive rejections, keyed by the cache slot AND the url that was refused.
|
|
69
|
+
*
|
|
70
|
+
* The slot is in the key because it names the cache directory: two configs
|
|
71
|
+
* pointed at different cache directories are different caches and must not
|
|
72
|
+
* inherit each other's verdicts. The url is in the key as well because
|
|
73
|
+
* `destinationPath` is the caller's to choose, so nothing here may assume the
|
|
74
|
+
* slot was derived from the url it is being asked about.
|
|
75
|
+
*/
|
|
76
|
+
const rememberedRejections = new Map();
|
|
77
|
+
function rememberedRejectionKey(url, destinationPath) {
|
|
78
|
+
return `${destinationPath}\u0000${url}`;
|
|
79
|
+
}
|
|
80
|
+
/** The unexpired rejection recorded for this url in this slot, if there is one. */
|
|
81
|
+
function readRememberedRejection(url, destinationPath, now) {
|
|
82
|
+
const key = rememberedRejectionKey(url, destinationPath);
|
|
83
|
+
const remembered = rememberedRejections.get(key);
|
|
84
|
+
if (remembered === undefined) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (remembered.expiresAt <= now) {
|
|
88
|
+
rememberedRejections.delete(key);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
// A copy, so a caller that mutates what it was handed cannot edit the record
|
|
92
|
+
// every later call is answered from.
|
|
93
|
+
return { ...remembered.result };
|
|
94
|
+
}
|
|
95
|
+
function rememberRejection(url, destinationPath, result, now) {
|
|
96
|
+
const key = rememberedRejectionKey(url, destinationPath);
|
|
97
|
+
// Delete before set, so re-recording a key moves it to the back of the Map's
|
|
98
|
+
// insertion order - which is the order the eviction below reads as recency.
|
|
99
|
+
rememberedRejections.delete(key);
|
|
100
|
+
rememberedRejections.set(key, {
|
|
101
|
+
expiresAt: now + REMEMBERED_REJECTION_TTL_MS,
|
|
102
|
+
result: { ...result }
|
|
103
|
+
});
|
|
104
|
+
if (rememberedRejections.size <= REMEMBERED_REJECTION_MAX_ENTRIES) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Over the cap. Expired records first - they answer nothing anyway - and then
|
|
108
|
+
// oldest-recorded first until the set fits. Deleting during iteration is
|
|
109
|
+
// defined for a Map: the iterator skips what has already been removed.
|
|
110
|
+
for (const [candidate, entry] of rememberedRejections) {
|
|
111
|
+
if (entry.expiresAt <= now) {
|
|
112
|
+
rememberedRejections.delete(candidate);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const candidate of rememberedRejections.keys()) {
|
|
116
|
+
if (rememberedRejections.size <= REMEMBERED_REJECTION_MAX_ENTRIES) {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
rememberedRejections.delete(candidate);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Drop every remembered rejection.
|
|
124
|
+
*
|
|
125
|
+
* The records are process-local and expire on their own, so this exists for a
|
|
126
|
+
* caller that wants the next resolve to ask the repositories again without
|
|
127
|
+
* waiting out {@link REMEMBERED_REJECTION_TTL_MS} - a test isolating itself from
|
|
128
|
+
* the record another test left behind, above all.
|
|
129
|
+
*/
|
|
130
|
+
export function clearRememberedRejections() {
|
|
131
|
+
rememberedRejections.clear();
|
|
132
|
+
}
|
|
43
133
|
/** Location of the sidecar describing `destinationPath`. Destination-adjacent. */
|
|
44
134
|
export function downloadSidecarPath(destinationPath) {
|
|
45
135
|
return `${destinationPath}${DOWNLOAD_SIDECAR_SUFFIX}`;
|
|
@@ -681,13 +771,24 @@ async function cachedBytesResult(url, destinationPath, sidecar, cacheStatus) {
|
|
|
681
771
|
* a zip reader opens it - so it must satisfy neither an immutable hit nor a
|
|
682
772
|
* stale-if-error fallback. Treating it as absent lets the next transfer replace
|
|
683
773
|
* it instead of pinning it forever.
|
|
774
|
+
*
|
|
775
|
+
* That equivalence stops at "missing", the same line {@link describeFileIfPresent}
|
|
776
|
+
* draws: only {@link isMissingFileError} answers a cache miss. A present-but-
|
|
777
|
+
* unreadable entry (EACCES from a locked-down cache directory, EISDIR, EIO)
|
|
778
|
+
* propagates instead of collapsing to 0, because this is the first stat this
|
|
779
|
+
* module makes on the path - collapsing it here would let `resolveCachedDownload`
|
|
780
|
+
* read "no cached bytes" and fall through to a live transfer without ever
|
|
781
|
+
* reaching the read path that already reports this failure correctly.
|
|
684
782
|
*/
|
|
685
783
|
function cachedByteCount(filePath) {
|
|
686
784
|
try {
|
|
687
785
|
return statSync(filePath).size;
|
|
688
786
|
}
|
|
689
|
-
catch {
|
|
690
|
-
|
|
787
|
+
catch (error) {
|
|
788
|
+
if (isMissingFileError(error)) {
|
|
789
|
+
return 0;
|
|
790
|
+
}
|
|
791
|
+
throw error;
|
|
691
792
|
}
|
|
692
793
|
}
|
|
693
794
|
/**
|
|
@@ -737,6 +838,17 @@ export async function resolveCachedDownload(url, destinationPath, options) {
|
|
|
737
838
|
// cache miss, so fall through to the transfer below - failing here would
|
|
738
839
|
// hand the caller's repository loop an ENOENT as its reason to fail over.
|
|
739
840
|
}
|
|
841
|
+
if (freshness === "immutable") {
|
|
842
|
+
// A repository's definitive answer about this url, still inside its window.
|
|
843
|
+
// Below the cached-bytes hit above on purpose: bytes we hold outrank a
|
|
844
|
+
// remembered refusal, and the 404 leg further down deliberately keeps the
|
|
845
|
+
// bytes it found. Only immutable urls are ever recorded, so this can never
|
|
846
|
+
// answer for a -SNAPSHOT.
|
|
847
|
+
const remembered = readRememberedRejection(url, destinationPath, Date.now());
|
|
848
|
+
if (remembered !== undefined) {
|
|
849
|
+
return remembered;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
740
852
|
const conditionalHeaders = freshness === "revalidate" ? conditionalHeadersFor(sidecar) : undefined;
|
|
741
853
|
/**
|
|
742
854
|
* Run the transfer, carrying the cached copy's validators only when asked.
|
|
@@ -920,13 +1032,21 @@ export async function resolveCachedDownload(url, destinationPath, options) {
|
|
|
920
1032
|
// this module's call - and the record goes back with them, so a later
|
|
921
1033
|
// revalidation that finds the artifact restored still has its validators.
|
|
922
1034
|
restoreRetiredSidecar();
|
|
923
|
-
|
|
1035
|
+
const rejection = {
|
|
924
1036
|
ok: false,
|
|
925
1037
|
statusCode: downloaded.statusCode,
|
|
926
1038
|
etag: downloaded.etag,
|
|
927
1039
|
lastModified: downloaded.lastModified,
|
|
928
1040
|
contentLength: downloaded.contentLength
|
|
929
1041
|
};
|
|
1042
|
+
// Remember it, so the next resolve of this url inside the window answers
|
|
1043
|
+
// from here instead of re-issuing the request. Immutable urls only: a
|
|
1044
|
+
// -SNAPSHOT is republished under the same name, so its 404 can be a
|
|
1045
|
+
// publish in progress rather than an answer that will hold.
|
|
1046
|
+
if (freshness === "immutable") {
|
|
1047
|
+
rememberRejection(url, destinationPath, rejection, Date.now());
|
|
1048
|
+
}
|
|
1049
|
+
return rejection;
|
|
930
1050
|
}
|
|
931
1051
|
// The same stale-if-error reuse, for a repository that answered rather than
|
|
932
1052
|
// failed to: a 5xx, a 429, an authentication hiccup. Nothing here says the
|