Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/fuzz.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Continuous property-based fuzz.
#
# The Tests.Fuzz project runs in the per-PR unit sweep at CsCheck's default
# case count — enough to catch a regression, not enough to exercise a shape a
# consumer hits once every million rows. This workflow runs the same
# properties at a much larger case count on a weekly cron.
#
# Failing cases: CsCheck auto-shrinks and prints the counter-example to test
# output; the run's reports are uploaded as an artifact for post-mortem.
#
# Refs #81.

name: Fuzz

on:
workflow_dispatch:
inputs:
iterations:
description: "CsCheck case count per property (default 100000)"
required: false
default: "100000"
type: string
schedule:
# Weekly Saturday 04:00 UTC.
- cron: '0 4 * * 6'

permissions:
contents: read

jobs:
fuzz:
name: Continuous fuzz (${{ inputs.iterations || '100000' }} iters)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false

# Guard so the scheduled run on main no-ops until Tests.Fuzz merges there.
- name: Detect Tests.Fuzz
id: check
shell: bash
run: |
if [ -f tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz.csproj ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::Tests.Fuzz project not present on this ref — skipping. Merge vNext to main to enable."
echo "found=false" >> "$GITHUB_OUTPUT"
fi

- name: Setup .NET
if: steps.check.outputs.found == 'true'
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
with:
dotnet-version: 10.0.x

- name: Restore + Build Tests.Fuzz (Release)
if: steps.check.outputs.found == 'true'
run: |
dotnet restore tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz.csproj
dotnet build tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz.csproj \
--no-restore --configuration Release

- name: Run fuzz suite
if: steps.check.outputs.found == 'true'
env:
CsCheck_Iterations: ${{ inputs.iterations || '100000' }}
CsCheck_Timeout: "1800000" # 30 min per property
run: |
mkdir -p reports
dotnet test tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz.csproj \
--no-build --configuration Release \
--filter 'Category=Fuzz' \
--logger "console;verbosity=detailed" \
--logger "trx;LogFileName=fuzz.trx" \
--results-directory reports/ \
2>&1 | tee reports/fuzz.log

- name: Upload fuzz reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: fuzz-reports
path: reports/
retention-days: 60
1 change: 1 addition & 0 deletions ETL-SqlBulkCopy.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@
<Project Path="tests/Wolfgang.Etl.SqlBulkCopy.Tests.Integration/Wolfgang.Etl.SqlBulkCopy.Tests.Integration.csproj" />
<Project Path="tests/Wolfgang.Etl.SqlBulkCopy.Tests.DocExamples/Wolfgang.Etl.SqlBulkCopy.Tests.DocExamples.csproj" />
<Project Path="tests/Wolfgang.Etl.SqlBulkCopy.AotSmoke/Wolfgang.Etl.SqlBulkCopy.AotSmoke.csproj" />
<Project Path="tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz.csproj" />
</Folder>
</Solution>
1 change: 1 addition & 0 deletions src/Wolfgang.Etl.SqlBulkCopy/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
[assembly: InternalsVisibleTo("Wolfgang.Etl.SqlBulkCopy.Tests.Integration")]
[assembly: InternalsVisibleTo("Wolfgang.Etl.SqlBulkCopy.Benchmarks")]
[assembly: InternalsVisibleTo("Wolfgang.Etl.SqlBulkCopy.AotSmoke")]
[assembly: InternalsVisibleTo("Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz")]
18 changes: 18 additions & 0 deletions tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/FuzzRecord.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz;

/// <summary>
/// A mappable record shape with a spread of column kinds (value type, reference
/// type, nullable value type, bool) used by the fuzz properties. Field values
/// are generated by CsCheck; the type itself is fixed because CsCheck cannot
/// generate .NET types dynamically.
/// </summary>
public sealed class FuzzRecord
{
public int A { get; set; }

public string B { get; set; } = string.Empty;

public int? C { get; set; }

public bool D { get; set; }
}
120 changes: 120 additions & 0 deletions tests/Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz/MappingFuzz.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Continuous property-based fuzz over the type-mapping layer.
//
// The per-PR unit + conformance suites cover hand-picked shapes; this asks
// CsCheck to generate large numbers of parameterised inputs ([Trait
// "Category=Fuzz"]) and asserts per-input invariants that must hold across
// every generated value. Case count comes from CsCheck_Iterations (set by
// fuzz.yaml for the scheduled run; small by default for local iteration).
// A failing input auto-shrinks to a minimal counter-example.
//
// Refs #81.

using System.Text;
using CsCheck;
using Wolfgang.Etl.SqlBulkCopy;
using Xunit;

namespace Wolfgang.Etl.SqlBulkCopy.Tests.Fuzz;

public class MappingFuzz
{
/// <summary>
/// For any non-blank schema/table override, the bracket-quoting applied by
/// <c>QualifiedTableName</c> must round-trip: un-quoting the result (undoubling
/// <c>]]</c>) recovers the exact original identifiers. Catches any escaping
/// bug that would let an identifier break out of its brackets.
/// </summary>
[Fact]
[Trait("Category", "Fuzz")]
public void QualifiedTableName_bracket_quoting_round_trips()
{
Gen.Select(Gen.String, Gen.String).Sample((schema, table) =>
{
// Blank overrides normalise to the type's attribute default rather
// than the override, so they aren't part of this invariant.
if (string.IsNullOrWhiteSpace(schema) || string.IsNullOrWhiteSpace(table))
{
return true;
}

var map = TypeMap.Create(typeof(FuzzRecord), schema, table);
var (recoveredSchema, recoveredTable) = Unbracket(map.QualifiedTableName);

return string.Equals(recoveredSchema, schema, System.StringComparison.Ordinal)
&& string.Equals(recoveredTable, table, System.StringComparison.Ordinal);
});
}



/// <summary>
/// For any record value, every mapped column's getter must return exactly
/// what reflection reads from the same property. This exercises the compiled
/// (or source-generated) getter path across arbitrary values.
/// </summary>
[Fact]
[Trait("Category", "Fuzz")]
public void ColumnMap_GetValue_matches_the_property_value()
{
var nullableInt = Gen.Int.Select(x => x % 7 == 0 ? (int?)null : x);

Gen.Select(Gen.Int, Gen.String, nullableInt, Gen.Bool).Sample((a, b, c, d) =>
{
var instance = new FuzzRecord { A = a, B = b ?? string.Empty, C = c, D = d };
var map = TypeMap.Create(typeof(FuzzRecord));

foreach (var column in map.Columns)
{
var property = typeof(FuzzRecord).GetProperty(column.PropertyName)!;
if (!Equals(property.GetValue(instance), column.GetValue(instance)))
{
return false;
}
}

return true;
});
}



// Inverse of TypeMap's `[schema].[table]` quoting, where a literal ']' inside
// an identifier is doubled to ']]'.
private static (string Schema, string Table) Unbracket(string qualified)
{
var index = 0;
var schema = ReadBracketedSegment(qualified, ref index);
index++; // skip the '.' separator between the two segments
var table = ReadBracketedSegment(qualified, ref index);
return (schema, table);
}



private static string ReadBracketedSegment(string value, ref int index)
{
index++; // skip the opening '['
var builder = new StringBuilder();

while (index < value.Length)
{
if (value[index] == ']')
{
if (index + 1 < value.Length && value[index + 1] == ']')
{
builder.Append(']');
index += 2;
continue;
}

index++; // consume the closing ']'
break;
}

builder.Append(value[index]);
index++;
}

return builder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!-- net10.0 only: CsCheck is generator-driven; running the same property
across every TFM adds hours to a scheduled fuzz run without materially
widening coverage. TFM-conditional behaviour is caught by pr.yaml's
per-TFM unit sweep. -->
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>

<!-- Fuzz-property assemblies are permitted broader shapes than library code. -->
<NoWarn>$(NoWarn);MA0004;S1144;S6966;NU1903;CA1707;CA1062;CA2007</NoWarn>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Wolfgang.Etl.SqlBulkCopy\Wolfgang.Etl.SqlBulkCopy.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" allowedVersions="(,3.0.0)">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="CsCheck" Version="4.0.0" />
<PackageReference Include="JetBrains.Annotations" Version="2024.3.0" PrivateAssets="all" />
</ItemGroup>

</Project>