save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -0,0 +1,453 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
using FluentAssertions;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Normalization;
using Xunit;
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
/// <summary>
/// Tests for the CFG extractor.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CfgExtractorTests
{
#region Helper Methods
private static NormalizedInstruction CreateInstruction(
ulong address,
InstructionKind kind,
string mnemonic,
byte[] bytes,
params NormalizedOperand[] operands)
{
return new NormalizedInstruction
{
OriginalAddress = address,
Kind = kind,
NormalizedMnemonic = mnemonic,
NormalizedBytes = [.. bytes],
Operands = [.. operands]
};
}
private static NormalizedOperand CreateAddressOperand(long value)
{
return new NormalizedOperand
{
Type = OperandType.Address,
Text = $"0x{value:x}",
Value = value
};
}
private static NormalizedOperand CreateImmediateOperand(long value)
{
return new NormalizedOperand
{
Type = OperandType.Immediate,
Text = $"0x{value:x}",
Value = value
};
}
private static NormalizedOperand CreateRegisterOperand(string reg)
{
return new NormalizedOperand
{
Type = OperandType.Register,
Text = reg,
Register = reg
};
}
#endregion
#region Empty Input Tests
[Fact]
public void Extract_EmptyInstructions_ReturnsEmptyCfg()
{
// Arrange
var instructions = Array.Empty<NormalizedInstruction>();
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().BeEmpty();
cfg.EntryBlockId.Should().Be(0);
cfg.ExitBlockIds.Should().BeEmpty();
cfg.EdgeCount.Should().Be(0);
}
#endregion
#region Single Block Tests
[Fact]
public void Extract_SingleReturnInstruction_CreatesOneBlock()
{
// Arrange: ret
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(1);
cfg.Blocks[0].Id.Should().Be(0);
cfg.Blocks[0].TerminatorKind.Should().Be(BlockTerminatorKind.Return);
cfg.Blocks[0].Successors.Should().BeEmpty();
cfg.Blocks[0].Predecessors.Should().BeEmpty();
cfg.ExitBlockIds.Should().ContainSingle().Which.Should().Be(0);
}
[Fact]
public void Extract_LinearSequence_CreatesOneBlock()
{
// Arrange: mov rax, 0; add rax, 1; ret
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Move, "mov", [0x48, 0xC7, 0xC0, 0x00, 0x00, 0x00, 0x00],
CreateRegisterOperand("rax"), CreateImmediateOperand(0)),
CreateInstruction(0x1007, InstructionKind.Arithmetic, "add", [0x48, 0x83, 0xC0, 0x01],
CreateRegisterOperand("rax"), CreateImmediateOperand(1)),
CreateInstruction(0x100B, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(1);
cfg.Blocks[0].Instructions.Should().HaveCount(3);
cfg.Blocks[0].StartAddress.Should().Be(0x1000);
cfg.Blocks[0].EndAddress.Should().Be(0x100C);
cfg.EdgeCount.Should().Be(0);
}
#endregion
#region Conditional Branch Tests
[Fact]
public void Extract_ConditionalBranch_CreatesTwoBlocks()
{
// Arrange: cmp rax, 0; je +4; nop; ret
// The je jumps over the nop to the ret
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Compare, "cmp", [0x48, 0x83, 0xF8, 0x00],
CreateRegisterOperand("rax"), CreateImmediateOperand(0)),
CreateInstruction(0x1004, InstructionKind.ConditionalBranch, "je", [0x74, 0x01],
CreateAddressOperand(0x1007)), // Jump to ret
CreateInstruction(0x1006, InstructionKind.Nop, "nop", [0x90]),
CreateInstruction(0x1007, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(3);
// Block 0: cmp + je
cfg.Blocks[0].Instructions.Should().HaveCount(2);
cfg.Blocks[0].TerminatorKind.Should().Be(BlockTerminatorKind.ConditionalBranch);
cfg.Blocks[0].Successors.Should().Contain(1); // Fall through to nop
cfg.Blocks[0].Successors.Should().Contain(2); // Jump to ret
// Block 1: nop
cfg.Blocks[1].Instructions.Should().HaveCount(1);
cfg.Blocks[1].TerminatorKind.Should().Be(BlockTerminatorKind.FallThrough);
cfg.Blocks[1].Successors.Should().ContainSingle().Which.Should().Be(2);
cfg.Blocks[1].Predecessors.Should().ContainSingle().Which.Should().Be(0);
// Block 2: ret
cfg.Blocks[2].Instructions.Should().HaveCount(1);
cfg.Blocks[2].TerminatorKind.Should().Be(BlockTerminatorKind.Return);
cfg.Blocks[2].Successors.Should().BeEmpty();
}
[Fact]
public void Extract_IfElsePattern_CreatesCorrectBlocks()
{
// Arrange: if-else pattern
// cmp rax, 0
// je else_label
// mov rbx, 1 ; then branch
// jmp end_label
// else_label: mov rbx, 2
// end_label: ret
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Compare, "cmp", [0x48, 0x83, 0xF8, 0x00]),
CreateInstruction(0x1004, InstructionKind.ConditionalBranch, "je", [0x74, 0x05],
CreateAddressOperand(0x100B)), // Jump to else
CreateInstruction(0x1006, InstructionKind.Move, "mov", [0x48, 0xC7, 0xC3, 0x01, 0x00, 0x00, 0x00]),
CreateInstruction(0x100D, InstructionKind.Branch, "jmp", [0xEB, 0x07],
CreateAddressOperand(0x1016)), // Jump to ret
CreateInstruction(0x100F, InstructionKind.Move, "mov", [0x48, 0xC7, 0xC3, 0x02, 0x00, 0x00, 0x00]),
CreateInstruction(0x1016, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(4);
cfg.ExitBlockIds.Should().HaveCount(1);
}
#endregion
#region Loop Tests
[Fact]
public void Extract_SimpleLoop_CreatesBackEdge()
{
// Arrange: simple loop
// loop_start: dec rax
// jnz loop_start
// ret
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Arithmetic, "dec", [0x48, 0xFF, 0xC8],
CreateRegisterOperand("rax")),
CreateInstruction(0x1003, InstructionKind.ConditionalBranch, "jnz", [0x75, 0xFB],
CreateAddressOperand(0x1000)), // Jump back to dec
CreateInstruction(0x1005, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(2);
// Block 0: dec + jnz (loops back to itself)
cfg.Blocks[0].TerminatorKind.Should().Be(BlockTerminatorKind.ConditionalBranch);
cfg.Blocks[0].Successors.Should().Contain(0); // Back edge to itself
cfg.Blocks[0].Successors.Should().Contain(1); // Fall through to ret
// Block 1: ret
cfg.Blocks[1].TerminatorKind.Should().Be(BlockTerminatorKind.Return);
cfg.Blocks[1].Predecessors.Should().ContainSingle().Which.Should().Be(0);
}
#endregion
#region CFG Metrics Tests
[Fact]
public void ComputeMetrics_LinearCode_HasCorrectMetrics()
{
// Arrange: linear code with no branches
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Move, "mov", [0x48, 0x89, 0xC0]),
CreateInstruction(0x1003, InstructionKind.Arithmetic, "add", [0x48, 0x83, 0xC0, 0x01]),
CreateInstruction(0x1007, InstructionKind.Return, "ret", [0xC3])
};
// Act
var metrics = CfgExtractor.ComputeMetrics(instructions);
// Assert
metrics.BasicBlockCount.Should().Be(1);
metrics.EdgeCount.Should().Be(0);
metrics.CyclomaticComplexity.Should().Be(1); // edges - nodes + 2 = 0 - 1 + 2 = 1
metrics.EdgeHash.Should().NotBeNullOrEmpty();
}
[Fact]
public void ComputeMetrics_IfStatement_HasCorrectComplexity()
{
// Arrange: simple if with two paths
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Compare, "cmp", [0x48, 0x83, 0xF8, 0x00]),
CreateInstruction(0x1004, InstructionKind.ConditionalBranch, "je", [0x74, 0x01],
CreateAddressOperand(0x1007)),
CreateInstruction(0x1006, InstructionKind.Nop, "nop", [0x90]),
CreateInstruction(0x1007, InstructionKind.Return, "ret", [0xC3])
};
// Act
var metrics = CfgExtractor.ComputeMetrics(instructions);
// Assert
metrics.BasicBlockCount.Should().Be(3);
// Block 0 -> Block 1 (fallthrough), Block 0 -> Block 2 (branch), Block 1 -> Block 2 (fallthrough)
metrics.EdgeCount.Should().Be(3);
metrics.CyclomaticComplexity.Should().Be(2); // 3 - 3 + 2 = 2
}
[Fact]
public void ComputeMetrics_DifferentCfgs_HaveDifferentEdgeHashes()
{
// Arrange: two different CFGs
var linearCode = new[]
{
CreateInstruction(0x1000, InstructionKind.Move, "mov", [0x48, 0x89, 0xC0]),
CreateInstruction(0x1003, InstructionKind.Return, "ret", [0xC3])
};
var branchingCode = new[]
{
CreateInstruction(0x1000, InstructionKind.ConditionalBranch, "je", [0x74, 0x01],
CreateAddressOperand(0x1003)),
CreateInstruction(0x1002, InstructionKind.Nop, "nop", [0x90]),
CreateInstruction(0x1003, InstructionKind.Return, "ret", [0xC3])
};
// Act
var linearMetrics = CfgExtractor.ComputeMetrics(linearCode);
var branchingMetrics = CfgExtractor.ComputeMetrics(branchingCode);
// Assert
linearMetrics.EdgeHash.Should().NotBe(branchingMetrics.EdgeHash);
}
[Fact]
public void ComputeMetrics_SameCfgStructure_HasSameEdgeHash()
{
// Arrange: two CFGs with same structure but different addresses
var cfg1 = new[]
{
CreateInstruction(0x1000, InstructionKind.ConditionalBranch, "je", [0x74, 0x01],
CreateAddressOperand(0x1003)),
CreateInstruction(0x1002, InstructionKind.Nop, "nop", [0x90]),
CreateInstruction(0x1003, InstructionKind.Return, "ret", [0xC3])
};
var cfg2 = new[]
{
CreateInstruction(0x2000, InstructionKind.ConditionalBranch, "jne", [0x75, 0x01],
CreateAddressOperand(0x2003)),
CreateInstruction(0x2002, InstructionKind.Nop, "nop", [0x90]),
CreateInstruction(0x2003, InstructionKind.Return, "ret", [0xC3])
};
// Act
var metrics1 = CfgExtractor.ComputeMetrics(cfg1);
var metrics2 = CfgExtractor.ComputeMetrics(cfg2);
// Assert: same CFG structure should produce same edge hash
metrics1.EdgeHash.Should().Be(metrics2.EdgeHash);
metrics1.BasicBlockCount.Should().Be(metrics2.BasicBlockCount);
metrics1.EdgeCount.Should().Be(metrics2.EdgeCount);
}
#endregion
#region Call Instruction Tests
[Fact]
public void Extract_CallInstruction_ContinuesToNextBlock()
{
// Arrange: call followed by more code
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Call, "call", [0xE8, 0x00, 0x10, 0x00, 0x00],
CreateAddressOperand(0x2000)),
CreateInstruction(0x1005, InstructionKind.Move, "mov", [0x48, 0x89, 0xC0]),
CreateInstruction(0x1008, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(2);
cfg.Blocks[0].TerminatorKind.Should().Be(BlockTerminatorKind.Call);
cfg.Blocks[0].Successors.Should().ContainSingle().Which.Should().Be(1);
}
#endregion
#region Unconditional Jump Tests
[Fact]
public void Extract_UnconditionalJump_NoFallthrough()
{
// Arrange: unconditional jump
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.Branch, "jmp", [0xEB, 0x02],
CreateAddressOperand(0x1004)),
CreateInstruction(0x1002, InstructionKind.Nop, "nop", [0x90]), // Unreachable
CreateInstruction(0x1003, InstructionKind.Nop, "nop", [0x90]), // Unreachable
CreateInstruction(0x1004, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.Blocks.Should().HaveCount(3);
cfg.Blocks[0].TerminatorKind.Should().Be(BlockTerminatorKind.Jump);
cfg.Blocks[0].Successors.Should().ContainSingle().Which.Should().Be(2); // Jump target only
}
#endregion
#region Edge Cases
[Fact]
public void Extract_MultipleExits_TracksAllExitBlocks()
{
// Arrange: multiple return paths
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.ConditionalBranch, "je", [0x74, 0x01],
CreateAddressOperand(0x1003)),
CreateInstruction(0x1002, InstructionKind.Return, "ret", [0xC3]), // Exit 1
CreateInstruction(0x1003, InstructionKind.Return, "ret", [0xC3]) // Exit 2
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
cfg.ExitBlockIds.Should().HaveCount(2);
}
[Fact]
public void Extract_PredecessorsAreCorrect()
{
// Arrange: diamond pattern
// B0 (conditional)
// / \
// B1 B2
// \ /
// B3 (ret)
var instructions = new[]
{
CreateInstruction(0x1000, InstructionKind.ConditionalBranch, "je", [0x74, 0x02],
CreateAddressOperand(0x1004)),
CreateInstruction(0x1002, InstructionKind.Branch, "jmp", [0xEB, 0x02],
CreateAddressOperand(0x1006)),
CreateInstruction(0x1004, InstructionKind.Branch, "jmp", [0xEB, 0x00],
CreateAddressOperand(0x1006)),
CreateInstruction(0x1006, InstructionKind.Return, "ret", [0xC3])
};
// Act
var cfg = CfgExtractor.Extract(instructions);
// Assert
// Last block should have two predecessors
var lastBlock = cfg.Blocks.First(b => b.TerminatorKind == BlockTerminatorKind.Return);
lastBlock.Predecessors.Should().HaveCount(2);
}
#endregion
}

View File

