Skip to content

Commit 78d02dc

Browse files
fw2568Copilot
andauthored
Fix multipart user-data MIME framing (#20)
* Fix multipart user-data MIME framing The multipart/mixed user-data was missing the RFC 2046 close delimiter (--==BOUNDARY==--), so strict MIME parsers treated the final part as unterminated and dropped it; with a single cloud-config part the entire payload was lost. Also add the blank line separating the container headers from the body and each part's headers from its body, so a body whose first line contains a colon is not mis-parsed as a header. The close delimiter is only emitted when at least one part was written, avoiding an orphan delimiter for empty input. Tests now round-trip the output through MimeKit to confirm parts survive a spec-conformant parser. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 3a44014 commit 78d02dc

3 files changed

Lines changed: 94 additions & 1 deletion

File tree

src/CloudInit.ConfigDrive.Core/UserDataSerializer.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ public async Task<Stream> SerializeUserData(IEnumerable<UserData> userData, User
1616
sb.Append("From nobody Fri Jan 11 07:00:00 1980\n");
1717
sb.Append("Content-Type: multipart/mixed; boundary=\"==BOUNDARY==\"\n");
1818
sb.Append("MIME-Version: 1.0\n");
19+
sb.Append("\n");
1920

21+
var hasParts = false;
2022
foreach (var data in userData)
2123
{
24+
hasParts = true;
2225
sb.Append("--==BOUNDARY==\n");
2326
sb.Append("MIME-Version: 1.0\n");
2427

@@ -37,11 +40,19 @@ public async Task<Stream> SerializeUserData(IEnumerable<UserData> userData, User
3740
sb.Append("Content-Transfer-Encoding: base64\n");
3841
}
3942

40-
sb.Append(contentString+ "\n");
43+
sb.Append("\n");
44+
sb.Append(contentString);
45+
sb.Append('\n');
4146

4247

4348
}
4449

50+
// Only emit the RFC 2046 close delimiter when at least one part was
51+
// written; a lone "--==BOUNDARY==--" with no opening boundary would be
52+
// a malformed multipart body.
53+
if (hasParts)
54+
sb.Append("--==BOUNDARY==--\n");
55+
4556
if (!options.GZip)
4657
return new MemoryStream(Encoding.ASCII.GetBytes(sb.ToString()));
4758

test/CloudInit.ConfigDrive.Core.Test/CloudInit.ConfigDrive.Core.Test.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
<ItemGroup>
99
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
10+
<PackageReference Include="MimeKit" Version="4.16.0" />
1011
<PackageReference Include="Moq" Version="4.20.72" />
1112
<PackageReference Include="xunit" Version="2.9.3" />
1213
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0">

test/CloudInit.ConfigDrive.Core.Test/UserDataSerializerTests.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Text;
66
using System.Threading.Tasks;
77
using Dbosoft.CloudInit.ConfigDrive;
8+
using MimeKit;
89
using Xunit;
910

1011
namespace CloudInit.ConfigDrive.Core.Test;
@@ -29,6 +30,7 @@ public async Task WritesFixedHeader()
2930
Assert.Equal(@"From nobody Fri Jan 11 07:00:00 1980
3031
Content-Type: multipart/mixed; boundary=""==BOUNDARY==""
3132
MIME-Version: 1.0
33+
3234
".Replace("\r\n", "\n"), act);
3335
}
3436

@@ -55,10 +57,13 @@ await serializer.SerializeUserData(
5557
Assert.Equal(@"From nobody Fri Jan 11 07:00:00 1980
5658
Content-Type: multipart/mixed; boundary=""==BOUNDARY==""
5759
MIME-Version: 1.0
60+
5861
--==BOUNDARY==
5962
MIME-Version: 1.0
6063
Content-Type: text/cloud-config; charset=""us-ascii""
64+
6165
some config
66+
--==BOUNDARY==--
6267
".Replace("\r\n", "\n"), act);
6368
}
6469

@@ -85,11 +90,14 @@ await serializer.SerializeUserData(
8590
Assert.Equal(@$"From nobody Fri Jan 11 07:00:00 1980
8691
Content-Type: multipart/mixed; boundary=""==BOUNDARY==""
8792
MIME-Version: 1.0
93+
8894
--==BOUNDARY==
8995
MIME-Version: 1.0
9096
Content-Type: text/cloud-config; charset=""us-ascii""
9197
Content-Transfer-Encoding: base64
98+
9299
{Convert.ToBase64String(Encoding.ASCII.GetBytes("some config"), Base64FormattingOptions.InsertLineBreaks)}
100+
--==BOUNDARY==--
93101
".Replace("\r\n", "\n"), act);
94102

95103
}
@@ -118,8 +126,81 @@ public async Task GZipCompressed()
118126
Assert.Equal(@"From nobody Fri Jan 11 07:00:00 1980
119127
Content-Type: multipart/mixed; boundary=""==BOUNDARY==""
120128
MIME-Version: 1.0
129+
121130
".Replace("\r\n", "\n"), act);
122131

123132
}
124133

134+
[Fact]
135+
public async Task EndsWithCloseDelimiter()
136+
{
137+
var serializer = new UserDataSerializer();
138+
139+
await using var resultStream =
140+
await serializer.SerializeUserData(
141+
new[]
142+
{
143+
new UserData(UserDataContentType.CloudConfig, "some config", Encoding.ASCII)
144+
}, new UserDataOptions
145+
{
146+
Base64Encode = false,
147+
GZip = false
148+
});
149+
150+
using var reader = new StreamReader(resultStream);
151+
var act = await reader.ReadToEndAsync();
152+
153+
Assert.EndsWith("--==BOUNDARY==--\n", act);
154+
}
155+
156+
[Fact]
157+
public async Task RoundTripsThroughMimeParserWithoutDroppingParts()
158+
{
159+
var serializer = new UserDataSerializer();
160+
161+
await using var resultStream =
162+
await serializer.SerializeUserData(
163+
new[]
164+
{
165+
new UserData(UserDataContentType.CloudConfig, "#cloud-config\nfoo: bar", Encoding.ASCII)
166+
}, new UserDataOptions
167+
{
168+
Base64Encode = false,
169+
GZip = false
170+
});
171+
172+
var message = await MimeMessage.LoadAsync(resultStream);
173+
var multipart = Assert.IsType<Multipart>(message.Body);
174+
175+
Assert.Single(multipart);
176+
var part = Assert.IsType<TextPart>(multipart[0]);
177+
Assert.Equal("text/cloud-config", part.ContentType.MimeType);
178+
Assert.Equal("#cloud-config\nfoo: bar", part.Text.Replace("\r\n", "\n"));
179+
}
180+
181+
[Fact]
182+
public async Task RoundTripsBodyWithColonFirstLine()
183+
{
184+
var serializer = new UserDataSerializer();
185+
186+
// A body whose first line contains a colon must not be mistaken for a header.
187+
await using var resultStream =
188+
await serializer.SerializeUserData(
189+
new[]
190+
{
191+
new UserData(UserDataContentType.CloudConfig, "foo: bar\nbaz: qux", Encoding.ASCII)
192+
}, new UserDataOptions
193+
{
194+
Base64Encode = false,
195+
GZip = false
196+
});
197+
198+
var message = await MimeMessage.LoadAsync(resultStream);
199+
var multipart = Assert.IsType<Multipart>(message.Body);
200+
201+
Assert.Single(multipart);
202+
var part = Assert.IsType<TextPart>(multipart[0]);
203+
Assert.Equal("foo: bar\nbaz: qux", part.Text.Replace("\r\n", "\n"));
204+
}
205+
125206
}

0 commit comments

Comments
 (0)