Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions TriasDev.Templify.Tests/Integration/StreamValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,33 @@ public void ProcessTemplate_NonSeekableTemplate_IsSupported()
Assert.Equal("Hello Alice!", verifier.GetParagraphText(0));
}

[Fact]
public void ProcessTemplate_ExistingLongerOutputFile_IsTruncated()
{
// An existing file opened without truncation (FileMode.OpenOrCreate) used to keep its trailing bytes after
// the template copy, so the package could not be opened and processing failed.
string path = Path.Combine(Path.GetTempPath(), "templify-longer-" + Guid.NewGuid().ToString("N") + ".docx");
try
{
File.WriteAllBytes(path, new byte[200 * 1024]);
ProcessingResult result;
using (FileStream output = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite))
{
result = new DocumentTemplateProcessor().ProcessTemplate(CreateTemplate(), output, _data);
}

Assert.True(result.IsSuccess, result.ErrorMessage);
byte[] written = File.ReadAllBytes(path);
Assert.True(written.Length < 200 * 1024);
using DocumentVerifier verifier = new DocumentVerifier(new MemoryStream(written));
Assert.Equal("Hello Alice!", verifier.GetParagraphText(0));
}
finally
{
File.Delete(path);
}
}

private static void AssertOutputStreamFailure(ProcessingResult result)
{
Assert.False(result.IsSuccess);
Expand Down
116 changes: 116 additions & 0 deletions TriasDev.Templify.Tests/Odt/OdtPackageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,122 @@ public void ProcessTemplateFile_WritesOutputFile_OnlyOnSuccess()
}
}

[Fact]
public void Process_LargeUnprocessedEntry_IsStreamedWithoutInflatingIntoMemory()
{
// A 64 MB entry that processing never reads (a picture) compresses to about 64 KB. It used to be inflated
// into memory and buffered twice (more than 3x its inflated size was allocated).
byte[] template = CreateTemplateWithLargeEntry(out int entrySize);

long before = GC.GetAllocatedBytesForCurrentThread();
ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(template, _data, out byte[] output);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;

Assert.True(result.IsSuccess, result.ErrorMessage);
Assert.True(allocated < entrySize / 4, $"Allocated {allocated:N0} bytes for a {entrySize:N0}-byte entry.");
AssertLargeEntryCopied(output, entrySize);
}

[Fact]
public void Process_NonSeekableTemplateWithLargeEntry_BuffersOnlyCompressedData()
{
byte[] template = CreateTemplateWithLargeEntry(out int entrySize);
using NonSeekableStream input = new NonSeekableStream(new MemoryStream(template));
using MemoryStream output = new MemoryStream();

long before = GC.GetAllocatedBytesForCurrentThread();
ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(input, output, _data);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;

Assert.True(result.IsSuccess, result.ErrorMessage);
Assert.True(allocated < entrySize / 4, $"Allocated {allocated:N0} bytes for a {entrySize:N0}-byte entry.");
AssertLargeEntryCopied(output.ToArray(), entrySize);
}

[Fact]
public void Open_XmlPartLargerThanLimit_ThrowsInvalidPackage()
{
byte[] template = new OdtDocumentBuilder().AddParagraph(new string('x', 4096)).ToBytes();

TriasDev.Templify.OpenDocument.InvalidOdtPackageException exception =
Assert.Throws<TriasDev.Templify.OpenDocument.InvalidOdtPackageException>(
() => TriasDev.Templify.OpenDocument.OdtPackage.Open(new MemoryStream(template), maxXmlPartBytes: 2048));

Assert.Equal(
"Invalid document: content.xml exceeds the maximum supported size (2048 bytes uncompressed).",
exception.Message);
}