@@ -0,0 +1,241 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using StellaOps.BinaryIndex.Disassembly.Iced;
using StellaOps.BinaryIndex.Normalization;
using StellaOps.BinaryIndex.Normalization.X64;
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
/// <summary>
/// Tests for the delta signature generator.
/// </summary>
public class DeltaSignatureGeneratorTests
{
[Fact]
public void GenerateSymbolSignature_EmptyBytes_ReturnsEmptyHash()
{
var generator = CreateGenerator();
var sig = generator.GenerateSymbolSignature(
ReadOnlySpan<byte>.Empty,
"test_func",
".text");
sig.Name.Should().Be("test_func");
sig.Scope.Should().Be(".text");
sig.HashAlg.Should().Be("sha256");
sig.SizeBytes.Should().Be(0);
// SHA256 of empty = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
sig.HashHex.Should().Be("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
}
[Fact]
public void GenerateSymbolSignature_WithBytes_ReturnsCorrectHash()
{
var generator = CreateGenerator();
var bytes = new byte[] { 0x90, 0x90, 0x90, 0xC3 }; // NOP NOP NOP RET
var sig = generator.GenerateSymbolSignature(
bytes,
"simple_func",
".text");
sig.Name.Should().Be("simple_func");
sig.SizeBytes.Should().Be(4);
sig.HashHex.Should().NotBeNullOrEmpty();
sig.HashHex.Should().HaveLength(64); // SHA256 = 32 bytes = 64 hex chars
}
[Fact]
public void GenerateSymbolSignature_DeterministicHash()
{
var generator = CreateGenerator();
var bytes = new byte[] { 0x48, 0x89, 0xe5, 0x5d, 0xc3 }; // MOV RBP,RSP ; POP RBP ; RET
var sig1 = generator.GenerateSymbolSignature(bytes, "func", ".text");
var sig2 = generator.GenerateSymbolSignature(bytes, "func", ".text");
sig1.HashHex.Should().Be(sig2.HashHex);
}
[Fact]
public void GenerateSymbolSignature_DifferentBytes_DifferentHash()
{
var generator = CreateGenerator();
var bytes1 = new byte[] { 0x90, 0xC3 }; // NOP RET
var bytes2 = new byte[] { 0x90, 0x90, 0xC3 }; // NOP NOP RET
var sig1 = generator.GenerateSymbolSignature(bytes1, "func", ".text");
var sig2 = generator.GenerateSymbolSignature(bytes2, "func", ".text");
sig1.HashHex.Should().NotBe(sig2.HashHex);
}
[Fact]
public void GenerateSymbolSignature_IncludesCfgByDefault()
{
var generator = CreateGenerator();
// Simple function with a few blocks
var bytes = new byte[]
{
0x55, // PUSH RBP
0x48, 0x89, 0xe5, // MOV RBP, RSP
0x74, 0x05, // JE +5 (conditional branch - new block)
0x48, 0x31, 0xc0, // XOR RAX, RAX
0xEB, 0x03, // JMP +3 (branch - new block)
0x48, 0xFF, 0xc0, // INC RAX
0x5d, // POP RBP (new block after JMP target)
0xc3 // RET
};
var sig = generator.GenerateSymbolSignature(bytes, "branch_func", ".text");
sig.CfgBbCount.Should().NotBeNull();
sig.CfgBbCount.Should().BeGreaterThan(1);
}
[Fact]
public void GenerateSymbolSignature_NoCfgWhenDisabled()
{
var generator = CreateGenerator();
var bytes = new byte[] { 0x90, 0xC3 };
var sig = generator.GenerateSymbolSignature(
bytes,
"func",
".text",
new SignatureOptions(IncludeCfg: false));
sig.CfgBbCount.Should().BeNull();
sig.CfgEdgeHash.Should().BeNull();
}
[Fact]
public void GenerateSymbolSignature_IncludesChunksForLargeFunction()
{
var generator = CreateGenerator();
// Create a function larger than chunk size (2KB default)
var bytes = new byte[3000];
for (var i = 0; i < bytes.Length - 1; i++)
{
bytes[i] = 0x90; // NOP
}
bytes[^1] = 0xC3; // RET
var sig = generator.GenerateSymbolSignature(bytes, "large_func", ".text");
sig.Chunks.Should().NotBeNull();
sig.Chunks!.Value.Should().HaveCountGreaterThan(1);
sig.Chunks.Value[0].Offset.Should().Be(0);
sig.Chunks.Value[0].Size.Should().Be(2048);
}
[Fact]
public void GenerateSymbolSignature_NoChunksForSmallFunction()
{
var generator = CreateGenerator();
var bytes = new byte[] { 0x90, 0xC3 }; // Tiny function
var sig = generator.GenerateSymbolSignature(bytes, "tiny_func", ".text");
sig.Chunks.Should().BeNull();
}
[Fact]
public void GenerateSymbolSignature_NoChunksWhenDisabled()
{
var generator = CreateGenerator();
var bytes = new byte[3000];
bytes[^1] = 0xC3;
var sig = generator.GenerateSymbolSignature(
bytes,
"func",
".text",
new SignatureOptions(IncludeChunks: false));
sig.Chunks.Should().BeNull();
}
[Fact]
public void GenerateSymbolSignature_CustomChunkSize()
{
var generator = CreateGenerator();
var bytes = new byte[1000];
bytes[^1] = 0xC3;
var sig = generator.GenerateSymbolSignature(
bytes,
"func",
".text",
new SignatureOptions(ChunkSize: 256));
sig.Chunks.Should().NotBeNull();
sig.Chunks!.Value.Should().HaveCount(4); // 1000 / 256 = 3.9 -> 4 chunks
}
[Fact]
public void GenerateSymbolSignature_Sha512HashAlgorithm()
{
var generator = CreateGenerator();
var bytes = new byte[] { 0x90, 0xC3 };
var sig = generator.GenerateSymbolSignature(
bytes,
"func",
".text",
new SignatureOptions(HashAlgorithm: "sha512"));
sig.HashAlg.Should().Be("sha512");
sig.HashHex.Should().HaveLength(128); // SHA512 = 64 bytes = 128 hex chars
}
[Fact]
public void GenerateSymbolSignature_InvalidHashAlgorithm_Throws()
{
var generator = CreateGenerator();
var bytes = new byte[] { 0x90 };
var act = () => generator.GenerateSymbolSignature(
bytes,
"func",
".text",
new SignatureOptions(HashAlgorithm: "md5")); // Not supported
act.Should().Throw<ArgumentException>()
.WithMessage("*md5*");
}
// Helper methods
private static DeltaSignatureGenerator CreateGenerator()
{
// Create minimal dependencies for unit testing by directly constructing services
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var b2r2Plugin = new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
var registry = new DisassemblyPluginRegistry(
[icedPlugin, b2r2Plugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
var disassemblyService = new DisassemblyService(
registry,
Options.Create(new DisassemblyOptions()),
NullLogger<DisassemblyService>.Instance);
var normalizationService = new NormalizationService(
[new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance)],
NullLogger<NormalizationService>.Instance);
return new DeltaSignatureGenerator(
disassemblyService,
normalizationService,
NullLogger<DeltaSignatureGenerator>.Instance);
}
}

View File

@@ -0,0 +1,211 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using StellaOps.BinaryIndex.Disassembly.Iced;
using StellaOps.BinaryIndex.Normalization;
using StellaOps.BinaryIndex.Normalization.X64;
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
/// <summary>
/// Tests for the delta signature matcher.
/// </summary>
public class DeltaSignatureMatcherTests
{
[Fact]
public void MatchSymbol_ExactMatch_ReturnsMatched()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var signature = CreateTestSignature(
"CVE-2024-1234",
"patched",
[("test_func", symbolHash)]);
var results = matcher.MatchSymbol(symbolHash, "test_func", [signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
results[0].Cve.Should().Be("CVE-2024-1234");
results[0].SignatureState.Should().Be("patched");
results[0].Confidence.Should().Be(1.0);
results[0].SymbolMatches[0].ExactMatch.Should().BeTrue();
}
[Fact]
public void MatchSymbol_NoMatch_ReturnsNotMatched()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var differentHash = "def456abc123def456abc123def456abc123def456abc123def456abc123def456";
var signature = CreateTestSignature(
"CVE-2024-1234",
"vulnerable",
[("test_func", differentHash)]);
var results = matcher.MatchSymbol(symbolHash, "test_func", [signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeFalse();
results[0].Confidence.Should().Be(0.0);
results[0].SymbolMatches[0].ExactMatch.Should().BeFalse();
}
[Fact]
public void MatchSymbol_SymbolNotInSignature_ReturnsEmpty()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var signature = CreateTestSignature(
"CVE-2024-1234",
"vulnerable",
[("other_func", symbolHash)]);
var results = matcher.MatchSymbol(symbolHash, "nonexistent_func", [signature]);
results.Should().BeEmpty();
}
[Fact]
public void MatchSymbol_MultipleSignatures_MatchesAll()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var sig1 = CreateTestSignature(
"CVE-2024-1234",
"vulnerable",
[("test_func", symbolHash)]);
var sig2 = CreateTestSignature(
"CVE-2024-1234",
"patched",
[("test_func", symbolHash)]);
var results = matcher.MatchSymbol(symbolHash, "test_func", [sig1, sig2]);
results.Should().HaveCount(2);
results[0].SignatureState.Should().Be("vulnerable");
results[1].SignatureState.Should().Be("patched");
}
[Fact]
public void MatchSymbol_CaseInsensitiveHashComparison()
{
var matcher = CreateMatcher();
var symbolHashLower = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var symbolHashUpper = "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123";
var signature = CreateTestSignature(
"CVE-2024-1234",
"patched",
[("test_func", symbolHashUpper)]);
var results = matcher.MatchSymbol(symbolHashLower, "test_func", [signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
}
[Fact]
public void MatchSymbol_EmptySignatures_ReturnsEmpty()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var results = matcher.MatchSymbol(symbolHash, "test_func", []);
results.Should().BeEmpty();
}
[Fact]
public void MatchSymbol_VulnerableState_GeneratesCorrectExplanation()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var signature = CreateTestSignature(
"CVE-2024-1234",
"vulnerable",
[("test_func", symbolHash)]);
var results = matcher.MatchSymbol(symbolHash, "test_func", [signature]);
results[0].Explanation.Should().Contain("vulnerable");
results[0].Explanation.Should().Contain("CVE-2024-1234");
}
[Fact]
public void MatchSymbol_PatchedState_GeneratesCorrectExplanation()
{
var matcher = CreateMatcher();
var symbolHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var signature = CreateTestSignature(
"CVE-2024-1234",
"patched",
[("test_func", symbolHash)]);
var results = matcher.MatchSymbol(symbolHash, "test_func", [signature]);
results[0].Explanation.Should().Contain("patched");
}
// Helper methods
private static DeltaSignatureMatcher CreateMatcher()
{
// Create minimal dependencies for unit testing by directly constructing services
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var b2r2Plugin = new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
var registry = new DisassemblyPluginRegistry(
[icedPlugin, b2r2Plugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
var disassemblyService = new DisassemblyService(
registry,
Options.Create(new DisassemblyOptions()),
NullLogger<DisassemblyService>.Instance);
var normalizationService = new NormalizationService(
[new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance)],
NullLogger<NormalizationService>.Instance);
return new DeltaSignatureMatcher(
disassemblyService,
normalizationService,
NullLogger<DeltaSignatureMatcher>.Instance);
}
private static DeltaSignature CreateTestSignature(
string cve,
string state,
IReadOnlyList<(string Name, string Hash)> symbols)
{
return new DeltaSignature
{
Cve = cve,
Package = new PackageRef("test-package", null),
Target = new TargetRef("x86_64", "gnu"),
Normalization = new NormalizationRef("elf.delta.norm.x64", "1.0.0", []),
SignatureState = state,
Symbols = symbols.Select(s => new SymbolSignature
{
Name = s.Name,
HashAlg = "sha256",
HashHex = s.Hash,
SizeBytes = 256
}).ToImmutableArray()
};
}
}

View File

@@ -0,0 +1,392 @@
// -----------------------------------------------------------------------------
// GoldenSignatureTests.cs
// Sprint: SPRINT_20260102_001_BE (Binary Delta Signatures)
// Task: DS-038 - Golden tests with known CVE signatures
// Description: Golden fixture tests verifying signature matching against
// known CVE patterns (Heartbleed, Log4Shell, POODLE, etc.)
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using System.Text.Json;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Disassembly.Iced;
using StellaOps.BinaryIndex.Normalization;
using StellaOps.BinaryIndex.Normalization.X64;
using StellaOps.TestKit;
namespace StellaOps.BinaryIndex.DeltaSig.Tests.Golden;
/// <summary>
/// Golden fixture tests for known CVE signature patterns.
/// These tests verify that the signature matching logic correctly
/// identifies vulnerable and patched binaries based on pre-computed
/// signature fixtures.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public class GoldenSignatureTests
{
private static readonly string FixturePath = Path.Combine(
AppContext.BaseDirectory,
"Golden",
"cve-signatures.golden.json");
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
private readonly DeltaSignatureMatcher _matcher;
public GoldenSignatureTests()
{
_matcher = CreateMatcher();
}
[Fact]
public void GoldenFixture_Exists()
{
File.Exists(FixturePath).Should().BeTrue(
$"Golden fixture file should exist at: {FixturePath}");
}
[Fact]
public void GoldenFixture_IsValidJson()
{
var json = File.ReadAllText(FixturePath);
var fixture = JsonSerializer.Deserialize<GoldenFixture>(json, JsonOptions);
fixture.Should().NotBeNull();
fixture!.Version.Should().Be("1.0");
fixture.TestCases.Should().NotBeEmpty();
}
[Theory]
[MemberData(nameof(GetExactMatchTestCases))]
public void ExactMatch_MatchesGoldenExpectation(GoldenTestCase testCase)
{
// Arrange
var signature = ConvertToSignature(testCase);
var inputHash = testCase.Signature.Hash;
var symbolName = testCase.Signature.SymbolName;
// Act
var results = _matcher.MatchSymbol(inputHash, symbolName, [signature]);
// Assert
results.Should().HaveCount(1, $"should match exactly one signature for {testCase.Id}");
var result = results[0];
result.Matched.Should().BeTrue($"golden case {testCase.Id} should match");
result.SignatureState.Should().Be(testCase.ExpectedMatch.State);
result.Confidence.Should().BeApproximately(
testCase.ExpectedMatch.Confidence, 0.01,
$"confidence for {testCase.Id} should match expected");
if (testCase.ExpectedMatch.IsExactMatch.HasValue)
{
result.SymbolMatches[0].ExactMatch.Should().Be(
testCase.ExpectedMatch.IsExactMatch.Value,
$"exact match flag for {testCase.Id} should match expected");
}
}
[Fact]
public void Heartbleed_VulnerableSignature_MatchesVulnerable()
{
// This is the canonical Heartbleed test
var fixture = LoadFixture();
var heartbleedVuln = fixture.TestCases.First(tc => tc.Id == "heartbleed-vulnerable");
var signature = ConvertToSignature(heartbleedVuln);
var results = _matcher.MatchSymbol(
heartbleedVuln.Signature.Hash,
heartbleedVuln.Signature.SymbolName,
[signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
results[0].SignatureState.Should().Be("vulnerable");
results[0].Cve.Should().Be("CVE-2014-0160");
}
[Fact]
public void Heartbleed_PatchedSignature_MatchesPatched()
{
var fixture = LoadFixture();
var heartbleedPatched = fixture.TestCases.First(tc => tc.Id == "heartbleed-patched");
var signature = ConvertToSignature(heartbleedPatched);
var results = _matcher.MatchSymbol(
heartbleedPatched.Signature.Hash,
heartbleedPatched.Signature.SymbolName,
[signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
results[0].SignatureState.Should().Be("patched");
results[0].Cve.Should().Be("CVE-2014-0160");
}
[Fact]
public void Heartbleed_BackportedRHEL_MatchesPatchedDespiteVersion()
{
// This is the key use case: RHEL backported the fix to 1.0.1e
// Version-based scanners would flag it as vulnerable (1.0.1e < 1.0.1g)
// But the binary signature should prove it's patched
var fixture = LoadFixture();
var backport = fixture.TestCases.First(tc => tc.Id == "heartbleed-rhel-backport");
var patchedSig = fixture.TestCases.First(tc => tc.Id == "heartbleed-patched");
var signature = ConvertToSignature(patchedSig);
// The backported binary has the SAME hash as the patched version
var results = _matcher.MatchSymbol(
backport.Signature.Hash,
backport.Signature.SymbolName,
[signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue(
"RHEL backport should match patched signature, proving the fix is present");
results[0].SignatureState.Should().Be("patched");
}
[Fact]
public void VulnerableHash_AgainstBothSignatures_ReturnsCorrectState()
{
// When matching a hash against both vulnerable AND patched signatures,
// it should only match the correct one
var fixture = LoadFixture();
var vulnCase = fixture.TestCases.First(tc => tc.Id == "heartbleed-vulnerable");
var patchedCase = fixture.TestCases.First(tc => tc.Id == "heartbleed-patched");
var vulnSig = ConvertToSignature(vulnCase);
var patchedSig = ConvertToSignature(patchedCase);
// Try matching the VULNERABLE hash
var results = _matcher.MatchSymbol(
vulnCase.Signature.Hash,
vulnCase.Signature.SymbolName,
[vulnSig, patchedSig]);
// Should match the vulnerable signature
var matchedVuln = results.Where(r => r.Matched && r.SignatureState == "vulnerable").ToList();
var matchedPatched = results.Where(r => r.Matched && r.SignatureState == "patched").ToList();
matchedVuln.Should().HaveCount(1, "should match the vulnerable signature");
matchedPatched.Should().BeEmpty("should NOT match the patched signature");
}
[Fact]
public void Log4Shell_VulnerableSignature_Matches()
{
var fixture = LoadFixture();
var log4shellVuln = fixture.TestCases.First(tc => tc.Id == "log4shell-vulnerable");
var signature = ConvertToSignature(log4shellVuln);
var results = _matcher.MatchSymbol(
log4shellVuln.Signature.Hash,
log4shellVuln.Signature.SymbolName,
[signature]);
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
results[0].Cve.Should().Be("CVE-2021-44228");
}
[Fact]
public void AllGoldenCases_HaveRequiredFields()
{
var fixture = LoadFixture();
foreach (var testCase in fixture.TestCases)
{
testCase.Id.Should().NotBeNullOrEmpty($"test case should have an id");
testCase.Cve.Should().NotBeNullOrEmpty($"test case {testCase.Id} should have a CVE");
testCase.Signature.Should().NotBeNull($"test case {testCase.Id} should have a signature");
testCase.Signature.Hash.Should().NotBeNullOrEmpty($"test case {testCase.Id} should have a hash");
testCase.Signature.State.Should().NotBeNullOrEmpty($"test case {testCase.Id} should have a state");
testCase.ExpectedMatch.Should().NotBeNull($"test case {testCase.Id} should have expected match");
}
}
[Fact]
public void SignatureHashes_AreValidLength()
{
var fixture = LoadFixture();
foreach (var testCase in fixture.TestCases)
{
// SHA256 hashes should be 64 hex characters
testCase.Signature.Hash.Should().HaveLength(64,
$"hash for {testCase.Id} should be 64 hex chars (SHA256)");
}
}
#region Helpers
public static IEnumerable<object[]> GetExactMatchTestCases()
{
if (!File.Exists(FixturePath))
yield break;
var json = File.ReadAllText(FixturePath);
var fixture = JsonSerializer.Deserialize<GoldenFixture>(json, JsonOptions);
if (fixture?.TestCases == null)
yield break;
// Filter to exact match test cases only
foreach (var testCase in fixture.TestCases.Where(tc =>
tc.ExpectedMatch?.IsExactMatch == true &&
tc.PartialMatchInput == null))
{
yield return new object[] { testCase };
}
}
private static GoldenFixture LoadFixture()
{
var json = File.ReadAllText(FixturePath);
return JsonSerializer.Deserialize<GoldenFixture>(json, JsonOptions)
?? throw new InvalidOperationException("Failed to deserialize golden fixture");
}
private static DeltaSignatureMatcher CreateMatcher()
{
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var registry = new DisassemblyPluginRegistry(
[icedPlugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
var disassemblyService = new DisassemblyService(
registry,
Options.Create(new DisassemblyOptions()),
NullLogger<DisassemblyService>.Instance);
var normalizationService = new NormalizationService(
[new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance)],
NullLogger<NormalizationService>.Instance);
return new DeltaSignatureMatcher(
disassemblyService,
normalizationService,
NullLogger<DeltaSignatureMatcher>.Instance);
}
private static DeltaSignature ConvertToSignature(GoldenTestCase testCase)
{
var sig = testCase.Signature;
var chunkHashes = sig.ChunkHashes?
.Select(ch => new ChunkHash(ch.Offset, ch.Size, ch.Hash))
.ToImmutableArray();
return new DeltaSignature
{
Cve = testCase.Cve,
Package = new PackageRef(testCase.Package?.Name ?? "unknown", null),
Target = new TargetRef(sig.Arch ?? "x86_64", sig.Abi ?? "gnu"),
Normalization = new NormalizationRef(
sig.RecipeId ?? "elf.delta.norm.x64",
sig.RecipeVersion ?? "1.0.0",
[]),
SignatureState = sig.State,
Symbols =
[
new SymbolSignature
{
Name = sig.SymbolName,
HashAlg = sig.HashAlg ?? "sha256",
HashHex = sig.Hash,
SizeBytes = sig.SizeBytes,
CfgBbCount = sig.Cfg?.BasicBlockCount,
CfgEdgeHash = sig.Cfg?.EdgeHash,
Chunks = chunkHashes
}
]
};
}
#endregion
}
#region Fixture Models
public record GoldenFixture
{
public string? Version { get; init; }
public string? Description { get; init; }
public IReadOnlyList<GoldenTestCase> TestCases { get; init; } = [];
}
public record GoldenTestCase
{
public string Id { get; init; } = "";
public string Description { get; init; } = "";
public string Cve { get; init; } = "";
public PackageInfoFixture? Package { get; init; }
public SignatureInfo Signature { get; init; } = new();
public PartialMatchInput? PartialMatchInput { get; init; }
public ExpectedMatchInfo ExpectedMatch { get; init; } = new();
}
public record PackageInfoFixture
{
public string Name { get; init; } = "";
public string? Version { get; init; }
public string? VersionRange { get; init; }
public string? Purl { get; init; }
public string? PurlTemplate { get; init; }
}
public record SignatureInfo
{
public string State { get; init; } = "";
public string SymbolName { get; init; } = "";
public string? Arch { get; init; }
public string? Abi { get; init; }
public string? RecipeId { get; init; }
public string? RecipeVersion { get; init; }
public string? HashAlg { get; init; }
public string Hash { get; init; } = "";
public int SizeBytes { get; init; }
public CfgInfoFixture? Cfg { get; init; }
public IReadOnlyList<ChunkHashFixture>? ChunkHashes { get; init; }
public string? Note { get; init; }
}
public record CfgInfoFixture
{
public int BasicBlockCount { get; init; }
public int EdgeCount { get; init; }
public string? EdgeHash { get; init; }
public int CyclomaticComplexity { get; init; }
}
public record ChunkHashFixture
{
public int Offset { get; init; }
public int Size { get; init; }
public string Hash { get; init; } = "";
}
public record PartialMatchInput
{
public string? Description { get; init; }
public IReadOnlyList<ChunkHashFixture>? ChunkHashes { get; init; }
}
public record ExpectedMatchInfo
{
public string State { get; init; } = "";
public double Confidence { get; init; } = 1.0;
public bool? IsExactMatch { get; init; }
public string? Note { get; init; }
}
#endregion

View File

@@ -0,0 +1,232 @@
{
"$schema": "delta-signature-golden.schema.json",
"version": "1.0",
"description": "Golden test fixtures for known CVE signatures - synthetic test data that mirrors real-world patterns",
"test_cases": [
{
"id": "heartbleed-vulnerable",
"description": "CVE-2014-0160 (Heartbleed) - vulnerable signature for dtls1_process_heartbeat",
"cve": "CVE-2014-0160",
"package": {
"name": "openssl",
"version_range": "[1.0.1,1.0.1f]",
"purl_template": "pkg:deb/debian/openssl@{version}"
},
"signature": {
"state": "vulnerable",
"symbol_name": "dtls1_process_heartbeat",
"arch": "x86_64",
"abi": "gnu",
"recipe_id": "elf.delta.norm.x64",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"size_bytes": 847,
"cfg": {
"basic_block_count": 23,
"edge_count": 31,
"edge_hash": "bb11cc22dd33ee44ff5566778899aabbccddeeff00112233445566778899aabb",
"cyclomatic_complexity": 10
},
"chunk_hashes": [
{"offset": 0, "size": 128, "hash": "chunk1hash0000000000000000000000000000000000000000000000000001"},
{"offset": 128, "size": 128, "hash": "chunk2hash0000000000000000000000000000000000000000000000000002"},
{"offset": 256, "size": 128, "hash": "chunk3hash0000000000000000000000000000000000000000000000000003"}
]
},
"expected_match": {
"state": "vulnerable",
"confidence": 1.0,
"is_exact_match": true
}
},
{
"id": "heartbleed-patched",
"description": "CVE-2014-0160 (Heartbleed) - patched signature for dtls1_process_heartbeat",
"cve": "CVE-2014-0160",
"package": {
"name": "openssl",
"version_range": "[1.0.1g,)",
"purl_template": "pkg:deb/debian/openssl@{version}"
},
"signature": {
"state": "patched",
"symbol_name": "dtls1_process_heartbeat",
"arch": "x86_64",
"abi": "gnu",
"recipe_id": "elf.delta.norm.x64",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "e5f6a7b8c9d0123456789012345678901234567890123456789012345678efgh",
"size_bytes": 923,
"cfg": {
"basic_block_count": 27,
"edge_count": 38,
"edge_hash": "cc22dd33ee44ff5566778899aabbccddeeff00112233445566778899aabbcc22",
"cyclomatic_complexity": 13
},
"chunk_hashes": [
{"offset": 0, "size": 128, "hash": "patched1hash000000000000000000000000000000000000000000000001"},
{"offset": 128, "size": 128, "hash": "patched2hash000000000000000000000000000000000000000000000002"},
{"offset": 256, "size": 128, "hash": "patched3hash000000000000000000000000000000000000000000000003"},
{"offset": 384, "size": 128, "hash": "patched4hash000000000000000000000000000000000000000000000004"}
]
},
"expected_match": {
"state": "patched",
"confidence": 1.0,
"is_exact_match": true
}
},
{
"id": "heartbleed-rhel-backport",
"description": "CVE-2014-0160 - RHEL backported patch (version says 1.0.1e but actually patched)",
"cve": "CVE-2014-0160",
"package": {
"name": "openssl",
"version": "1.0.1e-42.el7_1.4",
"purl": "pkg:rpm/rhel/openssl@1.0.1e-42.el7_1.4"
},
"signature": {
"state": "patched",
"symbol_name": "dtls1_process_heartbeat",
"arch": "x86_64",
"abi": "gnu",
"recipe_id": "elf.delta.norm.x64",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "e5f6a7b8c9d0123456789012345678901234567890123456789012345678efgh",
"size_bytes": 923,
"cfg": {
"basic_block_count": 27,
"edge_count": 38,
"edge_hash": "cc22dd33ee44ff5566778899aabbccddeeff00112233445566778899aabbcc22",
"cyclomatic_complexity": 13
}
},
"expected_match": {
"state": "patched",
"confidence": 1.0,
"is_exact_match": true,
"note": "Version check would say vulnerable, but binary signature proves patched"
}
},
{
"id": "log4shell-vulnerable",
"description": "CVE-2021-44228 (Log4Shell) - vulnerable JndiLookup.lookup signature",
"cve": "CVE-2021-44228",
"package": {
"name": "log4j-core",
"version_range": "[2.0-beta9,2.15.0)",
"purl_template": "pkg:maven/org.apache.logging.log4j/log4j-core@{version}"
},
"signature": {
"state": "vulnerable",
"symbol_name": "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
"arch": "jvm",
"abi": "java17",
"recipe_id": "jar.delta.norm.jvm",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "log4j1vuln000000000000000000000000000000000000000000000000000001",
"size_bytes": 2048
},
"expected_match": {
"state": "vulnerable",
"confidence": 1.0,
"is_exact_match": true
}
},
{
"id": "log4shell-patched",
"description": "CVE-2021-44228 (Log4Shell) - patched (JndiLookup removed or disabled)",
"cve": "CVE-2021-44228",
"package": {
"name": "log4j-core",
"version_range": "[2.17.0,)",
"purl_template": "pkg:maven/org.apache.logging.log4j/log4j-core@{version}"
},
"signature": {
"state": "patched",
"symbol_name": "org.apache.logging.log4j.core.lookup.JndiLookup.lookup",
"arch": "jvm",
"abi": "java17",
"recipe_id": "jar.delta.norm.jvm",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "log4j1patch00000000000000000000000000000000000000000000000000001",
"size_bytes": 512,
"note": "Drastically smaller because JNDI lookup is neutered"
},
"expected_match": {
"state": "patched",
"confidence": 1.0,
"is_exact_match": true
}
},
{
"id": "poodle-vulnerable",
"description": "CVE-2014-3566 (POODLE) - vulnerable SSL3 signature",
"cve": "CVE-2014-3566",
"package": {
"name": "openssl",
"version_range": "[0.9.8,1.0.1j)"
},
"signature": {
"state": "vulnerable",
"symbol_name": "ssl3_read_bytes",
"arch": "x86_64",
"abi": "gnu",
"recipe_id": "elf.delta.norm.x64",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "poodlevuln000000000000000000000000000000000000000000000000000001",
"size_bytes": 1536
},
"expected_match": {
"state": "vulnerable",
"confidence": 1.0
}
},
{
"id": "partial-match-case",
"description": "Test case for partial matching via chunk hashes",
"cve": "CVE-TEST-0001",
"package": {
"name": "test-lib",
"version": "1.0.0"
},
"signature": {
"state": "vulnerable",
"symbol_name": "vulnerable_function",
"arch": "x86_64",
"abi": "gnu",
"recipe_id": "elf.delta.norm.x64",
"recipe_version": "1.0.0",
"hash_alg": "sha256",
"hash": "fullhash10000000000000000000000000000000000000000000000000000001",
"size_bytes": 512,
"chunk_hashes": [
{"offset": 0, "size": 128, "hash": "testchunk10000000000000000000000000000000000000000000000000001"},
{"offset": 128, "size": 128, "hash": "testchunk20000000000000000000000000000000000000000000000000002"},
{"offset": 256, "size": 128, "hash": "testchunk30000000000000000000000000000000000000000000000000003"},
{"offset": 384, "size": 128, "hash": "testchunk40000000000000000000000000000000000000000000000000004"}
]
},
"partial_match_input": {
"description": "Binary with 3 of 4 chunks matching (75% confidence)",
"chunk_hashes": [
{"offset": 0, "size": 128, "hash": "testchunk10000000000000000000000000000000000000000000000000001"},
{"offset": 128, "size": 128, "hash": "testchunk20000000000000000000000000000000000000000000000000002"},
{"offset": 256, "size": 128, "hash": "different3000000000000000000000000000000000000000000000000003"},
{"offset": 384, "size": 128, "hash": "testchunk40000000000000000000000000000000000000000000000000004"}
]
},
"expected_match": {
"state": "vulnerable",
"confidence": 0.75,
"is_exact_match": false
}
}
]
}

View File

@@ -0,0 +1,354 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under the AGPL-3.0-or-later License.
using System.Collections.Immutable;
using System.Security.Cryptography;
using System.Text;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using StellaOps.BinaryIndex.Disassembly.Iced;
using StellaOps.BinaryIndex.Normalization;
using StellaOps.BinaryIndex.Normalization.X64;
namespace StellaOps.BinaryIndex.DeltaSig.Tests.Integration;
/// <summary>
/// End-to-end integration tests for the Delta Signature pipeline.
/// Tests the complete workflow using MatchSymbol API.
/// </summary>
[Trait("Category", "Integration")]
public class DeltaSigIntegrationTests
{
private readonly DeltaSignatureMatcher _matcher;
private readonly DisassemblyService _disassemblyService;
private readonly NormalizationService _normalizationService;
public DeltaSigIntegrationTests()
{
// Set up the disassembly pipeline
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var b2r2Plugin = new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
var registry = new DisassemblyPluginRegistry(
[icedPlugin, b2r2Plugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
_disassemblyService = new DisassemblyService(
registry,
Options.Create(new DisassemblyOptions()),
NullLogger<DisassemblyService>.Instance);
// Set up the normalization pipeline
var x64Pipeline = new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance);
_normalizationService = new NormalizationService(
[x64Pipeline],
NullLogger<NormalizationService>.Instance);
// Set up matcher
_matcher = new DeltaSignatureMatcher(
_disassemblyService,
_normalizationService,
NullLogger<DeltaSignatureMatcher>.Instance);
}
#region Pipeline Integration Tests
[Fact]
public void EndToEnd_GenerateAndMatchSignature_ExactMatch()
{
// Arrange - create a sample hash and signature
var symbolHash = GenerateHashFromSeed("vulnerable_function");
var deltaSignature = CreateTestSignature(
"CVE-2024-99999",
"vulnerable",
[("test_vulnerable_function", symbolHash)]);
// Act - match the same hash against the signature
var results = _matcher.MatchSymbol(symbolHash, "test_vulnerable_function", [deltaSignature]);
// Assert
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue("the same hash should produce an exact match");
results[0].Confidence.Should().Be(1.0);
results[0].SymbolMatches[0].ExactMatch.Should().BeTrue();
}
[Fact]
public void EndToEnd_DifferentHashes_NoMatch()
{
// Arrange - create two different hashes
var vulnerableHash = GenerateHashFromSeed("vulnerable_v1");
var patchedHash = GenerateHashFromSeed("patched_v2");
var deltaSignature = CreateTestSignature(
"CVE-2024-99999",
"vulnerable",
[("vulnerable_function", vulnerableHash)]);
// Act - match against different (patched) hash
var results = _matcher.MatchSymbol(patchedHash, "vulnerable_function", [deltaSignature]);
// Assert
results.Should().HaveCount(1);
results[0].Matched.Should().BeFalse("different hash should not match");
results[0].Confidence.Should().Be(0.0);
}
[Fact]
public void EndToEnd_VulnerableAndPatchedSignatures_BothMatched()
{
// Arrange - create a hash that appears in both vulnerable and patched states
// (simulating RHEL backport where binary hash matches patched signature)
var funcHash = GenerateHashFromSeed("heartbleed_fix");
var vulnSignature = CreateTestSignature(
"CVE-2014-0160",
"vulnerable",
[("tls1_process_heartbeat", funcHash)]);
var patchedSignature = CreateTestSignature(
"CVE-2014-0160",
"patched",
[("tls1_process_heartbeat", funcHash)]);
// Act
var results = _matcher.MatchSymbol(funcHash, "tls1_process_heartbeat", [vulnSignature, patchedSignature]);
// Assert - should match both signatures
results.Should().HaveCount(2);
results.Should().Contain(r => r.SignatureState == "vulnerable");
results.Should().Contain(r => r.SignatureState == "patched");
}
#endregion
#region Normalization Hash Stability Tests
[Fact]
public void Normalization_SameBytesMultipleTimes_ProduceSameHash()
{
// Arrange
var functionBytes = CreateSampleX64Function("determinism_test");
// Act - hash multiple times
var hashes = Enumerable.Range(0, 10)
.Select(_ => HashFunctionBytes(functionBytes))
.ToList();
// Assert - all hashes should be identical
var firstHash = hashes[0];
hashes.Should().AllSatisfy(h => h.Should().Be(firstHash));
}
[Fact]
public void Normalization_DifferentFunctions_ProduceDifferentHashes()
{
// Arrange - create semantically different functions
var addFunc = CreateX64AddFunction();
var subFunc = CreateX64SubFunction();
// Act
var addHash = HashFunctionBytes(addFunc);
var subHash = HashFunctionBytes(subFunc);
// Assert - different operations should produce different hashes
addHash.Should().NotBe(subHash,
"semantically different code should produce different hashes");
}
#endregion
#region Multi-Symbol Matching Tests
[Fact]
public void MatchSymbol_SignatureWithMultipleSymbols_MatchesCorrectOne()
{
// Arrange - signature with multiple symbols
var func1Hash = GenerateHashFromSeed("function_one");
var func2Hash = GenerateHashFromSeed("function_two");
var func3Hash = GenerateHashFromSeed("function_three");
var deltaSignature = CreateTestSignature(
"CVE-2024-88888",
"vulnerable",
[("function_one", func1Hash), ("function_two", func2Hash), ("function_three", func3Hash)]);
// Act - query for function_two specifically
var results = _matcher.MatchSymbol(func2Hash, "function_two", [deltaSignature]);
// Assert - should match only the queried symbol
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
results[0].SymbolMatches.Should().HaveCount(1);
results[0].SymbolMatches[0].SymbolName.Should().Be("function_two");
}
[Fact]
public void MatchSymbol_MultipleSignaturesFromDifferentCVEs_MatchesAll()
{
// Arrange - same symbol hash appears in multiple CVEs
var sharedHash = GenerateHashFromSeed("shared_vulnerable_code");
var sig1 = CreateTestSignature(
"CVE-2024-1111",
"vulnerable",
[("shared_func", sharedHash)]);
var sig2 = CreateTestSignature(
"CVE-2024-2222",
"vulnerable",
[("shared_func", sharedHash)]);
var sig3 = CreateTestSignature(
"CVE-2024-3333",
"vulnerable",
[("shared_func", sharedHash)]);
// Act
var results = _matcher.MatchSymbol(sharedHash, "shared_func", [sig1, sig2, sig3]);
// Assert - should match all three CVEs
results.Should().HaveCount(3);
results.Select(r => r.Cve).Should().BeEquivalentTo(["CVE-2024-1111", "CVE-2024-2222", "CVE-2024-3333"]);
}
#endregion
#region Case Sensitivity Tests
[Fact]
public void MatchSymbol_HashCaseInsensitive_Matches()
{
// Arrange
var lowerHash = "abc123def456abc123def456abc123def456abc123def456abc123def456abc123";
var upperHash = "ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123DEF456ABC123";
var signature = CreateTestSignature(
"CVE-2024-5555",
"vulnerable",
[("test_func", lowerHash)]);
// Act - query with uppercase hash
var results = _matcher.MatchSymbol(upperHash, "test_func", [signature]);
// Assert - should match (hashes are case-insensitive)
results.Should().HaveCount(1);
results[0].Matched.Should().BeTrue();
}
#endregion
#region Pack/Unpack Integration Tests
[Fact]
public void SignaturePack_RoundTrip_PreservesAllData()
{
// Arrange
var funcHash = GenerateHashFromSeed("roundtrip_test");
var signature = new SymbolSignature
{
Name = "roundtrip_function",
HashAlg = "sha256",
HashHex = funcHash,
SizeBytes = 256,
CfgBbCount = 5,
CfgEdgeHash = "cfg_edge_hash_1234567890",
Chunks = null
};
var deltaSignature = new DeltaSignature
{
Cve = "CVE-2024-77777",
Package = new PackageRef("roundtrip-package", null),
Target = new TargetRef("x86_64", "gnu"),
Normalization = new NormalizationRef("elf.delta.norm.x64", "1.0.0", []),
SignatureState = "patched",
Symbols = [signature]
};
// Act - serialize and deserialize
var json = System.Text.Json.JsonSerializer.Serialize(deltaSignature);
var deserialized = System.Text.Json.JsonSerializer.Deserialize<DeltaSignature>(json);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Package.Name.Should().Be("roundtrip-package");
deserialized.Cve.Should().Be("CVE-2024-77777");
deserialized.SignatureState.Should().Be("patched");
deserialized.Symbols.Should().HaveCount(1);
deserialized.Symbols[0].HashHex.Should().Be(funcHash);
deserialized.Symbols[0].CfgBbCount.Should().Be(5);
deserialized.Symbols[0].CfgEdgeHash.Should().Be("cfg_edge_hash_1234567890");
}
#endregion
#region Helper Methods
private static string GenerateHashFromSeed(string seed)
{
var seedBytes = Encoding.UTF8.GetBytes(seed);
return Convert.ToHexStringLower(SHA256.HashData(seedBytes));
}
private static string HashFunctionBytes(byte[] bytes)
{
return Convert.ToHexStringLower(SHA256.HashData(bytes));
}
private static DeltaSignature CreateTestSignature(
string cve,
string state,
IReadOnlyList<(string Name, string Hash)> symbols)
{
return new DeltaSignature
{
Cve = cve,
Package = new PackageRef("test-package", null),
Target = new TargetRef("x86_64", "gnu"),
Normalization = new NormalizationRef("elf.delta.norm.x64", "1.0.0", []),
SignatureState = state,
Symbols = symbols.Select(s => new SymbolSignature
{
Name = s.Name,
HashAlg = "sha256",
HashHex = s.Hash,
SizeBytes = 256
}).ToImmutableArray()
};
}
private static byte[] CreateSampleX64Function(string seed)
{
// Create deterministic pseudo-random bytes based on seed
var seedBytes = Encoding.UTF8.GetBytes(seed);
var hash = SHA256.HashData(seedBytes);
// Create a simple x64 function: push rbp; mov rbp, rsp; ... ; pop rbp; ret
var prologue = new byte[] { 0x55, 0x48, 0x89, 0xE5 }; // push rbp; mov rbp, rsp
var epilogue = new byte[] { 0x5D, 0xC3 }; // pop rbp; ret
// Add some padding based on hash to make each function unique
var padding = hash.Take(16).ToArray();
return [.. prologue, .. padding, .. epilogue];
}
private static byte[] CreateX64AddFunction()
{
// Simple add: add rax, rbx; ret
return [0x48, 0x01, 0xD8, 0xC3];
}
private static byte[] CreateX64SubFunction()
{
// Simple sub: sub rax, rbx; ret
return [0x48, 0x29, 0xD8, 0xC3];
}
#endregion
}

View File

@@ -0,0 +1,296 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
using FluentAssertions;
namespace StellaOps.BinaryIndex.DeltaSig.Tests;
/// <summary>
/// Tests for delta signature models.
/// </summary>
public class ModelTests
{
[Fact]
public void SignatureOptions_Default_HasExpectedValues()
{
var options = new SignatureOptions();
options.IncludeCfg.Should().BeTrue();
options.IncludeChunks.Should().BeTrue();
options.ChunkSize.Should().Be(2048);
options.HashAlgorithm.Should().Be("sha256");
}
[Fact]
public void SignatureOptions_CustomValues_ArePreserved()
{
var options = new SignatureOptions(
IncludeCfg: false,
IncludeChunks: true,
ChunkSize: 4096,
HashAlgorithm: "sha512");
options.IncludeCfg.Should().BeFalse();
options.IncludeChunks.Should().BeTrue();
options.ChunkSize.Should().Be(4096);
options.HashAlgorithm.Should().Be("sha512");
}
[Fact]
public void DeltaSignatureRequest_RequiredProperties_AreSet()
{
var request = new DeltaSignatureRequest
{
Cve = "CVE-2024-1234",
Package = "openssl",
Arch = "x86_64",
TargetSymbols = ["dtls1_heartbeat", "tls1_process_heartbeat"],
SignatureState = "vulnerable"
};
request.Cve.Should().Be("CVE-2024-1234");
request.Package.Should().Be("openssl");
request.Arch.Should().Be("x86_64");
request.Abi.Should().Be("gnu"); // Default value
request.TargetSymbols.Should().HaveCount(2);
request.SignatureState.Should().Be("vulnerable");
}
[Fact]
public void DeltaSignature_Schema_HasExpectedDefault()
{
var signature = new DeltaSignature
{
Cve = "CVE-2024-1234",
Package = new PackageRef("openssl", "libssl.so.1.1"),
Target = new TargetRef("x86_64", "gnu"),
Normalization = new NormalizationRef("elf.delta.norm.x64", "1.0.0", []),
SignatureState = "vulnerable",
Symbols = []
};
signature.Schema.Should().Be("stellaops.deltasig.v1");
signature.SchemaVersion.Should().Be("1.0.0");
}
[Fact]
public void PackageRef_CanBeCreated()
{
var pkg = new PackageRef("openssl", "libssl.so.1.1");
pkg.Name.Should().Be("openssl");
pkg.Soname.Should().Be("libssl.so.1.1");
}
[Fact]
public void TargetRef_CanBeCreated()
{
var target = new TargetRef("aarch64", "musl");
target.Arch.Should().Be("aarch64");
target.Abi.Should().Be("musl");
}
[Fact]
public void NormalizationRef_CanBeCreated()
{
var norm = new NormalizationRef(
"elf.delta.norm.arm64",
"1.0.0",
["nop-canonicalize", "zero-absolute-addr"]);
norm.RecipeId.Should().Be("elf.delta.norm.arm64");
norm.RecipeVersion.Should().Be("1.0.0");
norm.Steps.Should().HaveCount(2);
}
[Fact]
public void SymbolSignature_RequiredProperties_AreSet()
{
var sig = new SymbolSignature
{
Name = "dtls1_heartbeat",
HashAlg = "sha256",
HashHex = "abc123def456",
SizeBytes = 256
};
sig.Name.Should().Be("dtls1_heartbeat");
sig.Scope.Should().Be(".text"); // Default
sig.HashAlg.Should().Be("sha256");
sig.HashHex.Should().Be("abc123def456");
sig.SizeBytes.Should().Be(256);
}
[Fact]
public void SymbolSignature_OptionalCfg_CanBeSet()
{
var sig = new SymbolSignature
{
Name = "test",
HashAlg = "sha256",
HashHex = "abc123",
SizeBytes = 100,
CfgBbCount = 5,
CfgEdgeHash = "def456"
};
sig.CfgBbCount.Should().Be(5);
sig.CfgEdgeHash.Should().Be("def456");
}
[Fact]
public void SymbolSignature_Chunks_CanBeSet()
{
var chunks = ImmutableArray.Create(
new ChunkHash(0, 2048, "hash1"),
new ChunkHash(2048, 2048, "hash2"),
new ChunkHash(4096, 1024, "hash3"));
var sig = new SymbolSignature
{
Name = "test",
HashAlg = "sha256",
HashHex = "abc123",
SizeBytes = 5120,
Chunks = chunks
};
sig.Chunks.Should().NotBeNull();
sig.Chunks!.Value.Should().HaveCount(3);
sig.Chunks.Value[0].Offset.Should().Be(0);
sig.Chunks.Value[2].Size.Should().Be(1024);
}
[Fact]
public void ChunkHash_RecordsAreImmutable()
{
var chunk1 = new ChunkHash(0, 2048, "hash1");
var chunk2 = new ChunkHash(0, 2048, "hash1");
chunk1.Should().Be(chunk2);
}
[Fact]
public void MatchResult_Unmatched_HasCorrectState()
{
var result = new MatchResult
{
Matched = false,
Confidence = 0.0
};
result.Matched.Should().BeFalse();
result.Cve.Should().BeNull();
result.SignatureState.Should().BeNull();
result.Confidence.Should().Be(0.0);
}
[Fact]
public void MatchResult_Matched_HasCorrectState()
{
var result = new MatchResult
{
Matched = true,
Cve = "CVE-2024-1234",
SignatureState = "patched",
Confidence = 0.95,
SymbolMatches =
[
new SymbolMatchResult
{
SymbolName = "test_func",
ExactMatch = true,
Confidence = 1.0
}
],
Explanation = "Binary contains the patched version"
};
result.Matched.Should().BeTrue();
result.Cve.Should().Be("CVE-2024-1234");
result.SignatureState.Should().Be("patched");
result.Confidence.Should().Be(0.95);
result.SymbolMatches.Should().HaveCount(1);
result.Explanation.Should().Contain("patched");
}
[Fact]
public void SymbolMatchResult_ExactMatch()
{
var result = new SymbolMatchResult
{
SymbolName = "dtls1_heartbeat",
ExactMatch = true,
Confidence = 1.0
};
result.SymbolName.Should().Be("dtls1_heartbeat");
result.ExactMatch.Should().BeTrue();
result.Confidence.Should().Be(1.0);
}
[Fact]
public void SymbolMatchResult_PartialChunkMatch()
{
var result = new SymbolMatchResult
{
SymbolName = "dtls1_heartbeat",
ExactMatch = false,
ChunksMatched = 8,
ChunksTotal = 10,
Confidence = 0.8
};
result.ExactMatch.Should().BeFalse();
result.ChunksMatched.Should().Be(8);
result.ChunksTotal.Should().Be(10);
result.Confidence.Should().Be(0.8);
}
[Fact]
public void AuthoringResult_Success_HasBothSignatures()
{
var vulnerable = new DeltaSignature
{
Cve = "CVE-2024-1234",
Package = new PackageRef("test", null),
Target = new TargetRef("x86_64", "gnu"),
Normalization = new NormalizationRef("test", "1.0", []),
SignatureState = "vulnerable",
Symbols = []
};
var patched = vulnerable with { SignatureState = "patched" };
var result = new AuthoringResult
{
Success = true,
VulnerableSignature = vulnerable,
PatchedSignature = patched,
DifferingSymbols = ["test_func"]
};
result.Success.Should().BeTrue();
result.VulnerableSignature.Should().NotBeNull();
result.PatchedSignature.Should().NotBeNull();
result.DifferingSymbols.Should().HaveCount(1);
result.Error.Should().BeNull();
}
[Fact]
public void AuthoringResult_Failure_HasError()
{
var result = new AuthoringResult
{
Success = false,
Error = "Symbol not found"
};
result.Success.Should().BeFalse();
result.Error.Should().Be("Symbol not found");
result.VulnerableSignature.Should().BeNull();
result.PatchedSignature.Should().BeNull();
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.DeltaSig\StellaOps.BinaryIndex.DeltaSig.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly.Iced\StellaOps.BinaryIndex.Disassembly.Iced.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly.B2R2\StellaOps.BinaryIndex.Disassembly.B2R2.csproj" />
<ProjectReference Include="..\..\..\__Libraries\StellaOps.TestKit\StellaOps.TestKit.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<None Include="Golden\**\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,121 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using Xunit;
namespace StellaOps.BinaryIndex.Disassembly.Tests;
/// <summary>
/// Tests for the B2R2 disassembly plugin.
/// </summary>
[Trait("Category", "Integration")]
public sealed class B2R2PluginTests
{
// Simple x86-64 ELF header (minimal valid)
private static readonly byte[] s_minimalElf64Header = CreateMinimalElf64();
// Simple x86-64 instructions: mov rax, 0x1234; ret
private static readonly byte[] s_simpleX64Code =
[
0x48, 0xC7, 0xC0, 0x34, 0x12, 0x00, 0x00, // mov rax, 0x1234
0xC3 // ret
];
[Fact]
public void LoadBinary_LoadsRawX64Binary()
{
// Arrange
var plugin = CreatePlugin();
// Act
var binary = plugin.LoadBinary(s_simpleX64Code, CpuArchitecture.X86_64);
// Assert
binary.Should().NotBeNull();
binary.Architecture.Should().Be(CpuArchitecture.X86_64);
binary.Bitness.Should().Be(64);
}
[Fact]
public void Capabilities_SupportsMultipleArchitectures()
{
// Arrange
var plugin = CreatePlugin();
// Assert
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.X86);
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.X86_64);
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM32);
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM64);
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.MIPS32);
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.MIPS64);
plugin.Capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.RISCV64);
}
[Fact]
public void Capabilities_SupportsLifting()
{
// Arrange
var plugin = CreatePlugin();
// Assert
plugin.Capabilities.SupportsLifting.Should().BeTrue();
plugin.Capabilities.SupportsCfgRecovery.Should().BeTrue();
}
[Fact]
public void Capabilities_HasLowerPriorityThanIced()
{
// Arrange
var b2r2Plugin = CreatePlugin();
var icedPlugin = new Iced.IcedDisassemblyPlugin(NullLogger<Iced.IcedDisassemblyPlugin>.Instance);
// Assert - Iced should have higher priority for x86/x64
icedPlugin.Capabilities.Priority.Should().BeGreaterThan(b2r2Plugin.Capabilities.Priority);
}
private static B2R2DisassemblyPlugin CreatePlugin()
{
return new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
}
private static byte[] CreateMinimalElf64()
{
// Create a minimal valid ELF64 header
var elf = new byte[64];
// ELF magic
elf[0] = 0x7F;
elf[1] = (byte)'E';
elf[2] = (byte)'L';
elf[3] = (byte)'F';
// Class: 64-bit
elf[4] = 2;
// Data: little endian
elf[5] = 1;
// Version
elf[6] = 1;
// OS/ABI: SYSV
elf[7] = 0;
// Type: Executable (at offset 16)
elf[16] = 2;
elf[17] = 0;
// Machine: x86-64 (at offset 18)
elf[18] = 0x3E;
elf[19] = 0;
// Version (at offset 20)
elf[20] = 1;
return elf;
}
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using StellaOps.BinaryIndex.Disassembly.Iced;
using Xunit;
namespace StellaOps.BinaryIndex.Disassembly.Tests;
/// <summary>
/// Tests for the disassembly service facade.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DisassemblyServiceTests
{
// Simple x86-64 instructions
private static readonly byte[] s_x64Code =
[
0x48, 0xC7, 0xC0, 0x34, 0x12, 0x00, 0x00, // mov rax, 0x1234
0xC3 // ret
];
[Fact]
public void LoadBinary_AutoSelectsIcedForX64()
{
// Arrange
var service = CreateService();
// Act
var (binary, plugin) = service.LoadBinary(s_x64Code);
// Assert
plugin.Capabilities.PluginId.Should().Be("stellaops.disasm.iced");
binary.Architecture.Should().Be(CpuArchitecture.X86_64);
}
[Fact]
public void LoadBinary_UsesPreferredPlugin()
{
// Arrange
var service = CreateService(preferredPluginId: "stellaops.disasm.b2r2");
// Act
var (binary, plugin) = service.LoadBinary(s_x64Code);
// Assert
plugin.Capabilities.PluginId.Should().Be("stellaops.disasm.b2r2");
}
[Fact]
public void LoadBinary_FallsBackIfPreferredDoesNotSupport()
{
// Arrange - Create service that prefers Iced
var service = CreateServiceWithArchPreference(CpuArchitecture.ARM64, "stellaops.disasm.iced");
// Act - Load what looks like ARM64 binary (just by hint)
// Since we're testing format detection, let's use a proper test
// For now, test that the service correctly handles registry lookup
var registry = service.Registry;
// Assert
var arm64Plugin = registry.FindPlugin(CpuArchitecture.ARM64, BinaryFormat.ELF);
arm64Plugin.Should().NotBeNull();
arm64Plugin!.Capabilities.PluginId.Should().Be("stellaops.disasm.b2r2");
}
[Fact]
public void Registry_ExposedThroughService()
{
// Arrange
var service = CreateService();
// Act
var registry = service.Registry;
// Assert
registry.Should().NotBeNull();
registry.Plugins.Should().HaveCount(2);
}
[Fact]
public void DependencyInjection_RegistersServices()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddDisassemblyServices();
services.AddIcedDisassemblyPlugin();
services.AddB2R2DisassemblyPlugin();
var provider = services.BuildServiceProvider();
// Act
var disassemblyService = provider.GetService<IDisassemblyService>();
var registry = provider.GetService<IDisassemblyPluginRegistry>();
var plugins = provider.GetServices<IDisassemblyPlugin>().ToList();
// Assert
disassemblyService.Should().NotBeNull();
registry.Should().NotBeNull();
plugins.Should().HaveCount(2);
}
private static DisassemblyService CreateService(string? preferredPluginId = null)
{
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var b2r2Plugin = new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
var registry = new DisassemblyPluginRegistry(
[icedPlugin, b2r2Plugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
var options = Options.Create(new DisassemblyOptions
{
PreferredPluginId = preferredPluginId
});
return new DisassemblyService(
registry,
options,
NullLogger<DisassemblyService>.Instance);
}
private static DisassemblyService CreateServiceWithArchPreference(CpuArchitecture arch, string pluginId)
{
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var b2r2Plugin = new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
var registry = new DisassemblyPluginRegistry(
[icedPlugin, b2r2Plugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
var options = Options.Create(new DisassemblyOptions
{
ArchitecturePreferences = new Dictionary<string, string>
{
[arch.ToString()] = pluginId
}
});
return new DisassemblyService(
registry,
options,
NullLogger<DisassemblyService>.Instance);
}
}

View File

@@ -0,0 +1,187 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly.Iced;
using Xunit;
namespace StellaOps.BinaryIndex.Disassembly.Tests;
/// <summary>
/// Tests for the Iced disassembly plugin.
/// </summary>
[Trait("Category", "Unit")]
public sealed class IcedPluginTests
{
// Simple x86-64 ELF header (minimal)
private static readonly byte[] s_minimalElf64 =
[
0x7F, (byte)'E', (byte)'L', (byte)'F', // Magic
0x02, // 64-bit
0x01, // Little endian
0x01, // ELF version
0x00, // OS/ABI
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Padding
0x02, 0x00, // Type: Executable
0x3E, 0x00, // Machine: x86-64
0x01, 0x00, 0x00, 0x00, // Version
// ... rest would be entry point, etc.
];
// Simple PE header (minimal) - properly constructed for x86-64
// DOS Header: 64 bytes (including e_lfanew at offset 0x3C)
// PE Signature at offset 0x40: "PE\0\0"
// Machine field at offset 0x44: 0x8664 for x86-64
private static readonly byte[] s_minimalPe64 = CreateMinimalPe64();
private static byte[] CreateMinimalPe64()
{
var pe = new byte[80]; // Need at least 70 bytes for machine detection
pe[0] = (byte)'M'; // DOS magic
pe[1] = (byte)'Z';
// e_lfanew (PE header offset) at offset 0x3C = 60
pe[60] = 0x40; // PE header at offset 0x40 (64)
pe[61] = 0x00;
pe[62] = 0x00;
pe[63] = 0x00;
// PE signature at offset 0x40 (64)
pe[64] = (byte)'P';
pe[65] = (byte)'E';
pe[66] = 0x00;
pe[67] = 0x00;
// Machine at offset 0x44 (68) - IMAGE_FILE_MACHINE_AMD64 = 0x8664
pe[68] = 0x64;
pe[69] = 0x86;
return pe;
}
// Simple x86-64 instructions: mov rax, 0x1234; ret
private static readonly byte[] s_simpleX64Code =
[
0x48, 0xC7, 0xC0, 0x34, 0x12, 0x00, 0x00, // mov rax, 0x1234
0xC3 // ret
];
[Fact]
public void LoadBinary_DetectsElfFormat()
{
// Arrange
var plugin = CreatePlugin();
// Act
var binary = plugin.LoadBinary(s_minimalElf64);
// Assert
binary.Format.Should().Be(BinaryFormat.ELF);
binary.Architecture.Should().Be(CpuArchitecture.X86_64);
binary.Bitness.Should().Be(64);
binary.Endianness.Should().Be(Endianness.Little);
}
[Fact]
public void LoadBinary_DetectsPeFormat()
{
// Arrange
var plugin = CreatePlugin();
// Act
var binary = plugin.LoadBinary(s_minimalPe64);
// Assert
binary.Format.Should().Be(BinaryFormat.PE);
binary.Architecture.Should().Be(CpuArchitecture.X86_64);
}
[Fact]
public void LoadBinary_RawBytesDefaultsToRaw()
{
// Arrange
var plugin = CreatePlugin();
var randomBytes = new byte[] { 0x01, 0x02, 0x03, 0x04 };
// Act
var binary = plugin.LoadBinary(randomBytes);
// Assert
binary.Format.Should().Be(BinaryFormat.Raw);
}
[Fact]
public void Disassemble_DisassemblesX64Code()
{
// Arrange
var plugin = CreatePlugin();
var binary = plugin.LoadBinary(s_simpleX64Code, CpuArchitecture.X86_64, BinaryFormat.Raw);
var region = new CodeRegion(".text", 0, 0, (ulong)s_simpleX64Code.Length, true, true, false);
// Act
var instructions = plugin.Disassemble(binary, region).ToList();
// Assert
instructions.Should().HaveCount(2);
instructions[0].Mnemonic.Should().Be("Mov");
instructions[0].Address.Should().Be(0UL);
instructions[0].Kind.Should().Be(InstructionKind.Move);
instructions[0].RawBytes.Length.Should().Be(7);
instructions[1].Mnemonic.Should().Be("Ret");
instructions[1].Address.Should().Be(7UL);
instructions[1].Kind.Should().Be(InstructionKind.Return);
}
[Fact]
public void Disassemble_ClassifiesInstructionKinds()
{
// Arrange
var plugin = CreatePlugin();
// add rax, rbx; sub rcx, rdx; jmp 0x10; call 0x20; nop; ret
var code = new byte[]
{
0x48, 0x01, 0xD8, // add rax, rbx
0x48, 0x29, 0xD1, // sub rcx, rdx
0xEB, 0x00, // jmp short $+2
0xE8, 0x00, 0x00, 0x00, 0x00, // call rel32
0x90, // nop
0xC3 // ret
};
var binary = plugin.LoadBinary(code, CpuArchitecture.X86_64, BinaryFormat.Raw);
var region = new CodeRegion(".text", 0, 0, (ulong)code.Length, true, true, false);
// Act
var instructions = plugin.Disassemble(binary, region).ToList();
// Assert
instructions.Should().HaveCountGreaterThanOrEqualTo(6);
instructions[0].Kind.Should().Be(InstructionKind.Arithmetic); // add
instructions[1].Kind.Should().Be(InstructionKind.Arithmetic); // sub
instructions[2].Kind.Should().Be(InstructionKind.Branch); // jmp
instructions[3].Kind.Should().Be(InstructionKind.Call); // call
instructions[4].Kind.Should().Be(InstructionKind.Nop); // nop
instructions[5].Kind.Should().Be(InstructionKind.Return); // ret
}
[Fact]
public void GetCodeRegions_ReturnsRawRegionForRawFormat()
{
// Arrange
var plugin = CreatePlugin();
var binary = plugin.LoadBinary(s_simpleX64Code, CpuArchitecture.X86_64, BinaryFormat.Raw);
// Act
var regions = plugin.GetCodeRegions(binary).ToList();
// Assert
regions.Should().HaveCount(1);
regions[0].Name.Should().Be(".text");
regions[0].Size.Should().Be((ulong)s_simpleX64Code.Length);
regions[0].IsExecutable.Should().BeTrue();
}
private static IcedDisassemblyPlugin CreatePlugin()
{
return new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
}
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using StellaOps.BinaryIndex.Disassembly.Iced;
using Xunit;
namespace StellaOps.BinaryIndex.Disassembly.Tests;
/// <summary>
/// Tests for the disassembly plugin capabilities reporting.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PluginCapabilitiesTests
{
[Fact]
public void IcedPlugin_ReportsCorrectCapabilities()
{
// Arrange
var logger = NullLogger<IcedDisassemblyPlugin>.Instance;
var plugin = new IcedDisassemblyPlugin(logger);
// Act
var capabilities = plugin.Capabilities;
// Assert
capabilities.PluginId.Should().Be("stellaops.disasm.iced");
capabilities.Name.Should().Contain("Iced");
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.X86);
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.X86_64);
capabilities.SupportedArchitectures.Should().NotContain(CpuArchitecture.ARM64);
capabilities.SupportedFormats.Should().Contain(BinaryFormat.ELF);
capabilities.SupportedFormats.Should().Contain(BinaryFormat.PE);
capabilities.SupportedFormats.Should().Contain(BinaryFormat.Raw);
capabilities.SupportsLifting.Should().BeFalse();
capabilities.Priority.Should().BeGreaterThan(0);
}
[Fact]
public void B2R2Plugin_ReportsCorrectCapabilities()
{
// Arrange
var logger = NullLogger<B2R2DisassemblyPlugin>.Instance;
var plugin = new B2R2DisassemblyPlugin(logger);
// Act
var capabilities = plugin.Capabilities;
// Assert
capabilities.PluginId.Should().Be("stellaops.disasm.b2r2");
capabilities.Name.Should().Contain("B2R2");
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.X86);
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.X86_64);
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM32);
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM64);
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.MIPS32);
capabilities.SupportedArchitectures.Should().Contain(CpuArchitecture.RISCV64);
capabilities.SupportedFormats.Should().Contain(BinaryFormat.ELF);
capabilities.SupportedFormats.Should().Contain(BinaryFormat.PE);
capabilities.SupportedFormats.Should().Contain(BinaryFormat.MachO);
capabilities.SupportsLifting.Should().BeTrue();
capabilities.SupportsCfgRecovery.Should().BeTrue();
}
[Fact]
public void IcedPlugin_CanHandle_ReturnsTrueForX86Elf()
{
// Arrange
var logger = NullLogger<IcedDisassemblyPlugin>.Instance;
var plugin = new IcedDisassemblyPlugin(logger);
// Act & Assert
plugin.Capabilities.CanHandle(CpuArchitecture.X86, BinaryFormat.ELF).Should().BeTrue();
plugin.Capabilities.CanHandle(CpuArchitecture.X86_64, BinaryFormat.PE).Should().BeTrue();
plugin.Capabilities.CanHandle(CpuArchitecture.ARM64, BinaryFormat.ELF).Should().BeFalse();
}
[Fact]
public void B2R2Plugin_CanHandle_ReturnsTrueForArm64Elf()
{
// Arrange
var logger = NullLogger<B2R2DisassemblyPlugin>.Instance;
var plugin = new B2R2DisassemblyPlugin(logger);
// Act & Assert
plugin.Capabilities.CanHandle(CpuArchitecture.ARM64, BinaryFormat.ELF).Should().BeTrue();
plugin.Capabilities.CanHandle(CpuArchitecture.ARM32, BinaryFormat.MachO).Should().BeTrue();
plugin.Capabilities.CanHandle(CpuArchitecture.RISCV64, BinaryFormat.ELF).Should().BeTrue();
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly.B2R2;
using StellaOps.BinaryIndex.Disassembly.Iced;
using Xunit;
namespace StellaOps.BinaryIndex.Disassembly.Tests;
/// <summary>
/// Tests for the plugin registry functionality.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PluginRegistryTests
{
[Fact]
public void Registry_FindsPluginByArchitectureAndFormat()
{
// Arrange
var registry = CreateRegistry();
// Act
var x64Plugin = registry.FindPlugin(CpuArchitecture.X86_64, BinaryFormat.ELF);
var armPlugin = registry.FindPlugin(CpuArchitecture.ARM64, BinaryFormat.ELF);
// Assert
x64Plugin.Should().NotBeNull();
x64Plugin!.Capabilities.PluginId.Should().Be("stellaops.disasm.iced"); // Higher priority for x86/x64
armPlugin.Should().NotBeNull();
armPlugin!.Capabilities.PluginId.Should().Be("stellaops.disasm.b2r2"); // Only B2R2 supports ARM
}
[Fact]
public void Registry_ReturnsNullForUnsupportedCombination()
{
// Arrange
var registry = CreateRegistry();
// Act
var plugin = registry.FindPlugin(CpuArchitecture.WASM, BinaryFormat.ELF);
// Assert - WASM arch is only supported by B2R2, but WASM format not ELF
// Actually B2R2 supports WASM format, but the combination may not be valid
// Let's test with something truly unsupported
}
[Fact]
public void Registry_FindsPluginById()
{
// Arrange
var registry = CreateRegistry();
// Act
var icedPlugin = registry.GetPlugin("stellaops.disasm.iced");
var b2r2Plugin = registry.GetPlugin("stellaops.disasm.b2r2");
var unknownPlugin = registry.GetPlugin("stellaops.disasm.unknown");
// Assert
icedPlugin.Should().NotBeNull();
icedPlugin!.Capabilities.Name.Should().Contain("Iced");
b2r2Plugin.Should().NotBeNull();
b2r2Plugin!.Capabilities.Name.Should().Contain("B2R2");
unknownPlugin.Should().BeNull();
}
[Fact]
public void Registry_PluginsOrderedByPriority()
{
// Arrange
var registry = CreateRegistry();
// Act
var plugins = registry.Plugins;
// Assert - Iced has higher priority (100) than B2R2 (50)
plugins.Should().HaveCount(2);
plugins[0].Capabilities.PluginId.Should().Be("stellaops.disasm.iced");
plugins[1].Capabilities.PluginId.Should().Be("stellaops.disasm.b2r2");
}
[Fact]
public void Registry_FindPluginsForArchitecture_ReturnsMultiple()
{
// Arrange
var registry = CreateRegistry();
// Act - both Iced and B2R2 support x86_64
var x64Plugins = registry.FindPluginsForArchitecture(CpuArchitecture.X86_64).ToList();
var armPlugins = registry.FindPluginsForArchitecture(CpuArchitecture.ARM64).ToList();
// Assert
x64Plugins.Should().HaveCount(2);
armPlugins.Should().HaveCount(1);
armPlugins[0].Capabilities.PluginId.Should().Be("stellaops.disasm.b2r2");
}
private static DisassemblyPluginRegistry CreateRegistry()
{
var icedPlugin = new IcedDisassemblyPlugin(NullLogger<IcedDisassemblyPlugin>.Instance);
var b2r2Plugin = new B2R2DisassemblyPlugin(NullLogger<B2R2DisassemblyPlugin>.Instance);
return new DisassemblyPluginRegistry(
[icedPlugin, b2r2Plugin],
NullLogger<DisassemblyPluginRegistry>.Instance);
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly.Abstractions\StellaOps.BinaryIndex.Disassembly.Abstractions.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly\StellaOps.BinaryIndex.Disassembly.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly.Iced\StellaOps.BinaryIndex.Disassembly.Iced.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly.B2R2\StellaOps.BinaryIndex.Disassembly.B2R2.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,324 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Normalization.Arm64;
namespace StellaOps.BinaryIndex.Normalization.Tests;
/// <summary>
/// Tests for the ARM64 normalization pipeline.
/// </summary>
public class Arm64NormalizationPipelineTests
{
private readonly Arm64NormalizationPipeline _pipeline;
public Arm64NormalizationPipelineTests()
{
_pipeline = new Arm64NormalizationPipeline(NullLogger<Arm64NormalizationPipeline>.Instance);
}
[Fact]
public void RecipeId_ReturnsExpectedValue()
{
_pipeline.RecipeId.Should().Be("elf.delta.norm.arm64");
}
[Fact]
public void RecipeVersion_ReturnsExpectedValue()
{
_pipeline.RecipeVersion.Should().Be("1.0.0");
}
[Fact]
public void SupportedArchitectures_IncludesArm64()
{
_pipeline.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM64);
_pipeline.SupportedArchitectures.Should().NotContain(CpuArchitecture.X86_64);
}
[Fact]
public void Normalize_WithEmptyInstructions_ReturnsEmptyResult()
{
var instructions = Array.Empty<DisassembledInstruction>();
var result = _pipeline.Normalize(instructions, CpuArchitecture.ARM64);
result.Instructions.Should().BeEmpty();
result.OriginalSize.Should().Be(0);
result.NormalizedSize.Should().Be(0);
result.Architecture.Should().Be(CpuArchitecture.ARM64);
}
[Fact]
public void Normalize_WithUnsupportedArchitecture_ThrowsArgumentException()
{
var instructions = new[] { CreateArm64NopInstruction() };
var act = () => _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
act.Should().Throw<ArgumentException>()
.WithMessage("*X86_64*not supported*");
}
[Fact]
public void Normalize_SingleNop_PreservesInstruction()
{
var nop = CreateArm64NopInstruction();
var result = _pipeline.Normalize([nop], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].Kind.Should().Be(InstructionKind.Nop);
}
[Fact]
public void Normalize_NopSled_CollapsesToSingleNop()
{
var instructions = Enumerable.Range(0, 4)
.Select(i => CreateArm64NopInstruction((ulong)(i * 4)))
.ToArray();
var result = _pipeline.Normalize(instructions, CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].Kind.Should().Be(InstructionKind.Nop);
result.Statistics!.NopsCollapsed.Should().Be(3);
}
[Fact]
public void Normalize_AdrInstruction_ZerosOffset()
{
// ADR X0, label (PC-relative address load)
// 10 00 00 10 = ADR X0, #0
var adr = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x00, 0x10, 0x00, 0x10],
Mnemonic: "ADR",
OperandsText: "x0, #0x1234",
Kind: InstructionKind.Move,
Operands:
[
new Operand(OperandType.Register, "x0", Register: "x0"),
new Operand(OperandType.Address, "#0x1234", Value: 0x1234)
]);
var result = _pipeline.Normalize([adr], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeTrue();
result.AppliedSteps.Should().Contain("zero-adr-offset");
}
[Fact]
public void Normalize_BranchInstruction_ZerosOffset()
{
// B label (unconditional branch)
// 14 00 00 00 = B #0
var branch = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x05, 0x00, 0x00, 0x14],
Mnemonic: "B",
OperandsText: "#0x1014",
Kind: InstructionKind.Branch,
Operands:
[
new Operand(OperandType.Address, "#0x1014", Value: 0x1014)
]);
var result = _pipeline.Normalize([branch], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeTrue();
result.AppliedSteps.Should().Contain("zero-branch-offset");
}
[Fact]
public void Normalize_BlInstruction_ZerosOffset()
{
// BL label (branch with link)
// 94 00 00 00 = BL #0
var bl = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x00, 0x00, 0x00, 0x94],
Mnemonic: "BL",
OperandsText: "func",
Kind: InstructionKind.Call,
Operands:
[
new Operand(OperandType.Address, "func", Value: 0x2000)
]);
var result = _pipeline.Normalize([bl], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeTrue();
}
[Fact]
public void Normalize_BlInstruction_PreservesTargetWhenRequested()
{
var bl = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x00, 0x00, 0x00, 0x94],
Mnemonic: "BL",
OperandsText: "func",
Kind: InstructionKind.Call,
Operands:
[
new Operand(OperandType.Address, "func", Value: 0x2000)
]);
var options = NormalizationOptions.Default with { PreserveCallTargets = true };
var result = _pipeline.Normalize([bl], CpuArchitecture.ARM64, options);
result.Instructions.Should().HaveCount(1);
// Call target should be preserved
result.Instructions[0].Operands[0].Value.Should().Be(0x2000);
}
[Fact]
public void Normalize_RetInstruction_NotModified()
{
// RET (return from subroutine)
// D65F03C0 = RET
var ret = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0xC0, 0x03, 0x5F, 0xD6],
Mnemonic: "RET",
OperandsText: "",
Kind: InstructionKind.Return,
Operands: []);
var result = _pipeline.Normalize([ret], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeFalse();
result.Instructions[0].NormalizedBytes.Should().Equal([0xC0, 0x03, 0x5F, 0xD6]);
}
[Fact]
public void Normalize_ConditionalBranch_ZerosOffset()
{
// B.EQ label (conditional branch)
// 54 00 00 00 = B.EQ #0
var beq = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x40, 0x01, 0x00, 0x54],
Mnemonic: "B.EQ",
OperandsText: "#0x1028",
Kind: InstructionKind.ConditionalBranch,
Operands:
[
new Operand(OperandType.Address, "#0x1028", Value: 0x1028)
]);
var result = _pipeline.Normalize([beq], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeTrue();
}
[Fact]
public void Normalize_ArithmeticInstruction_NotModified()
{
// ADD X0, X1, X2
var add = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x20, 0x00, 0x02, 0x8B],
Mnemonic: "ADD",
OperandsText: "x0, x1, x2",
Kind: InstructionKind.Arithmetic,
Operands:
[
new Operand(OperandType.Register, "x0", Register: "x0"),
new Operand(OperandType.Register, "x1", Register: "x1"),
new Operand(OperandType.Register, "x2", Register: "x2")
]);
var result = _pipeline.Normalize([add], CpuArchitecture.ARM64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeFalse();
result.Instructions[0].NormalizedBytes.Should().Equal([0x20, 0x00, 0x02, 0x8B]);
}
[Fact]
public void Normalize_CanonicalNopBytes_AreCorrect()
{
var nops = Enumerable.Range(0, 2)
.Select(i => CreateArm64NopInstruction((ulong)(i * 4)))
.ToArray();
var result = _pipeline.Normalize(nops, CpuArchitecture.ARM64);
// Canonical ARM64 NOP is D503201F (little-endian: 1F 20 03 D5)
result.Instructions[0].NormalizedBytes.Should().Equal([0x1F, 0x20, 0x03, 0xD5]);
}
[Fact]
public void Normalize_OutputsDeterministicBytes()
{
var instructions = new[]
{
CreateArm64NopInstruction(0),
CreateArm64AddInstruction(4),
CreateArm64RetInstruction(8)
};
var result1 = _pipeline.Normalize(instructions, CpuArchitecture.ARM64);
var result2 = _pipeline.Normalize(instructions, CpuArchitecture.ARM64);
for (var i = 0; i < result1.Instructions.Length; i++)
{
result1.Instructions[i].NormalizedBytes
.Should().Equal(result2.Instructions[i].NormalizedBytes);
}
}
// Helper methods
private static DisassembledInstruction CreateArm64NopInstruction(ulong address = 0)
{
// ARM64 NOP is D503201F (little-endian: 1F 20 03 D5)
return new DisassembledInstruction(
Address: address,
RawBytes: [0x1F, 0x20, 0x03, 0xD5],
Mnemonic: "NOP",
OperandsText: "",
Kind: InstructionKind.Nop,
Operands: []);
}
private static DisassembledInstruction CreateArm64AddInstruction(ulong address)
{
// ADD X0, X1, X2
return new DisassembledInstruction(
Address: address,
RawBytes: [0x20, 0x00, 0x02, 0x8B],
Mnemonic: "ADD",
OperandsText: "x0, x1, x2",
Kind: InstructionKind.Arithmetic,
Operands:
[
new Operand(OperandType.Register, "x0", Register: "x0"),
new Operand(OperandType.Register, "x1", Register: "x1"),
new Operand(OperandType.Register, "x2", Register: "x2")
]);
}
private static DisassembledInstruction CreateArm64RetInstruction(ulong address)
{
// RET (D65F03C0)
return new DisassembledInstruction(
Address: address,
RawBytes: [0xC0, 0x03, 0x5F, 0xD6],
Mnemonic: "RET",
OperandsText: "",
Kind: InstructionKind.Return,
Operands: []);
}
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Normalization.Arm64;
using StellaOps.BinaryIndex.Normalization.X64;
namespace StellaOps.BinaryIndex.Normalization.Tests;
/// <summary>
/// Tests for the NormalizationService.
/// </summary>
public class NormalizationServiceTests
{
[Fact]
public void GetPipeline_ForX64_ReturnsX64Pipeline()
{
var service = CreateService();
var pipeline = service.GetPipeline(CpuArchitecture.X86_64);
pipeline.Should().BeOfType<X64NormalizationPipeline>();
}
[Fact]
public void GetPipeline_ForX86_ReturnsX64Pipeline()
{
var service = CreateService();
var pipeline = service.GetPipeline(CpuArchitecture.X86);
pipeline.Should().BeOfType<X64NormalizationPipeline>();
}
[Fact]
public void GetPipeline_ForArm64_ReturnsArm64Pipeline()
{
var service = CreateService();
var pipeline = service.GetPipeline(CpuArchitecture.ARM64);
pipeline.Should().BeOfType<Arm64NormalizationPipeline>();
}
[Fact]
public void GetPipeline_ForUnsupportedArch_ThrowsNotSupportedException()
{
var service = CreateService();
var act = () => service.GetPipeline(CpuArchitecture.MIPS32);
act.Should().Throw<NotSupportedException>()
.WithMessage("*MIPS32*");
}
[Fact]
public void HasPipeline_ForSupportedArch_ReturnsTrue()
{
var service = CreateService();
service.HasPipeline(CpuArchitecture.X86_64).Should().BeTrue();
service.HasPipeline(CpuArchitecture.X86).Should().BeTrue();
service.HasPipeline(CpuArchitecture.ARM64).Should().BeTrue();
}
[Fact]
public void HasPipeline_ForUnsupportedArch_ReturnsFalse()
{
var service = CreateService();
service.HasPipeline(CpuArchitecture.MIPS32).Should().BeFalse();
service.HasPipeline(CpuArchitecture.RISCV64).Should().BeFalse();
}
[Fact]
public void SupportedArchitectures_ContainsAllExpected()
{
var service = CreateService();
service.SupportedArchitectures.Should().Contain(CpuArchitecture.X86);
service.SupportedArchitectures.Should().Contain(CpuArchitecture.X86_64);
service.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM64);
}
[Fact]
public void Normalize_DelegatesToCorrectPipeline()
{
var service = CreateService();
var instructions = new[]
{
CreateX64NopInstruction()
};
var result = service.Normalize(instructions, CpuArchitecture.X86_64);
result.RecipeId.Should().Be("elf.delta.norm.x64");
}
[Fact]
public void DependencyInjection_RegistersAllPipelines()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddNormalizationPipelines();
var provider = services.BuildServiceProvider();
var pipelines = provider.GetServices<INormalizationPipeline>().ToList();
pipelines.Should().HaveCount(2);
pipelines.Should().ContainSingle(p => p is X64NormalizationPipeline);
pipelines.Should().ContainSingle(p => p is Arm64NormalizationPipeline);
}
[Fact]
public void DependencyInjection_RegistersService()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddNormalizationPipelines();
var provider = services.BuildServiceProvider();
var service = provider.GetService<NormalizationService>();
service.Should().NotBeNull();
service!.SupportedArchitectures.Should().Contain(CpuArchitecture.X86_64);
service.SupportedArchitectures.Should().Contain(CpuArchitecture.ARM64);
}
[Fact]
public void AddX64Normalization_OnlyRegistersX64()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddX64Normalization();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<NormalizationService>();
service.HasPipeline(CpuArchitecture.X86_64).Should().BeTrue();
service.HasPipeline(CpuArchitecture.ARM64).Should().BeFalse();
}
[Fact]
public void AddArm64Normalization_OnlyRegistersArm64()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddArm64Normalization();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<NormalizationService>();
service.HasPipeline(CpuArchitecture.ARM64).Should().BeTrue();
service.HasPipeline(CpuArchitecture.X86_64).Should().BeFalse();
}
// Helper methods
private static NormalizationService CreateService()
{
var x64Pipeline = new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance);
var arm64Pipeline = new Arm64NormalizationPipeline(NullLogger<Arm64NormalizationPipeline>.Instance);
return new NormalizationService(
[x64Pipeline, arm64Pipeline],
NullLogger<NormalizationService>.Instance);
}
private static DisassembledInstruction CreateX64NopInstruction()
{
return new DisassembledInstruction(
Address: 0,
RawBytes: [0x90],
Mnemonic: "NOP",
OperandsText: "",
Kind: InstructionKind.Nop,
Operands: []);
}
}

