Skip to content

Commit c055125

Browse files
wenytang-msCopilot
andcommitted
feat: linkify stack traces in log files
Preserve complete clipboard content and bound document link scans by total characters and lines so saved log files can be handled safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6e50dc26-84c4-4532-a7d3-88f6fe016780
1 parent 4d47da9 commit c055125

2 files changed

Lines changed: 51 additions & 31 deletions

File tree

src/stackTraceLinkProvider.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,23 @@ import { getJavaExtensionAPI, isJavaExtEnabled, ServerMode } from "./utility";
1212
const ANALYZE_STACK_TRACE_COMMAND = "java.debug.analyzeStackTrace";
1313
const NAVIGATE_TO_STACK_FRAME_COMMAND = "_java.debug.navigateToStackFrame";
1414

15-
// Only linkify pasted traces in untitled (scratch) documents - including the one opened by the
16-
// `Analyze Stack Trace` command. Kept deliberately narrow: a `.log` opened without a Java project
17-
// couldn't resolve anyway, so we don't scan `.log` files or every plaintext file the user opens.
15+
// Linkify stack traces in scratch documents and .log files. Other plaintext documents stay
16+
// excluded so the extension does not passively scan unrelated files.
1817
const STACK_TRACE_DOCUMENT_SELECTOR: DocumentSelector = [
1918
{ scheme: "untitled" },
19+
{ pattern: "**/*.log" },
2020
];
2121

22-
// Guard against pathological input: cap the length of a scanned line (mitigates ReDoS on the
23-
// nested-quantifier regex) and the number of links produced for very large pasted traces.
22+
// Bound the work performed for large documents and pathological input. The per-line cap mitigates
23+
// ReDoS in the nested-quantifier regex; the document budgets keep large logs from being fully scanned.
2424
const MAX_SCANNED_LINE_LENGTH = 1000;
25+
const MAX_SCANNED_LINES_PER_DOCUMENT = 10000;
26+
const MAX_SCANNED_CHARACTERS_PER_DOCUMENT = 1000000;
2527
const MAX_LINKS_PER_DOCUMENT = 2000;
2628

2729
// Only resolve to source locations the language server is expected to return.
2830
const ALLOWED_SOURCE_SCHEMES = new Set<string>(["file", "jdt"]);
2931

30-
// Bound both stack-trace detection and scratch-document prefill so a large clipboard cannot create
31-
// an expensive untitled document (and keeps the detection regex input bounded).
32-
const MAX_CLIPBOARD_PREFILL_LENGTH = 20000;
33-
3432
interface IStackFrameLinkArgs {
3533
stackTrace: string;
3634
methodName: string;
@@ -66,12 +64,20 @@ function isStackFrameLinkArgs(args: unknown): args is IStackFrameLinkArgs {
6664
export class JavaStackTraceLinkProvider implements DocumentLinkProvider {
6765
public provideDocumentLinks(document: TextDocument, token: CancellationToken): ProviderResult<DocumentLink[]> {
6866
const links: DocumentLink[] = [];
69-
for (let i = 0; i < document.lineCount; i++) {
67+
let scannedCharacters = 0;
68+
const linesToScan = Math.min(document.lineCount, MAX_SCANNED_LINES_PER_DOCUMENT);
69+
for (let i = 0; i < linesToScan; i++) {
7070
if (token.isCancellationRequested || links.length >= MAX_LINKS_PER_DOCUMENT) {
7171
break;
7272
}
7373

7474
const lineText = document.lineAt(i).text;
75+
const lineScanCost = lineText.length + 1;
76+
if (scannedCharacters + lineScanCost > MAX_SCANNED_CHARACTERS_PER_DOCUMENT) {
77+
break;
78+
}
79+
scannedCharacters += lineScanCost;
80+
7581
if (lineText.length > MAX_SCANNED_LINE_LENGTH) {
7682
continue;
7783
}
@@ -145,14 +151,13 @@ async function navigateToStackFrame(args: unknown): Promise<void> {
145151
}
146152

147153
/**
148-
* Opens a scratch document prefilled with bounded clipboard content. The document link provider
149-
* scans the content after the document opens and makes any stack frames clickable.
154+
* Opens a scratch document prefilled with the clipboard content. The document link provider scans
155+
* a bounded portion after the document opens and makes any stack frames clickable.
150156
*/
151157
async function analyzeStackTrace(): Promise<void> {
152158
// The command itself is auto-instrumented via instrumentOperationAsVsCodeCommand, so no
153159
// manual telemetry is needed here to track invocations.
154-
const clipboard = await env.clipboard.readText();
155-
const clipboardContent = clipboard.slice(0, MAX_CLIPBOARD_PREFILL_LENGTH);
160+
const clipboardContent = await env.clipboard.readText();
156161
const document = await workspace.openTextDocument({ language: "log", content: clipboardContent });
157162
await window.showTextDocument(document);
158163
}

test/stackTraceLinkProvider.test.ts

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,45 @@ import { CancellationTokenSource, workspace } from "vscode";
77
import { JavaStackTraceLinkProvider } from "../src/stackTraceLinkProvider";
88

99
suite("JavaStackTraceLinkProvider", () => {
10-
test("encodes command URI arguments as an array", async () => {
11-
const stackTrace = "com.example.App.main(App.java:42)";
12-
const document = await workspace.openTextDocument({
13-
language: "log",
14-
content: `\tat ${stackTrace}`,
15-
});
10+
async function provideLinks(content: string) {
11+
const document = await workspace.openTextDocument({ language: "log", content });
1612
const cancellation = new CancellationTokenSource();
1713

1814
try {
19-
const links = await Promise.resolve(
15+
return await Promise.resolve(
2016
new JavaStackTraceLinkProvider().provideDocumentLinks(document, cancellation.token),
2117
);
22-
assert.ok(links);
23-
assert.strictEqual(links.length, 1);
24-
25-
const target = links[0].target;
26-
assert.ok(target);
27-
assert.deepStrictEqual(JSON.parse(decodeURIComponent(target.query)), [{
28-
stackTrace,
29-
methodName: "com.example.App.main",
30-
lineNumber: 42,
31-
}]);
3218
} finally {
3319
cancellation.dispose();
3420
}
21+
}
22+
23+
test("encodes command URI arguments as an array", async () => {
24+
const stackTrace = "com.example.App.main(App.java:42)";
25+
const links = await provideLinks(`\tat ${stackTrace}`);
26+
27+
assert.ok(links);
28+
assert.strictEqual(links.length, 1);
29+
30+
const target = links[0].target;
31+
assert.ok(target);
32+
assert.deepStrictEqual(JSON.parse(decodeURIComponent(target.query)), [{
33+
stackTrace,
34+
methodName: "com.example.App.main",
35+
lineNumber: 42,
36+
}]);
37+
});
38+
39+
test("stops scanning after the document character budget", async () => {
40+
const longPrefix = `${"x".repeat(1000)}\n`.repeat(1000);
41+
const links = await provideLinks(`${longPrefix}\tat com.example.App.main(App.java:42)`);
42+
43+
assert.deepStrictEqual(links, []);
44+
});
45+
46+
test("stops scanning after the document line budget", async () => {
47+
const links = await provideLinks(`${"\n".repeat(10000)}\tat com.example.App.main(App.java:42)`);
48+
49+
assert.deepStrictEqual(links, []);
3550
});
3651
});

0 commit comments

Comments
 (0)