diff --git a/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs b/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs index 7decb5809..2edb9a890 100644 --- a/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs +++ b/src/DocumentFormat.OpenXml.Framework/Features/StreamPackageFeature.cs @@ -56,7 +56,12 @@ public StreamPackageFeature(Stream stream, PackageOpenMode openMode, bool isOwne } catch when (isOwned) { - // Ensure that the stream if created is disposed before leaving the constructor so we don't hold onto it + if (_stream is not null && OpenXmlPackage.IsEncryptedOfficeFile(_stream)) + { + _stream.Dispose(); + throw new OpenXmlPackageException(ExceptionMessages.EncryptedPackageNotSupported); + } + _stream?.Dispose(); throw; } diff --git a/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs b/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs index c8ce6524a..b85b9fe17 100644 --- a/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs +++ b/src/DocumentFormat.OpenXml.Framework/Packaging/OpenXmlPackage.cs @@ -612,5 +612,97 @@ public void Save() /// public override IFeatureCollection Features => _features ??= new PackageFeatureCollection(this); + + /// + /// Determines whether the provided stream represents an encrypted Office Open XML file. + /// + /// The to check. The stream must be seekable and not null. + /// + /// true if the stream is an encrypted Office file (either OLE Compound File or contains an encrypted package part); otherwise, false. + /// + /// Thrown if is null. + /// Thrown if is not seekable. + /// + /// This method checks for the OLE Compound File signature at the start of the stream, which is used for encrypted Office files. + /// If not found, it attempts to open the stream as an OPC package and checks for the presence of an encrypted package part. + /// The stream position is restored after the check. + /// + public static bool IsEncryptedOfficeFile(Stream inputStream) + { + if (inputStream is null) + { + throw new ArgumentNullException(nameof(inputStream)); + } + + if (!inputStream.CanSeek) + { + throw new ArgumentException("Stream must be seekable."); + } + + long originalPosition = inputStream.Position; + + try + { + byte[] header = new byte[8]; + inputStream.Seek(0, SeekOrigin.Begin); + int read = inputStream.Read(header, 0, header.Length); + inputStream.Seek(originalPosition, SeekOrigin.Begin); + + // OLE Compound File signature for encrypted Office files + if (read == 8 && header.SequenceEqual(new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 })) + { + return true; + } + + // If not OLE, try to open as package and check for encrypted part + try + { + using (var package = System.IO.Packaging.Package.Open(inputStream, FileMode.Open, FileAccess.Read)) + { + foreach (var part in package.GetParts()) + { + if (part.ContentType.Equals("application/vnd.openxmlformats-officedocument.encrypted-package", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + catch + { + return false; + } + + return false; + } + finally + { + inputStream.Seek(originalPosition, SeekOrigin.Begin); + } + } + + /// + /// Determines whether the file at the specified path is an encrypted Office Open XML file. + /// + /// The path to the file to check. Must not be null. + /// + /// true if the file is an encrypted Office file (either OLE Compound File or contains an encrypted package part); otherwise, false. + /// + /// Thrown if is null. + /// + /// This method opens the file at the specified path and checks its contents using . + /// + public static bool IsEncryptedOfficeFile(string filePath) + { + if (filePath is null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + using (FileStream fileStream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + return IsEncryptedOfficeFile(fileStream); + } + } } } diff --git a/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt b/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt index abbee3a8e..c128a4b58 100644 --- a/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt +++ b/src/DocumentFormat.OpenXml.Framework/PublicAPI/PublicAPI.Shipped.txt @@ -1010,3 +1010,5 @@ DocumentFormat.OpenXml.OpenXmlPartWriterSettings.OpenXmlPartWriterSettings() -> DocumentFormat.OpenXml.OpenXmlPartWriter.OpenXmlPartWriter(DocumentFormat.OpenXml.Packaging.OpenXmlPart! openXmlPart, DocumentFormat.OpenXml.OpenXmlPartWriterSettings! settings) -> void DocumentFormat.OpenXml.OpenXmlPartWriter.OpenXmlPartWriter(System.IO.Stream! partStream, DocumentFormat.OpenXml.OpenXmlPartWriterSettings! settings) -> void virtual DocumentFormat.OpenXml.OpenXmlCompositeElement.IsValidChild(DocumentFormat.OpenXml.OpenXmlElement! element) -> bool +static DocumentFormat.OpenXml.Packaging.OpenXmlPackage.IsEncryptedOfficeFile(System.IO.Stream! inputStream) -> bool +static DocumentFormat.OpenXml.Packaging.OpenXmlPackage.IsEncryptedOfficeFile(string! filePath) -> bool diff --git a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs index cf1eaed38..fb33da11e 100644 --- a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs +++ b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.Designer.cs @@ -19,7 +19,7 @@ namespace DocumentFormat.OpenXml { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class ExceptionMessages { @@ -258,6 +258,15 @@ internal static string EmptyCollection { } } + /// + /// Looks up a localized string similar to Encrypted packages are not supported.. + /// + internal static string EncryptedPackageNotSupported { + get { + return ResourceManager.GetString("EncryptedPackageNotSupported", resourceCulture); + } + } + /// /// Looks up a localized string similar to The contentType parameter has incorrect value.. /// diff --git a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx index b0aaa58a4..a83880a81 100644 --- a/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx +++ b/src/DocumentFormat.OpenXml.Framework/Resources/ExceptionMessages.resx @@ -414,4 +414,7 @@ The enumerable contained more than a single element when only zero or one are allowed. + + Encrypted packages are not supported. + \ No newline at end of file diff --git a/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs b/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs index 1a98fd917..753367d77 100644 --- a/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs +++ b/test/DocumentFormat.OpenXml.Framework.Tests/Features/StreamPackageFeatureTests.cs @@ -11,6 +11,8 @@ using System.Linq; using Xunit; +using static DocumentFormat.OpenXml.Tests.TestAssets; + namespace DocumentFormat.OpenXml.Features.Tests; public class StreamPackageFeatureTests @@ -431,6 +433,17 @@ protected override void Dispose(bool disposing) } } + [Fact] + public void ThrowsForEncryptedOfficeFile() + { + using (Stream stream = GetStream(TestFiles.Encrypted_pptx, false)) + { + // Act & Assert + var ex = Assert.Throws(() => new StreamPackageFeature(stream, PackageOpenMode.Read, isOwned: true)); + Assert.Equal(ExceptionMessages.EncryptedPackageNotSupported, ex.Message); + } + } + private static readonly PartInfo Part1 = new(new("/part1", UriKind.Relative), "type1/content"); private static readonly PartInfo Part2 = new(new("/part2", UriKind.Relative), "type2/content"); private static readonly PartInfo PartRels = new(new("/_rels/.rels", UriKind.Relative), "application/vnd.openxmlformats-package.relationships+xml"); diff --git a/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs b/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs index 3bb51863d..589390087 100644 --- a/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs +++ b/test/DocumentFormat.OpenXml.Packaging.Tests/OpenXmlPackageTests.cs @@ -342,5 +342,61 @@ public void SucceedWithMissingCalcChainPart() Assert.NotNull(spd); } + + [Fact] + public void IsEncryptedOfficeFile_ReturnsTrue_ForEncryptedFile() + { + using (Stream stream = GetStream(TestFiles.Encrypted_pptx, false)) + { + Assert.True(OpenXmlPackage.IsEncryptedOfficeFile(stream)); + } + } + + [Fact] + public void IsEncryptedOfficeFile_ReturnsFalse_ForUnencryptedFile() + { + using (Stream stream = GetStream(TestFiles.Presentation, false)) + { + Assert.False(OpenXmlPackage.IsEncryptedOfficeFile(stream)); + } + } + + [Fact] + public void IsEncryptedOfficeFile_ThrowsArgumentNullException_ForNullStream() + { + Assert.Throws(() => OpenXmlPackage.IsEncryptedOfficeFile((Stream)null!)); + } + + [Fact] + public void IsEncryptedOfficeFile_ThrowsArgumentException_ForUnseekableStream() + { + var unseekable = new UnseekableStream(); + Assert.Throws(() => OpenXmlPackage.IsEncryptedOfficeFile(unseekable)); + } + + private class UnseekableStream : MemoryStream + { + public override bool CanSeek => false; + } + + [Fact] + public void IsEncryptedOfficeFile_ReturnsTrue_ForEncryptedFilePath() + { + string filePath = GetTestFilePath(TestFiles.Encrypted_pptx); + Assert.True(OpenXmlPackage.IsEncryptedOfficeFile(filePath)); + + // Clean up the test file path + File.Delete(filePath); + } + + [Fact] + public void IsEncryptedOfficeFile_ReturnsFalse_ForUnencryptedFile_FromString() + { + string filePath = GetTestFilePath(TestFiles.Presentation); + Assert.False(OpenXmlPackage.IsEncryptedOfficeFile(filePath)); + + // Clean up the test file path + File.Delete(filePath); + } } } diff --git a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs index f19a7a182..336759bcb 100644 --- a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs +++ b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.TestFiles.cs @@ -127,6 +127,8 @@ public static class Templates public const string Of16_09_unknownelement_docx = "TestFiles.Of16-09-UnknownElement.docx"; public const string Of16_10_symex_docx = "TestFiles.Of16-10-SymEx.docx"; + + public const string Encrypted_pptx = "TestFiles.encrypted_pptx.pptx"; } } } diff --git a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs index a52f09bfb..e5e29a5d5 100644 --- a/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs +++ b/test/DocumentFormat.OpenXml.Tests.Assets/TestAssets.cs @@ -71,6 +71,26 @@ public static Stream GetStream(string name, bool isEditable) return isEditable ? stream.AsMemoryStream() : stream; } + /// + /// Extracts an embedded test resource to a temporary file and returns its file path. + /// + /// The name of the embedded resource to extract. + /// The full path to the temporary file containing the resource data. + /// + /// The caller is responsible for deleting the temporary file after use. + /// + public static string GetTestFilePath(string resourceName) + { + string tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + Path.GetExtension(resourceName)); + + using (Stream stream = GetStream(resourceName, false)) + using (FileStream fileStream = File.Create(tempPath)) + { + stream.CopyTo(fileStream); + return tempPath; + } + } + private static Stream AsMemoryStream(this Stream stream) { if (stream is MemoryStream ms) diff --git a/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestFiles/encrypted_pptx.pptx b/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestFiles/encrypted_pptx.pptx new file mode 100644 index 000000000..2d5228fb4 Binary files /dev/null and b/test/DocumentFormat.OpenXml.Tests.Assets/assets/TestFiles/encrypted_pptx.pptx differ