[Fact]
public void Process_ExistingLongerOutputFile_IsTruncated()
{
// File.OpenWrite does not truncate: without cutting off, the rest of a longer earlier file stayed behind.
string path = Path.Combine(Path.GetTempPath(), "templify-odt-openwrite-" + Guid.NewGuid().ToString("N") + ".odt");
try
{
File.WriteAllBytes(path, new byte[200 * 1024]);
byte[] template = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes();
ProcessingResult result;
using (FileStream output = File.OpenWrite(path))
{
result = new OdtTemplateProcessor().ProcessTemplate(new MemoryStream(template), output, _data);
}

Assert.True(result.IsSuccess, result.ErrorMessage);
byte[] written = File.ReadAllBytes(path);
Assert.True(written.Length < 200 * 1024);
OdtDocumentVerifier verifier = new OdtDocumentVerifier(written);
verifier.AssertValidOdtPackage();
Assert.Equal("Hello World", verifier.GetParagraphTexts()[0]);
}
finally
{
File.Delete(path);
}
}

[Fact]
public void Process_SeekableOutputWithPrefix_KeepsPrefixAndCutsOffTheRest()
{
byte[] template = new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes();
using MemoryStream output = new MemoryStream();
output.Write(new byte[] { 1, 2, 3 });
output.Write(new byte[100 * 1024]);
output.Position = 3;

ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(new MemoryStream(template), output, _data);

Assert.True(result.IsSuccess, result.ErrorMessage);
Assert.Equal(output.Position, output.Length);
byte[] written = output.ToArray();
Assert.Equal(new byte[] { 1, 2, 3 }, written[..3]);
Assert.Equal("Hello World", new OdtDocumentVerifier(written[3..]).GetParagraphTexts()[0]);
}

private static byte[] CreateTemplateWithLargeEntry(out int entrySize)
{
entrySize = 64 * 1024 * 1024;
return new OdtDocumentBuilder()
.AddParagraph("{{Name}}")
.AddEntry("Pictures/big.bin", new byte[entrySize])
.ToBytes();
}

private static void AssertLargeEntryCopied(byte[] output, int entrySize)
{
using ZipArchive archive = new ZipArchive(new MemoryStream(output), ZipArchiveMode.Read);
Assert.Equal(entrySize, archive.GetEntry("Pictures/big.bin")!.Length);
Assert.True(output.Length < entrySize / 100, $"Output of {output.Length:N0} bytes is not compressed.");
Assert.Equal("World", ReadParagraphText(archive));
}

private static string ReadParagraphText(ZipArchive archive)
{
using Stream content = archive.GetEntry("content.xml")!.Open();
System.Xml.Linq.XDocument document = System.Xml.Linq.XDocument.Load(content);
return document.Descendants(OdtDocumentVerifier.Text + "p").Single().Value;
}

private static byte[] CreateZip(params (string Name, string Content)[] entries)
{
using MemoryStream stream = new MemoryStream();
Expand Down
8 changes: 8 additions & 0 deletions TriasDev.Templify/Core/DocumentTemplateProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,14 @@ private ProcessingResult ProcessTemplateCore(
}

templateStream.CopyTo(outputStream);

// An output with longer earlier content (an existing file opened without truncation) would keep
// trailing bytes after the copy, and the package could not be opened.
if (outputStream.Length > outputStream.Position)
{
outputStream.SetLength(outputStream.Position);
}

outputStream.Position = 0;

// Track missing variables and warnings
Expand Down
3 changes: 2 additions & 1 deletion TriasDev.Templify/Core/OdtTemplateProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ public OdtTemplateProcessor(PlaceholderReplacementOptions? options = null)
/// <param name="templateStream">Stream containing the template .odt or .ott file. Must be readable.</param>
/// <param name="outputStream">
/// Stream to write the processed .odt document to. Must be writable. The document is built in memory and
/// written only when processing succeeds; on failure nothing is written.
/// written only when processing succeeds; on failure nothing is written. A seekable stream is cut off after the
/// written document.
/// </param>
/// <param name="data">Dictionary containing variable names and their replacement values.</param>
/// <returns>
Expand Down
Loading
Loading