349 lines
11 KiB
C#
349 lines
11 KiB
C#
// Copyright (c) StellaOps. All rights reserved.
|
|
// Licensed under BUSL-1.1. See LICENSE in the project root.
|
|
|
|
using FluentAssertions;
|
|
using StellaOps.Scanner.ChangeTrace.ByteDiff;
|
|
|
|
namespace StellaOps.Scanner.ChangeTrace.Tests.ByteDiff;
|
|
|
|
/// <summary>
|
|
/// Tests for ByteLevelDiffer.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class ByteLevelDifferTests
|
|
{
|
|
private readonly ByteLevelDiffer _differ = new();
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_IdenticalStreams_ReturnsNoDeltas()
|
|
{
|
|
// Arrange
|
|
var data = CreateTestData(4096);
|
|
using var fromStream = new MemoryStream(data);
|
|
using var toStream = new MemoryStream(data);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().BeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_DifferentStreams_ReturnsDeltas()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = CreateTestData(4096, seed: 99); // Different seed = different data
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().NotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_PartialChange_DetectsChangedWindow()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(8192);
|
|
var toData = (byte[])fromData.Clone();
|
|
// Modify only the second window (bytes 2048-4095)
|
|
for (var i = 2048; i < 4096; i++)
|
|
{
|
|
toData[i] = (byte)(toData[i] ^ 0xFF);
|
|
}
|
|
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().HaveCount(1);
|
|
deltas[0].Offset.Should().Be(2048);
|
|
deltas[0].Size.Should().Be(2048);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_TruncatedToFile_ReportsDeltas()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(8192);
|
|
var toData = fromData[..4096]; // Truncated to half
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().NotBeEmpty();
|
|
deltas.Should().Contain(d => d.ToHash == "truncated");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_WithOptions_UsesWindowSize()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = (byte[])fromData.Clone();
|
|
// Modify only first 512 bytes
|
|
for (var i = 0; i < 512; i++)
|
|
{
|
|
toData[i] = (byte)(toData[i] ^ 0xFF);
|
|
}
|
|
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
var options = new ByteDiffOptions
|
|
{
|
|
WindowSize = 1024,
|
|
StepSize = 1024
|
|
};
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
|
|
|
|
// Assert
|
|
deltas.Should().HaveCount(1);
|
|
deltas[0].Size.Should().Be(1024); // Reports full window even though only 512 changed
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_WithContext_IncludesContextDescription()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = CreateTestData(4096, seed: 99);
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
var options = new ByteDiffOptions
|
|
{
|
|
IncludeContext = true
|
|
};
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
|
|
|
|
// Assert
|
|
// Context is included when there are multiple consecutive changes
|
|
deltas.Should().NotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_LargeFiles_UsesSampling()
|
|
{
|
|
// Arrange
|
|
var size = 11 * 1024 * 1024; // 11MB - over default 10MB limit
|
|
var fromData = new byte[size];
|
|
var toData = new byte[size];
|
|
new Random(42).NextBytes(fromData);
|
|
new Random(43).NextBytes(toData);
|
|
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
var options = new ByteDiffOptions
|
|
{
|
|
MaxFileSize = 10 * 1024 * 1024
|
|
};
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
|
|
|
|
// Assert - Should complete without memory issues and return sampled deltas
|
|
deltas.Should().NotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_DisableSectionAnalysis_UsesFullBinaryComparison()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = CreateTestData(4096, seed: 99);
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
var options = new ByteDiffOptions
|
|
{
|
|
AnalyzeBySections = false
|
|
};
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
|
|
|
|
// Assert
|
|
deltas.Should().NotBeEmpty();
|
|
deltas.Should().AllSatisfy(d => d.Section.Should().BeNull());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_CancellationToken_Respected()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = CreateTestData(4096, seed: 99);
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
// Act & Assert
|
|
var action = async () => await _differ.CompareAsync(fromStream, toStream, ct: cts.Token);
|
|
await action.Should().ThrowAsync<OperationCanceledException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_NullFromStream_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
using var toStream = new MemoryStream();
|
|
|
|
// Act & Assert
|
|
var action = async () => await _differ.CompareAsync(null!, toStream);
|
|
await action.Should().ThrowAsync<ArgumentNullException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_NullToStream_ThrowsArgumentNullException()
|
|
{
|
|
// Arrange
|
|
using var fromStream = new MemoryStream();
|
|
|
|
// Act & Assert
|
|
var action = async () => await _differ.CompareAsync(fromStream, null!);
|
|
await action.Should().ThrowAsync<ArgumentNullException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_DeltaHasCorrectScope()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = CreateTestData(4096, seed: 99);
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().AllSatisfy(d => d.Scope.Should().Be("byte"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_DeltasAreDeterministic()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(8192);
|
|
var toData = CreateTestData(8192, seed: 99);
|
|
|
|
// Act - Compare twice
|
|
using var fromStream1 = new MemoryStream(fromData);
|
|
using var toStream1 = new MemoryStream(toData);
|
|
var deltas1 = await _differ.CompareAsync(fromStream1, toStream1);
|
|
|
|
using var fromStream2 = new MemoryStream(fromData);
|
|
using var toStream2 = new MemoryStream(toData);
|
|
var deltas2 = await _differ.CompareAsync(fromStream2, toStream2);
|
|
|
|
// Assert - Results should be identical
|
|
deltas1.Should().HaveCount(deltas2.Count);
|
|
for (var i = 0; i < deltas1.Count; i++)
|
|
{
|
|
deltas1[i].Offset.Should().Be(deltas2[i].Offset);
|
|
deltas1[i].Size.Should().Be(deltas2[i].Size);
|
|
deltas1[i].FromHash.Should().Be(deltas2[i].FromHash);
|
|
deltas1[i].ToHash.Should().Be(deltas2[i].ToHash);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_HashesAreValidSha256Hex()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = CreateTestData(4096, seed: 99);
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().NotBeEmpty();
|
|
foreach (var delta in deltas)
|
|
{
|
|
if (delta.FromHash != "absent" && delta.FromHash != "truncated" && delta.FromHash != "removed")
|
|
{
|
|
delta.FromHash.Should().MatchRegex("^[a-f0-9]{64}$");
|
|
}
|
|
if (delta.ToHash != "absent" && delta.ToHash != "truncated" && delta.ToHash != "removed")
|
|
{
|
|
delta.ToHash.Should().MatchRegex("^[a-f0-9]{64}$");
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_MinConsecutiveChanges_MergesDeltas()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(8192);
|
|
var toData = CreateTestData(8192, seed: 99); // Completely different
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
var options = new ByteDiffOptions
|
|
{
|
|
WindowSize = 1024,
|
|
StepSize = 1024,
|
|
MinConsecutiveChanges = 2
|
|
};
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream, options);
|
|
|
|
// Assert - Should have merged consecutive changes
|
|
deltas.Should().NotBeEmpty();
|
|
// With 8192 bytes and 1024 window, there are 8 windows
|
|
// All windows are different, so they should be merged into fewer deltas
|
|
deltas.Count.Should().BeLessThan(8);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompareAsync_AddedBytes_DetectedAsAbsent()
|
|
{
|
|
// Arrange
|
|
var fromData = CreateTestData(4096);
|
|
var toData = new byte[8192];
|
|
Array.Copy(fromData, toData, fromData.Length);
|
|
new Random(99).NextBytes(toData.AsSpan(4096)); // Add random data at end
|
|
|
|
using var fromStream = new MemoryStream(fromData);
|
|
using var toStream = new MemoryStream(toData);
|
|
|
|
// Act
|
|
var deltas = await _differ.CompareAsync(fromStream, toStream);
|
|
|
|
// Assert
|
|
deltas.Should().Contain(d => d.FromHash == "absent");
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
private static byte[] CreateTestData(int size, int seed = 42)
|
|
{
|
|
var data = new byte[size];
|
|
new Random(seed).NextBytes(data);
|
|
return data;
|
|
}
|
|
}
|