diff --git a/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs b/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs
index cf2b081..19a1668 100644
--- a/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs
+++ b/TriasDev.Templify.Tests/Integration/StreamValidationTests.cs
@@ -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));
+ }
+
+ /// A seekable, readable and writable stream whose length cannot be set.
+ 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);
diff --git a/TriasDev.Templify.Tests/Odt/OdtPackageBranchTests.cs b/TriasDev.Templify.Tests/Odt/OdtPackageBranchTests.cs
index bdef390..717228b 100644
--- a/TriasDev.Templify.Tests/Odt/OdtPackageBranchTests.cs
+++ b/TriasDev.Templify.Tests/Odt/OdtPackageBranchTests.cs
@@ -122,11 +122,45 @@ 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()),
@@ -134,18 +168,46 @@ public void SeekableOutputThatCannotChangeItsLength_IsWrittenWithoutTruncation()
_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]);
+ }
+
+ /// A seekable, writable stream whose length cannot be set ( throws).
+ 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]
diff --git a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs
index 0def0d5..04cdce6 100644
--- a/TriasDev.Templify/Core/DocumentTemplateProcessor.cs
+++ b/TriasDev.Templify/Core/DocumentTemplateProcessor.cs
@@ -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
@@ -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}");
diff --git a/TriasDev.Templify/Core/OdtTemplateProcessor.cs b/TriasDev.Templify/Core/OdtTemplateProcessor.cs
index 4067c51..36a1582 100644
--- a/TriasDev.Templify/Core/OdtTemplateProcessor.cs
+++ b/TriasDev.Templify/Core/OdtTemplateProcessor.cs
@@ -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}");
diff --git a/TriasDev.Templify/Core/TemplateExceptions.cs b/TriasDev.Templify/Core/TemplateExceptions.cs
index c9f7f03..bd414d3 100644
--- a/TriasDev.Templify/Core/TemplateExceptions.cs
+++ b/TriasDev.Templify/Core/TemplateExceptions.cs
@@ -62,3 +62,38 @@ public TemplateDataException(string message)
{
}
}
+
+///
+/// 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 ( is not
+/// supported). Writing would leave trailing bytes of the earlier content, i.e. a corrupt file.
+///
+/// The template processors convert it into a failed result with its message.
+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)
+ {
+ }
+
+ ///
+ /// Truncates a seekable at its current position.
+ ///
+ /// The stream does not support .
+ public static void TruncateAtPosition(Stream output)
+ {
+ try
+ {
+ output.SetLength(output.Position);
+ }
+ catch (NotSupportedException ex)
+ {
+ throw new OutputStreamNotTruncatableException(ex);
+ }
+ }
+}
diff --git a/TriasDev.Templify/OpenDocument/OdtPackage.cs b/TriasDev.Templify/OpenDocument/OdtPackage.cs
index 2867c2f..03a293b 100644
--- a/TriasDev.Templify/OpenDocument/OdtPackage.cs
+++ b/TriasDev.Templify/OpenDocument/OdtPackage.cs
@@ -5,6 +5,7 @@
using System.Text;
using System.Xml;
using System.Xml.Linq;
+using TriasDev.Templify.Core;
namespace TriasDev.Templify.OpenDocument;
@@ -256,8 +257,13 @@ public XDocument AddXml(string entryName, XDocument document)
///
/// 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.
///
+ ///
+ /// The output has earlier content after its position that is longer than the package and cannot be truncated;
+ /// nothing is written.
+ ///
public void Save(Stream output)
{
if (IsTemplate)
@@ -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);
}
///
@@ -343,27 +356,6 @@ internal static byte[] Serialize(XDocument document)
return stream.ToArray();
}
- ///
- /// Cuts off what follows the written package in a seekable output, e.g. the rest of a longer file opened with
- /// .
- ///
- 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).
diff --git a/docs/for-developers/opendocument.md b/docs/for-developers/opendocument.md
index 8a3639d..310412b 100644
--- a/docs/for-developers/opendocument.md
+++ b/docs/for-developers/opendocument.md
@@ -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.
@@ -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
diff --git a/docs/for-developers/quick-start.md b/docs/for-developers/quick-start.md
index 9ac09d4..56f2ff4 100644
--- a/docs/for-developers/quick-start.md
+++ b/docs/for-developers/quick-start.md
@@ -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