Skip to content

Commit a2f1fd6

Browse files
committed
Add VBA project round-trip tests
Add tests that compile VBA projects and verify they can be decompiled. Catches compound file corruption where streams exist but directory entries have incorrect size/offset metadata, making the VBA code unreadable despite the file appearing valid.
1 parent ac61867 commit a2f1fd6

4 files changed

Lines changed: 233 additions & 3 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Copyright 2022 Cisco Systems, Inc.
2+
// Licensed under MIT-style license (see LICENSE.txt file).
3+
4+
using System;
5+
using System.IO;
6+
using System.Linq;
7+
using System.Text;
8+
using Kavod.Vba.Compression;
9+
using OpenMcdf;
10+
using VbadDecompiler = vbad;
11+
12+
namespace vbamc.Tests.Streams
13+
{
14+
/// <summary>
15+
/// Integration tests that validate VBA project compilation produces
16+
/// a valid CompoundFile that can be decompiled using vbad logic.
17+
///
18+
/// These tests ensure the compiled VBA project binary has correct
19+
/// structure and stream mappings. With broken OpenMcdf versions,
20+
/// the compilation produces files where streams are not correctly
21+
/// mapped to folders, making the VBA project unusable.
22+
/// </summary>
23+
class VbaProjectRoundTripTests
24+
{
25+
private byte[] _compiledVbaProject = null!;
26+
private byte[] _compiledMultiModuleVbaProject = null!;
27+
28+
[SetUp]
29+
public void Setup()
30+
{
31+
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
32+
33+
var sourcePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "data");
34+
var modulePath = Path.Combine(sourcePath, "TestModule.vb");
35+
var classPath = Path.Combine(sourcePath, "Class.vb");
36+
37+
// Compile single-module project
38+
var compiler = CreateTestCompiler("RoundTripTest");
39+
compiler.AddModule(modulePath);
40+
using var stream = compiler.CompileVbaProject();
41+
_compiledVbaProject = stream.ToArray();
42+
43+
// Compile multi-module project
44+
var multiCompiler = CreateTestCompiler("MultiModuleTest");
45+
multiCompiler.AddModule(modulePath);
46+
multiCompiler.AddClass(classPath);
47+
using var multiStream = multiCompiler.CompileVbaProject();
48+
_compiledMultiModuleVbaProject = multiStream.ToArray();
49+
}
50+
51+
private static VbaCompiler CreateTestCompiler(string projectName)
52+
{
53+
return new VbaCompiler
54+
{
55+
ProjectId = Guid.NewGuid(),
56+
ProjectName = projectName,
57+
ProjectVersion = "1.0.0",
58+
CompanyName = "TestCompany"
59+
};
60+
}
61+
62+
[Test]
63+
public void CompileVbaProject_ShouldProduceDecompilableCompoundFile()
64+
{
65+
using var compoundFile = new CompoundFile(new MemoryStream(_compiledVbaProject));
66+
var root = compoundFile.RootStorage;
67+
68+
// Verify PROJECT stream exists and has content
69+
// Empty stream data indicates directory metadata wasn't flushed (broken OpenMcdf)
70+
var projectStream = root.GetStream("PROJECT");
71+
var projectData = projectStream.GetData();
72+
ClassicAssert.Greater(projectData.Length, 0,
73+
"PROJECT stream is empty - stream may not have been disposed");
74+
75+
// Verify PROJECTwm stream exists and has content
76+
var projectWmStream = root.GetStream("PROJECTwm");
77+
var projectWmData = projectWmStream.GetData();
78+
ClassicAssert.Greater(projectWmData.Length, 0,
79+
"PROJECTwm stream is empty - stream may not have been disposed");
80+
81+
// Verify VBA storage exists
82+
var vbaStorage = root.GetStorage("VBA");
83+
ClassicAssert.IsNotNull(vbaStorage, "VBA storage should exist");
84+
85+
// Verify _VBA_PROJECT stream exists and has content
86+
var vbaProjectSubStream = vbaStorage.GetStream("_VBA_PROJECT");
87+
var vbaProjectData = vbaProjectSubStream.GetData();
88+
ClassicAssert.Greater(vbaProjectData.Length, 0,
89+
"_VBA_PROJECT stream is empty - stream may not have been disposed");
90+
91+
// Verify dir stream exists, has content, and can be decompressed
92+
var dirStream = vbaStorage.GetStream("dir");
93+
var dirData = dirStream.GetData();
94+
ClassicAssert.Greater(dirData.Length, 0,
95+
"dir stream is empty - stream may not have been disposed");
96+
97+
var decompressedDir = VbaCompression.Decompress(dirData);
98+
ClassicAssert.Greater(decompressedDir.Length, 0, "Decompressed dir should have content");
99+
}
100+
101+
[Test]
102+
public void CompileVbaProject_ShouldProduceReadableModules()
103+
{
104+
using var compoundFile = new CompoundFile(new MemoryStream(_compiledVbaProject));
105+
var vbaStorage = compoundFile.RootStorage.GetStorage("VBA");
106+
107+
// Verify dir stream has content before decompression
108+
var dirStreamData = vbaStorage.GetStream("dir").GetData();
109+
ClassicAssert.Greater(dirStreamData.Length, 0,
110+
"dir stream is empty - stream may not have been disposed");
111+
112+
var dirData = VbaCompression.Decompress(dirStreamData);
113+
114+
var modules = VbadDecompiler.DirStream.GetModules(dirData).ToList();
115+
ClassicAssert.Greater(modules.Count, 0, "Should have at least one module");
116+
117+
foreach (var module in modules)
118+
{
119+
ClassicAssert.IsNotEmpty(module.Name, "Module name should not be empty");
120+
121+
var moduleStream = vbaStorage.GetStream(module.Name);
122+
var moduleData = moduleStream.GetData();
123+
124+
// Check raw stream data is not empty before processing
125+
ClassicAssert.Greater(moduleData.Length, 0,
126+
$"Module '{module.Name}' stream is empty - stream may not have been disposed");
127+
128+
ClassicAssert.Greater(moduleData.Length, (int)module.Offset,
129+
$"Module '{module.Name}' data length ({moduleData.Length}) should be greater than offset ({module.Offset})");
130+
131+
var moduleCode = moduleData.AsSpan().Slice((int)module.Offset).ToArray();
132+
ClassicAssert.Greater(moduleCode.Length, 0,
133+
$"Module '{module.Name}' code after offset is empty");
134+
135+
// Decompress module code - this is the critical test
136+
// If the CompoundFile structure is broken, decompression will fail
137+
var decompressedCode = VbaCompression.Decompress(moduleCode);
138+
ClassicAssert.Greater(decompressedCode.Length, 0, "Decompressed code should have content");
139+
}
140+
}
141+
142+
[Test]
143+
public void CompileVbaProject_DecompiledCodeShouldContainOriginalSource()
144+
{
145+
using var compoundFile = new CompoundFile(new MemoryStream(_compiledVbaProject));
146+
var vbaStorage = compoundFile.RootStorage.GetStorage("VBA");
147+
148+
// Verify dir stream has content before decompression
149+
var dirStreamData = vbaStorage.GetStream("dir").GetData();
150+
ClassicAssert.Greater(dirStreamData.Length, 0,
151+
"dir stream is empty - stream may not have been disposed");
152+
153+
var dirData = VbaCompression.Decompress(dirStreamData);
154+
155+
var modules = VbadDecompiler.DirStream.GetModules(dirData).ToList();
156+
var testModule = modules.First(m => m.Name == "TestModule");
157+
158+
var moduleStream = vbaStorage.GetStream(testModule.Name!);
159+
var moduleData = moduleStream.GetData();
160+
161+
// Check raw stream data is not empty
162+
ClassicAssert.Greater(moduleData.Length, 0,
163+
"TestModule stream is empty - stream may not have been disposed");
164+
165+
var moduleCode = moduleData.AsSpan().Slice((int)testModule.Offset).ToArray();
166+
ClassicAssert.Greater(moduleCode.Length, 0,
167+
"TestModule code after offset is empty");
168+
169+
var decompressedSource = Encoding.GetEncoding(1252).GetString(VbaCompression.Decompress(moduleCode));
170+
171+
ClassicAssert.IsTrue(decompressedSource.Contains("HelloWorld"),
172+
"Decompiled source should contain 'HelloWorld' function");
173+
ClassicAssert.IsTrue(decompressedSource.Contains("AddNumbers"),
174+
"Decompiled source should contain 'AddNumbers' function");
175+
ClassicAssert.IsTrue(decompressedSource.Contains("MsgBox"),
176+
"Decompiled source should contain 'MsgBox' call");
177+
}
178+
179+
[Test]
180+
public void CompileVbaProject_WithMultipleModules_AllShouldBeDecompilable()
181+
{
182+
using var compoundFile = new CompoundFile(new MemoryStream(_compiledMultiModuleVbaProject));
183+
var vbaStorage = compoundFile.RootStorage.GetStorage("VBA");
184+
185+
// Verify dir stream has content before decompression
186+
var dirStreamData = vbaStorage.GetStream("dir").GetData();
187+
ClassicAssert.Greater(dirStreamData.Length, 0,
188+
"dir stream is empty - stream may not have been disposed");
189+
190+
var dirData = VbaCompression.Decompress(dirStreamData);
191+
192+
var modules = VbadDecompiler.DirStream.GetModules(dirData).ToList();
193+
ClassicAssert.AreEqual(2, modules.Count, "Should have exactly 2 modules (TestModule and Class)");
194+
195+
foreach (var module in modules)
196+
{
197+
var moduleStream = vbaStorage.GetStream(module.Name);
198+
var moduleData = moduleStream.GetData();
199+
200+
// Check raw stream data is not empty before processing
201+
ClassicAssert.Greater(moduleData.Length, 0,
202+
$"Module '{module.Name}' stream is empty - stream may not have been disposed");
203+
204+
var moduleCode = moduleData.AsSpan().Slice((int)module.Offset).ToArray();
205+
ClassicAssert.Greater(moduleCode.Length, 0,
206+
$"Module '{module.Name}' code after offset is empty");
207+
208+
var decompressedCode = VbaCompression.Decompress(moduleCode);
209+
ClassicAssert.IsNotNull(decompressedCode, $"Module '{module.Name}' should decompress successfully");
210+
}
211+
}
212+
}
213+
}

