Skip to content

Commit 8dcce13

Browse files
committed
Add test for basic SSH functionality
1 parent 497efe3 commit 8dcce13

7 files changed

Lines changed: 395 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,46 @@ jobs:
113113
test_command="dotnet test LibGit2Sharp.sln --configuration Release -p:TargetFrameworks=${{ matrix.tfm }} --logger "GitHubActions" -p:ExtraDefine=LEAKS_IDENTIFYING"
114114
docker run -t --rm --platform linux/${{ matrix.arch }} -v "$PWD:/app" -w /app -e OPENSSL_ENABLE_SHA1_SIGNATURES=1 gittools/build-images:${{ matrix.distro }}-sdk-${{ matrix.sdk }} sh -c "$git_command && $test_command"
115115
116+
ssh-validation:
117+
name: SSH Validation / ${{ matrix.os }}
118+
needs: [build]
119+
if: github.event_name != 'schedule'
120+
runs-on: ${{ matrix.os }}
121+
strategy:
122+
matrix:
123+
os: [windows-2022, ubuntu-22.04]
124+
fail-fast: false
125+
steps:
126+
- name: Checkout
127+
uses: actions/checkout@v6
128+
- name: Install .NET SDK
129+
uses: actions/setup-dotnet@v5
130+
with:
131+
dotnet-version: 8.0.x
132+
- name: Download package artifact
133+
uses: actions/download-artifact@v8
134+
with:
135+
name: NuGet packages
136+
path: staging
137+
- name: Determine package version
138+
id: pkg
139+
shell: bash
140+
run: |
141+
PKG=$(find staging -name 'Octopus.LibGit2Sharp.*.nupkg' ! -name '*NativeBinaries*' | head -n 1)
142+
if [ -z "$PKG" ]; then
143+
echo "::error::Octopus.LibGit2Sharp package not found under staging/"
144+
exit 1
145+
fi
146+
VERSION=$(basename "$PKG" | sed -E 's/^Octopus\.LibGit2Sharp\.([0-9].*)\.nupkg$/\1/')
147+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
148+
echo "Found Octopus.LibGit2Sharp $VERSION"
149+
- name: Add local package source
150+
shell: bash
151+
run: dotnet nuget add source "$GITHUB_WORKSPACE/staging" --name local-build
152+
- name: Run SSH validation
153+
shell: bash
154+
run: dotnet run --project SshCloneTestApp -c Release "/p:LibGit2SharpVersion=${{ steps.pkg.outputs.version }}"
155+
116156
nuget-push:
117157
name: Octopus NuGet Push
118158
needs: [build, test, test-linux]

LibGit2Sharp/LibGit2Sharp.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
</PropertyGroup>
3131

3232
<ItemGroup>
33-
<PackageReference Include="Octopus.LibGit2Sharp.NativeBinaries" Version="2.0.323-octopus.4.octopus-em-static-wi.62" PrivateAssets="none" />
33+
<PackageReference Include="Octopus.LibGit2Sharp.NativeBinaries" Version="2.0.323-octopus.4" PrivateAssets="none" />
3434
<PackageReference Include="MinVer" Version="6.0.0" PrivateAssets="all" />
3535
</ItemGroup>
3636

