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
54 changes: 54 additions & 0 deletions TriasDev.Templify.Tests/Integration/StreamValidationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,60 @@ public void ProcessTemplate_ExistingLongerOutputFile_IsTruncated()
}
}

[Fact]
public void ProcessTemplate_OutputWithEarlierContentThatCannotBeTruncated_FailsWithoutWriting()
{
// The earlier content would stay behind the document (a corrupt package), so processing fails before the
// template is copied into the output.
byte[] earlier = Enumerable.Repeat((byte)0xAB, 200 * 1024).ToArray();
using NonTruncatableStream output = new NonTruncatableStream(earlier);

ProcessingResult result = new DocumentTemplateProcessor().ProcessTemplate(CreateTemplate(), output, _data);

Assert.False(result.IsSuccess);
Assert.Equal(TriasDev.Templify.Core.OutputStreamNotTruncatableException.DefaultMessage, result.ErrorMessage);
Assert.Equal(earlier, output.ToArray());
}

[Fact]
public void ProcessTemplate_FacadeWithOutputThatCannotBeTruncated_FailsWithoutWriting()
{
byte[] earlier = Enumerable.Repeat((byte)0xAB, 200 * 1024).ToArray();
using NonTruncatableStream output = new NonTruncatableStream(earlier);

ProcessingResult result = new TemplateProcessor().ProcessTemplate(CreateTemplate(), output, _data);

Assert.Equal(TriasDev.Templify.Core.OutputStreamNotTruncatableException.DefaultMessage, result.ErrorMessage);
Assert.Equal(earlier, output.ToArray());
}

[Fact]
public void ProcessTemplate_OutputWithShorterEarlierContent_IsTruncatedBeforeTheCopy()
{
using MemoryStream output = new MemoryStream();
output.Write(Enumerable.Repeat((byte)0xAB, 16).ToArray());
output.Position = 0;

ProcessingResult result = new DocumentTemplateProcessor().ProcessTemplate(CreateTemplate(), output, _data);

Assert.True(result.IsSuccess, result.ErrorMessage);
using DocumentVerifier verifier = new DocumentVerifier(output);
Assert.Equal("Hello Alice!", verifier.GetParagraphText(0));
}

/// <summary>A seekable, readable and writable stream whose length cannot be set.</summary>
private sealed class NonTruncatableStream : MemoryStream
{
public NonTruncatableStream(byte[] content)
: base(content.Length)
{
Write(content);
Position = 0;
}

public override void SetLength(long value) => throw new NotSupportedException();
}

private static void AssertOutputStreamFailure(ProcessingResult result)
{
Assert.False(result.IsSuccess);
Expand Down
82 changes: 72 additions & 10 deletions TriasDev.Templify.Tests/Odt/OdtPackageBranchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,30 +122,92 @@ public void GetXml_OfAnEntryThatIsNotAnXmlPart_Throws()
}

[Fact]
public void SeekableOutputThatCannotChangeItsLength_IsWrittenWithoutTruncation()
public void SeekableOutputThatCannotBeTruncated_WithLongerEarlierContent_FailsWithoutWriting()
{
// The rest of a longer seekable output is cut off where possible. A stream that cannot change its length
// does not fail the processing; the package is written from the start and the stream keeps its length.
using FixedLengthStream output = new FixedLengthStream(new byte[1024 * 1024]);
// The package is shorter than the earlier content, and the stream cannot be truncated: writing would leave
// trailing bytes of the earlier content (a corrupt file), so processing fails and nothing is written.
byte[] earlier = Enumerable.Repeat((byte)0xAB, 1024 * 1024).ToArray();
using NonTruncatableStream output = new NonTruncatableStream(earlier.ToArray());

ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(
new MemoryStream(new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes()),
output,
_data);

Assert.False(result.IsSuccess);
Assert.Equal(OutputStreamNotTruncatableException.DefaultMessage, result.ErrorMessage);
Assert.StartsWith("Invalid output stream:", result.ErrorMessage);
Assert.Equal(earlier, output.ToArray());
Assert.Equal(0, output.Position);
}

[Fact]
public void SeekableOutputThatCannotBeTruncated_ThroughTheFacade_FailsWithoutWriting()
{
byte[] earlier = Enumerable.Repeat((byte)0xAB, 1024 * 1024).ToArray();
using NonTruncatableStream output = new NonTruncatableStream(earlier.ToArray());

ProcessingResult result = new TemplateProcessor().ProcessTemplate(
new MemoryStream(new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes()),
output,
_data);

Assert.Equal(OutputStreamNotTruncatableException.DefaultMessage, result.ErrorMessage);
Assert.Equal(earlier, output.ToArray());
}

