Skip to content

Commit bf80f6d

Browse files
Blackclawsclaude
andauthored
fix(writing): zero the padding of a fixed-length string (#177)
Padding of 256 bytes or more was written directly from a MemoryPool buffer. Rent does not zero the memory it hands out, so those bytes were whatever had last been in that buffer. Two consequences. A reader stops at the first zero byte among the leaked bytes rather than at the end of the value, so a string shorter than its declared width reads back with trailing garbage attached. And the file carries uninitialized process memory, which matters for a file that is shared or archived. Reaching it needs a padding of at least 256 bytes, since below that the writer uses a cleared stackalloc, and it only shows once something has dirtied the pool - a filtered write does, because compression rents buffers too. Neither a wide string nor a filter alone reproduces it. Found when an empty string member of a compound in a deflated dataset read back as "OHDR" followed by a fragment of the next object header. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5a03e11 commit bf80f6d

2 files changed

Lines changed: 155 additions & 1 deletion

File tree

src/PureHDF/VOL/Native/FileFormat/Level2/ObjectHeaderMessages/Datatype/DatatypeMessage.Writing.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,14 @@ void encode(object source, IH5WriteStream target)
744744
else
745745
{
746746
using var paddingBufferOwner = MemoryPool<byte>.Shared.Rent(padding);
747-
target.WriteDataset(paddingBufferOwner.Memory.Span[..padding]);
747+
var paddingBuffer = paddingBufferOwner.Memory.Span[..padding];
748+
749+
// Rent does not zero the buffer, so without this the padding written to the file is
750+
// whatever was last in that pooled memory. The stackalloc branch above clears for the
751+
// same reason.
752+
paddingBuffer.Clear();
753+
754+
target.WriteDataset(paddingBuffer);
748755
}
749756
}
750757
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
using System.Buffers;
2+
using Xunit;
3+
4+
namespace PureHDF.Tests.Writing;
5+
6+
/// <summary>
7+
/// A fixed-length string is padded with zero bytes, whatever the padding length.
8+
/// </summary>
9+
/// <remarks>
10+
/// The padding was written straight from a <see cref="MemoryPool{T}" /> buffer, which
11+
/// <see cref="MemoryPool{T}.Rent" /> does not zero - so the bytes written after the value were whatever
12+
/// had last been in that pooled memory. Two consequences: a reader stops at the first zero byte among
13+
/// those bytes rather than at the end of the value, so a short string reads back with trailing garbage
14+
/// attached; and the file carries uninitialized process memory, which matters for any file that is
15+
/// shared or archived.
16+
/// <para>
17+
/// Reaching it needs a padding of 256 bytes or more, since below that the writer uses a cleared
18+
/// stackalloc, and it only shows up once something has dirtied the pool - a filtered write does, because
19+
/// compression rents buffers too. Neither a wide string nor a filter alone reproduces it, which is
20+
/// presumably why it went unnoticed.
21+
/// </para>
22+
/// </remarks>
23+
public class FixedLengthStringPaddingTests
24+
{
25+
/// <summary>Wide enough that a short value's padding lands on the pooled branch.</summary>
26+
private const int Width = 512;
27+
28+
private struct Row
29+
{
30+
public string Text;
31+
}
32+
33+
/// <summary>
34+
/// Fills and returns pooled buffers so that the writer is handed a dirty one, which makes the test
35+
/// deterministic instead of dependent on whatever the process left in memory.
36+
/// </summary>
37+
private static void DirtyThePool()
38+
{
39+
for (var i = 0; i < 8; i++)
40+
{
41+
var owner = MemoryPool<byte>.Shared.Rent(Width);
42+
owner.Memory.Span.Fill(0xAB);
43+
owner.Dispose();
44+
}
45+
}
46+
47+
private static bool IsAllZero(ReadOnlySpan<byte> bytes)
48+
{
49+
foreach (var value in bytes)
50+
{
51+
if (value != 0)
52+
return false;
53+
}
54+
55+
return true;
56+
}
57+
58+
[Theory]
59+
[InlineData("")]
60+
[InlineData("short")]
61+
public void AStringAttributeShorterThanTheDeclaredWidthRoundTrips(string value)
62+
{
63+
// Arrange
64+
DirtyThePool();
65+
66+
var file = new H5File();
67+
file.Attributes["text"] = value;
68+
69+
var stream = new MemoryStream();
70+
71+
// Act
72+
file.Write(stream, new H5WriteOptions(DefaultStringLength: Width));
73+
stream.Seek(0, SeekOrigin.Begin);
74+
75+
using var actual = H5File.Open(stream);
76+
77+
// Assert
78+
Assert.Equal(value, actual.Attribute("text").Read<string>());
79+
}
80+
81+
/// <summary>
82+
/// The case this was found through: a compound member declares the width, and the filter has dirtied
83+
/// the pool by the time the padding is written.
84+
/// </summary>
85+
[Theory]
86+
[InlineData("")]
87+
[InlineData("child/")]
88+
public void ACompoundStringMemberInAFilteredDatasetRoundTrips(string value)
89+
{
90+
// Arrange
91+
DirtyThePool();
92+
93+
var rows = new Row[64];
94+
95+
for (var i = 0; i < rows.Length; i++)
96+
{
97+
rows[i] = new Row { Text = value };
98+
}
99+
100+
var file = new H5File { ["rows"] = new H5Dataset(rows) };
101+
var stream = new MemoryStream();
102+
103+
// Act
104+
file.Write(stream, new H5WriteOptions(
105+
DefaultStringLength: Width,
106+
Filters: [PureHDF.Filters.DeflateFilter.Id]));
107+
108+
stream.Seek(0, SeekOrigin.Begin);
109+
110+
using var actual = H5File.Open(stream);
111+
var read = actual.Dataset("rows").Read<Row[]>();
112+
113+
// Assert
114+
Assert.All(read, row => Assert.Equal(value, row.Text));
115+
}
116+
117+
/// <summary>
118+
/// Asserts the bytes in the file, not only the round-trip: a reader that stops at the first zero byte
119+
/// hides a leak that happens to begin with one, so the round-trip alone is not sufficient evidence
120+
/// that nothing was written.
121+
/// </summary>
122+
[Fact]
123+
public void ThePaddingWrittenToTheFileIsZero()
124+
{
125+
// Arrange
126+
DirtyThePool();
127+
128+
var file = new H5File();
129+
file.Attributes["text"] = "xxxxxxxx";
130+
131+
var stream = new MemoryStream();
132+
133+
// Act - no filter, so the padding is in the file uncompressed and can be inspected.
134+
file.Write(stream, new H5WriteOptions(DefaultStringLength: Width));
135+
136+
// Assert
137+
var bytes = stream.ToArray();
138+
var valueStart = bytes.AsSpan().IndexOf("xxxxxxxx"u8);
139+
140+
Assert.True(valueStart >= 0, "the attribute value was not found in the file");
141+
142+
Assert.True(
143+
IsAllZero(bytes.AsSpan(valueStart + 8, Width - 8)),
144+
"the padding after a fixed-length string contains non-zero bytes, so uninitialized pooled "
145+
+ "memory reached the file");
146+
}
147+
}

0 commit comments

Comments
 (0)