SshCloneTestApp/AuthProbe.cs

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using LibGit2Sharp;
5+
6+
namespace SshCloneTestApp;
7+
8+
/// <summary>The classified result of one clone attempt.</summary>
9+
public enum Outcome
10+
{
11+
/// <summary>Authentication was rejected — the SSH pipeline worked end to end. PASS.</summary>
12+
AuthFailure,
13+
14+
/// <summary>The key could not be parsed or its algorithm is unsupported by the backend. FAIL.</summary>
15+
KeyParseOrUnsupported,
16+
17+
/// <summary>The clone unexpectedly succeeded with a throwaway key. FAIL.</summary>
18+
UnexpectedSuccess,
19+
20+
/// <summary>The failure matched no known signature. FAIL — needs investigation/calibration.</summary>
21+
Unknown,
22+
}
23+
24+
/// <summary>The outcome of a probe plus a human-readable detail string.</summary>
25+
public sealed record ProbeResult(Outcome Outcome, string Detail)
26+
{
27+
public bool IsPass => Outcome == Outcome.AuthFailure;
28+
}
29+
30+
/// <summary>
31+
/// Attempts an SSH clone with an in-memory key and classifies the result.
32+
/// </summary>
33+
public static class AuthProbe
34+
{
35+
// Signatures (matched case-insensitively) indicating the key could not be loaded or the
36+
// algorithm is unsupported by the active crypto backend. CHECKED FIRST, because a
37+
// backend may wrap a parse failure inside a generic "failed to authenticate" message.
38+
private static readonly string[] ParseOrUnsupportedSignatures =
39+
{
40+
"extract public key",
41+
"unable to extract",
42+
"unsupported",
43+
"unimplemented",
44+
"invalid privatekey",
45+
"unable to parse",
46+
"failed to initialize ssh",
47+
"could not load",
48+
"wrong passphrase",
49+
};
50+
51+
// Signatures indicating authentication was attempted and rejected (the expected outcome).
52+
private static readonly string[] AuthFailureSignatures =
53+
{
54+
"authentication",
55+
"authenticate",
56+
"too many redirects or authentication replays",
57+
"permission denied",
58+
"combination invalid",
59+
"username/publickey",
60+
"username does not match",
61+
"callback returned an invalid",
62+
};
63+
64+
/// <summary>Clones <paramref name="url"/> with the given in-memory key and classifies the result.</summary>
65+
public static ProbeResult Probe(string url, SshKeyGenerator.GeneratedKey key)
66+
{
67+
var options = new CloneOptions
68+
{
69+
FetchOptions =
70+
{
71+
CredentialsProvider = (_, userFromUrl, _) => new SshKeyMemoryCredentials
72+
{
73+
Username = string.IsNullOrEmpty(userFromUrl) ? "git" : userFromUrl,
74+
PublicKey = key.PublicKey,
75+
PrivateKey = key.PrivateKey,
76+
Passphrase = string.Empty,
77+
},
78+
CertificateCheck = (_, _, _) => true, // accept the host key; part of "the process working"
79+
},
80+
};
81+
82+
string destination = Path.Combine(Path.GetTempPath(), "octossh-" + Path.GetRandomFileName());
83+
84+
try
85+
{
86+
Repository.Clone(url, destination, options);
87+
return new ProbeResult(Outcome.UnexpectedSuccess,
88+
"Clone succeeded with a throwaway key — the key must not be authorized.");
89+
}
90+
catch (Exception ex)
91+
{
92+
string message = Flatten(ex);
93+
string lower = message.ToLowerInvariant();
94+
95+
foreach (var sig in ParseOrUnsupportedSignatures)
96+
{
97+
if (lower.Contains(sig))
98+
{
99+
return new ProbeResult(Outcome.KeyParseOrUnsupported, message);
100+
}
101+
}
102+
103+
foreach (var sig in AuthFailureSignatures)
104+
{
105+
if (lower.Contains(sig))
106+
{
107+
return new ProbeResult(Outcome.AuthFailure, message);
108+
}
109+
}
110+
111+
return new ProbeResult(Outcome.Unknown, message);
112+
}
113+
finally
114+
{
115+
TryDelete(destination);
116+
}
117+
}
118+
119+
private static string Flatten(Exception ex)
120+
{
121+
var parts = new List<string>();
122+
for (Exception? e = ex; e != null; e = e.InnerException)
123+
{
124+
parts.Add($"{e.GetType().Name}: {e.Message}");
125+
}
126+
return string.Join(" | ", parts);
127+
}
128+
129+
private static void TryDelete(string path)
130+
{
131+
try
132+
{
133+
if (Directory.Exists(path))
134+
{
135+
Directory.Delete(path, recursive: true);
136+
}
137+
}
138+
catch
139+
{
140+
// best-effort cleanup of the (empty/partial) clone target
141+
}
142+
}
143+
}

