diff --git a/.gitignore b/.gitignore
index 09a7fd6..3831da5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,21 @@
+# Ignore build outputs
+**/bin/
+**/obj/
+*.nupkg
+
+# macOS
+.DS_Store
+
+# Visual Studio / Rider
+.vs/
+*.user
+*.suo
+
+# VS Code
+.vscode/
+
+# Solution files that should not be committed from local experimentation
+test-server.sln
*.swp
test-server
/recordings
diff --git a/sdks/dotnet/BinaryInstaller.cs b/sdks/dotnet/BinaryInstaller.cs
new file mode 100644
index 0000000..33f9c65
--- /dev/null
+++ b/sdks/dotnet/BinaryInstaller.cs
@@ -0,0 +1,228 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Runtime.InteropServices;
+using System.Diagnostics;
+using System.IO.Compression;
+using SharpCompress.Archives;
+using SharpCompress.Archives.Tar;
+using SharpCompress.Common;
+using SharpCompress.Readers;
+
+namespace TestServerSdk
+{
+ public static class BinaryInstaller
+ {
+ private const string GithubOwner = "google";
+ private const string GithubRepo = "test-server";
+ private const string ProjectName = "test-server";
+
+ ///
+ /// Ensures the test-server binary for the given version is present under /sdks/dotnet/bin.
+ /// It will download the release asset from GitHub, verify SHA256 using the checksums.json found in the repo, extract it and set executable bits.
+ ///
+ public static async Task EnsureBinaryAsync(string outDir, string version = "v0.2.6")
+ {
+ var embeddedCandidate = Path.Combine(AppContext.BaseDirectory, "checksums.json");
+ var repoChecksumsPath = File.Exists(embeddedCandidate)
+ ? embeddedCandidate
+ : FindChecksumsJson() ?? throw new FileNotFoundException("Could not locate sdks/typescript/checksums.json in repository parents or embedded in output. Please run this command from within the repo or provide checksums.json manually.");
+
+ using var doc = JsonDocument.Parse(File.ReadAllText(repoChecksumsPath));
+ var versionNode = doc.RootElement.TryGetProperty(version, out var vNode)
+ ? vNode
+ : throw new InvalidOperationException($"Checksums.json does not contain an entry for version {version}.");
+
+ var (goOs, archPart, archiveExt, platform) = GetPlatformDetails();
+ var archiveName = $"{ProjectName}_{goOs}_{archPart}{archiveExt}";
+
+ var expectedChecksumNode = versionNode.TryGetProperty(archiveName, out var cNode)
+ ? cNode
+ : throw new InvalidOperationException($"Checksums.json for {version} does not contain an entry for {archiveName}.");
+
+ var expectedChecksum = expectedChecksumNode.GetString();
+ if (string.IsNullOrEmpty(expectedChecksum) || expectedChecksum.StartsWith("PLEASE_RUN_UPDATE_SCRIPT"))
+ throw new InvalidOperationException($"Checksum for {archiveName} in {version} looks invalid or is a placeholder.");
+
+ var binDir = Path.GetFullPath(outDir);
+ Directory.CreateDirectory(binDir);
+ var finalBinaryPath = Path.Combine(binDir, platform == "win32" ? ProjectName + ".exe" : ProjectName);
+ if (File.Exists(finalBinaryPath))
+ {
+ Console.WriteLine($"{ProjectName} binary already exists at {finalBinaryPath}. Skipping download.");
+ EnsureExecutable(finalBinaryPath);
+ return;
+ }
+
+ var downloadUrl = $"https://github.com/{GithubOwner}/{GithubRepo}/releases/download/{version}/{archiveName}";
+ var archivePath = Path.Combine(binDir, archiveName);
+
+ try
+ {
+ await DownloadFileAsync(downloadUrl, archivePath);
+ var actualChecksum = await ComputeSha256Async(archivePath);
+ if (!string.Equals(actualChecksum, expectedChecksum, StringComparison.OrdinalIgnoreCase))
+ {
+ File.Delete(archivePath);
+ throw new InvalidOperationException($"Checksum mismatch for {archiveName}. Expected: {expectedChecksum}, Actual: {actualChecksum}");
+ }
+
+ ExtractArchive(archivePath, archiveExt, binDir);
+ EnsureExecutable(finalBinaryPath);
+
+ Console.WriteLine($"{ProjectName} ready at {finalBinaryPath}");
+ }
+ catch
+ {
+ if (File.Exists(archivePath))
+ {
+ try { File.Delete(archivePath); } catch { }
+ }
+ throw;
+ }
+ }
+
+ private static string RepoRootPathFrom(string checksumsPath)
+ {
+ // checksumsPath is expected to be /sdks/dotnet/checksums.json
+ return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(checksumsPath) ?? string.Empty, "..", ".."));
+ }
+
+ private static string? FindChecksumsJson()
+ {
+ // Start from AppContext.BaseDirectory and walk up to find sdks/dotnet/checksums.json
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ for (int i = 0; i < 8 && dir != null; i++)
+ {
+ var candidate = Path.Combine(dir.FullName, "sdks", "dotnet", "checksums.json");
+ if (File.Exists(candidate)) return candidate;
+ dir = dir.Parent;
+ }
+ // Also try the current working directory
+ dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+ for (int i = 0; i < 4 && dir != null; i++)
+ {
+ var candidate = Path.Combine(dir.FullName, "sdks", "dotnet", "checksums.json");
+ if (File.Exists(candidate)) return candidate;
+ dir = dir.Parent;
+ }
+ return null;
+ }
+
+ private static (string goOs, string archPart, string archiveExt, string platform) GetPlatformDetails()
+ {
+ string platform;
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) platform = "darwin";
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) platform = "linux";
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) platform = "win32";
+ else throw new PlatformNotSupportedException("Unsupported OS platform");
+
+ var arch = RuntimeInformation.ProcessArchitecture;
+ string archPart;
+ if (arch == Architecture.X64) archPart = "x86_64";
+ else if (arch == Architecture.Arm64) archPart = "arm64";
+ else throw new PlatformNotSupportedException("Unsupported architecture");
+
+ string goOs = platform == "darwin" ? "Darwin" : platform == "linux" ? "Linux" : "Windows";
+ string archiveExt = platform == "win32" ? ".zip" : ".tar.gz";
+ return (goOs, archPart, archiveExt, platform);
+ }
+
+ private static async Task DownloadFileAsync(string url, string destinationPath)
+ {
+ Console.WriteLine($"Downloading {url} -> {destinationPath}...");
+ using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(2) };
+ using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
+ resp.EnsureSuccessStatusCode();
+ using var stream = await resp.Content.ReadAsStreamAsync();
+ using var fs = new FileStream(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ await stream.CopyToAsync(fs);
+ Console.WriteLine("Download complete.");
+ }
+
+ private static async Task ComputeSha256Async(string filePath)
+ {
+ using var stream = File.OpenRead(filePath);
+ using var sha = SHA256.Create();
+ var hash = await sha.ComputeHashAsync(stream);
+ return BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant();
+ }
+
+ private static void ExtractArchive(string archivePath, string archiveExt, string destDir)
+ {
+ Console.WriteLine($"Extracting {archivePath} to {destDir}...");
+ if (archiveExt == ".zip")
+ {
+ ZipFile.ExtractToDirectory(archivePath, destDir);
+ }
+ else
+ {
+ using var fileStream = File.OpenRead(archivePath);
+ using var reader = ReaderFactory.Open(fileStream);
+ while (reader.MoveToNextEntry())
+ {
+ var entry = reader.Entry;
+ if (entry.IsDirectory) continue;
+ var outPath = Path.Combine(destDir, entry.Key);
+ var outDir = Path.GetDirectoryName(outPath);
+ if (!string.IsNullOrEmpty(outDir)) Directory.CreateDirectory(outDir);
+ reader.WriteEntryToDirectory(destDir, new ExtractionOptions { ExtractFullPath = true, Overwrite = true });
+ }
+ }
+ File.Delete(archivePath);
+ Console.WriteLine("Extraction complete.");
+ }
+
+ private static void EnsureExecutable(string binaryPath)
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
+ try
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "chmod",
+ Arguments = $"755 {QuotePath(binaryPath)}",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false
+ };
+ using var p = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start 'chmod' process to set executable permissions");
+ p.WaitForExit();
+ if (p.ExitCode != 0)
+ {
+ var err = p.StandardError.ReadToEnd();
+ Console.WriteLine($"chmod failed: {err}");
+ }
+ else
+ {
+ Console.WriteLine($"Set executable permissions on {binaryPath}");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Could not set executable permission: {ex.Message}");
+ }
+ }
+
+ private static string QuotePath(string p) => p.Contains(' ') ? '"' + p + '"' : p;
+ }
+}
diff --git a/sdks/dotnet/README.md b/sdks/dotnet/README.md
new file mode 100644
index 0000000..6e7d8d7
--- /dev/null
+++ b/sdks/dotnet/README.md
@@ -0,0 +1,16 @@
+This folder contains the .NET SDK for `test-server`. It provides a small runtime wrapper to start/stop the `test-server` binary and a helper installer that downloads and verifies the native binary. During test runtime, the SDK first checks if the `test-server` binary is already downloaded and verified, otherwise it downloads and verifies it.
+
+## Example of setting TestServerOptions
+
+```csharp
+using TestServerSdk;
+
+var binaryPathDir = "dir/you/want/the/binary/to/be/downloaded";
+var options = new TestServerOptions
+{
+ ConfigPath = Path.GetFullPath("../test-server.yml"),
+ RecordingDir = Path.GetFullPath("../Recordings"),
+ Mode = "replay",
+ BinaryPath = Path.GetFullPath(Path.Combine(binaryPathDir, "test-server"))
+};
+```
diff --git a/sdks/dotnet/TestServerSdk.cs b/sdks/dotnet/TestServerSdk.cs
new file mode 100644
index 0000000..41cdde3
--- /dev/null
+++ b/sdks/dotnet/TestServerSdk.cs
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Text.Json;
+using YamlDotNet.RepresentationModel;
+using System.Net.Http;
+
+namespace TestServerSdk
+{
+ public class TestServerOptions
+ {
+ public required string ConfigPath { get; set; }
+ public required string RecordingDir { get; set; }
+ public required string Mode { get; set; } // "record" or "replay"
+ public required string BinaryPath { get; set; }
+
+ public Action? OnStdOut { get; set; }
+ public Action? OnStdErr { get; set; }
+ public Action? OnExit { get; set; }
+ public Action? OnError { get; set; }
+ }
+
+ public class TestServerProcess
+ {
+ private Process? _process;
+ private readonly TestServerOptions _options;
+ private readonly string _binaryPath;
+
+ public TestServerProcess(TestServerOptions options)
+ {
+ _options = options;
+ _binaryPath = GetBinaryPath();
+ }
+
+ private string GetBinaryPath()
+ {
+ var binaryName = Environment.OSVersion.Platform == PlatformID.Win32NT ? "test-server.exe" : "test-server";
+
+ var p = Path.GetFullPath(_options.BinaryPath);
+ if (File.Exists(p)) return p;
+
+ // If the binary does not exist at the provided path, attempt to install it into that folder
+ try
+ {
+ var targetDir = Path.GetDirectoryName(p) ?? Path.GetFullPath(Directory.GetCurrentDirectory());
+ Console.WriteLine($"[TestServerSdk] test-server not found at {p}. Installing into {targetDir}...");
+ BinaryInstaller.EnsureBinaryAsync(targetDir, "v0.2.6").GetAwaiter().GetResult();
+ if (File.Exists(p)) return p;
+ throw new FileNotFoundException($"After installation, test-server binary still not found at: {p}");
+ }
+ catch (Exception ex)
+ {
+ throw new FileNotFoundException($"TestServerOptions.BinaryPath was set but file not found and installer failed: {p}", ex);
+ }
+ }
+
+ public async Task StartAsync()
+ {
+ var args = $"{_options.Mode} --config {_options.ConfigPath} --recording-dir {_options.RecordingDir}";
+ var psi = new ProcessStartInfo
+ {
+ FileName = _binaryPath,
+ Arguments = args,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ _process = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ _process.OutputDataReceived += (s, e) => { if (e.Data != null) _options.OnStdOut?.Invoke(e.Data); };
+ _process.ErrorDataReceived += (s, e) => { if (e.Data != null) _options.OnStdErr?.Invoke(e.Data); };
+ _process.Exited += (s, e) => _options.OnExit?.Invoke(_process?.ExitCode, _process?.ExitCode.ToString() ?? string.Empty);
+ try
+ {
+ _process.Start();
+ _process.BeginOutputReadLine();
+ _process.BeginErrorReadLine();
+ await AwaitHealthyTestServer();
+ return _process;
+ }
+ catch (Exception ex)
+ {
+ _options.OnError?.Invoke(ex);
+ throw;
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ if (_process == null || _process.HasExited)
+ return;
+ _process.Kill();
+ await Task.Run(() => _process.WaitForExit(5000));
+ }
+
+ private async Task AwaitHealthyTestServer()
+ {
+ var yaml = File.ReadAllText(_options.ConfigPath);
+ var input = new StringReader(yaml);
+ var yamlStream = new YamlStream();
+ yamlStream.Load(input);
+ var root = (YamlMappingNode)yamlStream.Documents[0].RootNode;
+ if (!root.Children.ContainsKey(new YamlScalarNode("endpoints"))) return;
+ var endpoints = (YamlSequenceNode)root.Children[new YamlScalarNode("endpoints")];
+ foreach (YamlMappingNode endpoint in endpoints)
+ {
+ if (!endpoint.Children.ContainsKey(new YamlScalarNode("health"))) continue;
+ var sourceType = endpoint.Children[new YamlScalarNode("source_type")].ToString();
+ var sourcePort = endpoint.Children[new YamlScalarNode("source_port")].ToString();
+ var healthPath = endpoint.Children[new YamlScalarNode("health")].ToString();
+ var url = $"{sourceType}://localhost:{sourcePort}{healthPath}";
+ await HealthCheck(url);
+ }
+ }
+
+ private async Task HealthCheck(string url)
+ {
+ using var client = new HttpClient();
+ const int maxRetries = 10;
+ int delay = 100;
+ for (int i = 0; i < maxRetries; i++)
+ {
+ try
+ {
+ var response = await client.GetAsync(url);
+ if (response.IsSuccessStatusCode) return;
+ }
+ catch { }
+ await Task.Delay(delay);
+ delay *= 2;
+ }
+ throw new Exception($"Health check failed for {url}");
+ }
+ }
+}
diff --git a/sdks/dotnet/TestServerSdk.csproj b/sdks/dotnet/TestServerSdk.csproj
new file mode 100644
index 0000000..c30cf49
--- /dev/null
+++ b/sdks/dotnet/TestServerSdk.csproj
@@ -0,0 +1,22 @@
+
+
+ net8.0
+ latest
+ enable
+ enable
+ true
+ 0.1.0
+ false
+ false
+ Library
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
diff --git a/sdks/dotnet/checksums.json b/sdks/dotnet/checksums.json
new file mode 100644
index 0000000..d00da43
--- /dev/null
+++ b/sdks/dotnet/checksums.json
@@ -0,0 +1,82 @@
+{
+ "v0.0.1": {
+ "test-server_Darwin_arm64.tar.gz": "8d5ff282451b8d49fa6f290ec11d4ee9fcc948f28ae2713f928f4e3eeaf35d2e",
+ "test-server_Darwin_x86_64.tar.gz": "793b97d96d1a4a65fdef4510266047725aca6a7f39be3958f039b5f5d377c5f3",
+ "test-server_Linux_arm64.tar.gz": "b77c68d7549eb8f1ba0569434f11236cb08222bead8235bef2f6dc194eff4318",
+ "test-server_Linux_i386.tar.gz": "e9ec96227854a9def19cd112dfc22bfc776a6595314fb104b80f9d74d27a8ba2",
+ "test-server_Linux_x86_64.tar.gz": "35a157c5c9fbf2639ac8f8282f45186397ebd428b31808780421e2fa34923866",
+ "test-server_Windows_arm64.zip": "bf708e74aa1e6fd15529031c9a8f7d75b8fcc35cb7ef24c8c81a5b3e1ba2fca4",
+ "test-server_Windows_i386.zip": "13c5a1cde66b2795cb49b02dc672da66bbc2f934c67f733b7313ad4c10c68c96",
+ "test-server_Windows_x86_64.zip": "6105a98d7b245a3b8868c173d2e36c0e2a41c9e88a0e266b0f318b21f96a313f"
+ },
+ "v0.2.0": {
+ "test-server_Darwin_arm64.tar.gz": "87a63147c318e012e5963fd4ae706aede56267db1913272baeeafe4b9aef95c0",
+ "test-server_Darwin_x86_64.tar.gz": "79dee942fd1673d4000f99742464cb7cf238b773523a0da294da9017f30e43c4",
+ "test-server_Linux_arm64.tar.gz": "50b3667ee7c7543b08decff81936654cb878a8ad84d1ed2f9d4f40108f027be1",
+ "test-server_Linux_i386.tar.gz": "9599bf857fac1594ef38ed44193f8da7374ac2d1e82e5aa169d474b6ffece04b",
+ "test-server_Linux_x86_64.tar.gz": "84b5b2ef12e002461fa9961d689d415fec80780231d8dd107c6eb40bbc327759",
+ "test-server_Windows_arm64.zip": "d00178c9bc523ee9c37576efff94f1ba09c4b30e375636b603e9b46ca5be5a25",
+ "test-server_Windows_i386.zip": "1beff64cd68fffa7bd4936d6a2df8641cc371b0822a9ef647e3ab15710ced045",
+ "test-server_Windows_x86_64.zip": "dfa622a481a8abad115a177e12fd5bfc7fc0270cb37618511ba183b34ad6f0d1"
+ },
+ "v0.2.1": {
+ "test-server_Darwin_arm64.tar.gz": "3bd64892e9943e65e2bd769b15a212f6d54021ff526ef42c0e4f8dc13be25eb9",
+ "test-server_Darwin_x86_64.tar.gz": "35941ef52f8c2fd3ac49b1128f964b81e04ceedb5e1f359cd51ece7dde15ff98",
+ "test-server_Linux_arm64.tar.gz": "e284be5cdc497db55ea09471e6dfcd3768b40d3f0eff915794b04386a6d0f18e",
+ "test-server_Linux_i386.tar.gz": "12cb4f8167baca5965b90cf4d2157bff78380f1f7853ccf45b87e26abd63f52d",
+ "test-server_Linux_x86_64.tar.gz": "5dab0a8041cfee8801a91ded6a98a682a3952649d35e6a05c3edfb2c32383c7b",
+ "test-server_Windows_arm64.zip": "884e84dc43491ecbfbb2c03f6d98ec8d247a4eb7c80a3cac61849c1ccbcfc92c",
+ "test-server_Windows_i386.zip": "51980525c42121674aef6953eddb0579d4897576c06ae411064ad92e828a45e7",
+ "test-server_Windows_x86_64.zip": "e368ac54ec00443ddcde0c63ee806b7b865be403388b69f256992ac49acad7e1"
+ },
+ "v0.2.2": {
+ "test-server_Darwin_arm64.tar.gz": "1e568d5447597dd06f535806a5e52fe2063c7f9b57edf3b590699a9bd0675b8d",
+ "test-server_Darwin_x86_64.tar.gz": "a6e3127cf5622332c4b4200957ce5ddab2ff84e91b4ff984514071a716877852",
+ "test-server_Linux_arm64.tar.gz": "87789743585853dddca65a88aaaccd7463fc2b714671438f0f711dac1cea8ea4",
+ "test-server_Linux_i386.tar.gz": "0482509d6dcd80be203988aadf3e5421e2116e43b33971c8540148de80bfa0da",
+ "test-server_Linux_x86_64.tar.gz": "89798849206ae210309cad36b3275c333a19d12d45941327384279be17dca07d",
+ "test-server_Windows_arm64.zip": "be8500c4577da4930397ecfebd626b2a090ab045975e594550c16f087ee343b7",
+ "test-server_Windows_i386.zip": "345f894e0e789442802a66806623f7a28b95cafac6d10ac5d7aa44084fb73bc5",
+ "test-server_Windows_x86_64.zip": "8d64f303463a697550903bb3587f63e8a37efa96b9eea1929d5cbd825f2840e4"
+ },
+ "v0.2.3": {
+ "test-server_Darwin_arm64.tar.gz": "e7ed97903e1850755321da023838bc27c30bf44365d4c347d0405ad2db80d901",
+ "test-server_Darwin_x86_64.tar.gz": "33af03f84b644efb7113371433a78bc35cf406fc909eac1f33f6003fec8afd38",
+ "test-server_Linux_arm64.tar.gz": "c1355f56d5c8480c71ad7c8c4e01160cd9b60e977af3bc15ae6599ae04958cd1",
+ "test-server_Linux_i386.tar.gz": "50619693d9b6a27a05d72a6af7d364333f67aa9b5c67428c85e0e8dbadd44dd3",
+ "test-server_Linux_x86_64.tar.gz": "7af3c0502b5c242565cb494a50a50188d38c342e08523f8168198ebb73506062",
+ "test-server_Windows_arm64.zip": "dc5cc3b28404fec303b5afc31da45de24cb69ce36a74590985b2b054d7cf78b9",
+ "test-server_Windows_i386.zip": "b42b75ae4d538aa1df3893612458c449ad6c132cae99feb9deaac075b04bd1dd",
+ "test-server_Windows_x86_64.zip": "22b7d25b7ad3bb3b586a6fba2996420f67a795131b9be3a06ccffe92cfd3f234"
+ },
+ "v0.2.4": {
+ "test-server_Darwin_arm64.tar.gz": "a80eca2362245ceb0f0b60cbc7121dc2bb35c0d95224051f7c5bb815f9cb3bb7",
+ "test-server_Darwin_x86_64.tar.gz": "4c55c667b1419ec09aef536cdf753d20ddb29af29199a099273ffed732e2b4f1",
+ "test-server_Linux_arm64.tar.gz": "c0a2a6b74a29dc6e2b4d17079872f0e64d9a3d392dc35d4ac8a6b02ba2b5278d",
+ "test-server_Linux_i386.tar.gz": "c20dbbbb89d00dcbd15ded3077d270111cf59118beff45e68114ff8f0bb3e79c",
+ "test-server_Linux_x86_64.tar.gz": "acddf79900182c4e7a0dfb03562f0dd24bfde922d50ec5f767cb234942b204fa",
+ "test-server_Windows_arm64.zip": "07bf8adc4a9c5aa353d95ce0afbf075c4c069c926ac14178ecfb283f6d072b4d",
+ "test-server_Windows_i386.zip": "62e5bc50e64ffa0fd0878203b351a393a990d70c75800572831d273a99b9d2b4",
+ "test-server_Windows_x86_64.zip": "5902ebf807667243b29af5ca1b7622aba7be5fc2d50378b35f066bea85832f8b"
+ },
+ "v0.2.5": {
+ "test-server_Darwin_arm64.tar.gz": "fe652704eee0f4568a2d4f8c3a43732dc28cab4d2bd9dfc6294372f6587e4e2b",
+ "test-server_Darwin_x86_64.tar.gz": "cf1a4bca0297b394173deaf1515cba6382dd73cad7e53057a5f2e77d6f3c0d33",
+ "test-server_Linux_arm64.tar.gz": "874b3799f6669dfd278cade47c1c3ac1f40754ffc8dc296e5143381eae76bd84",
+ "test-server_Linux_i386.tar.gz": "5ae39f7e06e9fefd9574674aee3450d73b77c3cab3a7b81aea5688320c823978",
+ "test-server_Linux_x86_64.tar.gz": "716d4bde33a842f4a97a8a8c9037027bd6a314cf4b4a769ee0acd7a37ca5e171",
+ "test-server_Windows_arm64.zip": "9894d08f6e9c2e58991c78418e65a6f8a12712c86a4ac98b18d74783c601f6f7",
+ "test-server_Windows_i386.zip": "b0daa8cac3133470afa9a049ad08a283c5c48c929a139c685773dee31b20d99e",
+ "test-server_Windows_x86_64.zip": "88c55b9208d66516a674b79be59fed3ec0262f4f96a499628b9ea609f13e3fc8"
+ },
+ "v0.2.6": {
+ "test-server_Darwin_arm64.tar.gz": "8e3f9b5a7ab4d5e398f44c0bdbe4e8f009b863b63d31dfadf00834d306c7b746",
+ "test-server_Darwin_x86_64.tar.gz": "4a59bb73ae6009ac92a274b4a0e6ce534b7ff90b0afb7312f8ae7e015cbcefe8",
+ "test-server_Linux_arm64.tar.gz": "f3273dce4bb2f492cc703fe790af37b6e0db1b258e94f770bd86493b5aa5558e",
+ "test-server_Linux_i386.tar.gz": "3f3878103935bf1507836360ba103bf7a5d1034fd21a285074574b22e67f58a4",
+ "test-server_Linux_x86_64.tar.gz": "f007c2a940dade8a1e4c08f2c954f768a351e3fa3b050dcc1753bf65e637b983",
+ "test-server_Windows_arm64.zip": "466137be1dad084fcdef86a8894080a2ef1086dfd3ee15bc123a6d2053515841",
+ "test-server_Windows_i386.zip": "6980c83e2118ed739dad53af29dc302b78ec89804f7ff7d7b5e39dcadbab3e83",
+ "test-server_Windows_x86_64.zip": "8a4e36c8fa2d17a256a31956a3cb2851d27a30f423449911caf0b3ec76b9a602"
+ }
+}
diff --git a/sdks/dotnet/tools/installer/Installer.csproj b/sdks/dotnet/tools/installer/Installer.csproj
new file mode 100644
index 0000000..1dae1ab
--- /dev/null
+++ b/sdks/dotnet/tools/installer/Installer.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
diff --git a/sdks/dotnet/tools/installer/Program.cs b/sdks/dotnet/tools/installer/Program.cs
new file mode 100644
index 0000000..77080ee
--- /dev/null
+++ b/sdks/dotnet/tools/installer/Program.cs
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+using System;
+using System.Threading.Tasks;
+using TestServerSdk;
+
+namespace TestServerSdk.InstallerTool
+{
+ class Program
+ {
+ static async Task Main(string[] args)
+ {
+ var version = args.Length > 0 ? args[0] : "v0.2.6";
+ try
+ {
+ await BinaryInstaller.EnsureBinaryAsync(version);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Installer failed: {ex.Message}");
+ return 2;
+ }
+ }
+ }
+}