tests/VbaCompiler.Tests/VbaCompiler.Tests.csproj

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
@@ -10,6 +10,7 @@
1010
<ItemGroup>
1111
<None Remove="data\Class.vb" />
1212
<None Remove="data\Module.vb" />
13+
<None Remove="data\TestModule.vb" />
1314
</ItemGroup>
1415

1516
<ItemGroup>
@@ -19,6 +20,9 @@
1920
<Content Include="data\Module.vb">
2021
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
2122
</Content>
23+
<Content Include="data\TestModule.vb">
24+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
25+
</Content>
2226
</ItemGroup>
2327

2428
<ItemGroup>
@@ -34,6 +38,12 @@
3438

3539
<ItemGroup>
3640
<ProjectReference Include="..\..\src\vbamc\vbamc.csproj" />
41+
<ProjectReference Include="..\..\utils\vbad\vbad.csproj" />
42+
</ItemGroup>
43+
44+
<ItemGroup>
45+
<PackageReference Include="NetOfficeFw.VbaCompression" Version="3.0.1" />
46+
<PackageReference Include="OpenMcdf" Version="2.4.1" />
3747
</ItemGroup>
3848

3949
</Project>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Public Sub HelloWorld()
2+
MsgBox "Hello from VBA!"
3+
End Sub
4+
5+
Public Function AddNumbers(a As Integer, b As Integer) As Integer
6+
AddNumbers = a + b
7+
End Function

utils/vbad/vbad.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFramework>net10.0</TargetFramework>
5+
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<Nullable>enable</Nullable>
88
</PropertyGroup>

0 commit comments

Comments
 (0)