[Fact]
public void SeekableOutputThatCannotBeTruncated_WithShorterEarlierContent_IsOverwritten()
{
// The package overwrites all earlier content, so no truncation is needed.
using NonTruncatableStream output = new NonTruncatableStream(Enumerable.Repeat((byte)0xAB, 64).ToArray());

ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(
new MemoryStream(new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes()),
output,
_data);

Assert.True(result.IsSuccess, result.ErrorMessage);
Assert.Equal(1024 * 1024, output.Length);
Assert.Equal("Hello World", new OdtDocumentVerifier(output.ToArray()[..(int)output.Position]).GetParagraphTexts()[0]);
Assert.Equal(0, output.SetLengthCalls);
Assert.Equal(output.Position, output.Length);
OdtDocumentVerifier verifier = new OdtDocumentVerifier(output.ToArray());
verifier.AssertValidOdtPackage();
Assert.Equal("Hello World", verifier.GetParagraphTexts()[0]);
}

private sealed class FixedLengthStream : MemoryStream
[Fact]
public void SeekableOutputWithLongerEarlierContent_IsTruncatedBeforeWriting()
{
public FixedLengthStream(byte[] buffer)
: base(buffer, writable: true)
// A fixed-size MemoryStream can shrink, so the earlier content is cut off.
using MemoryStream output = new MemoryStream(Enumerable.Repeat((byte)0xAB, 1024 * 1024).ToArray(), writable: true);

ProcessingResult result = new OdtTemplateProcessor().ProcessTemplate(
new MemoryStream(new OdtDocumentBuilder().AddParagraph("Hello {{Name}}").ToBytes()),
output,
_data);

Assert.True(result.IsSuccess, result.ErrorMessage);
Assert.Equal(output.Position, output.Length);
Assert.Equal("Hello World", new OdtDocumentVerifier(output.ToArray()).GetParagraphTexts()[0]);
}

/// <summary>A seekable, writable stream whose length cannot be set (<see cref="Stream.SetLength"/> throws).</summary>
private sealed class NonTruncatableStream : MemoryStream
{
public NonTruncatableStream(byte[] content)
: base(content.Length)
{
Write(content);
Position = 0;
}

public override void SetLength(long value) => throw new NotSupportedException();
public int SetLengthCalls { get; private set; }

public override void SetLength(long value)
{
SetLengthCalls++;
throw new NotSupportedException();
}
}

[Fact]
Expand Down
14 changes: 9 additions & 5 deletions TriasDev.Templify/Core/DocumentTemplateProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -402,15 +402,15 @@ private ProcessingResult ProcessTemplateCore(
templateStream.Position = 0;
}

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.
// An output with earlier content (an existing file opened without truncation) would keep trailing bytes
// after the copy, and the package could not be opened. It is truncated before the copy, so a stream that
// cannot be truncated fails before anything is written.
if (outputStream.Length > outputStream.Position)
{
outputStream.SetLength(outputStream.Position);
OutputStreamNotTruncatableException.TruncateAtPosition(outputStream);
}

templateStream.CopyTo(outputStream);
outputStream.Position = 0;

// Track missing variables and warnings
Expand Down Expand Up @@ -474,6 +474,10 @@ private ProcessingResult ProcessTemplateCore(
// that does not fit the template, is reported as a failed result.
throw ex.ToPublicException();
}
catch (OutputStreamNotTruncatableException ex)
{
return ProcessingResult.Failure(ex.Message);
}
catch (Exception ex)
{
return ProcessingResult.Failure($"Processing failed: {ex.Message}");
Expand Down
4 changes: 4 additions & 0 deletions TriasDev.Templify/Core/OdtTemplateProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,10 @@ private ProcessingResult ProcessTemplateCore(
{
return ProcessingResult.Failure(ex.Message);
}
catch (OutputStreamNotTruncatableException ex)
{
return ProcessingResult.Failure(ex.Message);
}
catch (Exception ex)
{
return ProcessingResult.Failure($"Processing failed: {ex.Message}");
Expand Down
35 changes: 35 additions & 0 deletions TriasDev.Templify/Core/TemplateExceptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,38 @@ public TemplateDataException(string message)
{
}
}

/// <summary>
/// Thrown before anything is written when the output stream holds earlier content after its position that the
/// document would not overwrite, and the stream cannot be truncated (<see cref="Stream.SetLength"/> is not
/// supported). Writing would leave trailing bytes of the earlier content, i.e. a corrupt file.
/// </summary>
/// <remarks>The template processors convert it into a failed result with its message.</remarks>
internal sealed class OutputStreamNotTruncatableException : IOException
{
internal const string DefaultMessage =
"Invalid output stream: the output stream has earlier content after the current position that would remain "
+ "after the document, and it cannot be truncated (SetLength is not supported). Pass an empty output stream "
+ "or one that supports SetLength (for example a FileStream opened with FileMode.Create).";

public OutputStreamNotTruncatableException(Exception innerException)
: base(DefaultMessage, innerException)
{
}

/// <summary>
/// Truncates a seekable <paramref name="output"/> at its current position.
/// </summary>
/// <exception cref="OutputStreamNotTruncatableException">The stream does not support <see cref="Stream.SetLength"/>.</exception>
public static void TruncateAtPosition(Stream output)
{
try
{
output.SetLength(output.Position);
}
catch (NotSupportedException ex)
{
throw new OutputStreamNotTruncatableException(ex);
}
}
}
38 changes: 15 additions & 23 deletions TriasDev.Templify/OpenDocument/OdtPackage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Text;
using System.Xml;
using System.Xml.Linq;
using TriasDev.Templify.Core;

namespace TriasDev.Templify.OpenDocument;

Expand Down Expand Up @@ -256,8 +257,13 @@ public XDocument AddXml(string entryName, XDocument document)

/// <summary>
/// Writes the package as an OpenDocument Text document (.odt) at the output's current position. A seekable
/// output is truncated after the package, so no bytes of longer earlier content remain.
/// output with longer earlier content is truncated before the package is written, so no bytes of that content
/// remain.
/// </summary>
/// <exception cref="OutputStreamNotTruncatableException">
/// The output has earlier content after its position that is longer than the package and cannot be truncated;
/// nothing is written.
/// </exception>
public void Save(Stream output)
{
if (IsTemplate)
Expand Down Expand Up @@ -316,9 +322,16 @@ public void Save(Stream output)
}
}

// A package at least as long as the earlier content after the position overwrites all of it. A shorter one
// needs the output truncated first; a stream that cannot be truncated would keep trailing bytes (a corrupt
// file), so it fails before anything is written.
if (output.CanSeek && output.Length - output.Position > buffer.Length)
{
OutputStreamNotTruncatableException.TruncateAtPosition(output);
}

buffer.Position = 0;
buffer.CopyTo(output);
TruncateAfterPosition(output);
}