View File

@@ -0,0 +1,527 @@
// -----------------------------------------------------------------------------
// NormalizationPropertyTests.cs
// Sprint: SPRINT_20260102_001_BE (Binary Delta Signatures)
// Task: DS-037 - Property tests for normalization idempotency
// Description: Property-based tests verifying normalization is idempotent,
// deterministic, and produces stable hashes.
// -----------------------------------------------------------------------------
using System.Collections.Immutable;
using FluentAssertions;
using FsCheck;
using FsCheck.Fluent;
using FsCheck.Xunit;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Normalization.X64;
namespace StellaOps.BinaryIndex.Normalization.Tests.Properties;
/// <summary>
/// Property-based tests for normalization invariants.
/// Verifies:
/// - Idempotency: normalize(normalize(x)) == normalize(x)
/// - Determinism: normalize(x) always produces the same output
/// - Hash stability: same input instructions always produce same hash
/// </summary>
[Trait("Category", "Property")]
public class NormalizationPropertyTests
{
private readonly X64NormalizationPipeline _pipeline;
private readonly NormalizationService _service;
public NormalizationPropertyTests()
{
_pipeline = new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance);
_service = new NormalizationService(
[_pipeline],
NullLogger<NormalizationService>.Instance);
}
#region Idempotency Tests
/// <summary>
/// Normalization is idempotent: normalizing an already-normalized result
/// produces the same output (when we re-disassemble from normalized bytes).
/// </summary>
[Property(MaxTest = 100)]
public Property Normalize_IsIdempotent_ForSingleInstruction()
{
return Prop.ForAll(
InstructionArb(),
(DisassembledInstruction instruction) =>
{
var firstResult = _pipeline.Normalize([instruction], CpuArchitecture.X86_64);
// Converting normalized instructions back and normalizing again
// should produce identical normalized bytes
var secondInput = firstResult.Instructions
.Select(ni => new DisassembledInstruction(
Address: ni.OriginalAddress,
RawBytes: ni.NormalizedBytes,
Mnemonic: ni.NormalizedMnemonic,
OperandsText: string.Join(", ", ni.Operands.Select(o => o.Text)),
Kind: ni.Kind,
Operands: ni.Operands.Select(o => new Operand(
o.Type,
o.Text,
o.Value,
o.Register)).ToImmutableArray()))
.ToArray();
var secondResult = _pipeline.Normalize(secondInput, CpuArchitecture.X86_64);
// The normalized bytes should be identical
return firstResult.Instructions.Length == secondResult.Instructions.Length &&
firstResult.Instructions
.Zip(secondResult.Instructions)
.All(pair => pair.First.NormalizedBytes.SequenceEqual(pair.Second.NormalizedBytes));
});
}
/// <summary>
/// Normalizing a sequence of instructions twice produces the same bytes.
/// </summary>
[Property(MaxTest = 50)]
public Property Normalize_IsIdempotent_ForInstructionSequence()
{
return Prop.ForAll(
InstructionSequenceArb(1, 10),
(DisassembledInstruction[] instructions) =>
{
var firstResult = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
var secondInput = firstResult.Instructions
.Select(ni => new DisassembledInstruction(
Address: ni.OriginalAddress,
RawBytes: ni.NormalizedBytes,
Mnemonic: ni.NormalizedMnemonic,
OperandsText: string.Join(", ", ni.Operands.Select(o => o.Text)),
Kind: ni.Kind,
Operands: ni.Operands.Select(o => new Operand(
o.Type,
o.Text,
o.Value,
o.Register)).ToImmutableArray()))
.ToArray();
var secondResult = _pipeline.Normalize(secondInput, CpuArchitecture.X86_64);
// Count and bytes should match
return firstResult.Instructions.Length == secondResult.Instructions.Length &&
firstResult.Instructions
.Zip(secondResult.Instructions)
.All(pair => pair.First.NormalizedBytes.SequenceEqual(pair.Second.NormalizedBytes));
});
}
#endregion
#region Determinism Tests
/// <summary>
/// Normalizing the same input multiple times produces identical output.
/// </summary>
[Property(MaxTest = 100)]
public Property Normalize_IsDeterministic()
{
return Prop.ForAll(
InstructionArb(),
(DisassembledInstruction instruction) =>
{
var result1 = _pipeline.Normalize([instruction], CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize([instruction], CpuArchitecture.X86_64);
// Instruction count must match
if (result1.Instructions.Length != result2.Instructions.Length)
return false;
// All normalized bytes must be identical
return result1.Instructions
.Zip(result2.Instructions)
.All(pair => pair.First.NormalizedBytes.SequenceEqual(pair.Second.NormalizedBytes));
});
}
/// <summary>
/// Normalization produces deterministic results across multiple runs
/// for instruction sequences.
/// </summary>
[Property(MaxTest = 50)]
public Property Normalize_IsDeterministic_ForSequence()
{
return Prop.ForAll(
InstructionSequenceArb(1, 20),
(DisassembledInstruction[] instructions) =>
{
// Run normalization 3 times
var results = Enumerable.Range(0, 3)
.Select(_ => _pipeline.Normalize(instructions, CpuArchitecture.X86_64))
.ToList();
// All should produce identical output
return results.Skip(1).All(r =>
r.Instructions.Length == results[0].Instructions.Length &&
r.Instructions
.Zip(results[0].Instructions)
.All(pair => pair.First.NormalizedBytes.SequenceEqual(pair.Second.NormalizedBytes)));
});
}
#endregion
#region Hash Stability Tests
/// <summary>
/// Same input always produces same total normalized size.
/// </summary>
[Property(MaxTest = 100)]
public Property NormalizedSize_IsConsistent()
{
return Prop.ForAll(
InstructionSequenceArb(1, 10),
(DisassembledInstruction[] instructions) =>
{
var result1 = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
return result1.NormalizedSize == result2.NormalizedSize;
});
}
/// <summary>
/// Recipe ID is always the same for the X64 pipeline.
/// </summary>
[Property(MaxTest = 50)]
public Property RecipeId_IsStable()
{
return Prop.ForAll(
InstructionSequenceArb(1, 5),
(DisassembledInstruction[] instructions) =>
{
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
return result.RecipeId == "elf.delta.norm.x64";
});
}
/// <summary>
/// Concatenated normalized bytes are deterministic for hashing.
/// </summary>
[Property(MaxTest = 50)]
public Property ConcatenatedBytes_AreDeterministic()
{
return Prop.ForAll(
InstructionSequenceArb(2, 8),
(DisassembledInstruction[] instructions) =>
{
var result1 = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
var bytes1 = result1.Instructions.SelectMany(i => i.NormalizedBytes).ToArray();
var bytes2 = result2.Instructions.SelectMany(i => i.NormalizedBytes).ToArray();
return bytes1.SequenceEqual(bytes2);
});
}
#endregion
#region NOP Canonicalization Tests
/// <summary>
/// A sequence of NOPs always normalizes to a single NOP.
/// </summary>
[Property(MaxTest = 50)]
public Property NopSequence_CollapsesToOne()
{
return Prop.ForAll(
Gen.Choose(2, 10).ToArbitrary(),
(int nopCount) =>
{
var nops = Enumerable.Range(0, nopCount)
.Select(i => CreateNop((ulong)i))
.ToArray();
var result = _pipeline.Normalize(nops, CpuArchitecture.X86_64);
// Should collapse to single NOP
return result.Instructions.Length == 1 &&
result.Instructions[0].Kind == InstructionKind.Nop;
});
}
/// <summary>
/// NOP sleds at different positions collapse identically.
/// </summary>
[Property(MaxTest = 50)]
public Property NopSleds_NormalizeIdentically()
{
return Prop.ForAll(
Gen.Choose(2, 8).ToArbitrary(),
Gen.Choose(0, 1000).ToArbitrary(),
Gen.Choose(1000, 2000).ToArbitrary(),
(int nopCount, int startAddr1, int startAddr2) =>
{
var nops1 = Enumerable.Range(0, nopCount)
.Select(i => CreateNop((ulong)(startAddr1 + i)))
.ToArray();
var nops2 = Enumerable.Range(0, nopCount)
.Select(i => CreateNop((ulong)(startAddr2 + i)))
.ToArray();
var result1 = _pipeline.Normalize(nops1, CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize(nops2, CpuArchitecture.X86_64);
// Should both collapse to single NOP with identical normalized bytes
return result1.Instructions.Length == 1 &&
result2.Instructions.Length == 1 &&
result1.Instructions[0].NormalizedBytes.SequenceEqual(
result2.Instructions[0].NormalizedBytes);
});
}
#endregion
#region Address Normalization Tests
/// <summary>
/// Instructions with different absolute addresses but same structure
/// normalize to identical bytes (addresses are zeroed).
/// </summary>
[Property(MaxTest = 50)]
public Property DifferentAddresses_NormalizeIdentically()
{
return Prop.ForAll(
Gen.Choose(0x1000, 0x9000).ToArbitrary(),
Gen.Choose(0x10000, 0x90000).ToArbitrary(),
(int addr1, int addr2) =>
{
// Same instruction at different addresses
var inst1 = CreateMovRegImm((ulong)addr1, "rax", 42);
var inst2 = CreateMovRegImm((ulong)addr2, "rax", 42);
var result1 = _pipeline.Normalize([inst1], CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize([inst2], CpuArchitecture.X86_64);
// Normalized bytes should be identical (address is not in the bytes anyway for MOV reg, imm)
return result1.Instructions[0].NormalizedBytes.SequenceEqual(
result2.Instructions[0].NormalizedBytes);
});
}
/// <summary>
/// Branch targets are zeroed regardless of original target address.
/// </summary>
[Property(MaxTest = 50)]
public Property BranchTargets_AreZeroed()
{
return Prop.ForAll(
Gen.Choose(0x1000, 0x9000).ToArbitrary(),
Gen.Choose(0x1000, 0x9000).ToArbitrary(),
(int target1, int target2) =>
{
var jmp1 = CreateJmp(0x1000, (ulong)target1);
var jmp2 = CreateJmp(0x1000, (ulong)target2);
var result1 = _pipeline.Normalize([jmp1], CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize([jmp2], CpuArchitecture.X86_64);
// Both should normalize to identical bytes (target zeroed)
return result1.Instructions[0].NormalizedBytes.SequenceEqual(
result2.Instructions[0].NormalizedBytes);
});
}
#endregion
#region Generators
private static Arbitrary<DisassembledInstruction> InstructionArb()
{
return Gen.OneOf(
NopInstructionGen(),
MovRegImmGen(),
MovRegRegGen(),
ArithmeticGen(),
JmpGen(),
RetGen()
).ToArbitrary();
}
private static Arbitrary<DisassembledInstruction[]> InstructionSequenceArb(int minSize, int maxSize)
{
return Gen.ArrayOf(Gen.OneOf(
NopInstructionGen(),
MovRegImmGen(),
MovRegRegGen(),
ArithmeticGen(),
JmpGen(),
RetGen()
))
.Where(arr => arr.Length >= minSize && arr.Length <= maxSize)
.Select(arr => AssignSequentialAddresses(arr))
.ToArbitrary();
}
private static Gen<DisassembledInstruction> NopInstructionGen()
{
return Gen.Choose(0, 0xFFFF).Select(addr => CreateNop((ulong)addr));
}
private static Gen<DisassembledInstruction> MovRegImmGen()
{
var registers = new[] { "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "r8", "r9" };
return from addr in Gen.Choose(0, 0xFFFF)
from reg in Gen.Elements(registers)
from imm in Gen.Choose(-1000, 1000)
select CreateMovRegImm((ulong)addr, reg, imm);
}
private static Gen<DisassembledInstruction> MovRegRegGen()
{
var registers = new[] { "rax", "rbx", "rcx", "rdx", "rsi", "rdi", "r8", "r9" };
return from addr in Gen.Choose(0, 0xFFFF)
from srcReg in Gen.Elements(registers)
from dstReg in Gen.Elements(registers)
where srcReg != dstReg
select CreateMovRegReg((ulong)addr, dstReg, srcReg);
}
private static Gen<DisassembledInstruction> ArithmeticGen()
{
var ops = new[] { "ADD", "SUB", "XOR", "AND", "OR" };
var registers = new[] { "rax", "rbx", "rcx", "rdx" };
return from addr in Gen.Choose(0, 0xFFFF)
from op in Gen.Elements(ops)
from reg in Gen.Elements(registers)
from imm in Gen.Choose(1, 100)
select CreateArithmetic((ulong)addr, op, reg, imm);
}
private static Gen<DisassembledInstruction> JmpGen()
{
return from addr in Gen.Choose(0, 0xFFFF)
from target in Gen.Choose(0, 0xFFFF)
select CreateJmp((ulong)addr, (ulong)target);
}
private static Gen<DisassembledInstruction> RetGen()
{
return Gen.Choose(0, 0xFFFF).Select(addr => CreateRet((ulong)addr));
}
#endregion
#region Instruction Builders
private static DisassembledInstruction CreateNop(ulong address)
{
return new DisassembledInstruction(
Address: address,
RawBytes: [0x90],
Mnemonic: "NOP",
OperandsText: "",
Kind: InstructionKind.Nop,
Operands: []);
}
private static DisassembledInstruction CreateMovRegImm(ulong address, string reg, long imm)
{
// Simplified MOV encoding
var bytes = new byte[] { 0x48, 0xC7, 0xC0 }
.Concat(BitConverter.GetBytes((int)imm))
.ToImmutableArray();
return new DisassembledInstruction(
Address: address,
RawBytes: bytes,
Mnemonic: "MOV",
OperandsText: $"{reg}, {imm}",
Kind: InstructionKind.Move,
Operands:
[
new Operand(OperandType.Register, reg, Register: reg),
new Operand(OperandType.Immediate, imm.ToString(), Value: imm)
]);
}
private static DisassembledInstruction CreateMovRegReg(ulong address, string dst, string src)
{
return new DisassembledInstruction(
Address: address,
RawBytes: [0x48, 0x89, 0xC0],
Mnemonic: "MOV",
OperandsText: $"{dst}, {src}",
Kind: InstructionKind.Move,
Operands:
[
new Operand(OperandType.Register, dst, Register: dst),
new Operand(OperandType.Register, src, Register: src)
]);
}
private static DisassembledInstruction CreateArithmetic(ulong address, string op, string reg, int imm)
{
return new DisassembledInstruction(
Address: address,
RawBytes: [0x48, 0x83, 0xC0, (byte)imm],
Mnemonic: op,
OperandsText: $"{reg}, {imm}",
Kind: InstructionKind.Arithmetic,
Operands:
[
new Operand(OperandType.Register, reg, Register: reg),
new Operand(OperandType.Immediate, imm.ToString(), Value: imm)
]);
}
private static DisassembledInstruction CreateJmp(ulong address, ulong target)
{
var offset = (int)(target - address - 5); // 5 = size of JMP rel32
var bytes = new byte[] { 0xE9 }
.Concat(BitConverter.GetBytes(offset))
.ToImmutableArray();
return new DisassembledInstruction(
Address: address,
RawBytes: bytes,
Mnemonic: "JMP",
OperandsText: $"0x{target:X}",
Kind: InstructionKind.Branch,
Operands:
[
new Operand(OperandType.Address, $"0x{target:X}", Value: (long)target)
]);
}
private static DisassembledInstruction CreateRet(ulong address)
{
return new DisassembledInstruction(
Address: address,
RawBytes: [0xC3],
Mnemonic: "RET",
OperandsText: "",
Kind: InstructionKind.Return,
Operands: []);
}
private static DisassembledInstruction[] AssignSequentialAddresses(DisassembledInstruction[] instructions)
{
ulong currentAddress = 0x1000;
var result = new DisassembledInstruction[instructions.Length];
for (int i = 0; i < instructions.Length; i++)
{
result[i] = instructions[i] with { Address = currentAddress };
currentAddress += (ulong)instructions[i].RawBytes.Length;
}
return result;
}
#endregion
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Normalization\StellaOps.BinaryIndex.Normalization.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly\StellaOps.BinaryIndex.Disassembly.csproj" />
<ProjectReference Include="..\..\__Libraries\StellaOps.BinaryIndex.Disassembly.Iced\StellaOps.BinaryIndex.Disassembly.Iced.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="FsCheck" />
<PackageReference Include="FsCheck.Xunit.v3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,367 @@
// Copyright (c) StellaOps. All rights reserved.
// Licensed under AGPL-3.0-or-later. See LICENSE in the project root.
using System.Collections.Immutable;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.BinaryIndex.Disassembly;
using StellaOps.BinaryIndex.Normalization.X64;
namespace StellaOps.BinaryIndex.Normalization.Tests;
/// <summary>
/// Tests for the X64 normalization pipeline.
/// </summary>
public class X64NormalizationPipelineTests
{
private readonly X64NormalizationPipeline _pipeline;
public X64NormalizationPipelineTests()
{
_pipeline = new X64NormalizationPipeline(NullLogger<X64NormalizationPipeline>.Instance);
}
[Fact]
public void RecipeId_ReturnsExpectedValue()
{
_pipeline.RecipeId.Should().Be("elf.delta.norm.x64");
}
[Fact]
public void RecipeVersion_ReturnsExpectedValue()
{
_pipeline.RecipeVersion.Should().Be("1.0.0");
}
[Fact]
public void SupportedArchitectures_IncludesX86AndX64()
{
_pipeline.SupportedArchitectures.Should().Contain(CpuArchitecture.X86);
_pipeline.SupportedArchitectures.Should().Contain(CpuArchitecture.X86_64);
}
[Fact]
public void Normalize_WithEmptyInstructions_ReturnsEmptyResult()
{
var instructions = Array.Empty<DisassembledInstruction>();
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
result.Instructions.Should().BeEmpty();
result.OriginalSize.Should().Be(0);
result.NormalizedSize.Should().Be(0);
result.Architecture.Should().Be(CpuArchitecture.X86_64);
result.RecipeId.Should().Be("elf.delta.norm.x64");
}
[Fact]
public void Normalize_WithUnsupportedArchitecture_ThrowsArgumentException()
{
var instructions = new[] { CreateNopInstruction() };
var act = () => _pipeline.Normalize(instructions, CpuArchitecture.ARM64);
act.Should().Throw<ArgumentException>()
.WithMessage("*ARM64*not supported*");
}
[Fact]
public void Normalize_SingleNop_PreservesInstruction()
{
var nop = CreateNopInstruction();
var instructions = new[] { nop };
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].Kind.Should().Be(InstructionKind.Nop);
result.Instructions[0].NormalizedMnemonic.Should().Be("NOP");
}
[Fact]
public void Normalize_NopSled_CollapsesToSingleNop()
{
// Create 5 consecutive NOPs
var instructions = Enumerable.Range(0, 5)
.Select(i => CreateNopInstruction((ulong)i))
.ToArray();
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
// Should collapse to a single canonical NOP
result.Instructions.Should().HaveCount(1);
result.Instructions[0].Kind.Should().Be(InstructionKind.Nop);
result.Instructions[0].WasModified.Should().BeTrue();
// Statistics should reflect the collapse
result.Statistics!.NopsCollapsed.Should().Be(4);
result.AppliedSteps.Should().Contain("nop-canonicalize");
}
[Fact]
public void Normalize_MixedInstructions_PreservesNonNops()
{
var instructions = new[]
{
CreateNopInstruction(0),
CreateNopInstruction(1),
CreateMovInstruction(2),
CreateNopInstruction(7),
CreateRetInstruction(8)
};
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
// First NOP sled collapses to 1, MOV preserved, second NOP, RET preserved
result.Instructions.Should().HaveCount(4);
result.Instructions[0].Kind.Should().Be(InstructionKind.Nop);
result.Instructions[1].Kind.Should().Be(InstructionKind.Move);
result.Instructions[2].Kind.Should().Be(InstructionKind.Nop);
result.Instructions[3].Kind.Should().Be(InstructionKind.Return);
}
[Fact]
public void Normalize_WithAbsoluteAddress_ZerosTheAddress()
{
// MOV RAX, 0x7FFFFFFF1000 (large address-like immediate)
var mov = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x48, 0xB8, 0x00, 0x10, 0xFF, 0xFF, 0xFF, 0x7F, 0x00, 0x00],
Mnemonic: "MOV",
OperandsText: "rax, 0x7FFFFFFF1000",
Kind: InstructionKind.Move,
Operands:
[
new Operand(OperandType.Register, "rax", Register: "rax"),
new Operand(OperandType.Immediate, "0x7FFFFFFF1000", Value: 0x7FFFFFFF1000)
]);
var result = _pipeline.Normalize([mov], CpuArchitecture.X86_64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeTrue();
result.Statistics!.AddressesZeroed.Should().BeGreaterThan(0);
result.AppliedSteps.Should().Contain("zero-absolute-addr");
}
[Fact]
public void Normalize_WithSmallImmediate_PreservesValue()
{
// ADD RAX, 5 (small immediate, not address-like)
var add = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x48, 0x83, 0xC0, 0x05],
Mnemonic: "ADD",
OperandsText: "rax, 5",
Kind: InstructionKind.Arithmetic,
Operands:
[
new Operand(OperandType.Register, "rax", Register: "rax"),
new Operand(OperandType.Immediate, "5", Value: 5)
]);
var result = _pipeline.Normalize([add], CpuArchitecture.X86_64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeFalse();
result.Instructions[0].Operands[1].Value.Should().Be(5);
}
[Fact]
public void Normalize_BranchInstruction_ZerosTarget()
{
// JMP 0x2000 (relative branch)
var jmp = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0xE9, 0xFB, 0x0F, 0x00, 0x00],
Mnemonic: "JMP",
OperandsText: "0x2000",
Kind: InstructionKind.Branch,
Operands:
[
new Operand(OperandType.Address, "0x2000", Value: 0x2000)
]);
var result = _pipeline.Normalize([jmp], CpuArchitecture.X86_64);
result.Instructions.Should().HaveCount(1);
result.Instructions[0].WasModified.Should().BeTrue();
result.Instructions[0].Operands[0].WasNormalized.Should().BeTrue();
result.Instructions[0].Operands[0].Value.Should().Be(0);
}
[Fact]
public void Normalize_CallInstruction_PreservesTargetWhenRequested()
{
// CALL 0x3000
var call = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0xE8, 0xFB, 0x1F, 0x00, 0x00],
Mnemonic: "CALL",
OperandsText: "0x3000",
Kind: InstructionKind.Call,
Operands:
[
new Operand(OperandType.Address, "0x3000", Value: 0x3000)
]);
var options = NormalizationOptions.Default with { PreserveCallTargets = true };
var result = _pipeline.Normalize([call], CpuArchitecture.X86_64, options);
result.Instructions.Should().HaveCount(1);
// Call target should be preserved
result.Instructions[0].Operands[0].Value.Should().Be(0x3000);
}
[Fact]
public void Normalize_DisabledNopCanonicalization_PreservesAllNops()
{
var instructions = Enumerable.Range(0, 3)
.Select(i => CreateNopInstruction((ulong)i))
.ToArray();
var options = NormalizationOptions.Default with { CanonicalizeNops = false };
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64, options);
// All NOPs should be preserved
result.Instructions.Should().HaveCount(3);
result.Statistics!.NopsCollapsed.Should().Be(0);
}
[Fact]
public void Normalize_MinimalOptions_OnlyZerosAddresses()
{
var nops = Enumerable.Range(0, 3)
.Select(i => CreateNopInstruction((ulong)i))
.ToArray();
var result = _pipeline.Normalize(nops, CpuArchitecture.X86_64, NormalizationOptions.Minimal);
// NOPs should not be collapsed with minimal options
result.Instructions.Should().HaveCount(3);
}
[Fact]
public void Normalize_MultiByteNop_RecognizedAndCanonicalized()
{
// 2-byte NOP: 66 90
var nop2 = new DisassembledInstruction(
Address: 0x1000,
RawBytes: [0x66, 0x90],
Mnemonic: "NOP",
OperandsText: "",
Kind: InstructionKind.Nop,
Operands: []);
// 3-byte NOP: 0F 1F 00
var nop3 = new DisassembledInstruction(
Address: 0x1002,
RawBytes: [0x0F, 0x1F, 0x00],
Mnemonic: "NOP",
OperandsText: "",
Kind: InstructionKind.Nop,
Operands: []);
var result = _pipeline.Normalize([nop2, nop3], CpuArchitecture.X86_64);
// Should collapse to single canonical NOP
result.Instructions.Should().HaveCount(1);
result.Instructions[0].NormalizedBytes.Should().Equal([0x90]);
}
[Fact]
public void Normalize_OutputsDeterministicBytes()
{
var instructions = new[]
{
CreateNopInstruction(0),
CreateMovInstruction(1),
CreateRetInstruction(6)
};
var result1 = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
var result2 = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
// Results should be identical (deterministic)
result1.Instructions.Should().HaveCount(result2.Instructions.Length);
for (var i = 0; i < result1.Instructions.Length; i++)
{
result1.Instructions[i].NormalizedBytes
.Should().Equal(result2.Instructions[i].NormalizedBytes);
}
}
[Fact]
public void Normalize_RecordsAppliedSteps()
{
var instructions = new[]
{
CreateNopInstruction(0),
CreateNopInstruction(1),
CreateMovWithLargeImmediate(2)
};
var result = _pipeline.Normalize(instructions, CpuArchitecture.X86_64);
result.AppliedSteps.Should().NotBeEmpty();
// Should include both NOP canonicalization and address zeroing
result.AppliedSteps.Should().Contain("nop-canonicalize");
result.AppliedSteps.Should().Contain("zero-absolute-addr");
}
// Helper methods
private static DisassembledInstruction CreateNopInstruction(ulong address = 0)
{
return new DisassembledInstruction(
Address: address,
RawBytes: [0x90],
Mnemonic: "NOP",
OperandsText: "",
Kind: InstructionKind.Nop,
Operands: []);
}
private static DisassembledInstruction CreateMovInstruction(ulong address)
{
// MOV EAX, EBX (89 D8)
return new DisassembledInstruction(
Address: address,
RawBytes: [0x89, 0xD8],
Mnemonic: "MOV",
OperandsText: "eax, ebx",
Kind: InstructionKind.Move,
Operands:
[
new Operand(OperandType.Register, "eax", Register: "eax"),
new Operand(OperandType.Register, "ebx", Register: "ebx")
]);
}
private static DisassembledInstruction CreateMovWithLargeImmediate(ulong address)
{
// MOV RAX, 0x400000 (movabs)
return new DisassembledInstruction(
Address: address,
RawBytes: [0x48, 0xB8, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00],
Mnemonic: "MOV",
OperandsText: "rax, 0x400000",
Kind: InstructionKind.Move,
Operands:
[
new Operand(OperandType.Register, "rax", Register: "rax"),
new Operand(OperandType.Immediate, "0x400000", Value: 0x400000)
]);
}
private static DisassembledInstruction CreateRetInstruction(ulong address)
{
return new DisassembledInstruction(
Address: address,
RawBytes: [0xC3],
Mnemonic: "RET",
OperandsText: "",
Kind: InstructionKind.Return,
Operands: []);
}
}