Skip to content

Commit 687485c

Browse files
committed
Add trace level enumeration and session tracing support in SshSession
Introduce `SshTraceLevel` enumeration to manage libssh2 debugging trace levels. Extend `SshSession` with `SetTrace` method for enabling and handling session trace output. Add tests for trace handler functionality and update native libraries.
1 parent 3de93e9 commit 687485c

File tree

17 files changed

+275
-0
lines changed

17 files changed

+275
-0
lines changed

src/NullOpsDevs.LibSsh.Test/Program.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ private static async Task RunCommandTests()
228228
await RunTest("Streaming command exit code", TestStreamingCommandExitCode);
229229
await RunTest("Streaming async command", TestStreamingCommandAsync);
230230
await RunTest("Streaming incremental output", TestStreamingIncrementalOutput);
231+
await RunTest("Streaming long output (2 min wait)", TestStreamingLongOutputWithTimeout);
231232
}
232233

233234
private static Task<bool> TestSimpleCommand()
@@ -500,6 +501,77 @@ private static async Task<bool> TestStreamingIncrementalOutput()
500501
return result.ExitCode == 0;
501502
}
502503

504+
private static async Task<bool> TestStreamingLongOutputWithTimeout()
505+
{
506+
// This is an optional long-running test, skip unless explicitly enabled
507+
if (Environment.GetEnvironmentVariable("LONG_RUNNING_STREAM_TEST") == null)
508+
{
509+
AnsiConsole.MarkupLine("[yellow] Skipped (set LONG_RUNNING_STREAM_TEST=1 to enable)[/]");
510+
skippedTests++;
511+
return true;
512+
}
513+
514+
AnsiConsole.MarkupLine("[dim] Starting long output streaming test (2 min timeout)...[/]");
515+
516+
using var session = TestHelper.CreateConnectAndAuthenticate();
517+
AnsiConsole.MarkupLine("[dim] Session authenticated, executing streaming command...[/]");
518+
519+
// Execute a command that produces output over 2 minutes (24 lines, 5 seconds apart)
520+
using var stream = await session.ExecuteCommandStreamingAsync(
521+
"for i in $(seq 1 24); do echo \"Line $i at $(date +%H:%M:%S)\"; sleep 5; done");
522+
AnsiConsole.MarkupLine("[dim] Command started, reading stdout with StreamReader (~2 min output, 3 min timeout)...[/]");
523+
524+
var lines = new List<string>();
525+
var stopwatch = Stopwatch.StartNew();
526+
527+
using var reader = new StreamReader(stream.Stdout);
528+
var lineNum = 0;
529+
530+
while (await reader.ReadLineAsync() is { } line)
531+
{
532+
lineNum++;
533+
lines.Add(line);
534+
var escapedLine = Markup.Escape(line);
535+
if (escapedLine.Length > 60) escapedLine = escapedLine[..57] + "...";
536+
AnsiConsole.MarkupLine($"[dim] Line #{lineNum}: \"{escapedLine}\" at {stopwatch.ElapsedMilliseconds}ms[/]");
537+
}
538+
539+
stopwatch.Stop();
540+
AnsiConsole.MarkupLine($"[dim] Stream reading complete. Total lines: {lines.Count}, Total time: {stopwatch.ElapsedMilliseconds}ms[/]");
541+
542+
var result = stream.WaitForExit();
543+
AnsiConsole.MarkupLine($"[dim] Command exited with code: {result.ExitCode}[/]");
544+
545+
// Verify we received all 24 lines
546+
if (lines.Count != 24)
547+
{
548+
AnsiConsole.MarkupLine($"[red] FAILED: Expected 24 lines, got {lines.Count}[/]");
549+
return false;
550+
}
551+
552+
// Verify the content contains expected line markers
553+
for (int i = 1; i <= 24; i++)
554+
{
555+
if (!lines.Any(l => l.Contains($"Line {i} at")))
556+
{
557+
AnsiConsole.MarkupLine($"[red] FAILED: Missing 'Line {i}' in output[/]");
558+
return false;
559+
}
560+
}
561+
562+
// Verify timing: total time should be at least 115 seconds (24 lines with 5 second delay each = ~120s)
563+
if (stopwatch.Elapsed < TimeSpan.FromSeconds(115))
564+
{
565+
AnsiConsole.MarkupLine($"[red] FAILED: Total time ({stopwatch.ElapsedMilliseconds}ms) too short, expected at least 115000ms[/]");
566+
return false;
567+
}
568+
569+
AnsiConsole.MarkupLine($"[green] -> [/] Received {lines.Count} lines in {stopwatch.Elapsed.TotalSeconds:F1}s (~2 min)");
570+
AnsiConsole.MarkupLine($"[green] -> [/] All output received successfully");
571+
572+
return result.ExitCode == 0;
573+
}
574+
503575
#endregion
504576