/// <summary>
Expand All @@ -343,27 +356,6 @@ internal static byte[] Serialize(XDocument document)
return stream.ToArray();
}

/// <summary>
/// Cuts off what follows the written package in a seekable output, e.g. the rest of a longer file opened with
/// <see cref="File.OpenWrite(string)"/>.
/// </summary>
private static void TruncateAfterPosition(Stream output)
{
if (!output.CanSeek || output.Length <= output.Position)
{
return;
}

try
{
output.SetLength(output.Position);
}
catch (NotSupportedException)
{
// A seekable stream with a fixed length (e.g. a MemoryStream over a byte array): nothing to cut off with.
}
}

private static void SetLastWriteTime(ZipArchiveEntry entry, DateTimeOffset time)
{
// ZIP (DOS) timestamps cover 1980-2107; out-of-range source times keep the default (now).
Expand Down
12 changes: 7 additions & 5 deletions docs/for-developers/opendocument.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ The requirements are looser than for Word documents:
in memory and written in one go, and **only when processing succeeds**. On failure nothing is written.
- To write a file, open it with **`File.Create`**, which creates the file or truncates an existing one.
`File.OpenWrite` does not truncate: it writes over an existing file from the start and keeps any bytes after the
new content, which leaves a corrupt ZIP file when the old file was longer. Templify cuts off seekable outputs after
the document (see [Memory Use](#memory-use)), but `File.Create` does not depend on that.
new content, which leaves a corrupt ZIP file when the old file was longer. Templify truncates seekable outputs first
(see [Memory Use](#memory-use)), but `File.Create` does not depend on that.

A template that is not an OpenDocument Text package is reported as a failed result, with an `ErrorMessage` that
names what was found. This covers a Word file, a spreadsheet, a flat `.fodt` file or a password-protected document.
Expand All @@ -85,9 +85,11 @@ these limits fails with an `ErrorMessage` such as `content.xml exceeds the maxim
are far below these limits. Treat templates from untrusted sources with care anyway: an entry that unpacks to a very
large size is not held in memory, but it still costs time to copy.

When the output stream is seekable (a file or a `MemoryStream`), it is cut off after the written document, so even
an existing, longer file opened with `File.OpenWrite` does not keep bytes of its earlier content. Prefer
`File.Create` anyway.
When the output stream is seekable (a file or a `MemoryStream`) and holds earlier content after its position that is
longer than the document, it is truncated before the document is written, so even an existing, longer file opened
with `File.OpenWrite` does not keep bytes of its earlier content. A seekable stream that cannot be truncated
(`SetLength` throws `NotSupportedException`) would keep those bytes and give a corrupt file, so processing fails with
an `Invalid output stream: ...` error and nothing is written. Prefer `File.Create` anyway.

### Options

Expand Down
5 changes: 4 additions & 1 deletion docs/for-developers/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ results), `TriasDev.Templify.Conditionals` (standalone condition evaluation), `T
The output stream must be **readable, writable and seekable** (a `MemoryStream`, or a `FileStream` opened
with `FileAccess.ReadWrite` such as `File.Create`; `File.OpenWrite` is write-only and does not work), because the
document is edited in place after the template has been copied into it. An unusable stream is reported as a
failed result (`IsSuccess == false` with an `ErrorMessage`) before anything is written to it.
failed result (`IsSuccess == false` with an `ErrorMessage`) before anything is written to it. An output with earlier
content (for example an existing file opened with `FileMode.OpenOrCreate`) is truncated first; if it cannot be
truncated (`SetLength` is not supported), processing fails with an `Invalid output stream: ...` error and the
earlier content is left unchanged.

### Step 3: Run It

Expand Down
Loading