From df3260544cfdcebd8583511d3912aaebc7cf4c04 Mon Sep 17 00:00:00 2001 From: Vaceslav Ustinov Date: Sun, 27 Sep 2026 15:34:09 +0200 Subject: [PATCH] fix: never leave trailing bytes when the output stream cannot be truncated --- .../Integration/StreamValidationTests.cs | 54 ++++++++++++ .../Odt/OdtPackageBranchTests.cs | 82 ++++++++++++++++--- .../Core/DocumentTemplateProcessor.cs | 14 ++-- .../Core/OdtTemplateProcessor.cs | 4 + TriasDev.Templify/Core/TemplateExceptions.cs | 35 ++++++++ TriasDev.Templify/OpenDocument/OdtPackage.cs | 38 ++++----- docs/for-developers/opendocument.md | 12 +-- docs/for-developers/quick-start.md | 5 +- 8 files changed, 200 insertions(+), 44 deletions(-) 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