505577
#region File Transfer Tests
@@ -756,6 +828,7 @@ private static async Task RunEdgeCaseTests()
756828
await RunTest("Timeout test", TimeoutTest);
757829
await RunTest("Won't connect with deprecated methods", WontConnectWithDeprecatedMethods);
758830
await RunTest("Parallel sessions test", ParallelSessionsTest);
831+
await RunTest("Trace handler test", TestTraceHandler);
759832
}
760833

761834
private static Task<bool> ParallelSessionsTest()
@@ -879,6 +952,62 @@ private static Task<bool> TestMultipleOperations()
879952
return Task.FromResult(true);
880953
}
881954

955+
private static Task<bool> TestTraceHandler()
956+
{
957+
var traceMessages = new List<string>();
958+
959+
using var session = TestHelper.CreateAndConnect();
960+
961+
// Enable tracing with our handler
962+
session.SetTrace(
963+
SshTraceLevel.Authentication | SshTraceLevel.Error | SshTraceLevel.KeyExchange,
964+
message => traceMessages.Add(message));
965+
966+
// Authenticate - this should generate trace messages
967+
var credential = SshCredential.FromPassword(TestConfig.Username, TestConfig.Password);
968+
var authResult = session.Authenticate(credential);
969+
970+
if (!authResult)
971+
{
972+
AnsiConsole.MarkupLine("[red] Authentication failed[/]");
973+
return Task.FromResult(false);
974+
}
975+
976+
// Run a simple command
977+
var result = session.ExecuteCommand("echo 'trace test'");
978+
979+
if (!result.Successful)
980+
{
981+
AnsiConsole.MarkupLine("[red] Command execution failed[/]");
982+
return Task.FromResult(false);
983+
}
984+
985+
// Disable tracing
986+
session.SetTrace(SshTraceLevel.None, null);
987+
988+
AnsiConsole.MarkupLine($"[green] -> [/] Captured {traceMessages.Count} trace messages");
989+
990+
// We should have received some trace messages during auth
991+
if (traceMessages.Count == 0)
992+
{
993+
AnsiConsole.MarkupLine("[red] No trace messages received[/]");
994+
return Task.FromResult(false);
995+
}
996+
997+
// Print first few trace messages for debugging
998+
foreach (var msg in traceMessages.Take(5))
999+
{
1000+
var escaped = Markup.Escape(msg.Trim());
1001+
if (escaped.Length > 70) escaped = escaped[..67] + "...";
1002+
AnsiConsole.MarkupLine($"[dim] {escaped}[/]");
1003+
}
1004+
1005+
if (traceMessages.Count > 5)
1006+
AnsiConsole.MarkupLine($"[dim] ... and {traceMessages.Count - 5} more[/]");
1007+
1008+
return Task.FromResult(true);
1009+
}
1010+
8821011
#endregion
8831012

8841013
#region Test Infrastructure
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using JetBrains.Annotations;
2+
using NullOpsDevs.LibSsh.Generated;
3+
4+
namespace NullOpsDevs.LibSsh.Core;
5+
6+
/// <summary>
7+
/// Specifies the trace levels for libssh2 debugging output.
8+
/// These values can be combined using bitwise OR to enable multiple trace categories.
9+
/// </summary>
10+
[Flags]
11+
[PublicAPI]
12+
public enum SshTraceLevel
13+
{
14+
/// <summary>
15+
/// No tracing enabled.
16+
/// </summary>
17+
None = 0,
18+
19+
/// <summary>
20+
/// Trace transport layer operations.
21+
/// </summary>
22+
Transport = LibSshNative.LIBSSH2_TRACE_TRANS,
23+
24+
/// <summary>
25+
/// Trace key exchange operations.
26+
/// </summary>
27+
KeyExchange = LibSshNative.LIBSSH2_TRACE_KEX,
28+
29+
/// <summary>
30+
/// Trace authentication operations.
31+
/// </summary>
32+
Authentication = LibSshNative.LIBSSH2_TRACE_AUTH,
33+
34+
/// <summary>
35+
/// Trace connection layer operations.
36+
/// </summary>
37+
Connection = LibSshNative.LIBSSH2_TRACE_CONN,
38+
39+
/// <summary>
40+
/// Trace SCP operations.
41+
/// </summary>
42+
Scp = LibSshNative.LIBSSH2_TRACE_SCP,
43+
44+
/// <summary>
45+
/// Trace SFTP operations.
46+
/// </summary>
47+
Sftp = LibSshNative.LIBSSH2_TRACE_SFTP,
48+
49+
/// <summary>
50+
/// Trace error messages.
51+
/// </summary>
52+
Error = LibSshNative.LIBSSH2_TRACE_ERROR,
53+
54+
/// <summary>
55+
/// Trace public key operations.
56+
/// </summary>
57+
PublicKey = LibSshNative.LIBSSH2_TRACE_PUBLICKEY,
58+
59+
/// <summary>
60+
/// Trace socket operations.
61+
/// </summary>
62+
Socket = LibSshNative.LIBSSH2_TRACE_SOCKET,
63+
64+
/// <summary>
65+
/// Enable all trace levels.
66+
/// </summary>
67+
All = Transport | KeyExchange | Authentication | Connection | Scp | Sftp | Error | PublicKey | Socket
68+
}

