From 4f07a36a243ff74e720a67cad05cbfda168d22c6 Mon Sep 17 00:00:00 2001 From: Chizzy Date: Mon, 30 Mar 2026 09:53:32 +0100 Subject: [PATCH 1/3] Enable attach-mode preflight and workspace timeout handling; skip binary validation when attaching --- .gitignore | 2 ++ extensions/vscode/README.md | 2 ++ extensions/vscode/src/cli/debuggerProcess.ts | 21 +++++++++------ extensions/vscode/src/extension.ts | 28 +++++++++++++++----- man/man1/soroban-debug-remote.1 | 14 +++++----- man/man1/soroban-debug-server.1 | 8 +++++- 6 files changed, 53 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index baeaf8c9..870b98a6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ Cargo.lock # Logs *.log + +extensions/vscode/docs diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index fb11b2ba..2969e809 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -303,6 +303,8 @@ soroban-debug server \ > Security note: when connecting over a non-loopback network, run the server behind an SSH tunnel or a VPN. The wire protocol does not include TLS. +Maintainers: for the implementation/testing checklist, see `docs/remote-attach-configuration.md`. + --- ## Advanced Configuration diff --git a/extensions/vscode/src/cli/debuggerProcess.ts b/extensions/vscode/src/cli/debuggerProcess.ts index 9c8a54b6..2ff477fd 100644 --- a/extensions/vscode/src/cli/debuggerProcess.ts +++ b/extensions/vscode/src/cli/debuggerProcess.ts @@ -1116,14 +1116,19 @@ export async function validateLaunchConfig( const issues: LaunchPreflightIssue[] = []; const resolvedBinaryPath = resolveDebuggerBinaryPath(config); - if (!looksLikeVariableReference(resolvedBinaryPath)) { - pushFileIssue( - issues, - "binaryPath", - resolvedBinaryPath, - "a readable soroban-debug binary path or a command available on PATH.", - ["pickBinary", "openLaunchConfig", "openSettings"], - ); + // Only validate the binary path when we expect to spawn a local server. + // In attach mode (spawnServer === false) the extension should not attempt + // to launch a binary on the host running the extension. + if (config.spawnServer !== false) { + if (!looksLikeVariableReference(resolvedBinaryPath)) { + pushFileIssue( + issues, + "binaryPath", + resolvedBinaryPath, + "a readable soroban-debug binary path or a command available on PATH.", + ["pickBinary", "openLaunchConfig", "openSettings"], + ); + } } if (!config.contractPath || config.contractPath.trim().length === 0) { diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index de907046..2a6cf063 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -30,7 +30,7 @@ class SorobanDebugConfigurationProvider return this.createDefaultLaunchConfig(folder) } - if (config.type !== 'soroban' || config.request !== 'launch') { + if (config.type !== 'soroban') { return config } @@ -38,19 +38,35 @@ class SorobanDebugConfigurationProvider 'soroban-debugger', folder ) + // Apply workspace-level timeouts to both launch and attach configs config.requestTimeoutMs = config.requestTimeoutMs ?? settings.get('requestTimeoutMs') config.connectTimeoutMs = config.connectTimeoutMs ?? settings.get('connectTimeoutMs') - const preflight = await validateLaunchConfig(config) - if (preflight.ok) { - return config + if (config.request === 'launch') { + const preflight = await validateLaunchConfig(config) + if (preflight.ok) { + return config + } + + await showPreflightIssueAndApplyFix(preflight.issues[0], folder, config.name); + return undefined } - await showPreflightIssueAndApplyFix(preflight.issues[0], folder, config.name); + if (config.request === 'attach') { + // Ensure attach-mode validation knows we will not spawn a server. + (config as any).spawnServer = false + const preflight = await validateLaunchConfig(config as any) + if (preflight.ok) { + return config + } + + await showPreflightIssueAndApplyFix(preflight.issues[0], folder, config.name); + return undefined + } - return undefined + return config } private createDefaultLaunchConfig( diff --git a/man/man1/soroban-debug-remote.1 b/man/man1/soroban-debug-remote.1 index a1965163..73e5b265 100644 --- a/man/man1/soroban-debug-remote.1 +++ b/man/man1/soroban-debug-remote.1 @@ -4,7 +4,7 @@ .SH NAME remote \- Connect to remote debug server .SH SYNOPSIS -\fBremote\fR <\fB\-r\fR|\fB\-\-remote\fR> [\fB\-t\fR|\fB\-\-token\fR] [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBremote\fR <\fB\-r\fR|\fB\-\-remote\fR> [\fB\-t\fR|\fB\-\-token\fR] [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Connect to remote debug server .SH OPTIONS @@ -21,17 +21,17 @@ Path to the contract WASM file \fB\-f\fR, \fB\-\-function\fR \fI\fR Function name to execute .TP -\fB\-a\fR, \fB\-\-args\fR \fI\fR -Function arguments as JSON array -.TP \fB\-\-tls\-cert\fR \fI\fR -Path to the client TLS certificate +TLS certificate file path (optional) .TP \fB\-\-tls\-key\fR \fI\fR -Path to the client TLS private key +TLS private key file path (optional) .TP \fB\-\-tls\-ca\fR \fI\fR -Path to the CA certificate for server verification +TLS CA certificate file path (optional, for self\-signed certs) +.TP +\fB\-a\fR, \fB\-\-args\fR \fI\fR +Function arguments as JSON array .TP \fB\-h\fR, \fB\-\-help\fR Print help diff --git a/man/man1/soroban-debug-server.1 b/man/man1/soroban-debug-server.1 index 5be0580e..73c32581 100644 --- a/man/man1/soroban-debug-server.1 +++ b/man/man1/soroban-debug-server.1 @@ -4,7 +4,7 @@ .SH NAME server \- Start debug server for remote connections .SH SYNOPSIS -\fBserver\fR [\fB\-p\fR|\fB\-\-port\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBserver\fR [\fB\-p\fR|\fB\-\-port\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-repeat\fR] [\fB\-\-storage\-filter\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Start debug server for remote connections .SH OPTIONS @@ -21,5 +21,11 @@ TLS certificate file path (optional) \fB\-\-tls\-key\fR \fI\fR TLS private key file path (optional) .TP +\fB\-\-repeat\fR \fI\fR +Repeat execution N times and show throughput/latency stats +.TP +\fB\-\-storage\-filter\fR \fI\fR +Filter storage view to only show keys matching pattern (repeatable) +.TP \fB\-h\fR, \fB\-\-help\fR Print help From a22a77da709ef422e8a8082d74c350c67c83d9b2 Mon Sep 17 00:00:00 2001 From: Chizzy Date: Mon, 30 Mar 2026 10:52:28 +0100 Subject: [PATCH 2/3] Enable attach-mode preflight and workspace timeout handling; skip binary validation when attaching --- extensions/vscode/src/cli/debuggerProcess.ts | 88 ++-- extensions/vscode/src/extension.ts | 405 +++++++++++-------- man/man1/soroban-debug-run.1 | 5 +- src/cli/args.rs | 3 + src/cli/commands.rs | 8 +- src/client/remote_client.rs | 67 ++- src/plugin/loader.rs | 377 ++++++++--------- src/server/debug_server.rs | 41 +- 8 files changed, 562 insertions(+), 432 deletions(-) diff --git a/extensions/vscode/src/cli/debuggerProcess.ts b/extensions/vscode/src/cli/debuggerProcess.ts index 2ff477fd..850f5218 100644 --- a/extensions/vscode/src/cli/debuggerProcess.ts +++ b/extensions/vscode/src/cli/debuggerProcess.ts @@ -176,26 +176,44 @@ export function formatProtocolMismatchMessage( } type DebugRequest = - | { type: 'Handshake'; client_name: string; client_version: string; protocol_min: number; protocol_max: number } - | { type: 'Authenticate'; token: string } - | { type: 'LoadContract'; contract_path: string } - | { type: 'Execute'; function: string; args?: string } - | { type: 'StepIn' } - | { type: 'Next' } - | { type: 'StepOut' } - | { type: 'Continue' } - | { type: 'Inspect' } - | { type: 'GetStorage' } - | { type: 'SetBreakpoint'; id?: string; function: string; condition?: string; hit_condition?: string; log_message?: string } - | { type: 'ClearBreakpoint'; id?: string; function?: string } - | { type: 'ResolveSourceBreakpoints'; source_path: string; lines: number[]; exported_functions: string[] } - | { type: 'Evaluate'; expression: string; frame_id?: number } - | { type: 'Cancel' } - | { type: 'Ping' } - | { type: 'Disconnect' } - | { type: 'LoadSnapshot'; snapshot_path: string } - | { type: 'GetCapabilities' } - | { type: 'Unknown' }; + | { + type: "Handshake"; + client_name: string; + client_version: string; + protocol_min: number; + protocol_max: number; + } + | { type: "Authenticate"; token: string } + | { type: "LoadContract"; contract_path: string } + | { type: "Execute"; function: string; args?: string } + | { type: "StepIn" } + | { type: "Next" } + | { type: "StepOut" } + | { type: "Continue" } + | { type: "Inspect" } + | { type: "GetStorage" } + | { + type: "SetBreakpoint"; + id?: string; + function: string; + condition?: string; + hit_condition?: string; + log_message?: string; + } + | { type: "ClearBreakpoint"; id?: string; function?: string } + | { + type: "ResolveSourceBreakpoints"; + source_path: string; + lines: number[]; + exported_functions: string[]; + } + | { type: "Evaluate"; expression: string; frame_id?: number } + | { type: "Cancel" } + | { type: "Ping" } + | { type: "Disconnect" } + | { type: "LoadSnapshot"; snapshot_path: string } + | { type: "GetCapabilities" } + | { type: "Unknown" }; type DebugResponse = | { @@ -617,17 +635,17 @@ export class DebuggerProcess { function: breakpoint.functionName, condition: breakpoint.condition, hit_condition: breakpoint.hitCondition, - log_message: breakpoint.logMessage - }as any); - this.expectResponse(response, 'BreakpointSet'); + log_message: breakpoint.logMessage, + } as any); + this.expectResponse(response, "BreakpointSet"); } async clearBreakpoint(breakpointId: string): Promise { const response = await this.sendRequest({ - type: 'ClearBreakpoint', - id: breakpointId + type: "ClearBreakpoint", + id: breakpointId, } as any); - this.expectResponse(response, 'BreakpointCleared'); + this.expectResponse(response, "BreakpointCleared"); } async evaluate( @@ -725,7 +743,11 @@ export class DebuggerProcess { functionName: bp.function, reasonCode: bp.reason_code, message: bp.message, - setBreakpoint: shouldPromoteToFunctionBreakpoint(bp.verified, bp.function, bp.reason_code), + setBreakpoint: shouldPromoteToFunctionBreakpoint( + bp.verified, + bp.function, + bp.reason_code, + ), })); } @@ -847,7 +869,9 @@ export class DebuggerProcess { await new Promise((resolve) => setTimeout(resolve, 100)); } - throw new Error(`Timed out waiting for debugger server on ${this.config.host ?? "127.0.0.1"}:${port}`); + throw new Error( + `Timed out waiting for debugger server on ${this.config.host ?? "127.0.0.1"}:${port}`, + ); } private async canConnect(port: number): Promise { @@ -1188,7 +1212,10 @@ export async function validateLaunchConfig( expected: "An available TCP port between 1 and 65535.", quickFixes: ["openLaunchConfig"], }); - } else if (config.spawnServer !== false && !(await isPortAvailable(config.port))) { + } else if ( + config.spawnServer !== false && + !(await isPortAvailable(config.port)) + ) { // Only check port availability when we are spawning a local server. // In attach mode (spawnServer: false) the port must already be in use. issues.push({ @@ -1205,7 +1232,8 @@ export async function validateLaunchConfig( issues.push({ field: "host", message: "Launch config field 'host' must be a non-empty string.", - expected: "A hostname or IP address such as '192.168.1.10' or 'debug.example.com'.", + expected: + "A hostname or IP address such as '192.168.1.10' or 'debug.example.com'.", quickFixes: ["openLaunchConfig"], }); } diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 2a6cf063..caeda79e 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -1,23 +1,23 @@ -import * as vscode from 'vscode' +import * as vscode from "vscode"; import { DebuggerProcessConfig, LaunchPreflightIssue, LaunchPreflightQuickFix, validateLaunchConfig, -} from './cli/debuggerProcess' -import { SorobanDebugAdapterDescriptorFactory } from './debug/adapter' -import { LogManager } from './debug/logManager' +} from "./cli/debuggerProcess"; +import { SorobanDebugAdapterDescriptorFactory } from "./debug/adapter"; +import { LogManager } from "./debug/logManager"; import { fromQuickPickLabel, runLaunchPreflightCommand, toQuickPickLabel, -} from './preflightCommand' -import { diagnoseBreakpoints } from './dap/sourceBreakpoints' -import { SorobanLaunchProgressReporter } from './launchProgress' +} from "./preflightCommand"; +import { diagnoseBreakpoints } from "./dap/sourceBreakpoints"; +import { SorobanLaunchProgressReporter } from "./launchProgress"; -type SorobanLaunchConfig = vscode.DebugConfiguration & DebuggerProcessConfig -const RUN_LAUNCH_PREFLIGHT_COMMAND = 'soroban-debugger.runLaunchPreflight' -const DIAGNOSE_SOURCE_MAP_COMMAND = 'soroban-debugger.diagnoseSourceMap' +type SorobanLaunchConfig = vscode.DebugConfiguration & DebuggerProcessConfig; +const RUN_LAUNCH_PREFLIGHT_COMMAND = "soroban-debugger.runLaunchPreflight"; +const DIAGNOSE_SOURCE_MAP_COMMAND = "soroban-debugger.diagnoseSourceMap"; class SorobanDebugConfigurationProvider implements vscode.DebugConfigurationProvider @@ -27,213 +27,225 @@ class SorobanDebugConfigurationProvider config: SorobanLaunchConfig, ): Promise { if (!config.type && !config.request && !config.name) { - return this.createDefaultLaunchConfig(folder) + return this.createDefaultLaunchConfig(folder); } - if (config.type !== 'soroban') { - return config + if (config.type !== "soroban") { + return config; } const settings = vscode.workspace.getConfiguration( - 'soroban-debugger', - folder - ) + "soroban-debugger", + folder, + ); // Apply workspace-level timeouts to both launch and attach configs config.requestTimeoutMs = - config.requestTimeoutMs ?? settings.get('requestTimeoutMs') + config.requestTimeoutMs ?? settings.get("requestTimeoutMs"); config.connectTimeoutMs = - config.connectTimeoutMs ?? settings.get('connectTimeoutMs') + config.connectTimeoutMs ?? settings.get("connectTimeoutMs"); - if (config.request === 'launch') { - const preflight = await validateLaunchConfig(config) + if (config.request === "launch") { + const preflight = await validateLaunchConfig(config); if (preflight.ok) { - return config + return config; } - await showPreflightIssueAndApplyFix(preflight.issues[0], folder, config.name); - return undefined + await showPreflightIssueAndApplyFix( + preflight.issues[0], + folder, + config.name, + ); + return undefined; } - if (config.request === 'attach') { + if (config.request === "attach") { // Ensure attach-mode validation knows we will not spawn a server. - (config as any).spawnServer = false - const preflight = await validateLaunchConfig(config as any) + (config as any).spawnServer = false; + const preflight = await validateLaunchConfig(config as any); if (preflight.ok) { - return config + return config; } - await showPreflightIssueAndApplyFix(preflight.issues[0], folder, config.name); - return undefined + await showPreflightIssueAndApplyFix( + preflight.issues[0], + folder, + config.name, + ); + return undefined; } - return config + return config; } private createDefaultLaunchConfig( - folder: vscode.WorkspaceFolder | undefined + folder: vscode.WorkspaceFolder | undefined, ): vscode.DebugConfiguration { - return createDefaultLaunchConfig(folder?.uri.fsPath ?? '${workspaceFolder}') + return createDefaultLaunchConfig( + folder?.uri.fsPath ?? "${workspaceFolder}", + ); } } -let logManager: LogManager | undefined -let launchProgressReporter: SorobanLaunchProgressReporter | undefined +let logManager: LogManager | undefined; +let launchProgressReporter: SorobanLaunchProgressReporter | undefined; export function activate(context: vscode.ExtensionContext): void { - logManager = new LogManager(context) - launchProgressReporter = new SorobanLaunchProgressReporter() + logManager = new LogManager(context); + launchProgressReporter = new SorobanLaunchProgressReporter(); const factory = new SorobanDebugAdapterDescriptorFactory( context, logManager, - launchProgressReporter - ) - const configurationProvider = new SorobanDebugConfigurationProvider() + launchProgressReporter, + ); + const configurationProvider = new SorobanDebugConfigurationProvider(); context.subscriptions.push( - vscode.debug.registerDebugAdapterDescriptorFactory('soroban', factory), + vscode.debug.registerDebugAdapterDescriptorFactory("soroban", factory), vscode.debug.registerDebugConfigurationProvider( - 'soroban', - configurationProvider + "soroban", + configurationProvider, ), vscode.commands.registerCommand( RUN_LAUNCH_PREFLIGHT_COMMAND, - runStandaloneLaunchPreflight + runStandaloneLaunchPreflight, ), vscode.commands.registerCommand(DIAGNOSE_SOURCE_MAP_COMMAND, async () => { - await runDiagnoseSourceMapCommand() + await runDiagnoseSourceMapCommand(); }), factory, - launchProgressReporter - ) + launchProgressReporter, + ); } export function deactivate(): void { - launchProgressReporter?.dispose() + launchProgressReporter?.dispose(); if (logManager) { - logManager.dispose() + logManager.dispose(); } } async function runDiagnoseSourceMapCommand(): Promise { - const editor = vscode.window.activeTextEditor + const editor = vscode.window.activeTextEditor; if (!editor) { await vscode.window.showWarningMessage( - 'Open a Rust file to diagnose source maps.' - ) - return + "Open a Rust file to diagnose source maps.", + ); + return; } - const currentFilePath = editor.document.uri.fsPath - if (!currentFilePath.endsWith('.rs')) { + const currentFilePath = editor.document.uri.fsPath; + if (!currentFilePath.endsWith(".rs")) { await vscode.window.showWarningMessage( - 'Active file is not a Rust (.rs) file.' - ) - return + "Active file is not a Rust (.rs) file.", + ); + return; } - const outputChannel = vscode.window.createOutputChannel('Soroban Diagnostics') - outputChannel.show(true) - outputChannel.appendLine(`=== Source Map & Breakpoint Diagnostics ===`) - outputChannel.appendLine(`File: ${currentFilePath}`) - outputChannel.appendLine(`Timestamp: ${new Date().toISOString()}`) - outputChannel.appendLine(`-------------------------------------------\n`) + const outputChannel = vscode.window.createOutputChannel( + "Soroban Diagnostics", + ); + outputChannel.show(true); + outputChannel.appendLine(`=== Source Map & Breakpoint Diagnostics ===`); + outputChannel.appendLine(`File: ${currentFilePath}`); + outputChannel.appendLine(`Timestamp: ${new Date().toISOString()}`); + outputChannel.appendLine(`-------------------------------------------\n`); const breakpoints = vscode.debug.breakpoints.filter( (bp): bp is vscode.SourceBreakpoint => bp instanceof vscode.SourceBreakpoint && - bp.location.uri.fsPath === currentFilePath - ) + bp.location.uri.fsPath === currentFilePath, + ); if (breakpoints.length === 0) { - outputChannel.appendLine('â„šī¸ No breakpoints set in the current file.') + outputChannel.appendLine("â„šī¸ No breakpoints set in the current file."); } else { outputChannel.appendLine( - `🔍 Found ${breakpoints.length} breakpoint(s) in this file:\n` - ) + `🔍 Found ${breakpoints.length} breakpoint(s) in this file:\n`, + ); - const lines = breakpoints.map((bp) => bp.location.range.start.line + 1) - const reports = diagnoseBreakpoints(currentFilePath, lines) + const lines = breakpoints.map((bp) => bp.location.range.start.line + 1); + const reports = diagnoseBreakpoints(currentFilePath, lines); reports.forEach((report) => { - outputChannel.appendLine(`Line ${report.line}:`) - outputChannel.appendLine(` Status: ${report.status}`) + outputChannel.appendLine(`Line ${report.line}:`); + outputChannel.appendLine(` Status: ${report.status}`); if (report.functionName) { outputChannel.appendLine( - ` Detected Function: '${report.functionName}'` - ) + ` Detected Function: '${report.functionName}'`, + ); } - outputChannel.appendLine(` Reason: ${report.reason}\n`) - }) + outputChannel.appendLine(` Reason: ${report.reason}\n`); + }); } - outputChannel.appendLine(`-------------------------------------------`) - outputChannel.appendLine(`General Troubleshooting:`) + outputChannel.appendLine(`-------------------------------------------`); + outputChannel.appendLine(`General Troubleshooting:`); outputChannel.appendLine( - `1. Ensure you compiled with debuginfo (e.g., profile.dev/profile.test).` - ) + `1. Ensure you compiled with debuginfo (e.g., profile.dev/profile.test).`, + ); outputChannel.appendLine( - `2. Ensure your launch.json 'contractPath' matches the compiled WASM.` - ) + `2. Ensure your launch.json 'contractPath' matches the compiled WASM.`, + ); } async function ensureLaunchConfig( - folder: vscode.WorkspaceFolder | undefined + folder: vscode.WorkspaceFolder | undefined, ): Promise { - const workspaceFolder = folder ?? vscode.workspace.workspaceFolders?.[0] + const workspaceFolder = folder ?? vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { await vscode.window.showInformationMessage( - 'Open a workspace folder first to generate a Soroban launch configuration.' - ) - return + "Open a workspace folder first to generate a Soroban launch configuration.", + ); + return; } - const vscodeDir = vscode.Uri.joinPath(workspaceFolder.uri, '.vscode') - const launchUri = vscode.Uri.joinPath(vscodeDir, 'launch.json') + const vscodeDir = vscode.Uri.joinPath(workspaceFolder.uri, ".vscode"); + const launchUri = vscode.Uri.joinPath(vscodeDir, "launch.json"); try { - await vscode.workspace.fs.createDirectory(vscodeDir) + await vscode.workspace.fs.createDirectory(vscodeDir); let launchJson: { - version: string - configurations: vscode.DebugConfiguration[] - } + version: string; + configurations: vscode.DebugConfiguration[]; + }; try { - const existing = await vscode.workspace.fs.readFile(launchUri) - launchJson = JSON.parse(Buffer.from(existing).toString('utf8')) as { - version: string - configurations: vscode.DebugConfiguration[] - } + const existing = await vscode.workspace.fs.readFile(launchUri); + launchJson = JSON.parse(Buffer.from(existing).toString("utf8")) as { + version: string; + configurations: vscode.DebugConfiguration[]; + }; } catch { - launchJson = { version: '0.2.0', configurations: [] } + launchJson = { version: "0.2.0", configurations: [] }; } const alreadyPresent = launchJson.configurations.some( (configuration) => - configuration.type === 'soroban' && configuration.request === 'launch' - ) + configuration.type === "soroban" && configuration.request === "launch", + ); if (!alreadyPresent) { launchJson.configurations.push( - createDefaultLaunchConfig('${workspaceFolder}') - ) + createDefaultLaunchConfig("${workspaceFolder}"), + ); await vscode.workspace.fs.writeFile( launchUri, - Buffer.from(`${JSON.stringify(launchJson, null, 2)}\n`, 'utf8') - ) + Buffer.from(`${JSON.stringify(launchJson, null, 2)}\n`, "utf8"), + ); } - const doc = await vscode.workspace.openTextDocument(launchUri) - await vscode.window.showTextDocument(doc, { preview: false }) + const doc = await vscode.workspace.openTextDocument(launchUri); + await vscode.window.showTextDocument(doc, { preview: false }); } catch (error) { await vscode.window.showErrorMessage( - `Failed to generate launch.json: ${String(error)}` - ) + `Failed to generate launch.json: ${String(error)}`, + ); } } function createDefaultLaunchConfig( - workspaceFolder: string + workspaceFolder: string, ): vscode.DebugConfiguration { return { name: "Soroban: Debug Contract", @@ -244,30 +256,30 @@ function createDefaultLaunchConfig( entrypoint: "main", args: [], trace: false, - binaryPath: `${workspaceFolder}/target/debug/${process.platform === 'win32' ? 'soroban-debug.exe' : 'soroban-debug'}`, - } + binaryPath: `${workspaceFolder}/target/debug/${process.platform === "win32" ? "soroban-debug.exe" : "soroban-debug"}`, + }; } async function runStandaloneLaunchPreflight(): Promise { const sources = (() => { - const folders = vscode.workspace.workspaceFolders + const folders = vscode.workspace.workspaceFolders; if (!folders || folders.length === 0) { return [ { configurations: vscode.workspace - .getConfiguration('launch') - .get('configurations'), + .getConfiguration("launch") + .get("configurations"), }, - ] + ]; } return folders.map((folder) => ({ folder, configurations: vscode.workspace - .getConfiguration('launch', folder) - .get('configurations'), - })) - })() + .getConfiguration("launch", folder) + .get("configurations"), + })); + })(); await runLaunchPreflightCommand({ launchConfigSources: sources, @@ -280,31 +292,40 @@ async function runStandaloneLaunchPreflight(): Promise { candidate, })), { - placeHolder: 'Select a Soroban launch configuration to validate', - } - ) - return picked?.candidate + placeHolder: "Select a Soroban launch configuration to validate", + }, + ); + return picked?.candidate; }, - validateLaunchConfig: async (config) => validateLaunchConfig(config as SorobanLaunchConfig), - showInformationMessage: async (message, ...actions) => vscode.window.showInformationMessage(message, ...actions), - showWarningMessage: async (message, ...actions) => vscode.window.showWarningMessage(message, ...actions), - showErrorMessage: async (message, ...actions) => vscode.window.showErrorMessage(message, ...actions), + validateLaunchConfig: async (config) => + validateLaunchConfig(config as SorobanLaunchConfig), + showInformationMessage: async (message, ...actions) => + vscode.window.showInformationMessage(message, ...actions), + showWarningMessage: async (message, ...actions) => + vscode.window.showWarningMessage(message, ...actions), + showErrorMessage: async (message, ...actions) => + vscode.window.showErrorMessage(message, ...actions), applyQuickFix: async (quickFix, folder, configName, field) => - applyQuickFix(quickFix, folder as vscode.WorkspaceFolder | undefined, configName, field) + applyQuickFix( + quickFix, + folder as vscode.WorkspaceFolder | undefined, + configName, + field, + ), }); } async function showPreflightIssueAndApplyFix( issue: LaunchPreflightIssue, folder: vscode.WorkspaceFolder | undefined, - configName?: string + configName?: string, ): Promise { - const actions = issue.quickFixes.map(toQuickPickLabel) + const actions = issue.quickFixes.map(toQuickPickLabel); const selected = await vscode.window.showErrorMessage( `${issue.message} Expected: ${issue.expected}`, - ...actions - ) - const quickFix = fromQuickPickLabel(selected) + ...actions, + ); + const quickFix = fromQuickPickLabel(selected); if (quickFix) { await applyQuickFix(quickFix, folder, configName, issue.field); } @@ -314,32 +335,50 @@ async function applyQuickFix( quickFix: LaunchPreflightQuickFix, folder: vscode.WorkspaceFolder | undefined, configName?: string, - field?: string + field?: string, ): Promise { switch (quickFix) { - case 'pickBinary': - await pickFile('Select soroban-debug binary', ['exe', 'bin', ''], folder, configName, field); + case "pickBinary": + await pickFile( + "Select soroban-debug binary", + ["exe", "bin", ""], + folder, + configName, + field, + ); return; - case 'pickContract': - await pickFile('Select Soroban contract WASM', ['wasm'], folder, configName, field); + case "pickContract": + await pickFile( + "Select Soroban contract WASM", + ["wasm"], + folder, + configName, + field, + ); return; - case 'pickSnapshot': - await pickFile('Select snapshot JSON', ['json'], folder, configName, field); + case "pickSnapshot": + await pickFile( + "Select snapshot JSON", + ["json"], + folder, + configName, + field, + ); return; - case 'openLaunchConfig': - await vscode.commands.executeCommand('workbench.action.debug.configure') - return - case 'generateLaunchConfig': - await ensureLaunchConfig(folder) - return - case 'openSettings': + case "openLaunchConfig": + await vscode.commands.executeCommand("workbench.action.debug.configure"); + return; + case "generateLaunchConfig": + await ensureLaunchConfig(folder); + return; + case "openSettings": await vscode.commands.executeCommand( - 'workbench.action.openSettings', - '@ext:soroban.soroban-debugger' - ) - return + "workbench.action.openSettings", + "@ext:soroban.soroban-debugger", + ); + return; default: - return + return; } } @@ -348,7 +387,7 @@ async function pickFile( extensions: string[], folder: vscode.WorkspaceFolder | undefined, configName?: string, - field?: string + field?: string, ): Promise { const filters = extensions.filter((ext) => ext.length > 0); const selected = await vscode.window.showOpenDialog({ @@ -357,7 +396,7 @@ async function pickFile( canSelectMany: false, openLabel: title, filters: filters.length > 0 ? { Files: filters } : undefined, - }) + }); if (selected && selected.length > 0) { const filePath = selected[0].fsPath; @@ -365,26 +404,32 @@ async function pickFile( if (configName && field) { const choice = await vscode.window.showInformationMessage( `Selected path: ${filePath}. Do you want to update "${configName}" in launch.json directly?`, - 'Update launch.json', - 'Copy to Clipboard' + "Update launch.json", + "Copy to Clipboard", ); - if (choice === 'Update launch.json') { + if (choice === "Update launch.json") { await patchLaunchConfig(folder, configName, field, filePath); - await vscode.window.showInformationMessage(`Updated ${field} in "${configName}" launch configuration.`); + await vscode.window.showInformationMessage( + `Updated ${field} in "${configName}" launch configuration.`, + ); return; } } await vscode.env.clipboard.writeText(filePath); - await vscode.window.showInformationMessage( - `Selected path copied to clipboard: ${filePath}`, - 'Open launch.json' - ).then(async (choice) => { - if (choice === 'Open launch.json') { - await vscode.commands.executeCommand('workbench.action.debug.configure'); - } - }); + await vscode.window + .showInformationMessage( + `Selected path copied to clipboard: ${filePath}`, + "Open launch.json", + ) + .then(async (choice) => { + if (choice === "Open launch.json") { + await vscode.commands.executeCommand( + "workbench.action.debug.configure", + ); + } + }); } } @@ -392,31 +437,39 @@ async function patchLaunchConfig( folder: vscode.WorkspaceFolder | undefined, configName: string, field: string, - value: any + value: any, ): Promise { - const settings = vscode.workspace.getConfiguration('launch', folder); - const configurations = settings.get('configurations') || []; + const settings = vscode.workspace.getConfiguration("launch", folder); + const configurations = settings.get("configurations") || []; const index = configurations.findIndex((c) => c.name === configName); if (index !== -1) { const updatedConfigurations = [...configurations]; updatedConfigurations[index] = { ...updatedConfigurations[index], - [field]: value + [field]: value, }; - await settings.update('configurations', updatedConfigurations, vscode.ConfigurationTarget.WorkspaceFolder); + await settings.update( + "configurations", + updatedConfigurations, + vscode.ConfigurationTarget.WorkspaceFolder, + ); } else { // If not found in workspace folder, try global (though usually it should be in workspace folder for debugging) - const globalSettings = vscode.workspace.getConfiguration('launch'); - const globalConfigs = globalSettings.get('configurations') || []; + const globalSettings = vscode.workspace.getConfiguration("launch"); + const globalConfigs = globalSettings.get("configurations") || []; const globalIndex = globalConfigs.findIndex((c) => c.name === configName); if (globalIndex !== -1) { const updatedGlobalConfigs = [...globalConfigs]; updatedGlobalConfigs[globalIndex] = { ...updatedGlobalConfigs[globalIndex], - [field]: value + [field]: value, }; - await globalSettings.update('configurations', updatedGlobalConfigs, vscode.ConfigurationTarget.Workspace); + await globalSettings.update( + "configurations", + updatedGlobalConfigs, + vscode.ConfigurationTarget.Workspace, + ); } } } diff --git a/man/man1/soroban-debug-run.1 b/man/man1/soroban-debug-run.1 index a21e2f99..2f26ea69 100644 --- a/man/man1/soroban-debug-run.1 +++ b/man/man1/soroban-debug-run.1 @@ -4,7 +4,7 @@ .SH NAME run \- Run a contract function with the debugger .SH SYNOPSIS -\fBrun\fR [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-network\-snapshot\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-\-server\fR] [\fB\-p\fR|\fB\-\-port\fR] [\fB\-\-remote\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-format\fR] [\fB\-\-output\fR] [\fB\-\-show\-events\fR] [\fB\-\-show\-auth\fR] [\fB\-\-json\fR] [\fB\-\-filter\-topic\fR] [\fB\-\-event\-filter\fR] [\fB\-\-repeat\fR] [\fB\-\-mock\fR] [\fB\-\-storage\-filter\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-dry\-run\fR] [\fB\-\-export\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-\-batch\-args\fR] [\fB\-\-generate\-test\fR] [\fB\-\-overwrite\fR] [\fB\-\-timeout\fR] [\fB\-\-alert\-on\-change\fR] [\fB\-\-expected\-hash\fR] [\fB\-\-show\-ledger\fR] [\fB\-\-ttl\-warning\-threshold\fR] [\fB\-\-trace\-output\fR] [\fB\-\-save\-output\fR] [\fB\-\-append\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBrun\fR [\fB\-c\fR|\fB\-\-contract\fR] [\fB\-f\fR|\fB\-\-function\fR] [\fB\-a\fR|\fB\-\-args\fR] [\fB\-s\fR|\fB\-\-storage\fR] [\fB\-b\fR|\fB\-\-breakpoint\fR] [\fB\-\-network\-snapshot\fR] [\fB\-v\fR|\fB\-\-verbose\fR] [\fB\-\-server\fR] [\fB\-p\fR|\fB\-\-port\fR] [\fB\-\-remote\fR] [\fB\-t\fR|\fB\-\-token\fR] [\fB\-\-tls\-cert\fR] [\fB\-\-tls\-key\fR] [\fB\-\-tls\-ca\fR] [\fB\-\-format\fR] [\fB\-\-output\fR] [\fB\-\-show\-events\fR] [\fB\-\-show\-auth\fR] [\fB\-\-json\fR] [\fB\-\-filter\-topic\fR] [\fB\-\-event\-filter\fR] [\fB\-\-repeat\fR] [\fB\-\-mock\fR] [\fB\-\-storage\-filter\fR] [\fB\-\-instruction\-debug\fR] [\fB\-\-step\-instructions\fR] [\fB\-\-step\-mode\fR] [\fB\-\-dry\-run\fR] [\fB\-\-export\-storage\fR] [\fB\-\-import\-storage\fR] [\fB\-\-batch\-args\fR] [\fB\-\-generate\-test\fR] [\fB\-\-overwrite\fR] [\fB\-\-timeout\fR] [\fB\-\-alert\-on\-change\fR] [\fB\-\-expected\-hash\fR] [\fB\-\-show\-ledger\fR] [\fB\-\-ttl\-warning\-threshold\fR] [\fB\-\-trace\-output\fR] [\fB\-\-save\-output\fR] [\fB\-\-append\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Run a contract function with the debugger .SH OPTIONS @@ -48,6 +48,9 @@ Path to TLS certificate file \fB\-\-tls\-key\fR \fI\fR Path to TLS key file .TP +\fB\-\-tls\-ca\fR \fI\fR +Path to TLS CA certificate file (optional, for self\-signed certs) +.TP \fB\-\-format\fR \fI\fR Output format (text, json) .TP diff --git a/src/cli/args.rs b/src/cli/args.rs index 099a9252..b340c489 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -277,6 +277,9 @@ pub struct RunArgs { /// Path to TLS key file #[arg(long)] pub tls_key: Option, + /// Path to TLS CA certificate file (optional, for self-signed certs) + #[arg(long)] + pub tls_ca: Option, /// Output format (text, json) #[arg(long)] pub format: Option, diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 6fcca5f0..9b999a0f 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -519,6 +519,8 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { token: args.token, tls_cert: args.tls_cert, tls_key: args.tls_key, + repeat: args.repeat, + storage_filter: args.storage_filter.clone(), }); } @@ -530,6 +532,9 @@ pub fn run(args: RunArgs, verbosity: Verbosity) -> Result<()> { token: args.token.clone(), contract: args.contract.clone(), function: args.function.clone(), + tls_cert: args.tls_cert.clone(), + tls_key: args.tls_key.clone(), + tls_ca: args.tls_ca.clone(), args: args.args.clone(), }, verbosity, @@ -1869,7 +1874,8 @@ pub fn remote(args: RemoteArgs, _verbosity: Verbosity) -> Result<()> { config.tls_cert = args.tls_cert.clone(); config.tls_key = args.tls_key.clone(); config.tls_ca = args.tls_ca.clone(); - let mut client = crate::client::RemoteClient::connect_with_config(&args.remote, args.token.clone(), config)?; + let mut client = + crate::client::RemoteClient::connect_with_config(&args.remote, args.token.clone(), config)?; if let Some(contract) = &args.contract { print_info(format!("Loading contract: {:?}", contract)); diff --git a/src/client/remote_client.rs b/src/client/remote_client.rs index 306ab855..1a958cfc 100644 --- a/src/client/remote_client.rs +++ b/src/client/remote_client.rs @@ -48,6 +48,7 @@ impl Default for RetryPolicy { } } +#[derive(Debug, Clone)] pub struct RemoteClientConfig { pub timeouts: RequestTimeouts, pub retry: RetryPolicy, @@ -176,7 +177,10 @@ impl RemoteClient { })?; let mut reader = BufReader::new(ca_file); let certs = rustls_pemfile::certs(&mut reader).map_err(|e| { - DebuggerError::FileError(format!("Failed to parse CA cert {:?}: {}", ca_path, e)) + DebuggerError::FileError(format!( + "Failed to parse CA cert {:?}: {}", + ca_path, e + )) })?; for cert in certs { root_store.add(&Certificate(cert)).map_err(|e| { @@ -189,59 +193,80 @@ impl RemoteClient { DebuggerError::NetworkError(format!("Failed to load native certs: {}", e)) })? { root_store.add(&Certificate(cert.0)).map_err(|e| { - DebuggerError::FileError(format!("Failed to add native cert to root store: {}", e)) + DebuggerError::FileError(format!( + "Failed to add native cert to root store: {}", + e + )) })?; } } - let mut client_config = ClientConfig::builder() + let builder = ClientConfig::builder() .with_safe_defaults() .with_root_certificates(root_store); - if let (Some(ref cert_path), Some(ref key_path)) = (&config.tls_cert, &config.tls_key) { + let client_config: ClientConfig = if let (Some(ref cert_path), Some(ref key_path)) = + (&config.tls_cert, &config.tls_key) + { let cert_file = std::fs::File::open(cert_path).map_err(|e| { - DebuggerError::FileError(format!("Failed to open client cert {:?}: {}", cert_path, e)) + DebuggerError::FileError(format!( + "Failed to open client cert {:?}: {}", + cert_path, e + )) })?; let mut cert_reader = BufReader::new(cert_file); let certs = rustls_pemfile::certs(&mut cert_reader) - .map_err(|e| DebuggerError::FileError(format!("Failed to parse client cert: {}", e)))? + .map_err(|e| { + DebuggerError::FileError(format!("Failed to parse client cert: {}", e)) + })? .into_iter() .map(Certificate) .collect(); let key_file = std::fs::File::open(key_path).map_err(|e| { - DebuggerError::FileError(format!("Failed to open client key {:?}: {}", key_path, e)) + DebuggerError::FileError(format!( + "Failed to open client key {:?}: {}", + key_path, e + )) })?; let mut key_reader = BufReader::new(key_file); - let keys = rustls_pemfile::pkcs8_private_keys(&mut key_reader) - .map_err(|e| DebuggerError::FileError(format!("Failed to parse client key: {}", e)))?; - + let keys = rustls_pemfile::pkcs8_private_keys(&mut key_reader).map_err(|e| { + DebuggerError::FileError(format!("Failed to parse client key: {}", e)) + })?; if let Some(key) = keys.into_iter().next() { - client_config = client_config.with_client_auth_cert(certs, PrivateKey(key)).map_err(|e| { - DebuggerError::FileError(format!("Failed to set client certificate: {}", e)) - })?; + builder + .with_client_auth_cert(certs, PrivateKey(key)) + .map_err(|e| { + DebuggerError::FileError(format!( + "Failed to set client certificate: {}", + e + )) + })? + } else { + builder.with_no_client_auth() } } else { - client_config = client_config.with_no_client_auth(); - } + builder.with_no_client_auth() + }; let host = addr.split(':').next().unwrap_or("localhost"); let server_name = ServerName::try_from(host).map_err(|e| { DebuggerError::NetworkError(format!("Invalid server name '{}': {}", host, e)) })?; - let conn = rustls::client::ClientConnection::new(Arc::new(client_config), server_name).map_err(|e| { - DebuggerError::NetworkError(format!("Failed to create TLS connection: {}", e)) - })?; + let conn = rustls::client::ClientConnection::new(Arc::new(client_config), server_name) + .map_err(|e| { + DebuggerError::NetworkError(format!("Failed to create TLS connection: {}", e)) + })?; - Ok(RemoteStream::Tls(rustls::StreamOwned::new(conn, tcp_stream))) + Ok(RemoteStream::Tls(rustls::StreamOwned::new( + conn, tcp_stream, + ))) } else { Ok(RemoteStream::Plain(tcp_stream)) } } - - /// Perform a protocol handshake and verify compatibility. pub fn handshake(&mut self, client_name: &str, client_version: &str) -> Result { let response = self.send_request(DebugRequest::Handshake { diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index 1b58e1c0..750686f3 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -435,8 +435,6 @@ impl Drop for LoadedPlugin { } } - - #[cfg(test)] impl LoadedPlugin { pub(crate) fn from_parts_for_tests( @@ -452,13 +450,16 @@ impl LoadedPlugin { manifest, trust, } + } +} + /// Checks if the plugin API version matches the host's expected version. pub fn check_api_version(plugin_version: u32) -> Result<(), crate::plugin::api::PluginError> { use crate::plugin::api::{PluginError, PLUGIN_API_VERSION}; if plugin_version != PLUGIN_API_VERSION { return Err(PluginError::VersionMismatch { - expected: PLUGIN_API_VERSION, - found: plugin_version, + required: PLUGIN_API_VERSION.to_string(), + found: plugin_version.to_string(), }); } Ok(()) @@ -467,199 +468,203 @@ pub fn check_api_version(plugin_version: u32) -> Result<(), crate::plugin::api:: #[cfg(test)] mod tests { use super::super::manifest::PluginSignature; -mod version_tests { - use super::*; - use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; - use base64::Engine; - use ed25519_dalek::{Signer, SigningKey}; - use std::collections::BTreeSet; - use std::path::Path; - - fn base_manifest(name: &str) -> PluginManifest { - PluginManifest { - name: name.to_string(), - version: "1.0.0".to_string(), - description: "test plugin".to_string(), - author: "test".to_string(), - license: Some("MIT".to_string()), - min_debugger_version: Some("0.1.0".to_string()), - capabilities: Default::default(), - library: "plugin.so".to_string(), - dependencies: vec![], - signature: None, + mod version_tests { + use super::*; + use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; + use base64::Engine; + use ed25519_dalek::{Signer, SigningKey}; + use std::collections::BTreeSet; + use std::path::Path; + + fn base_manifest(name: &str) -> PluginManifest { + PluginManifest { + name: name.to_string(), + version: "1.0.0".to_string(), + description: "test plugin".to_string(), + author: "test".to_string(), + license: Some("MIT".to_string()), + min_debugger_version: Some("0.1.0".to_string()), + capabilities: Default::default(), + library: "plugin.so".to_string(), + dependencies: vec![], + signature: None, + } } - } - fn sign_manifest( - mut manifest: PluginManifest, - signer_name: &str, - seed: u8, - library_bytes: &[u8], - ) -> PluginManifest { - let signing_key = SigningKey::from_bytes(&[seed; 32]); - let verifying_key = signing_key.verifying_key(); - let manifest_payload = manifest.canonical_manifest_payload().unwrap(); - let manifest_signature = signing_key.sign(&manifest_payload); - let library_signature = signing_key.sign(library_bytes); - manifest.signature = Some(PluginSignature { - signer: signer_name.to_string(), - public_key: BASE64_STANDARD.encode(verifying_key.to_bytes()), - manifest_signature: BASE64_STANDARD.encode(manifest_signature.to_bytes()), - library_signature: BASE64_STANDARD.encode(library_signature.to_bytes()), - }); - manifest - } - use crate::plugin::api::{PluginError, PLUGIN_API_VERSION}; + fn sign_manifest( + mut manifest: PluginManifest, + signer_name: &str, + seed: u8, + library_bytes: &[u8], + ) -> PluginManifest { + let signing_key = SigningKey::from_bytes(&[seed; 32]); + let verifying_key = signing_key.verifying_key(); + let manifest_payload = manifest.canonical_manifest_payload().unwrap(); + let manifest_signature = signing_key.sign(&manifest_payload); + let library_signature = signing_key.sign(library_bytes); + manifest.signature = Some(PluginSignature { + signer: signer_name.to_string(), + public_key: BASE64_STANDARD.encode(verifying_key.to_bytes()), + manifest_signature: BASE64_STANDARD.encode(manifest_signature.to_bytes()), + library_signature: BASE64_STANDARD.encode(library_signature.to_bytes()), + }); + manifest + } + use crate::plugin::api::{PluginError, PLUGIN_API_VERSION}; - #[test] - fn test_default_plugin_dir() { - let dir = PluginLoader::default_plugin_dir(); - assert!(dir.is_ok()); + #[test] + fn test_default_plugin_dir() { + let dir = PluginLoader::default_plugin_dir(); + assert!(dir.is_ok()); - let path = dir.unwrap(); - assert!(path.ends_with(".soroban-debug/plugins")); - } - fn test_api_version_check() { - let result = check_api_version(999); - assert!(matches!(result, Err(PluginError::VersionMismatch { .. }))); - - #[test] - fn test_loader_creation() { - let temp_dir = std::env::temp_dir(); - let loader = PluginLoader::new(temp_dir.clone()); - assert_eq!(loader.plugin_dir, temp_dir); - } + let path = dir.unwrap(); + assert!(path.ends_with(".soroban-debug/plugins")); + } + fn test_api_version_check() { + let result = check_api_version(999); + assert!(matches!(result, Err(PluginError::VersionMismatch { .. }))); + } - /// `discover_plugins` must return paths in sorted order so that repeated - /// calls on the same directory yield the same sequence regardless of the - /// order the OS returns directory entries. - #[test] - fn discover_plugins_returns_sorted_paths() { - use std::fs; - - let base = std::env::temp_dir().join("soroban-loader-sort-test"); - let _ = fs::remove_dir_all(&base); - - // Create three plugin sub-directories in reverse alphabetical order so - // a naive read_dir would likely return them unsorted. - //............ - for name in &["plugin-c", "plugin-a", "plugin-b"] { - let dir = base.join(name); - fs::create_dir_all(&dir).unwrap(); - fs::write(dir.join("plugin.toml"), "").unwrap(); + #[test] + fn test_loader_creation() { + let temp_dir = std::env::temp_dir(); + let loader = PluginLoader::new(temp_dir.clone()); + assert_eq!(loader.plugin_dir, temp_dir); } - let loader = PluginLoader::new(base.clone()); - let paths = loader.discover_plugins(); + /// `discover_plugins` must return paths in sorted order so that repeated + /// calls on the same directory yield the same sequence regardless of the + /// order the OS returns directory entries. + #[test] + fn discover_plugins_returns_sorted_paths() { + use std::fs; + + let base = std::env::temp_dir().join("soroban-loader-sort-test"); + let _ = fs::remove_dir_all(&base); + + // Create three plugin sub-directories in reverse alphabetical order so + // a naive read_dir would likely return them unsorted. + //............ + for name in &["plugin-c", "plugin-a", "plugin-b"] { + let dir = base.join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("plugin.toml"), "").unwrap(); + } - let names: Vec<&str> = paths - .iter() - .filter_map(|p| p.parent()?.file_name()?.to_str()) - .collect(); + let loader = PluginLoader::new(base.clone()); + let paths = loader.discover_plugins(); - assert_eq!(names, vec!["plugin-a", "plugin-b", "plugin-c"]); + let names: Vec<&str> = paths + .iter() + .filter_map(|p| p.parent()?.file_name()?.to_str()) + .collect(); - let _ = fs::remove_dir_all(&base); - } + assert_eq!(names, vec!["plugin-a", "plugin-b", "plugin-c"]); - #[test] - fn trust_policy_warns_but_allows_unsigned_plugins_by_default() { - let loader = PluginLoader::with_trust_policy( - std::env::temp_dir(), - PluginTrustPolicy { - mode: PluginTrustMode::Warn, - allowlist: BTreeSet::new(), - denylist: BTreeSet::new(), - allowed_signers: BTreeSet::new(), - }, - ); - let manifest = base_manifest("unsigned-plugin"); - - let assessment = loader - .assess_trust(&manifest, Path::new("unsigned-plugin.so"), b"library") - .expect("warn mode should allow unsigned plugin"); - - assert!(!assessment.trusted); - assert!(!assessment.warnings.is_empty()); - } + let _ = fs::remove_dir_all(&base); + } - #[test] - fn trust_policy_blocks_unsigned_plugins_in_enforce_mode() { - let loader = PluginLoader::with_trust_policy( - std::env::temp_dir(), - PluginTrustPolicy { - mode: PluginTrustMode::Enforce, - allowlist: BTreeSet::new(), - denylist: BTreeSet::new(), - allowed_signers: BTreeSet::new(), - }, - ); - let manifest = base_manifest("unsigned-plugin"); - - let err = loader - .assess_trust(&manifest, Path::new("unsigned-plugin.so"), b"library") - .unwrap_err(); - assert!( - matches!(err, PluginError::TrustViolation(message) if message.contains("unsigned") || message.contains("signature")) - ); - } + #[test] + fn trust_policy_warns_but_allows_unsigned_plugins_by_default() { + let loader = PluginLoader::with_trust_policy( + std::env::temp_dir(), + PluginTrustPolicy { + mode: PluginTrustMode::Warn, + allowlist: BTreeSet::new(), + denylist: BTreeSet::new(), + allowed_signers: BTreeSet::new(), + }, + ); + let manifest = base_manifest("unsigned-plugin"); - #[test] - fn trust_policy_blocks_denylisted_plugins() { - let mut denylist = BTreeSet::new(); - denylist.insert("blocked-plugin".to_string()); - let loader = PluginLoader::with_trust_policy( - std::env::temp_dir(), - PluginTrustPolicy { - mode: PluginTrustMode::Warn, - allowlist: BTreeSet::new(), - denylist, - allowed_signers: BTreeSet::new(), - }, - ); - - let err = loader - .assess_trust( - &base_manifest("blocked-plugin"), - Path::new("blocked.so"), - b"library", - ) - .unwrap_err(); - assert!( - matches!(err, PluginError::TrustViolation(message) if message.contains("denied by policy")) - ); - } + let assessment = loader + .assess_trust(&manifest, Path::new("unsigned-plugin.so"), b"library") + .expect("warn mode should allow unsigned plugin"); + + assert!(!assessment.trusted); + assert!(!assessment.warnings.is_empty()); + } + + #[test] + fn trust_policy_blocks_unsigned_plugins_in_enforce_mode() { + let loader = PluginLoader::with_trust_policy( + std::env::temp_dir(), + PluginTrustPolicy { + mode: PluginTrustMode::Enforce, + allowlist: BTreeSet::new(), + denylist: BTreeSet::new(), + allowed_signers: BTreeSet::new(), + }, + ); + let manifest = base_manifest("unsigned-plugin"); + + let err = loader + .assess_trust(&manifest, Path::new("unsigned-plugin.so"), b"library") + .unwrap_err(); + assert!( + matches!(err, PluginError::TrustViolation(message) if message.contains("unsigned") || message.contains("signature")) + ); + } + + #[test] + fn trust_policy_blocks_denylisted_plugins() { + let mut denylist = BTreeSet::new(); + denylist.insert("blocked-plugin".to_string()); + let loader = PluginLoader::with_trust_policy( + std::env::temp_dir(), + PluginTrustPolicy { + mode: PluginTrustMode::Warn, + allowlist: BTreeSet::new(), + denylist, + allowed_signers: BTreeSet::new(), + }, + ); - #[test] - fn trust_policy_accepts_valid_signed_plugins_from_allowed_signer() { - let library_bytes = b"signed library"; - let manifest = sign_manifest( - base_manifest("signed-plugin"), - "trusted-signer", - 9, - library_bytes, - ); - let mut allowed_signers = BTreeSet::new(); - allowed_signers.insert("trusted-signer".to_string()); - let loader = PluginLoader::with_trust_policy( - std::env::temp_dir(), - PluginTrustPolicy { - mode: PluginTrustMode::Enforce, - allowlist: BTreeSet::new(), - denylist: BTreeSet::new(), - allowed_signers, - }, - ); - - let assessment = loader - .assess_trust(&manifest, Path::new("signed.so"), library_bytes) - .expect("trusted signed plugin should load"); - - assert!(assessment.trusted); - assert!(assessment.warnings.is_empty()); - assert_eq!( - assessment.signer.as_ref().map(|s| s.signer.as_str()), - Some("trusted-signer") - ); - let result_ok = check_api_version(PLUGIN_API_VERSION); - assert!(result_ok.is_ok()); + let err = loader + .assess_trust( + &base_manifest("blocked-plugin"), + Path::new("blocked.so"), + b"library", + ) + .unwrap_err(); + assert!( + matches!(err, PluginError::TrustViolation(message) if message.contains("denied by policy")) + ); + } + + #[test] + fn trust_policy_accepts_valid_signed_plugins_from_allowed_signer() { + let library_bytes = b"signed library"; + let manifest = sign_manifest( + base_manifest("signed-plugin"), + "trusted-signer", + 9, + library_bytes, + ); + let mut allowed_signers = BTreeSet::new(); + allowed_signers.insert("trusted-signer".to_string()); + let loader = PluginLoader::with_trust_policy( + std::env::temp_dir(), + PluginTrustPolicy { + mode: PluginTrustMode::Enforce, + allowlist: BTreeSet::new(), + denylist: BTreeSet::new(), + allowed_signers, + }, + ); + + let assessment = loader + .assess_trust(&manifest, Path::new("signed.so"), library_bytes) + .expect("trusted signed plugin should load"); + + assert!(assessment.trusted); + assert!(assessment.warnings.is_empty()); + assert_eq!( + assessment.signer.as_ref().map(|s| s.signer.as_str()), + Some("trusted-signer") + ); + let result_ok = check_api_version(PLUGIN_API_VERSION); + assert!(result_ok.is_ok()); + } + } +} diff --git a/src/server/debug_server.rs b/src/server/debug_server.rs index d78da0d2..57c67061 100644 --- a/src/server/debug_server.rs +++ b/src/server/debug_server.rs @@ -189,9 +189,9 @@ impl DebugServer { .map_err(|_| miette::miette!("Connection closed")) }; - let mut heartbeat_interval = None; + let mut _heartbeat_interval = None; let mut idle_timeout = None; - let mut heartbeat_timer = None; + let mut _heartbeat_timer = None; loop { let next_message = if let Some(timeout) = idle_timeout { @@ -269,14 +269,14 @@ impl DebugServer { Ok(selected_version) => { handshake_done = true; // Support heartbeat/timeout negotiation - heartbeat_interval = *heartbeat_interval_ms; + _heartbeat_interval = *heartbeat_interval_ms; idle_timeout = *idle_timeout_ms; - if let Some(interval) = heartbeat_interval { + if let Some(interval) = _heartbeat_interval { info!("Negotiated heartbeat interval: {}ms", interval); let tx_heartbeat = tx_out.clone(); let interval_ms = interval as u64; - heartbeat_timer = Some(tokio::spawn(async move { + _heartbeat_timer = Some(tokio::spawn(async move { let mut interval_timer = tokio::time::interval(std::time::Duration::from_millis( interval_ms, @@ -305,7 +305,7 @@ impl DebugServer { protocol_min: PROTOCOL_MIN_VERSION, protocol_max: PROTOCOL_MAX_VERSION, selected_version: selected_version, - heartbeat_interval_ms: heartbeat_interval, + heartbeat_interval_ms: _heartbeat_interval, idle_timeout_ms: idle_timeout, }, ); @@ -475,7 +475,7 @@ impl DebugServer { if let Some(count) = self.repeat_count { if count > 1 { if let Some(wasm) = &self.contract_wasm { - let breakpoints = self.engine.as_ref().map(|e| e.breakpoints().list().into_iter().map(|b| b.function).collect()).unwrap_or_default(); + let breakpoints = self.engine.as_ref().map(|e| e.breakpoints().list()).unwrap_or_default(); let initial_storage = self.engine.as_ref().and_then(|e| e.executor().get_storage_snapshot().ok()).and_then(|s| serde_json::to_string(&s).ok()); let runner = crate::repeat::RepeatRunner::new(wasm.clone(), breakpoints, initial_storage); match runner.run(&function, args.as_deref(), count) { @@ -487,7 +487,7 @@ impl DebugServer { stats.min_memory, stats.max_memory, stats.avg_memory, if stats.inconsistent_results { "INCONSISTENT" } else { "CONSISTENT" } ); - return Ok(DebugResponse::ExecutionResult { + let response = DebugMessage::response(message.id, DebugResponse::ExecutionResult { success: true, output, error: None, @@ -495,14 +495,20 @@ impl DebugServer { completed: true, source_location: None, }); + send_msg(response)?; + continue; + } + Err(e) => { + let response = DebugMessage::response(message.id, DebugResponse::Error { message: e.to_string() }); + send_msg(response)?; + continue; } - Err(e) => return Ok(DebugResponse::Error { message: e.to_string() }), } } } } - match self.engine.as_mut() { + let response = match self.engine.as_mut() { Some(engine) if engine.breakpoints().should_break(&function) => { match current_storage(engine) { Ok(storage) => match engine.breakpoints_mut().on_hit( @@ -564,7 +570,9 @@ impl DebugServer { None => DebugResponse::Error { message: "No contract loaded".to_string(), }, - }, + }; + response + } DebugRequest::Step | DebugRequest::StepIn => match self.engine.as_mut() { Some(engine) => match engine.step_into() { Ok(_) => { @@ -981,7 +989,7 @@ impl DebugServer { message: e.to_string(), }, } - } + }, DebugRequest::Evaluate { expression, .. } => match self.engine.as_ref() { Some(engine) => { // First try to look up the expression as a storage key @@ -1136,8 +1144,7 @@ async fn setup_signal_handlers(shutdown: Arc) { let mut ctrl_c = Box::pin(tokio::signal::ctrl_c()); #[cfg(not(unix))] - let ctrl_c = tokio::signal::ctrl_c(); - let mut ctrl_c = Box::pin(tokio::signal::ctrl_c()); + let ctrl_c = Box::pin(tokio::signal::ctrl_c()); #[cfg(unix)] { @@ -1188,7 +1195,7 @@ mod tests { #[tokio::test] async fn test_graceful_shutdown_on_signal() { - let server = DebugServer::new(None, None, None).expect("Failed to create server"); + let server = DebugServer::new(None, None, None, None, Vec::new()).expect("Failed to create server"); let shutdown = server.shutdown.clone(); let local = tokio::task::LocalSet::new(); @@ -1211,7 +1218,7 @@ mod tests { #[test] fn test_server_initialization() { - let server = DebugServer::new(None, None, None).expect("Failed to create server"); + let server = DebugServer::new(None, None, None, None, Vec::new()).expect("Failed to create server"); assert!(server.engine.is_none()); assert!(server.token.is_none()); assert!(server.tls_config.is_none()); @@ -1221,7 +1228,7 @@ mod tests { fn test_server_with_token() { let token = "test-token-12345678".to_string(); let server = - DebugServer::new(Some(token.clone()), None, None).expect("Failed to create server"); + DebugServer::new(Some(token.clone()), None, None, None, Vec::new()).expect("Failed to create server"); assert_eq!(server.token, Some(token)); } } From ca5fcb5415358e962d27cbf2ec1c3158a6a40b3c Mon Sep 17 00:00:00 2001 From: Chizzy Date: Mon, 30 Mar 2026 11:38:35 +0100 Subject: [PATCH 3/3] Enable attach-mode preflight and workspace timeout handling; skip binary validation when attaching --- src/analyzer/symbolic.rs | 50 ++++++++++++++++++++++----- src/client/remote_client.rs | 6 ++++ src/plugin/loader.rs | 25 ++++---------- tests/server_signal_handling_tests.rs | 6 ++-- 4 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src/analyzer/symbolic.rs b/src/analyzer/symbolic.rs index 88926d40..206d7ceb 100644 --- a/src/analyzer/symbolic.rs +++ b/src/analyzer/symbolic.rs @@ -507,8 +507,8 @@ impl SymbolicAnalyzer { let base = [ "\"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF\"", // ZERO "\"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4H\"", // CONTRACT_ZERO - "\"GD5DJ3B6A2KHSXLYJZ3IGR7Q5UMVJ5J4GQTKTQYQDQXJQJ5YQZQKQZQ\"", // TEST_1 - "\"GBLO7VQYJLRU56W77WKHLYU7C3T73J3Y5PQUZLQJ5YQZQKQZQYX\"", // TEST_2 + "\"GD5DJ3B6A2KHSXLYJZ3IGR7Q5UMVJ5J4GQTKTQYQDQXJQJ5YQZQKQZQ\"", // TEST_1 + "\"GBLO7VQYJLRU56W77WKHLYU7C3T73J3Y5PQUZLQJ5YQZQKQZQYX\"", // TEST_2 ]; base.into_iter() .take(limit) @@ -791,6 +791,13 @@ mod tests { let mut module = Vec::new(); module.extend_from_slice(&[0x00, 0x61, 0x73, 0x6d]); module.extend_from_slice(&[0x01, 0x00, 0x00, 0x00]); + // Add a minimal `contractmeta` custom section so the host accepts the + // module as a contract (some host versions require metadata to be present). + let mut custom = Vec::new(); + push_name("contractmeta", &mut custom); + // Minimal JSON metadata with a contract_version so the host accepts it. + custom.extend_from_slice(b"{\"contract_version\": \"0.0.1\"}"); + append_section(&mut module, 0, &custom); // Type section: type 0 = () -> (), type 1 = (i64, i64) -> () let mut types = Vec::new(); @@ -862,8 +869,20 @@ mod tests { }; let mut seen_inputs = HashSet::new(); - SymbolicAnalyzer::record_outcome(&mut report, &mut seen_inputs, "[0]", Ok("1".into()), Vec::new()); - SymbolicAnalyzer::record_outcome(&mut report, &mut seen_inputs, "[1]", Ok("1".into()), Vec::new()); + SymbolicAnalyzer::record_outcome( + &mut report, + &mut seen_inputs, + "[0]", + Ok("1".into()), + Vec::new(), + ); + SymbolicAnalyzer::record_outcome( + &mut report, + &mut seen_inputs, + "[1]", + Ok("1".into()), + Vec::new(), + ); assert_eq!(report.paths.len(), 2); assert_eq!(report.panics_found, 0); @@ -894,8 +913,20 @@ mod tests { }; let mut seen_inputs = HashSet::new(); - SymbolicAnalyzer::record_outcome(&mut report, &mut seen_inputs, "[0]", Ok("1".into()), Vec::new()); - SymbolicAnalyzer::record_outcome(&mut report, &mut seen_inputs, "[0]", Ok("1".into()), Vec::new()); + SymbolicAnalyzer::record_outcome( + &mut report, + &mut seen_inputs, + "[0]", + Ok("1".into()), + Vec::new(), + ); + SymbolicAnalyzer::record_outcome( + &mut report, + &mut seen_inputs, + "[0]", + Ok("1".into()), + Vec::new(), + ); assert_eq!(report.paths.len(), 1); } @@ -1165,8 +1196,11 @@ mod tests { // Test Address let seeds = analyzer.generate_seeds_for_type("Address", &config, 0); assert!(seeds.len() >= 4); - assert!(seeds.contains(&"\"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF\"".to_string())); - assert!(seeds.contains(&"\"GBLO7VQYJLRU56W77WKHLYU7C3T73J3Y5PQUZLQJ5YQZQKQZQYX\"".to_string())); + assert!(seeds + .contains(&"\"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF\"".to_string())); + assert!( + seeds.contains(&"\"GBLO7VQYJLRU56W77WKHLYU7C3T73J3Y5PQUZLQJ5YQZQKQZQYX\"".to_string()) + ); // Test Map let seeds = analyzer.generate_seeds_for_type("Map", &config, 0); diff --git a/src/client/remote_client.rs b/src/client/remote_client.rs index 1a958cfc..d3e95167 100644 --- a/src/client/remote_client.rs +++ b/src/client/remote_client.rs @@ -1063,6 +1063,9 @@ mod tests { }, heartbeat_interval_ms: None, idle_timeout_ms: None, + tls_cert: None, + tls_key: None, + tls_ca: None, }; let mut client = @@ -1153,6 +1156,9 @@ mod tests { }, heartbeat_interval_ms: None, idle_timeout_ms: None, + tls_cert: None, + tls_key: None, + tls_ca: None, }; let mut client = diff --git a/src/plugin/loader.rs b/src/plugin/loader.rs index 750686f3..8c3ea29b 100644 --- a/src/plugin/loader.rs +++ b/src/plugin/loader.rs @@ -435,24 +435,6 @@ impl Drop for LoadedPlugin { } } -#[cfg(test)] -impl LoadedPlugin { - pub(crate) fn from_parts_for_tests( - plugin: Box, - path: PathBuf, - manifest: PluginManifest, - trust: PluginTrustAssessment, - ) -> Self { - Self { - plugin, - library: None, - path, - manifest, - trust, - } - } -} - /// Checks if the plugin API version matches the host's expected version. pub fn check_api_version(plugin_version: u32) -> Result<(), crate::plugin::api::PluginError> { use crate::plugin::api::{PluginError, PLUGIN_API_VERSION}; @@ -476,6 +458,13 @@ mod tests { use std::collections::BTreeSet; use std::path::Path; + // Import plugin types re-exported by the crate for tests + use crate::plugin::loader::check_api_version; + use crate::plugin::PluginLoader; + use crate::plugin::PluginManifest; + use crate::plugin::PluginTrustMode; + use crate::plugin::PluginTrustPolicy; + fn base_manifest(name: &str) -> PluginManifest { PluginManifest { name: name.to_string(), diff --git a/tests/server_signal_handling_tests.rs b/tests/server_signal_handling_tests.rs index 497d3a3d..46fae204 100644 --- a/tests/server_signal_handling_tests.rs +++ b/tests/server_signal_handling_tests.rs @@ -5,14 +5,14 @@ use std::path::Path; #[test] fn test_server_creation_without_token() { - let server = DebugServer::new(None, None, None); + let server = DebugServer::new(None, None, None, None, vec![]); assert!(server.is_ok(), "Server should be creatable without token"); } #[test] fn test_server_creation_with_token() { let token = "valid-test-token-1234567890".to_string(); - let server = DebugServer::new(Some(token.clone()), None, None) + let server = DebugServer::new(Some(token.clone()), None, None, None, vec![]) .expect("Failed to create server with token"); let _ = server; @@ -21,7 +21,7 @@ fn test_server_creation_with_token() { #[test] fn test_server_rejects_tls_configuration() { let fake_cert = Path::new("tests/fixtures/cert.pem"); - match DebugServer::new(None, Some(fake_cert), None) { + match DebugServer::new(None, Some(fake_cert), None, None, vec![]) { Ok(_) => panic!("expected TLS unsupported error"), Err(err) => { assert!(