SshCloneTestApp/KeySpec.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Collections.Generic;
2+
3+
namespace SshCloneTestApp;
4+
5+
/// <summary>
6+
/// Describes one SSH key type the harness exercises.
7+
/// </summary>
8+
/// <param name="Label">Human-readable identifier used in output and key comments.</param>
9+
/// <param name="SshKeygenType">The value passed to <c>ssh-keygen -t</c>.</param>
10+
/// <param name="Bits">Value for <c>ssh-keygen -b</c>, or null to omit the flag.</param>
11+
/// <param name="UsePemFormat">When true, emit a classic PEM key (<c>-m PEM</c>) for broad
12+
/// libssh2 compatibility. ED25519 has no PEM form and must use the native OpenSSH format.</param>
13+
/// <param name="SupportedOnWindows">False for key types the libssh2 WinCNG backend cannot
14+
/// handle (ED25519), which the harness skips on Windows.</param>
15+
public sealed record KeySpec(
16+
string Label,
17+
string SshKeygenType,
18+
int? Bits,
19+
bool UsePemFormat,
20+
bool SupportedOnWindows)
21+
{
22+
/// <summary>
23+
/// The full key-type matrix. ED25519 is unsupported by the libssh2 WinCNG backend.
24+
/// </summary>
25+
public static IReadOnlyList<KeySpec> All { get; } = new[]
26+
{
27+
new KeySpec("rsa-4096", "rsa", 4096, UsePemFormat: true, SupportedOnWindows: true),
28+
new KeySpec("ecdsa-nistp256", "ecdsa", 256, UsePemFormat: true, SupportedOnWindows: true),
29+
new KeySpec("ed25519", "ed25519", null, UsePemFormat: false, SupportedOnWindows: false),
30+
};
31+
}

SshCloneTestApp/Program.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Runtime.InteropServices;
4+
using LibGit2Sharp;
5+
6+
namespace SshCloneTestApp;
7+
8+
public static class Program
9+
{
10+
private const string RepoUrl = "git@github.com:OctopusDeploy/libgit2sharp.git";
11+
12+
public static int Main()
13+
{
14+
Console.WriteLine($"libgit2 version : {GlobalSettings.Version}");
15+
Console.WriteLine($"features : {GlobalSettings.Version.Features}");
16+
Console.WriteLine($"repository url : {RepoUrl}");
17+
Console.WriteLine($"os : {RuntimeInformation.OSDescription}");
18+
Console.WriteLine();
19+
Console.WriteLine("Each key is freshly generated and unknown to the server, so an");
20+
Console.WriteLine("AUTHENTICATION FAILURE is the expected (passing) outcome: it proves the");
21+
Console.WriteLine("SSH transport, host-key exchange and in-memory key handling all worked.");
22+
Console.WriteLine();
23+
24+
bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
25+
26+
var rows = new List<(string Label, string Status, string Detail)>();
27+
bool allPassed = true;
28+
29+
foreach (var spec in KeySpec.All)
30+
{
31+
if (isWindows && !spec.SupportedOnWindows)
32+
{
33+
Console.WriteLine($"=== {spec.Label}: SKIPPED — WinCNG has no {spec.SshKeygenType} support ===");
34+
Console.WriteLine();
35+
rows.Add((spec.Label, "SKIPPED", "WinCNG backend has no support for this key type"));
36+
continue;
37+
}
38+
39+
Console.WriteLine($"=== {spec.Label} ===");
40+
try
41+
{
42+
var key = SshKeyGenerator.Generate(spec);
43+
Console.WriteLine($"generated key : {spec.Label} ({key.Comment})");
44+
45+
var result = AuthProbe.Probe(RepoUrl, key);
46+
string status = result.IsPass ? "PASS" : "FAIL";
47+
if (!result.IsPass)
48+
{
49+
allPassed = false;
50+
}
51+
52+
Console.WriteLine($"outcome : {result.Outcome} -> {status}");
53+
Console.WriteLine($"detail : {result.Detail}");
54+
rows.Add((spec.Label, $"{status} ({result.Outcome})", result.Detail));
55+
}
56+
catch (Exception ex)
57+
{
58+
allPassed = false;
59+
Console.Error.WriteLine($"harness error : {ex.GetType().Name}: {ex.Message}");
60+
rows.Add((spec.Label, "FAIL (harness error)", ex.Message));
61+
}
62+
63+
Console.WriteLine();
64+
}
65+
66+
Console.WriteLine("==================== SUMMARY ====================");
67+
foreach (var row in rows)
68+
{
69+
Console.WriteLine($" {row.Label,-16} {row.Status}");
70+
}
71+
Console.WriteLine("================================================");
72+
Console.WriteLine(allPassed ? "RESULT: PASS" : "RESULT: FAIL");
73+
74+
return allPassed ? 0 : 1;
75+
}
76+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<OutputType>Exe</OutputType>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<RootNamespace>SshCloneTestApp</RootNamespace>
9+
<!-- This harness validates the *published* package, so the committed state references
10+
the package only. CI supplies the exact version: /p:LibGit2SharpVersion=<version>.
11+
For a local run, pack the library and pass the same property. -->
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="Octopus.LibGit2Sharp" Version="$(LibGit2SharpVersion)" />
16+
</ItemGroup>
17+
18+
</Project>

0 commit comments

Comments
 (0)