src/NullOpsDevs.LibSsh/SshSession.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Net.Sockets;
2+
using System.Runtime.CompilerServices;
23
using System.Runtime.InteropServices;
34
using JetBrains.Annotations;
45
using Microsoft.Extensions.Logging;
@@ -39,6 +40,12 @@ public sealed class SshSession(ILogger? logger = null) : IDisposable
3940

4041
private readonly Dictionary<SshMethod, string> methodPreferences = new();
4142

43+
#if NET5_0_OR_GREATER
44+
// Trace handler support - maps session pointers to handlers
45+
private static readonly Dictionary<nint, Action<string>> TraceHandlers = new();
46+
private static readonly object TraceHandlersLock = new();
47+
#endif
48+
4249
private void EnsureInitialized()
4350
{
4451
lock (LibSsh2.GlobalLock)
@@ -904,6 +911,69 @@ public Task<bool> AuthenticateAsync(SshCredential credential, CancellationToken
904911
return Task.Run(() => Authenticate(credential), cancellationToken);
905912
}
906913

914+
#if NET5_0_OR_GREATER
915+
/// <summary>
916+
/// Sets the trace level bitmask and handler for libssh2 debugging output.
917+
/// </summary>
918+
/// <param name="traceLevel">A combination of <see cref="SshTraceLevel"/> flags indicating which categories to trace.</param>
919+
/// <param name="handler">A callback function that receives trace messages. Pass null to disable tracing.</param>
920+
/// <remarks>
921+
/// <para>The session must be in <see cref="SshConnectionStatus.Connected"/> or <see cref="SshConnectionStatus.LoggedIn"/> status.</para>
922+
/// <para>This method is only available on .NET 5.0 or later.</para>
923+
/// </remarks>
924+
/// <example>
925+
/// <code>
926+
/// session.SetTrace(SshTraceLevel.Authentication | SshTraceLevel.Error,
927+
/// message => Console.WriteLine($"[SSH] {message}"));
928+
/// </code>
929+
/// </example>
930+
public unsafe void SetTrace(SshTraceLevel traceLevel, Action<string>? handler)
931+
{
932+
EnsureInitialized();
933+
EnsureInStatuses(SshConnectionStatus.Connected, SshConnectionStatus.LoggedIn);
934+
935+
var sessionKey = (nint)SessionPtr;
936+
937+
lock (TraceHandlersLock)
938+
{
939+
if (handler == null || traceLevel == SshTraceLevel.None)
940+
{
941+
TraceHandlers.Remove(sessionKey);
942+
libssh2_trace(SessionPtr, 0);
943+
libssh2_trace_sethandler(SessionPtr, null, null);
944+
}
945+
else
946+
{
947+
TraceHandlers[sessionKey] = handler;
948+
libssh2_trace(SessionPtr, (int)traceLevel);
949+
libssh2_trace_sethandler(SessionPtr, (void*)sessionKey, &NativeTraceCallback);
950+
}
951+
}
952+
}
953+
954+
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
955+
private static unsafe void NativeTraceCallback(_LIBSSH2_SESSION* session, void* context, sbyte* message, nuint length)
956+
{
957+
Action<string>? handler;
958+
lock (TraceHandlersLock)
959+
{
960+
TraceHandlers.TryGetValue((nint)context, out handler);
961+
}
962+
963+
if (handler == null) return;
964+
965+
try
966+
{
967+
var msg = length > 0 ? Marshal.PtrToStringUTF8((IntPtr)message, (int)length) : string.Empty;
968+
handler(msg ?? string.Empty);
969+
}
970+
catch
971+
{
972+
// Swallow exceptions to prevent crashes in native code
973+
}
974+
}
975+
#endif
976+
907977
/// <summary>
908978
/// Gets the last error message from the libssh2 session (for debugging).
909979
/// </summary>
@@ -930,6 +1000,14 @@ public unsafe void Dispose()
9301000

9311001
if (SessionPtr != null)
9321002
{
1003+
#if NET5_0_OR_GREATER
1004+
// Clean up trace handler
1005+
lock (TraceHandlersLock)
1006+
{
1007+
TraceHandlers.Remove((nint)SessionPtr);
1008+
}
1009+
#endif
1010+
9331011
_ = libssh2_session_disconnect_ex(SessionPtr, SSH_DISCONNECT_BY_APPLICATION, StringPointers.SessionDisposed, null);
9341012
_ = libssh2_session_free(SessionPtr);
9351013
SessionPtr = null;
44.5 KB
Binary file not shown.
328 Bytes
Binary file not shown.
328 Bytes
Binary file not shown.
328 Bytes
Binary file not shown.
41.8 KB
Binary file not shown.
16.2 KB
Binary file not shown.
16.2 KB
Binary file not shown.

0 commit comments

Comments
 (0)