diff --git a/.config/csharpier/.csharpierignore b/.config/csharpier/.csharpierignore
new file mode 100644
index 0000000..51045e2
--- /dev/null
+++ b/.config/csharpier/.csharpierignore
@@ -0,0 +1,2 @@
+**/*
+!**/*.cs
diff --git a/.config/csharpier/.csharpierrc.json b/.config/csharpier/.csharpierrc.json
new file mode 100644
index 0000000..963354f
--- /dev/null
+++ b/.config/csharpier/.csharpierrc.json
@@ -0,0 +1,3 @@
+{
+ "printWidth": 120
+}
diff --git a/.config/cspell/cspell.json b/.config/cspell/cspell.json
new file mode 100644
index 0000000..27b7028
--- /dev/null
+++ b/.config/cspell/cspell.json
@@ -0,0 +1,30 @@
+{
+ "version": "0.2",
+ "enableGlobDot": true,
+ "useGitignore": true,
+ "gitignoreRoot": ".",
+ "ignorePaths": ["LICENSE"],
+ "words": [
+ "CIFS",
+ "csharpierignore",
+ "csharpierrc",
+ "devcontainer",
+ "devcontainers",
+ "Finalizers",
+ "getgid",
+ "getuid",
+ "globalconfig",
+ "globaltool",
+ "ICMPV6",
+ "libc",
+ "msbuild",
+ "MSTEST",
+ "packagejson",
+ "reportgenerator",
+ "runas",
+ "runsettings",
+ "statvfs",
+ "Syscall",
+ "WINDIVERT"
+ ]
+}
diff --git a/.config/dotnet/.globalconfig b/.config/dotnet/.globalconfig
new file mode 100644
index 0000000..9cd5137
--- /dev/null
+++ b/.config/dotnet/.globalconfig
@@ -0,0 +1,3 @@
+dotnet_diagnostic.IDE0005.severity=warning
+dotnet_diagnostic.CA1852.severity=warning
+dotnet_diagnostic.CA2007.severity=warning
diff --git a/.config/dotnet/Format.targets b/.config/dotnet/Format.targets
new file mode 100644
index 0000000..ddcc8d5
--- /dev/null
+++ b/.config/dotnet/Format.targets
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/.config/dotnet/Packages.props b/.config/dotnet/Packages.props
new file mode 100644
index 0000000..038ebbd
--- /dev/null
+++ b/.config/dotnet/Packages.props
@@ -0,0 +1,15 @@
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.config/dotnet/Project.props b/.config/dotnet/Project.props
new file mode 100644
index 0000000..a384f84
--- /dev/null
+++ b/.config/dotnet/Project.props
@@ -0,0 +1,22 @@
+
+
+ enable
+ enable
+ true
+ preview
+
+
+
+
+
+
+
+
+
+ true
+ true
+ embedded
+ true
+ $(MSBuildProjectDirectory)=/_/$(MSBuildProjectName)
+
+
diff --git a/.config/dotnet/tools.json b/.config/dotnet/tools.json
new file mode 100644
index 0000000..20f019c
--- /dev/null
+++ b/.config/dotnet/tools.json
@@ -0,0 +1,16 @@
+{
+ "version": 1,
+ "isRoot": true,
+ "tools": {
+ "csharpier": {
+ "version": "1.1.2",
+ "commands": ["csharpier"],
+ "rollForward": false
+ },
+ "dotnet-reportgenerator-globaltool": {
+ "version": "5.4.18",
+ "commands": ["reportgenerator"],
+ "rollForward": false
+ }
+ }
+}
diff --git a/.gitattributes b/.config/git/attributes
similarity index 100%
rename from .gitattributes
rename to .config/git/attributes
diff --git a/.config/git/ignore b/.config/git/ignore
new file mode 100644
index 0000000..b2bcadf
--- /dev/null
+++ b/.config/git/ignore
@@ -0,0 +1,9 @@
+.*
+_*
+!.devcontainer/
+!**/.devcontainer/**
+!.config/
+!**/.config/**
+
+node_modules/
+!.github/
diff --git a/.config/pnpm/rc b/.config/pnpm/rc
new file mode 100644
index 0000000..863fba9
--- /dev/null
+++ b/.config/pnpm/rc
@@ -0,0 +1,3 @@
+lockfile=false
+resolution-mode=time-based
+store-dir=/home/dev/.local/share/pnpm/store
diff --git a/.config/prettier/.prettierrc.json b/.config/prettier/.prettierrc.json
new file mode 100644
index 0000000..b8ae6eb
--- /dev/null
+++ b/.config/prettier/.prettierrc.json
@@ -0,0 +1,9 @@
+{
+ "printWidth": 120,
+ "plugins": ["prettier-plugin-packagejson", "prettier-plugin-sh", "@prettier/plugin-xml", "prettier-plugin-ini"],
+ "xmlWhitespaceSensitivity": "ignore",
+ "overrides": [
+ { "files": "app.manifest", "options": { "parser": "xml" } },
+ { "files": "*.globalconfig", "options": { "parser": "ini" } }
+ ]
+}
diff --git a/.config/workspaces/Directory.Build.props b/.config/workspaces/Directory.Build.props
new file mode 100644
index 0000000..b7d1bef
--- /dev/null
+++ b/.config/workspaces/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ $(MSBuildThisFileDirectory)artifacts
+
+
+
+
+
+
diff --git a/.config/workspaces/pnpm-workspace.yaml b/.config/workspaces/pnpm-workspace.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/.devcontainer/.env b/.devcontainer/.env
new file mode 100644
index 0000000..9ae87c3
--- /dev/null
+++ b/.devcontainer/.env
@@ -0,0 +1,8 @@
+WORKSPACES=/workspaces
+XDG_CONFIG_HOME=/home/dev/.config
+XDG_CACHE_HOME=/home/dev/.cache
+XDG_DATA_HOME=/home/dev/.local/share
+XDG_STATE_HOME=/home/dev/.local/state
+XDG_DATA_DIRS=/usr/local/share:/usr/share
+XDG_CONFIG_DIRS=/etc/xdg
+NUGET_PACKAGES=/home/dev/.local/share/NuGet/global-packages
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..4c27707
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,6 @@
+FROM mcr.microsoft.com/devcontainers/javascript-node:22
+
+RUN apt-get update && apt-get install --yes \
+ cifs-utils
+
+RUN npm install --global pnpm@latest-10
diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml
new file mode 100644
index 0000000..13d9f6b
--- /dev/null
+++ b/.devcontainer/compose.yaml
@@ -0,0 +1,24 @@
+services:
+ devcontainer:
+ env_file:
+ - .env
+ - path: ../.local/.env
+ required: false
+ build:
+ context: .
+ dockerfile: Dockerfile
+ init: true
+ privileged: true
+ volumes:
+ - WORKSPACES:${WORKSPACES}
+ - ..:${WORKSPACES}/divert-windows
+ - XDG_CONFIG_HOME:${XDG_CONFIG_HOME}
+ - XDG_CACHE_HOME:${XDG_CACHE_HOME}
+ - XDG_DATA_HOME:${XDG_DATA_HOME}
+ - XDG_STATE_HOME:${XDG_STATE_HOME}
+volumes:
+ WORKSPACES:
+ XDG_CONFIG_HOME:
+ XDG_CACHE_HOME:
+ XDG_DATA_HOME:
+ XDG_STATE_HOME:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..0667149
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,41 @@
+// spell-checker:ignore esbenp dotnettools
+{
+ "name": "divert-windows",
+ "dockerComposeFile": "compose.yaml",
+ "service": "devcontainer",
+ "remoteUser": "dev",
+ "overrideCommand": true,
+ "workspaceFolder": "/workspaces/divert-windows",
+ "features": {
+ "ghcr.io/devcontainer-config/features/user-init:2": {},
+ "ghcr.io/devcontainer-config/features/dot-config:3": {},
+ "ghcr.io/devcontainers/features/dotnet:2": { "version": "8.0" }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "esbenp.prettier-vscode",
+ "ms-azuretools.vscode-docker",
+ "streetsidesoftware.code-spell-checker",
+ "ms-dotnettools.csharp",
+ "csharpier.csharpier-vscode",
+ "github.vscode-github-actions"
+ ],
+ "settings": {
+ "files.associations": {
+ "ignore": "ignore",
+ "attributes": "properties",
+ "rc": "properties",
+ "*.globalconfig": "ini",
+ "app.manifest": "xml"
+ },
+ "editor.formatOnSave": true,
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "cSpell.autoFormatConfigFile": true,
+ "cSpell.checkOnlyEnabledFileTypes": false,
+ "[csharp]": { "editor.defaultFormatter": "csharpier.csharpier-vscode" }
+ }
+ }
+ },
+ "onCreateCommand": "pnpm install && pnpm restore || true"
+}
diff --git a/.devcontainer/dot-config.json b/.devcontainer/dot-config.json
new file mode 100644
index 0000000..cda4619
--- /dev/null
+++ b/.devcontainer/dot-config.json
@@ -0,0 +1,15 @@
+{
+ "git": { "attributes": "/home/dev/.config/git/attributes", "ignore": "/home/dev/.config/git/ignore" },
+ "pnpm": { "rc": "/home/dev/.config/pnpm/rc" },
+ "prettier": { ".prettierrc.json": ".prettierrc.json" },
+ "cspell": { "cspell.json": "cspell.json" },
+ "workspaces": {
+ "../git/attributes": ".gitattributes",
+ "../git/ignore": ".gitignore",
+ "../../package.json": "package.json",
+ "pnpm-workspace.yaml": "pnpm-workspace.yaml",
+ "Directory.Build.props": "Directory.Build.props"
+ },
+ "csharpier": { ".csharpierrc.json": ".csharpierrc.json", ".csharpierignore": ".csharpierignore" },
+ "dotnet": { "tools.json": ".config/dotnet-tools.json" }
+}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
new file mode 100644
index 0000000..343d2b8
--- /dev/null
+++ b/.github/workflows/main.yml
@@ -0,0 +1,95 @@
+name: "Main"
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ steps:
+ - name: Set lowercase repository name
+ run: echo "GITHUB_REPOSITORY=${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV
+
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ github.token }}
+
+ - name: Pre-build dev container image
+ uses: devcontainers/ci@v0.3
+ with:
+ imageName: ghcr.io/${{ env.GITHUB_REPOSITORY }}/devcontainer
+ cacheFrom: ghcr.io/${{ env.GITHUB_REPOSITORY }}/devcontainer
+ push: always
+ runCmd: pnpm restore
+
+ - name: Lint
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ env.GITHUB_REPOSITORY }}/devcontainer
+ push: never
+ runCmd: pnpm lint
+
+ - name: Build
+ uses: devcontainers/ci@v0.3
+ with:
+ cacheFrom: ghcr.io/${{ env.GITHUB_REPOSITORY }}/devcontainer
+ push: never
+ runCmd: pnpm build
+
+ - name: Upload Build Artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: build-artifacts
+ path: |
+ .local/windows/Divert.Windows.TestRunner
+ .local/windows/Divert.Windows.Tests
+
+ test:
+ needs: build
+ runs-on: windows-latest
+ steps:
+ - name: Download Build Artifacts
+ uses: actions/download-artifact@v4
+ with:
+ name: build-artifacts
+ path: .local/windows
+
+ - name: Run Tests
+ run: .local/windows/Divert.Windows.TestRunner/Divert.Windows.TestRunner.exe
+
+ - name: Upload Test Results
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-coverage
+ path: .local/windows/TestResults/coverage.cobertura.xml
+
+ code-coverage:
+ needs: test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Download Test Results
+ uses: actions/download-artifact@v4
+ with:
+ name: test-coverage
+
+ - name: Codecov
+ uses: codecov/codecov-action@v5
+ with:
+ fail_ci_if_error: true
+ files: coverage.cobertura.xml
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..2068cbd
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,29 @@
+name: "Publish"
+
+on:
+ push:
+ tags:
+ - "[0-9]+.[0-9]+.[0-9]+"
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ env:
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ steps:
+ - name: Set lowercase repository name
+ run: echo "GITHUB_REPOSITORY=${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV
+
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Publish
+ uses: devcontainers/ci@v0.3
+ env:
+ NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
+ with:
+ imageName: ghcr.io/${{ env.GITHUB_REPOSITORY }}/devcontainer
+ cacheFrom: ghcr.io/${{ env.GITHUB_REPOSITORY }}/devcontainer
+ push: never
+ runCmd: pnpm run publish
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index 2246bd7..0000000
--- a/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-.*/
-obj/
-bin/
-/Build/
-/Publish/
diff --git a/Automation/Automation.csproj b/Automation/Automation.csproj
new file mode 100644
index 0000000..aa79bbb
--- /dev/null
+++ b/Automation/Automation.csproj
@@ -0,0 +1,12 @@
+
+
+ Exe
+ $(DefaultTargetFramework)
+ CA2007
+
+
+
+
+
+
+
diff --git a/Automation/Build.cs b/Automation/Build.cs
new file mode 100644
index 0000000..05e38cc
--- /dev/null
+++ b/Automation/Build.cs
@@ -0,0 +1,28 @@
+using Cake.Common.IO;
+using Cake.Common.Tools.DotNet;
+using Cake.Common.Tools.DotNet.MSBuild;
+using Cake.Frosting;
+
+namespace Automation;
+
+public class Build : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.CleanDirectory(Context.LocalWindowsDirectory);
+ context.DotNetBuild(
+ Context.ProjectRoot,
+ new()
+ {
+ MSBuildSettings = new()
+ {
+ TreatAllWarningsAs = MSBuildTreatAllWarningsAs.Error,
+ Properties =
+ {
+ ["DivertWindowsTests"] = ["true"], // Enable DivertValueTaskExecutorDelay
+ },
+ },
+ }
+ );
+ }
+}
diff --git a/Automation/Context.cs b/Automation/Context.cs
new file mode 100644
index 0000000..713ad16
--- /dev/null
+++ b/Automation/Context.cs
@@ -0,0 +1,20 @@
+using System.Runtime.CompilerServices;
+using Cake.Core;
+using Cake.Frosting;
+
+namespace Automation;
+
+public class Context(ICakeContext context) : FrostingContext(context)
+{
+ static string GetFilePath([CallerFilePath] string? path = null) => path!;
+
+ public static string ProjectRoot => new FileInfo(GetFilePath()).Directory!.Parent!.FullName;
+
+ public static string Workspaces => new DirectoryInfo(ProjectRoot).Parent!.FullName;
+
+ public static string LocalDirectory => Path.Combine(ProjectRoot, ".local");
+
+ public static string LocalWindowsDirectory => Path.Combine(LocalDirectory, "windows");
+
+ public static string PackagesDirectory => Path.Combine(LocalDirectory, "packages");
+}
diff --git a/Automation/Format.cs b/Automation/Format.cs
new file mode 100644
index 0000000..96bee98
--- /dev/null
+++ b/Automation/Format.cs
@@ -0,0 +1,9 @@
+using Automation.Tasks;
+using Cake.Frosting;
+
+namespace Automation;
+
+[IsDependentOn(typeof(PrettierFormat))]
+[IsDependentOn(typeof(DotNetFormat))]
+[IsDependentOn(typeof(CSharpierFormat))]
+public class Format : FrostingTask;
diff --git a/Automation/GitPush.cs b/Automation/GitPush.cs
new file mode 100644
index 0000000..18248f4
--- /dev/null
+++ b/Automation/GitPush.cs
@@ -0,0 +1,34 @@
+using Cake.Frosting;
+using LibGit2Sharp;
+
+namespace Automation;
+
+public class GitPush : FrostingTask
+{
+ private const string GIT_TOKEN = nameof(GIT_TOKEN);
+
+ public override void Run(Context context)
+ {
+ string token =
+ Environment.GetEnvironmentVariable(GIT_TOKEN)
+ ?? throw new InvalidOperationException($"Environment variable {GIT_TOKEN} is not set.");
+ using var repo = new Repository(Context.ProjectRoot);
+ string currentBranch = repo.Head.FriendlyName;
+
+ var remote = repo.Network.Remotes["origin"] ?? throw new InvalidOperationException();
+ var pushRefSpec = $"+refs/heads/{currentBranch}:refs/heads/{currentBranch}";
+ PushStatusError? error = null;
+ var options = new PushOptions
+ {
+ CredentialsProvider = (_, _, _) => new UsernamePasswordCredentials { Username = "git", Password = token },
+ OnPushStatusError = (pushStatusErrors) => error = pushStatusErrors,
+ };
+ repo.Network.Push(remote, pushRefSpec, options);
+ if (error is not null)
+ {
+ throw new InvalidOperationException(
+ $"Error pushing to remote. Reference: {error.Reference}, Message: {error.Message}"
+ );
+ }
+ }
+}
diff --git a/Automation/Lint.cs b/Automation/Lint.cs
new file mode 100644
index 0000000..5ebf948
--- /dev/null
+++ b/Automation/Lint.cs
@@ -0,0 +1,10 @@
+using Automation.Tasks;
+using Cake.Frosting;
+
+namespace Automation;
+
+[IsDependentOn(typeof(PrettierCheck))]
+[IsDependentOn(typeof(DotNetFormatCheck))]
+[IsDependentOn(typeof(CSharpierCheck))]
+[IsDependentOn(typeof(CSpell))]
+public class Lint : FrostingTask;
diff --git a/Automation/Pack.cs b/Automation/Pack.cs
new file mode 100644
index 0000000..15bcf74
--- /dev/null
+++ b/Automation/Pack.cs
@@ -0,0 +1,38 @@
+using Cake.Common.IO;
+using Cake.Common.Tools.DotNet;
+using Cake.Frosting;
+using Git = LibGit2Sharp;
+
+namespace Automation;
+
+public class Pack : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.CleanDirectory(Context.PackagesDirectory);
+
+ using var repository = new Git.Repository(Context.ProjectRoot);
+ string authors = repository.Config.Get("user.name").Value;
+
+ context.DotNetPack(
+ Path.Combine(Context.ProjectRoot, "Divert.Windows"),
+ new()
+ {
+ MSBuildSettings = new()
+ {
+ Properties =
+ {
+ ["ReadMePath"] = [Path.Combine(Context.ProjectRoot, "ReadMe.md")],
+ ["PackageOutputPath"] = [Context.PackagesDirectory],
+ ["Authors"] = [authors],
+ ["PackageDescription"] = ["High quality .NET APIs for WinDivert."],
+ ["PackageLicenseExpression"] = ["LGPL-3.0-only"],
+ ["PackageRequireLicenseAcceptance"] = ["true"],
+ ["PackageTags"] = ["WinDivert divert networking packet capture"],
+ ["PackageReadmeFile"] = ["ReadMe.md"],
+ },
+ },
+ }
+ );
+ }
+}
diff --git a/Automation/Program.cs b/Automation/Program.cs
new file mode 100644
index 0000000..f2fde84
--- /dev/null
+++ b/Automation/Program.cs
@@ -0,0 +1,5 @@
+using Automation;
+using Cake.Frosting;
+
+Directory.SetCurrentDirectory(Context.Workspaces);
+return new CakeHost().UseContext().Run(args.Concat(["--verbosity", "diagnostic"]));
diff --git a/Automation/Publish.cs b/Automation/Publish.cs
new file mode 100644
index 0000000..9046b3a
--- /dev/null
+++ b/Automation/Publish.cs
@@ -0,0 +1,27 @@
+using Cake.Common.Tools.DotNet;
+using Cake.Common.Tools.DotNet.NuGet.Push;
+using Cake.Frosting;
+
+namespace Automation;
+
+[IsDependentOn(typeof(Pack))]
+public class Publish : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ string package = Directory.GetFiles(Context.PackagesDirectory).Single();
+ string apiKey =
+ Environment.GetEnvironmentVariable("NUGET_API_KEY")
+ ?? throw new InvalidOperationException("NUGET_API_KEY is not set.");
+ string source = Environment.GetEnvironmentVariable("NUGET_SOURCE") ?? "https://api.nuget.org/v3/index.json";
+ context.DotNetNuGetPush(
+ package,
+ new DotNetNuGetPushSettings
+ {
+ Source = source,
+ ApiKey = apiKey,
+ SkipDuplicate = true,
+ }
+ );
+ }
+}
diff --git a/Automation/Publish/Program.cs b/Automation/Publish/Program.cs
deleted file mode 100644
index 35d589f..0000000
--- a/Automation/Publish/Program.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
-using Microsoft.DotNet.Cli.Utils;
-
-static string GetFilePath([CallerFilePath] string? path = null)
-{
- if (path is null)
- {
- throw new InvalidOperationException(nameof(path));
- }
- return path;
-}
-
-static void Run(string commandName, params string[] args)
-{
- var command = Command.Create(commandName, args);
- Console.WriteLine($"{commandName} {command.CommandArgs}");
- var result = command.Execute();
- if (result.ExitCode != 0)
- {
- throw new Win32Exception(result.ExitCode);
- }
-}
-
-static string GetOutput(string commandName, params string[] args)
-{
- var command = Command.Create(commandName, args).CaptureStdOut();
- var result = command.Execute();
- if (result.ExitCode != 0)
- {
- throw new Win32Exception(result.ExitCode);
- }
- return result.StdOut;
-}
-
-string version = args.Length > 0 ? args[0] : "1.0.0";
-
-string filePath = GetFilePath();
-string projectPath = new FileInfo(filePath).Directory?.Parent?.Parent?.FullName!;
-Console.WriteLine($"Project path: {projectPath}");
-
-string publishPath = Path.Combine(projectPath, "Publish");
-if (Directory.Exists(publishPath))
-{
- Directory.Delete(publishPath, recursive: true);
-}
-
-// Get metadata from Git.
-string userName = GetOutput("git", "config", "user.name").Trim();
-var remotes = GetOutput("git", "remote").Trim();
-string? repositoryUrl = remotes.Split('\n').FirstOrDefault() switch
-{
- null or "" => null,
- string remote => GetOutput("git", "remote", "get-url", remote.Trim()).Trim()
-};
-Console.WriteLine($"{nameof(userName)}: {userName}");
-Console.WriteLine($"{nameof(repositoryUrl)}: {repositoryUrl}");
-string projectName = "Divert.Windows";
-string description = "WinDivert .NET APIs.";
-
-var arguments = new List
-{
- "pack",
- Path.Combine(projectPath, projectName, $"{projectName}.csproj"),
- "--configuration", "Release",
- "--output", publishPath,
- $"-property:PackageVersion={version}",
- $"-property:Authors={userName}",
- $"-property:PackageDescription={description}",
- "-property:PackageLicenseExpression=MIT",
- "-property:PackageRequireLicenseAcceptance=true",
- "-property:PackageTags=WinDivert",
-};
-if (repositoryUrl is not null)
-{
- arguments.Add($"-property:RepositoryUrl={repositoryUrl}");
-}
-Run("dotnet", arguments.ToArray());
diff --git a/Automation/Publish/Publish.csproj b/Automation/Publish/Publish.csproj
deleted file mode 100644
index 7dbbd88..0000000
--- a/Automation/Publish/Publish.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Exe
- net6.0
- enable
- enable
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Automation/Restore.cs b/Automation/Restore.cs
new file mode 100644
index 0000000..58c1764
--- /dev/null
+++ b/Automation/Restore.cs
@@ -0,0 +1,16 @@
+using Automation.Tasks;
+using Cake.Common.Tools.Command;
+using Cake.Common.Tools.DotNet;
+using Cake.Frosting;
+
+namespace Automation;
+
+[IsDependentOn(typeof(CIFSMount))]
+public class Restore : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.Command(["dotnet"], "tool restore");
+ context.DotNetRestore(Context.ProjectRoot);
+ }
+}
diff --git a/Automation/Restore/Program.cs b/Automation/Restore/Program.cs
deleted file mode 100644
index 7a665d5..0000000
--- a/Automation/Restore/Program.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
-using Microsoft.DotNet.Cli.Utils;
-
-static string GetFilePath([CallerFilePath] string? path = null)
-{
- if (path is null)
- {
- throw new InvalidOperationException(nameof(path));
- }
- return path;
-}
-
-static void Run(string commandName, params string[] args)
-{
- var command = Command.Create(commandName, args);
- Console.WriteLine($"{commandName} {command.CommandArgs}");
- var result = command.Execute();
- if (result.ExitCode != 0)
- {
- throw new Win32Exception(result.ExitCode);
- }
-}
-
-string filePath = GetFilePath();
-string workspacePath = new FileInfo(filePath).Directory?.Parent?.Parent?.FullName!;
-Console.WriteLine($"Workspace path: {workspacePath}");
-
-Run("dotnet", "nuget", "locals", "http-cache", "--clear");
-Parallel.ForEach(Directory.EnumerateFiles(workspacePath, "*.csproj", SearchOption.AllDirectories), csProjectPath =>
-{
- Run("dotnet", "restore", csProjectPath);
-});
diff --git a/Automation/Restore/Restore.csproj b/Automation/Restore/Restore.csproj
deleted file mode 100644
index 7dbbd88..0000000
--- a/Automation/Restore/Restore.csproj
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
- Exe
- net6.0
- enable
- enable
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Automation/Tasks/CIFSMount.cs b/Automation/Tasks/CIFSMount.cs
new file mode 100644
index 0000000..a4f59b9
--- /dev/null
+++ b/Automation/Tasks/CIFSMount.cs
@@ -0,0 +1,76 @@
+using Cake.Common.Diagnostics;
+using Cake.Common.Tools.Command;
+using Cake.Core;
+using Cake.Core.Diagnostics;
+using Cake.Core.IO;
+using Cake.Frosting;
+using Mono.Unix.Native;
+
+namespace Automation.Tasks;
+
+internal sealed record class CIFSMountConfig(string RemoteHost, string Share, string UserName, string Password);
+
+// Optionally mounts Bin/ directory to a CIFS share, workaround for slow .exe startup time in WSL2 directories.
+public class CIFSMount : FrostingTask
+{
+ public const string CIFS_REMOTE_HOST = nameof(CIFS_REMOTE_HOST);
+ public const string CIFS_SHARE = nameof(CIFS_SHARE);
+ public const string CIFS_USERNAME = nameof(CIFS_USERNAME);
+ public const string CIFS_PASSWORD = nameof(CIFS_PASSWORD);
+
+ private static CIFSMountConfig? LoadConfig()
+ {
+ string? remoteHost = Environment.GetEnvironmentVariable(CIFS_REMOTE_HOST);
+ string? share = Environment.GetEnvironmentVariable(CIFS_SHARE);
+ string? userName = Environment.GetEnvironmentVariable(CIFS_USERNAME);
+ string? password = Environment.GetEnvironmentVariable(CIFS_PASSWORD);
+ if (share is null || userName is null || password is null)
+ {
+ return null;
+ }
+ return new CIFSMountConfig(
+ RemoteHost: remoteHost ?? "host.docker.internal",
+ Share: share,
+ UserName: userName,
+ Password: password
+ );
+ }
+
+ public override void Run(Context context)
+ {
+ var config = LoadConfig();
+ if (config is null)
+ {
+ context.Information("CIFS share not configured, skipping.");
+ return;
+ }
+
+ string mountPath = Context.LocalWindowsDirectory;
+ try
+ {
+ if (DriveInfo.GetDrives().Any(drive => drive.DriveType is DriveType.Network && drive.Name == mountPath))
+ {
+ context.Information("CIFS share already mounted at {0}, skipping.", mountPath);
+ // Already mounted.
+ return;
+ }
+
+ Directory.CreateDirectory(mountPath);
+ uint uid = Syscall.getuid();
+ uint gid = Syscall.getgid();
+ context.Command(
+ ["sudo"],
+ ProcessArgumentBuilder
+ .FromStrings(["mount", "--types", "cifs", $"//{config.RemoteHost}/{config.Share}", mountPath])
+ .AppendSwitchSecret(
+ "--options",
+ $"username={config.UserName},password={config.Password},uid={uid},gid={gid}"
+ )
+ );
+ }
+ catch (Exception ex)
+ {
+ context.Warning("Failed to mount CIFS share: {0}", ex.Message);
+ }
+ }
+}
diff --git a/Automation/Tasks/CSharpier.cs b/Automation/Tasks/CSharpier.cs
new file mode 100644
index 0000000..b099697
--- /dev/null
+++ b/Automation/Tasks/CSharpier.cs
@@ -0,0 +1,20 @@
+using Cake.Common.Tools.DotNet;
+using Cake.Frosting;
+
+namespace Automation.Tasks;
+
+public class CSharpierCheck : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.DotNetTool(Context.ProjectRoot, "csharpier", $"check {Context.ProjectRoot}");
+ }
+}
+
+public class CSharpierFormat : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.DotNetTool(Context.ProjectRoot, "csharpier", $"format {Context.ProjectRoot}");
+ }
+}
diff --git a/Automation/Tasks/CSpell.cs b/Automation/Tasks/CSpell.cs
new file mode 100644
index 0000000..75a23d4
--- /dev/null
+++ b/Automation/Tasks/CSpell.cs
@@ -0,0 +1,12 @@
+using Cake.Common.Tools.Command;
+using Cake.Frosting;
+
+namespace Automation.Tasks;
+
+public class CSpell : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.Command(["pnpm"], $"cspell {Context.ProjectRoot}");
+ }
+}
diff --git a/Automation/Tasks/DotNetFormat.cs b/Automation/Tasks/DotNetFormat.cs
new file mode 100644
index 0000000..f698863
--- /dev/null
+++ b/Automation/Tasks/DotNetFormat.cs
@@ -0,0 +1,26 @@
+using Cake.Common.Tools.DotNet;
+using Cake.Frosting;
+
+namespace Automation.Tasks;
+
+public class DotNetFormatCheck : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.DotNetMSBuild(
+ Context.ProjectRoot,
+ new() { Targets = { "GetTargetPath" }, Properties = { ["DotNetFormatCheck"] = ["true"] } }
+ );
+ }
+}
+
+public class DotNetFormat : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.DotNetMSBuild(
+ Context.ProjectRoot,
+ new() { Targets = { "GetTargetPath" }, Properties = { ["DotNetFormat"] = ["true"] } }
+ );
+ }
+}
diff --git a/Automation/Tasks/Prettier.cs b/Automation/Tasks/Prettier.cs
new file mode 100644
index 0000000..3b7fe1f
--- /dev/null
+++ b/Automation/Tasks/Prettier.cs
@@ -0,0 +1,20 @@
+using Cake.Common.Tools.Command;
+using Cake.Frosting;
+
+namespace Automation.Tasks;
+
+public class PrettierCheck : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.Command(["pnpm"], $"prettier --check {Context.ProjectRoot}");
+ }
+}
+
+public class PrettierFormat : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ context.Command(["pnpm"], $"prettier --write {Context.ProjectRoot}");
+ }
+}
diff --git a/Automation/Test.cs b/Automation/Test.cs
new file mode 100644
index 0000000..eece2d9
--- /dev/null
+++ b/Automation/Test.cs
@@ -0,0 +1,116 @@
+using System.Net.Sockets;
+using Cake.Common.Diagnostics;
+using Cake.Common.Tools.DotNet;
+using Cake.Common.Tools.DotNet.Tool;
+using Cake.Core.Diagnostics;
+using Cake.Core.IO;
+using Cake.Frosting;
+using Path = System.IO.Path;
+
+namespace Automation;
+
+public class Test : AsyncFrostingTask
+{
+ public const string TEST_HOST = nameof(TEST_HOST);
+ public const string TestProjectName = "Divert.Windows.Tests";
+ public const string TestRunnerProjectName = "Divert.Windows.TestRunner";
+
+ public static string GetTestHost() => Environment.GetEnvironmentVariable(TEST_HOST) ?? "host.docker.internal";
+
+ public static string TestResultsDirectory => Path.Combine(Context.LocalWindowsDirectory, "TestResults");
+
+ public static string CoverletOutput => Path.Combine(TestResultsDirectory, "coverage.cobertura.xml");
+
+ public static string TestReportsDirectory => Path.Combine(Context.LocalDirectory, "TestReports");
+
+ public static string TestRunnerLockFilePath =>
+ Path.Combine(Context.LocalWindowsDirectory, $"{TestRunnerProjectName}/TestRunner.lock");
+
+ public static void GenerateReport(Context context)
+ {
+ string sourcePath = Path.Combine(Context.ProjectRoot, "Divert.Windows");
+ // spell-checker: ignore sourcedirs targetdir reporttypes
+ context.DotNetTool(
+ "reportgenerator",
+ new DotNetToolSettings
+ {
+ ArgumentCustomization = _ =>
+ ProcessArgumentBuilder.FromStrings(
+ [
+ "reportgenerator",
+ $"-reports:{CoverletOutput}",
+ $"-sourcedirs:{sourcePath}",
+ $"-targetdir:{TestReportsDirectory}",
+ "-reporttypes:Html;MarkdownSummary",
+ ]
+ ),
+ }
+ );
+ }
+
+ public override async Task RunAsync(Context context)
+ {
+ context.DotNetBuild(
+ Path.Combine(Context.ProjectRoot, TestProjectName),
+ new()
+ {
+ MSBuildSettings = new()
+ {
+ Properties =
+ {
+ ["DivertWindowsTests"] = ["true"], // Enable DivertValueTaskExecutorDelay
+ },
+ },
+ }
+ );
+ if (!Directory.Exists(Path.Combine(Context.LocalWindowsDirectory, TestRunnerProjectName)))
+ {
+ context.DotNetBuild(Path.Combine(Context.ProjectRoot, TestRunnerProjectName));
+ }
+
+ context.Information($"Waiting for test runner lock file {TestRunnerLockFilePath}...");
+ int port = 0;
+ while (port is 0)
+ {
+ try
+ {
+ string text = await File.ReadAllTextAsync(TestRunnerLockFilePath);
+ port = int.Parse(text);
+ break;
+ }
+ catch
+ {
+ await Task.Delay(TimeSpan.FromMilliseconds(1000));
+ }
+ }
+
+ using var client = new TcpClient();
+ await client.ConnectAsync(GetTestHost(), port);
+ using var stream = client.GetStream();
+ using var reader = new StreamReader(stream);
+ string lastLine = string.Empty;
+ while (true)
+ {
+ string? line = await reader.ReadLineAsync();
+ if (line is null)
+ {
+ break;
+ }
+ lastLine = line;
+ Console.WriteLine(line);
+ }
+ if (!int.TryParse(lastLine, out int exitCode) || exitCode != 0)
+ {
+ throw new Exception("Tests failed");
+ }
+ GenerateReport(context);
+ }
+}
+
+public class TestReport : FrostingTask
+{
+ public override void Run(Context context)
+ {
+ Test.GenerateReport(context);
+ }
+}
diff --git a/Automation/UpdatePackages/Directory.Packages.props b/Automation/UpdatePackages/Directory.Packages.props
deleted file mode 100644
index a6ee229..0000000
--- a/Automation/UpdatePackages/Directory.Packages.props
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- false
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Automation/UpdatePackages/Program.cs b/Automation/UpdatePackages/Program.cs
deleted file mode 100644
index 723d72d..0000000
--- a/Automation/UpdatePackages/Program.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-using System.Collections.Immutable;
-using System.Runtime.CompilerServices;
-using Microsoft.Build.Construction;
-
-static string GetFilePath([CallerFilePath] string? path = null)
-{
- if (path is null)
- {
- throw new InvalidOperationException(nameof(path));
- }
- return path;
-}
-
-string filePath = GetFilePath();
-string workspacePath = new FileInfo(filePath).Directory?.Parent?.Parent?.FullName!;
-Console.WriteLine($"Workspace path: {workspacePath}");
-
-// Load package versions.
-string packagePropsFileName = "Directory.Packages.props";
-var packageVersions = await Task.Run(() =>
-{
- string packagePropsPath = Path.Combine(Path.GetDirectoryName(filePath)!, packagePropsFileName);
- var props = ProjectRootElement.Open(packagePropsPath);
- var result = new Dictionary();
- foreach (var item in props.Items)
- {
- if (item is
- {
- ElementName: "PackageVersion",
- Include: string packageName,
- FirstChild: ProjectMetadataElement
- {
- Name: "Version",
- Value: string version
- }
- })
- {
- result.Add(packageName, version);
- }
- }
- return result;
-});
-
-// Update package versions in .csproj files.
-var foundPackages = new HashSet();
-foreach (var csProjectPath in Directory.EnumerateFiles(workspacePath, "*.csproj", SearchOption.AllDirectories))
-{
- Console.WriteLine(csProjectPath);
- var projectRoot = ProjectRootElement.Open(csProjectPath, new(), preserveFormatting: true);
-
- foreach (var item in projectRoot.Items)
- {
- if (item is
- {
- ElementName: "PackageReference",
- Include: string packageName,
- FirstChild: ProjectMetadataElement packageVersion and
- {
- Name: "Version",
- Value: string version
- }
- })
- {
- if (packageVersions.TryGetValue(packageName, out string? specifiedVersion))
- {
- foundPackages.Add(packageName);
- if (version != specifiedVersion)
- {
- Console.WriteLine($"Updating {packageName} version from {version} to {specifiedVersion}.");
- packageVersion.Value = specifiedVersion;
- }
- }
- else
- {
- Console.WriteLine($"{packageName} version is not specified in {packagePropsFileName}.");
- }
- }
- }
-
- projectRoot.Save();
- Console.WriteLine();
-}
-
-foreach (var packageName in packageVersions.Keys.Except(foundPackages).ToImmutableSortedSet())
-{
- Console.WriteLine($"Package {packageName} is not referenced.");
-}
-
-Console.WriteLine("Done.");
diff --git a/Automation/UpdatePackages/UpdatePackages.csproj b/Automation/UpdatePackages/UpdatePackages.csproj
deleted file mode 100644
index 8379413..0000000
--- a/Automation/UpdatePackages/UpdatePackages.csproj
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
- Exe
- net6.0
- preview
- enable
- enable
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/Directory.Build.props b/Directory.Build.props
index d936144..2b3bcf1 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,20 +1,12 @@
-
- embedded
- true
- $(MSBuildProjectDirectory)=$(MSBuildProjectName)
-
-
- FreeBSD
- Linux
- OSX
- Windows
- Unknown
-
+
+
- $([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildProjectDirectory), "Directory.Build.props"))
- $([MSBuild]::MakeRelative($(WorkspacePath), $(MSBuildProjectDirectory)))
- $(WorkspacePath)/Build/$(OSName)/$(MSBuildProjectRelativePath)/obj
- $(WorkspacePath)/Build/$(OSName)/$(MSBuildProjectRelativePath)/bin
+ $(MSBuildThisFileDirectory)
+ $([MSBuild]::NormalizeDirectory($(ProjectRoot), ".config", "dotnet"))
+ $(ConfigDirectory)Packages.props
+ net8.0
-
\ No newline at end of file
+
+
+
diff --git a/Divert.Windows.TestRunner/CoverageSettings.xml b/Divert.Windows.TestRunner/CoverageSettings.xml
new file mode 100644
index 0000000..48a287b
--- /dev/null
+++ b/Divert.Windows.TestRunner/CoverageSettings.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+ .*\.g\.cs$
+ .*\.generated\.cs$
+
+
+
+
diff --git a/Divert.Windows.TestRunner/Divert.Windows.TestRunner.csproj b/Divert.Windows.TestRunner/Divert.Windows.TestRunner.csproj
new file mode 100644
index 0000000..70d7fbe
--- /dev/null
+++ b/Divert.Windows.TestRunner/Divert.Windows.TestRunner.csproj
@@ -0,0 +1,15 @@
+
+
+ Exe
+ $(DefaultTargetFramework)
+ app.manifest
+ win-x64
+ true
+ $(ProjectRoot).local/windows/$(MSBuildThisFileName)
+ CA2007
+
+
+
+
+
+
diff --git a/Divert.Windows.TestRunner/Program.cs b/Divert.Windows.TestRunner/Program.cs
new file mode 100644
index 0000000..72cbf1f
--- /dev/null
+++ b/Divert.Windows.TestRunner/Program.cs
@@ -0,0 +1,155 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using System.Reflection;
+using System.Text;
+using System.Threading.Channels;
+
+// Runs ../Divert.Windows.Tests with coverage collection enabled.
+// In "watch" mode, run tests when receiving requests from Devcontainer.
+
+string appPath = Path.GetDirectoryName(Environment.ProcessPath)!;
+string testResultsDirectory = Path.Combine(appPath, "../TestResults");
+string coverageSettingsPath = Path.Combine(appPath, "CoverageSettings.xml");
+string coverageOutputPath = Path.Combine(testResultsDirectory, "coverage.cobertura.xml");
+string testAppPath = Path.Combine(appPath, "../Divert.Windows.Tests");
+
+Directory.SetCurrentDirectory(testAppPath);
+
+Process LaunchTestProcess(bool redirect) =>
+ Process.Start(
+ new ProcessStartInfo()
+ {
+ FileName = Path.Combine(testAppPath, "Divert.Windows.Tests.exe"),
+ ArgumentList =
+ {
+ "--results-directory",
+ testResultsDirectory,
+ "--coverage",
+ "--coverage-settings",
+ coverageSettingsPath,
+ "--coverage-output-format",
+ "cobertura",
+ "--coverage-output",
+ coverageOutputPath,
+ },
+ RedirectStandardOutput = redirect,
+ RedirectStandardError = redirect,
+ Environment =
+ {
+ // MSTest should respect DOTNET_SYSTEM_CONSOLE_ALLOW_ANSI_COLOR_REDIRECTION instead.
+ ["GITHUB_ACTIONS"] = redirect.ToString().ToLowerInvariant(), // Trick MSTest to output ANSI colors.
+ },
+ }
+ )!;
+
+if (args is not ["watch", ..])
+{
+ using var process = LaunchTestProcess(redirect: false);
+ await process.WaitForExitAsync();
+ return process.ExitCode;
+}
+
+Console.WriteLine("Starting Test Runner in watch mode...");
+using var mutex = new Mutex(true, Assembly.GetExecutingAssembly().FullName, out bool createdNew);
+if (!createdNew)
+{
+ Console.WriteLine("Another instance is already running. Exiting...");
+ return 1;
+}
+
+using var cts = new CancellationTokenSource();
+Console.CancelKeyPress += (s, e) =>
+{
+ e.Cancel = true;
+ cts.Cancel();
+};
+var token = cts.Token;
+
+using var listener = new TcpListener(IPAddress.Any, 0);
+listener.Start();
+int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+Console.WriteLine($"Listening on port {port}...");
+using var lockFile = new FileStream(
+ Path.Combine(appPath, "TestRunner.lock"),
+ FileMode.Create,
+ FileAccess.ReadWrite,
+ FileShare.Read,
+ bufferSize: 0,
+ FileOptions.Asynchronous | FileOptions.DeleteOnClose
+);
+await lockFile.WriteAsync(Encoding.UTF8.GetBytes(port.ToString() + '\n'), token);
+await lockFile.FlushAsync(token);
+
+try
+{
+ while (!token.IsCancellationRequested)
+ {
+ Console.WriteLine("Waiting for client connection...");
+ using var client = await listener.AcceptSocketAsync(token);
+ Console.WriteLine("Received client connection.");
+
+ using var sessionCts = CancellationTokenSource.CreateLinkedTokenSource(token);
+ var sessionToken = sessionCts.Token;
+ using var stream = new NetworkStream(client, ownsSocket: false);
+ using var process = LaunchTestProcess(redirect: true);
+ using var _ = sessionToken.Register(() => process.Kill(entireProcessTree: true));
+
+ var lines = Channel.CreateBounded(1024);
+ async Task ForwardLines(StreamReader reader)
+ {
+ string? line = null;
+ while (true)
+ {
+ line = await reader.ReadLineAsync(sessionToken);
+ if (line is null)
+ {
+ break;
+ }
+ await lines.Writer.WriteAsync(line, sessionToken);
+ }
+ }
+ var readStdOutTask = ForwardLines(process.StandardOutput);
+ var readStdErrTask = ForwardLines(process.StandardError);
+
+ using var writer = new StreamWriter(stream) { AutoFlush = true };
+ var readTask = Task.Run(
+ async () =>
+ {
+ try
+ {
+ await client.ReceiveAsync(new byte[ushort.MaxValue], sessionToken); // Monitor disconnect
+ }
+ finally
+ {
+ sessionCts.Cancel();
+ }
+ },
+ sessionToken
+ );
+ var writeTask = Task.Run(
+ async () =>
+ {
+ await foreach (var line in lines.Reader.ReadAllAsync(sessionToken))
+ {
+ Console.WriteLine(line);
+ await writer.WriteLineAsync(line.AsMemory(), sessionToken);
+ }
+ },
+ sessionToken
+ );
+
+ await process.WaitForExitAsync(token);
+ lines.Writer.Complete();
+ await Task.WhenAll(readStdOutTask, readStdErrTask, writeTask)
+ .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+ await writer
+ .WriteLineAsync(process.ExitCode.ToString().AsMemory(), sessionToken)
+ .ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+ client.Close();
+ await readTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
+ }
+}
+catch (OperationCanceledException) when (token.IsCancellationRequested) { }
+
+return 0;
diff --git a/Divert.Windows.TestRunner/app.manifest b/Divert.Windows.TestRunner/app.manifest
new file mode 100644
index 0000000..76ff66a
--- /dev/null
+++ b/Divert.Windows.TestRunner/app.manifest
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/Divert.Windows.Tests/AssemblyInfo.cs b/Divert.Windows.Tests/AssemblyInfo.cs
new file mode 100644
index 0000000..a298fba
--- /dev/null
+++ b/Divert.Windows.Tests/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Runtime.Versioning;
+
+[assembly: DoNotParallelize]
+[assembly: SupportedOSPlatform("windows6.0.6000")]
diff --git a/Divert.Windows.Tests/ChecksumTests.cs b/Divert.Windows.Tests/ChecksumTests.cs
new file mode 100644
index 0000000..7c6cfae
--- /dev/null
+++ b/Divert.Windows.Tests/ChecksumTests.cs
@@ -0,0 +1,85 @@
+using System.Buffers.Binary;
+using System.Net;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+
+namespace Divert.Windows.Tests;
+
+[TestClass]
+public class ChecksumTests : DivertTests
+{
+ [TestMethod]
+ public async Task InvalidChecksum()
+ {
+ using var listener = CreateUdpListener(out int port);
+ var receive = listener.ReceiveAsync(Token).AsTask();
+
+ var filter =
+ DivertFilter.UDP
+ & DivertFilter.Loopback
+ & DivertFilter.Ip
+ & !DivertFilter.Impostor
+ & (DivertFilter.RemotePort == port);
+ using var service = new DivertService(filter);
+
+ var packetBuffer = new byte[ushort.MaxValue + 40];
+ var addressBuffer = new DivertAddress[1];
+
+ var divertReceive = service.ReceiveAsync(packetBuffer, addressBuffer, Token).AsTask();
+ Assert.IsFalse(divertReceive.IsCompleted);
+
+ // send 3 bytes payload
+ using var client = new UdpClient();
+ client.Connect(IPAddress.Loopback, port);
+ await client.SendAsync(new byte[] { 1, 2, 3 }, Token);
+
+ (int length, _) = await divertReceive;
+ var packet = packetBuffer.AsMemory(0, length);
+
+ // Calculate and then invalidate checksums
+ Assert.IsTrue(DivertHelper.CalculateChecksums(packet.Span));
+ ushort ipChecksum = BinaryPrimitives.ReadUInt16BigEndian(packet.Span[10..12]);
+ ushort udpChecksum = BinaryPrimitives.ReadUInt16BigEndian(packet.Span[26..28]);
+ BinaryPrimitives.WriteUInt16BigEndian(packet.Span[10..12], (ushort)~ipChecksum); // invalidate IP checksum
+ BinaryPrimitives.WriteUInt16BigEndian(packet.Span[26..28], (ushort)~udpChecksum); // invalidate UDP checksum
+
+ // Recalculate
+ addressBuffer[0].IsIPChecksumValid = false;
+ addressBuffer[0].IsTCPChecksumValid = false;
+ addressBuffer[0].IsUDPChecksumValid = false;
+ Assert.IsTrue(DivertHelper.CalculateChecksums(packet.Span, ref addressBuffer[0]));
+ Assert.AreEqual(ipChecksum, BinaryPrimitives.ReadUInt16BigEndian(packet.Span[10..12]));
+ Assert.AreEqual(udpChecksum, BinaryPrimitives.ReadUInt16BigEndian(packet.Span[26..28]));
+ Assert.IsTrue(addressBuffer[0].IsIPChecksumValid);
+ Assert.IsTrue(addressBuffer[0].IsUDPChecksumValid);
+ Assert.IsFalse(addressBuffer[0].IsTCPChecksumValid);
+
+ // Recalculate only UDP checksum
+ BinaryPrimitives.WriteUInt16BigEndian(packet.Span[10..12], (ushort)~ipChecksum); // invalidate IP checksum
+ BinaryPrimitives.WriteUInt16BigEndian(packet.Span[26..28], (ushort)~udpChecksum); // invalidate UDP checksum
+ addressBuffer[0].IsIPChecksumValid = false;
+ addressBuffer[0].IsTCPChecksumValid = false;
+ addressBuffer[0].IsUDPChecksumValid = false;
+ Assert.IsTrue(
+ DivertHelper.CalculateChecksums(packet.Span, ref addressBuffer[0], DivertHelperFlags.NoIPChecksum)
+ );
+ Assert.AreNotEqual(ipChecksum, BinaryPrimitives.ReadUInt16BigEndian(packet.Span[10..12]));
+ Assert.AreEqual(udpChecksum, BinaryPrimitives.ReadUInt16BigEndian(packet.Span[26..28]));
+ Assert.IsFalse(addressBuffer[0].IsIPChecksumValid);
+ Assert.IsTrue(addressBuffer[0].IsUDPChecksumValid);
+ Assert.IsFalse(addressBuffer[0].IsTCPChecksumValid);
+
+ // Recalculate only IP checksum
+ BinaryPrimitives.WriteUInt16BigEndian(packet.Span[10..12], (ushort)~ipChecksum); // invalidate IP checksum
+ BinaryPrimitives.WriteUInt16BigEndian(packet.Span[26..28], (ushort)~udpChecksum); // invalidate UDP checksum
+ Assert.IsTrue(DivertHelper.CalculateChecksums(packet.Span, DivertHelperFlags.NoUDPChecksum));
+ Assert.AreEqual(ipChecksum, BinaryPrimitives.ReadUInt16BigEndian(packet.Span[10..12]));
+ Assert.AreNotEqual(udpChecksum, BinaryPrimitives.ReadUInt16BigEndian(packet.Span[26..28]));
+
+ // Invalid packet
+ Assert.IsFalse(DivertHelper.CalculateChecksums(default));
+ Assert.AreEqual(0, Marshal.GetLastPInvokeError());
+ Assert.IsFalse(DivertHelper.CalculateChecksums(default, ref addressBuffer[0]));
+ Assert.AreEqual(0, Marshal.GetLastPInvokeError());
+ }
+}
diff --git a/Divert.Windows.Tests/Divert.Windows.Tests.csproj b/Divert.Windows.Tests/Divert.Windows.Tests.csproj
new file mode 100644
index 0000000..5d93a34
--- /dev/null
+++ b/Divert.Windows.Tests/Divert.Windows.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+ Exe
+ true
+ $(DefaultTargetFramework)
+ win-x64
+ true
+ $(ProjectRoot).local/windows/$(MSBuildThisFileName)
+ false
+ true
+ CA2007
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+
+
+
diff --git a/Divert.Windows.Tests/DivertServiceTests.cs b/Divert.Windows.Tests/DivertServiceTests.cs
new file mode 100644
index 0000000..624ed62
--- /dev/null
+++ b/Divert.Windows.Tests/DivertServiceTests.cs
@@ -0,0 +1,346 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using Windows.Win32;
+using Windows.Win32.Foundation;
+using Windows.Win32.Storage.FileSystem;
+
+namespace Divert.Windows.Tests;
+
+[TestClass]
+public sealed class DivertServiceTests : DivertTests
+{
+ [TestMethod]
+ public void Parameters()
+ {
+ Assert.Throws(() =>
+ new DivertService(false, priority: DivertService.HighestPriority + 1)
+ );
+ Assert.Throws(() =>
+ new DivertService(false, priority: DivertService.LowestPriority - 1)
+ );
+
+ using var service = new DivertService(false, priority: DivertService.HighestPriority);
+ using var _ = new DivertService(false, priority: DivertService.LowestPriority);
+ Assert.AreEqual(new Version(2, 2), service.Version);
+
+ Assert.AreEqual(DivertService.DefaultQueueLength, service.QueueLength);
+ service.QueueLength = DivertService.MinQueueLength;
+ Assert.AreEqual(DivertService.MinQueueLength, service.QueueLength);
+ service.QueueLength = DivertService.MaxQueueLength;
+ Assert.AreEqual(DivertService.MaxQueueLength, service.QueueLength);
+ Assert.Throws(() => service.QueueLength = DivertService.MinQueueLength - 1);
+ Assert.Throws(() => service.QueueLength = DivertService.MaxQueueLength + 1);
+
+ Assert.AreEqual(DivertService.DefaultQueueTime, service.QueueTime);
+ service.QueueTime = DivertService.MinQueueTime;
+ Assert.AreEqual(DivertService.MinQueueTime, service.QueueTime);
+ service.QueueTime = DivertService.MaxQueueTime;
+ Assert.AreEqual(DivertService.MaxQueueTime, service.QueueTime);
+ Assert.Throws(() =>
+ service.QueueTime = DivertService.MinQueueTime - TimeSpan.FromMilliseconds(1)
+ );
+ Assert.Throws(() =>
+ service.QueueTime = DivertService.MaxQueueTime + TimeSpan.FromMilliseconds(1)
+ );
+
+ Assert.AreEqual(DivertService.DefaultQueueSize, service.QueueSize);
+ service.QueueSize = DivertService.MinQueueSize;
+ Assert.AreEqual(DivertService.MinQueueSize, service.QueueSize);
+ service.QueueSize = DivertService.MaxQueueSize;
+ Assert.AreEqual(DivertService.MaxQueueSize, service.QueueSize);
+ Assert.Throws(() => service.QueueSize = DivertService.MinQueueSize - 1);
+ Assert.Throws(() => service.QueueSize = DivertService.MaxQueueSize + 1);
+ }
+
+ public static IEnumerable