From 083181f1e5e5ccc35699afb9a6508f7146f84351 Mon Sep 17 00:00:00 2001 From: Carlos Sanchez <1175054+carlossanlop@users.noreply.github.com> Date: Fri, 22 Apr 2022 09:17:29 -0700 Subject: [PATCH] [release/7.0-preview4] Backport new Tar APIs (#68337) * Implement Tar APIs (#67883) API proposal: https://github.com/dotnet/runtime/issues/65951 * Add assembly to NetCoreAppLibrary.props * Remove from src csproj since it is not OOB. * Add NetCoreAppCurrent to src csproj TargetFrameworks * Additional src csproj changes. * Allow sharing of input tar file for read Co-authored-by: carlossanlop Co-authored-by: Dan Moseley --- eng/Version.Details.xml | 4 + eng/Versions.props | 1 + .../Unix/System.Native/Interop.DeviceFiles.cs | 28 + .../Unix/System.Native/Interop.MkFifo.cs | 14 + .../Unix/System.Native/Interop.Stat.cs | 1 + .../Kernel32/Interop.CreateHardLink.cs | 28 + .../src/System/IO/Archiving.Utils.Unix.cs | 10 + .../src/System/IO/Archiving.Utils.Windows.cs} | 13 +- .../src/System/IO/Archiving.Utils.cs} | 20 +- .../Common/tests/Resources/Strings.resx | 5 +- src/libraries/NetCoreAppLibrary.props | 1 + .../System.Formats.Tar/System.Formats.Tar.sln | 48 ++ .../ref/System.Formats.Tar.cs | 123 +++ .../ref/System.Formats.Tar.csproj | 13 + .../src/Resources/Strings.resx | 258 ++++++ .../src/System.Formats.Tar.csproj | 74 ++ .../src/System/Formats/Tar/FieldLengths.cs | 56 ++ .../src/System/Formats/Tar/FieldLocations.cs | 53 ++ .../src/System/Formats/Tar/GnuTarEntry.cs | 73 ++ .../src/System/Formats/Tar/PaxTarEntry.cs | 123 +++ .../src/System/Formats/Tar/PosixTarEntry.cs | 102 +++ .../Formats/Tar/SeekableSubReadStream.cs | 111 +++ .../src/System/Formats/Tar/SubReadStream.cs | 180 ++++ .../src/System/Formats/Tar/TarEntry.Unix.cs | 60 ++ .../System/Formats/Tar/TarEntry.Windows.cs | 50 ++ .../src/System/Formats/Tar/TarEntry.cs | 470 ++++++++++ .../src/System/Formats/Tar/TarEntryType.cs | 101 +++ .../src/System/Formats/Tar/TarFile.cs | 274 ++++++ .../src/System/Formats/Tar/TarFileMode.cs | 66 ++ .../src/System/Formats/Tar/TarFormat.cs | 32 + .../src/System/Formats/Tar/TarHeader.Read.cs | 581 +++++++++++++ .../src/System/Formats/Tar/TarHeader.Write.cs | 631 ++++++++++++++ .../src/System/Formats/Tar/TarHeader.cs | 86 ++ .../src/System/Formats/Tar/TarHelpers.cs | 293 +++++++ .../src/System/Formats/Tar/TarReader.cs | 433 ++++++++++ .../src/System/Formats/Tar/TarWriter.Unix.cs | 96 +++ .../System/Formats/Tar/TarWriter.Windows.cs | 81 ++ .../src/System/Formats/Tar/TarWriter.cs | 320 +++++++ .../src/System/Formats/Tar/UstarTarEntry.cs | 38 + .../src/System/Formats/Tar/V7TarEntry.cs | 33 + .../tests/System.Formats.Tar.Tests.csproj | 72 ++ .../tests/TarEntry/TarEntryGnu.Tests.cs | 170 ++++ .../tests/TarEntry/TarEntryPax.Tests.cs | 168 ++++ .../tests/TarEntry/TarEntryUstar.Tests.cs | 166 ++++ .../tests/TarEntry/TarEntryV7.Tests.cs | 145 ++++ .../TarFile.CreateFromDirectory.File.Tests.cs | 165 ++++ ...arFile.CreateFromDirectory.Stream.Tests.cs | 44 + ...File.ExtractToDirectory.File.Tests.Unix.cs | 32 + ...e.ExtractToDirectory.File.Tests.Windows.cs | 32 + .../TarFile.ExtractToDirectory.File.Tests.cs | 143 ++++ ...TarFile.ExtractToDirectory.Stream.Tests.cs | 128 +++ .../TarReader.ExtractToFile.Tests.cs | 44 + .../tests/TarReader/TarReader.File.Tests.cs | 806 ++++++++++++++++++ .../TarReader/TarReader.GetNextEntry.Tests.cs | 254 ++++++ .../tests/TarTestsBase.Gnu.cs | 120 +++ .../tests/TarTestsBase.Pax.cs | 118 +++ .../tests/TarTestsBase.Posix.cs | 148 ++++ .../tests/TarTestsBase.Ustar.cs | 84 ++ .../tests/TarTestsBase.V7.cs | 26 + .../System.Formats.Tar/tests/TarTestsBase.cs | 285 +++++++ .../tests/TarWriter/TarWriter.Tests.cs | 131 +++ .../TarWriter.WriteEntry.Entry.Gnu.Tests.cs | 254 ++++++ .../TarWriter.WriteEntry.Entry.Pax.Tests.cs | 293 +++++++ .../TarWriter.WriteEntry.Entry.Ustar.Tests.cs | 175 ++++ .../TarWriter.WriteEntry.Entry.V7.Tests.cs | 151 ++++ .../TarWriter.WriteEntry.File.Tests.Unix.cs | 199 +++++ ...TarWriter.WriteEntry.File.Tests.Windows.cs | 79 ++ .../TarWriter.WriteEntry.File.Tests.cs | 255 ++++++ .../TarWriter/TarWriter.WriteEntry.Tests.cs | 59 ++ .../System.Formats.Tar/tests/WrappedStream.cs | 132 +++ .../src/System.IO.Compression.ZipFile.csproj | 9 +- .../System/IO/Compression/ZipFile.Create.cs | 8 +- ...pFileExtensions.ZipArchiveEntry.Extract.cs | 11 +- .../Compression/ZipFileValidFullName_Unix.cs | 15 - src/native/libs/Common/pal_config.h.in | 2 + src/native/libs/System.Native/entrypoints.c | 3 + src/native/libs/System.Native/pal_io.c | 33 + src/native/libs/System.Native/pal_io.h | 21 + src/native/libs/configure.cmake | 14 + 79 files changed, 9936 insertions(+), 42 deletions(-) create mode 100644 src/libraries/Common/src/Interop/Unix/System.Native/Interop.DeviceFiles.cs create mode 100644 src/libraries/Common/src/Interop/Unix/System.Native/Interop.MkFifo.cs create mode 100644 src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CreateHardLink.cs create mode 100644 src/libraries/Common/src/System/IO/Archiving.Utils.Unix.cs rename src/libraries/{System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidName_Windows.cs => Common/src/System/IO/Archiving.Utils.Windows.cs} (62%) rename src/libraries/{System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Utils.cs => Common/src/System/IO/Archiving.Utils.cs} (78%) create mode 100644 src/libraries/System.Formats.Tar/System.Formats.Tar.sln create mode 100644 src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs create mode 100644 src/libraries/System.Formats.Tar/ref/System.Formats.Tar.csproj create mode 100644 src/libraries/System.Formats.Tar/src/Resources/Strings.resx create mode 100644 src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLengths.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLocations.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/GnuTarEntry.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/SeekableSubReadStream.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/SubReadStream.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntryType.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFileMode.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFormat.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHelpers.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarReader.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/UstarTarEntry.cs create mode 100644 src/libraries/System.Formats.Tar/src/System/Formats/Tar/V7TarEntry.cs create mode 100644 src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj create mode 100644 src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryGnu.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryPax.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryUstar.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryV7.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.File.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.Stream.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarReader/TarReader.ExtractToFile.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarReader/TarReader.File.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarTestsBase.Gnu.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarTestsBase.Pax.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarTestsBase.Posix.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarTestsBase.Ustar.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarTestsBase.V7.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarTestsBase.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Gnu.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Ustar.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.V7.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Windows.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Tests.cs create mode 100644 src/libraries/System.Formats.Tar/tests/WrappedStream.cs delete mode 100644 src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidFullName_Unix.cs diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index bb8ef4ce77ffd..4beed3e070f6f 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -138,6 +138,10 @@ https://github.com/dotnet/runtime-assets 78cb33dbb0fb5156f049b9e1778f47b508f1be9f + + https://github.com/dotnet/runtime-assets + 78cb33dbb0fb5156f049b9e1778f47b508f1be9f + https://github.com/dotnet/runtime-assets 78cb33dbb0fb5156f049b9e1778f47b508f1be9f diff --git a/eng/Versions.props b/eng/Versions.props index 2c60a8dcb5a75..63a6ef6627f6c 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -130,6 +130,7 @@ 7.0.0-beta.22214.1 7.0.0-beta.22214.1 7.0.0-beta.22214.1 + 7.0.0-beta.22214.1 7.0.0-beta.22214.1 7.0.0-beta.22214.1 7.0.0-beta.22214.1 diff --git a/src/libraries/Common/src/Interop/Unix/System.Native/Interop.DeviceFiles.cs b/src/libraries/Common/src/Interop/Unix/System.Native/Interop.DeviceFiles.cs new file mode 100644 index 0000000000000..b212db6417c41 --- /dev/null +++ b/src/libraries/Common/src/Interop/Unix/System.Native/Interop.DeviceFiles.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + // mknod: https://man7.org/linux/man-pages/man2/mknod.2.html + // makedev, major and minor: https://man7.org/linux/man-pages/man3/makedev.3.html + internal static partial class Sys + { + internal static int CreateBlockDevice(string pathName, uint mode, uint major, uint minor) + { + return MkNod(pathName, mode | FileTypes.S_IFBLK, major, minor); + } + + internal static int CreateCharacterDevice(string pathName, uint mode, uint major, uint minor) + { + return MkNod(pathName, mode | FileTypes.S_IFCHR, major, minor); + } + + [LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_MkNod", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + private static partial int MkNod(string pathName, uint mode, uint major, uint minor); + + [LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_GetDeviceIdentifiers", SetLastError = true)] + internal static unsafe partial int GetDeviceIdentifiers(ulong dev, uint* majorNumber, uint* minorNumber); + } +} diff --git a/src/libraries/Common/src/Interop/Unix/System.Native/Interop.MkFifo.cs b/src/libraries/Common/src/Interop/Unix/System.Native/Interop.MkFifo.cs new file mode 100644 index 0000000000000..ed162a1057951 --- /dev/null +++ b/src/libraries/Common/src/Interop/Unix/System.Native/Interop.MkFifo.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + // mkfifo: https://man7.org/linux/man-pages/man3/mkfifo.3.html + internal static partial class Sys + { + [LibraryImport(Libraries.SystemNative, EntryPoint = "SystemNative_MkFifo", StringMarshalling = StringMarshalling.Utf8, SetLastError = true)] + internal static partial int MkFifo(string pathName, uint mode); + } +} diff --git a/src/libraries/Common/src/Interop/Unix/System.Native/Interop.Stat.cs b/src/libraries/Common/src/Interop/Unix/System.Native/Interop.Stat.cs index 395c99dd7fd67..b5136bbfe2777 100644 --- a/src/libraries/Common/src/Interop/Unix/System.Native/Interop.Stat.cs +++ b/src/libraries/Common/src/Interop/Unix/System.Native/Interop.Stat.cs @@ -41,6 +41,7 @@ internal static class FileTypes internal const int S_IFIFO = 0x1000; internal const int S_IFCHR = 0x2000; internal const int S_IFDIR = 0x4000; + internal const int S_IFBLK = 0x6000; internal const int S_IFREG = 0x8000; internal const int S_IFLNK = 0xA000; internal const int S_IFSOCK = 0xC000; diff --git a/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CreateHardLink.cs b/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CreateHardLink.cs new file mode 100644 index 0000000000000..07db336c846fd --- /dev/null +++ b/src/libraries/Common/src/Interop/Windows/Kernel32/Interop.CreateHardLink.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Runtime.InteropServices; + +internal static partial class Interop +{ + internal static partial class Kernel32 + { + internal static void CreateHardLink(string hardLinkFilePath, string targetFilePath) + { + string originalPath = hardLinkFilePath; + hardLinkFilePath = PathInternal.EnsureExtendedPrefix(hardLinkFilePath); + targetFilePath = PathInternal.EnsureExtendedPrefix(targetFilePath); + + if (!CreateHardLinkPrivate(hardLinkFilePath, targetFilePath, IntPtr.Zero)) + { + throw Win32Marshal.GetExceptionForLastWin32Error(originalPath); + } + } + + [LibraryImport(Libraries.Kernel32, EntryPoint = "CreateHardLinkW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CreateHardLinkPrivate(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes); + } +} diff --git a/src/libraries/Common/src/System/IO/Archiving.Utils.Unix.cs b/src/libraries/Common/src/System/IO/Archiving.Utils.Unix.cs new file mode 100644 index 0000000000000..ca9b37759a31c --- /dev/null +++ b/src/libraries/Common/src/System/IO/Archiving.Utils.Unix.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.IO +{ + internal static partial class ArchivingUtils + { + internal static string SanitizeEntryFilePath(string entryPath) => entryPath.Replace('\0', '_'); + } +} diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidName_Windows.cs b/src/libraries/Common/src/System/IO/Archiving.Utils.Windows.cs similarity index 62% rename from src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidName_Windows.cs rename to src/libraries/Common/src/System/IO/Archiving.Utils.Windows.cs index 6ab389dce6109..d8ee4623c4bd0 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidName_Windows.cs +++ b/src/libraries/Common/src/System/IO/Archiving.Utils.Windows.cs @@ -1,17 +1,16 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. - using System.Text; -namespace System.IO.Compression +namespace System.IO { - public static partial class ZipFileExtensions + internal static partial class ArchivingUtils { - internal static string SanitizeZipFilePath(string zipPath) + internal static string SanitizeEntryFilePath(string entryPath) { - StringBuilder builder = new StringBuilder(zipPath); - for (int i = 0; i < zipPath.Length; i++) + StringBuilder builder = new StringBuilder(entryPath); + for (int i = 0; i < entryPath.Length; i++) { if (((int)builder[i] >= 0 && (int)builder[i] < 32) || builder[i] == '?' || builder[i] == ':' || diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Utils.cs b/src/libraries/Common/src/System/IO/Archiving.Utils.cs similarity index 78% rename from src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Utils.cs rename to src/libraries/Common/src/System/IO/Archiving.Utils.cs index 6e20e29b052f3..9a46774c430b9 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Utils.cs +++ b/src/libraries/Common/src/System/IO/Archiving.Utils.cs @@ -5,11 +5,13 @@ using System.Collections.Generic; using System.Diagnostics; -namespace System.IO.Compression +namespace System.IO { - internal static partial class ZipFileUtils + internal static partial class ArchivingUtils { - // Per the .ZIP File Format Specification 4.4.17.1 all slashes should be forward slashes + // To ensure tar files remain compatible with Unix, + // and per the ZIP File Format Specification 4.4.17.1, + // all slashes should be forward slashes. private const char PathSeparatorChar = '/'; private const string PathSeparatorString = "/"; @@ -74,5 +76,17 @@ public static bool IsDirEmpty(DirectoryInfo possiblyEmptyDir) using (IEnumerator enumerator = Directory.EnumerateFileSystemEntries(possiblyEmptyDir.FullName).GetEnumerator()) return !enumerator.MoveNext(); } + + public static void AttemptSetLastWriteTime(string destinationFileName, DateTimeOffset lastWriteTime) + { + try + { + File.SetLastWriteTime(destinationFileName, lastWriteTime.DateTime); + } + catch (UnauthorizedAccessException) + { + // Some OSes like Android (#35374) might not support setting the last write time, the extraction should not fail because of that + } + } } } diff --git a/src/libraries/Common/tests/Resources/Strings.resx b/src/libraries/Common/tests/Resources/Strings.resx index 7d48d2b4fe49d..64414667b4da1 100644 --- a/src/libraries/Common/tests/Resources/Strings.resx +++ b/src/libraries/Common/tests/Resources/Strings.resx @@ -120,6 +120,9 @@ Argument_InvalidPathChars {0} + + Specified file length was too large for the file system. + IO_FileNotFound @@ -201,4 +204,4 @@ Stream aborted by peer ({0}). - \ No newline at end of file + diff --git a/src/libraries/NetCoreAppLibrary.props b/src/libraries/NetCoreAppLibrary.props index 5c46266cb56e7..66e816eed49f7 100644 --- a/src/libraries/NetCoreAppLibrary.props +++ b/src/libraries/NetCoreAppLibrary.props @@ -60,6 +60,7 @@ System.Drawing.Primitives; System.Dynamic.Runtime; System.Formats.Asn1; + System.Formats.Tar; System.Globalization; System.Globalization.Calendars; System.Globalization.Extensions; diff --git a/src/libraries/System.Formats.Tar/System.Formats.Tar.sln b/src/libraries/System.Formats.Tar/System.Formats.Tar.sln new file mode 100644 index 0000000000000..b2534aaf93fc8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/System.Formats.Tar.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32119.435 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ref", "ref", "{9BE8AFF4-D37B-49AF-AFD3-A15E514AC8AE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{55A8C7E4-925C-4F21-B68B-CEFC19137A4B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6CF0D830-3EE9-44B1-B548-EA8750AD7B3E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Formats.Tar", "ref\System.Formats.Tar.csproj", "{E0B882C6-2082-45F2-806E-568461A61975}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Formats.Tar", "src\System.Formats.Tar.csproj", "{9F751C2B-56DD-4604-A3F3-568627F8C006}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.Formats.Tar.Tests", "tests\System.Formats.Tar.Tests.csproj", "{6FD1E284-7B50-4077-B73A-5B31CB0E3577}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {E0B882C6-2082-45F2-806E-568461A61975}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0B882C6-2082-45F2-806E-568461A61975}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0B882C6-2082-45F2-806E-568461A61975}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0B882C6-2082-45F2-806E-568461A61975}.Release|Any CPU.Build.0 = Release|Any CPU + {9F751C2B-56DD-4604-A3F3-568627F8C006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F751C2B-56DD-4604-A3F3-568627F8C006}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F751C2B-56DD-4604-A3F3-568627F8C006}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F751C2B-56DD-4604-A3F3-568627F8C006}.Release|Any CPU.Build.0 = Release|Any CPU + {6FD1E284-7B50-4077-B73A-5B31CB0E3577}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6FD1E284-7B50-4077-B73A-5B31CB0E3577}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6FD1E284-7B50-4077-B73A-5B31CB0E3577}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6FD1E284-7B50-4077-B73A-5B31CB0E3577}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E0B882C6-2082-45F2-806E-568461A61975} = {9BE8AFF4-D37B-49AF-AFD3-A15E514AC8AE} + {9F751C2B-56DD-4604-A3F3-568627F8C006} = {55A8C7E4-925C-4F21-B68B-CEFC19137A4B} + {6FD1E284-7B50-4077-B73A-5B31CB0E3577} = {6CF0D830-3EE9-44B1-B548-EA8750AD7B3E} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F9B8DA67-C83B-466D-907C-9541CDBDCFEF} + EndGlobalSection +EndGlobal diff --git a/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs b/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs new file mode 100644 index 0000000000000..642433227b1cd --- /dev/null +++ b/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// ------------------------------------------------------------------------------ +// Changes to this file must follow the https://aka.ms/api-review process. +// ------------------------------------------------------------------------------ + +namespace System.Formats.Tar +{ + public sealed partial class GnuTarEntry : System.Formats.Tar.PosixTarEntry + { + public GnuTarEntry(System.Formats.Tar.TarEntryType entryType, string entryName) { } + public System.DateTimeOffset AccessTime { get { throw null; } set { } } + public System.DateTimeOffset ChangeTime { get { throw null; } set { } } + } + public sealed partial class PaxTarEntry : System.Formats.Tar.PosixTarEntry + { + public PaxTarEntry(System.Formats.Tar.TarEntryType entryType, string entryName) { } + public PaxTarEntry(System.Formats.Tar.TarEntryType entryType, string entryName, System.Collections.Generic.IEnumerable> extendedAttributes) { } + public System.Collections.Generic.IReadOnlyDictionary ExtendedAttributes { get { throw null; } } + } + public abstract partial class PosixTarEntry : System.Formats.Tar.TarEntry + { + internal PosixTarEntry() { } + public int DeviceMajor { get { throw null; } set { } } + public int DeviceMinor { get { throw null; } set { } } + public string GroupName { get { throw null; } set { } } + public string UserName { get { throw null; } set { } } + } + public abstract partial class TarEntry + { + internal TarEntry() { } + public int Checksum { get { throw null; } } + public System.IO.Stream? DataStream { get { throw null; } set { } } + public System.Formats.Tar.TarEntryType EntryType { get { throw null; } } + public int Gid { get { throw null; } set { } } + public long Length { get { throw null; } } + public string LinkName { get { throw null; } set { } } + public System.Formats.Tar.TarFileMode Mode { get { throw null; } set { } } + public System.DateTimeOffset ModificationTime { get { throw null; } set { } } + public string Name { get { throw null; } set { } } + public int Uid { get { throw null; } set { } } + public void ExtractToFile(string destinationFileName, bool overwrite) { } + public override string ToString() { throw null; } + } + public enum TarEntryType : byte + { + V7RegularFile = (byte)0, + RegularFile = (byte)48, + HardLink = (byte)49, + SymbolicLink = (byte)50, + CharacterDevice = (byte)51, + BlockDevice = (byte)52, + Directory = (byte)53, + Fifo = (byte)54, + ContiguousFile = (byte)55, + DirectoryList = (byte)68, + LongLink = (byte)75, + LongPath = (byte)76, + MultiVolume = (byte)77, + RenamedOrSymlinked = (byte)78, + SparseFile = (byte)83, + TapeVolume = (byte)86, + GlobalExtendedAttributes = (byte)103, + ExtendedAttributes = (byte)120, + } + public static partial class TarFile + { + public static void CreateFromDirectory(string sourceDirectoryName, System.IO.Stream destination, bool includeBaseDirectory) { } + public static void CreateFromDirectory(string sourceDirectoryName, string destinationFileName, bool includeBaseDirectory) { } + public static void ExtractToDirectory(System.IO.Stream source, string destinationDirectoryName, bool overwriteFiles) { } + public static void ExtractToDirectory(string sourceFileName, string destinationDirectoryName, bool overwriteFiles) { } + } + [System.FlagsAttribute] + public enum TarFileMode + { + None = 0, + OtherExecute = 1, + OtherWrite = 2, + OtherRead = 4, + GroupExecute = 8, + GroupWrite = 16, + GroupRead = 32, + UserExecute = 64, + UserWrite = 128, + UserRead = 256, + StickyBit = 512, + GroupSpecial = 1024, + UserSpecial = 2048, + } + public enum TarFormat + { + Unknown = 0, + V7 = 1, + Ustar = 2, + Pax = 3, + Gnu = 4, + } + public sealed partial class TarReader : System.IDisposable + { + public TarReader(System.IO.Stream archiveStream, bool leaveOpen = false) { } + public System.Formats.Tar.TarFormat Format { get { throw null; } } + public System.Collections.Generic.IReadOnlyDictionary? GlobalExtendedAttributes { get { throw null; } } + public void Dispose() { } + public System.Formats.Tar.TarEntry? GetNextEntry(bool copyData = false) { throw null; } + } + public sealed partial class TarWriter : System.IDisposable + { + public TarWriter(System.IO.Stream archiveStream, System.Collections.Generic.IEnumerable>? globalExtendedAttributes = null, bool leaveOpen = false) { } + public TarWriter(System.IO.Stream archiveStream, System.Formats.Tar.TarFormat archiveFormat, bool leaveOpen = false) { } + public System.Formats.Tar.TarFormat Format { get { throw null; } } + public void Dispose() { } + public void WriteEntry(System.Formats.Tar.TarEntry entry) { } + public void WriteEntry(string fileName, string? entryName) { } + } + public sealed partial class UstarTarEntry : System.Formats.Tar.PosixTarEntry + { + public UstarTarEntry(System.Formats.Tar.TarEntryType entryType, string entryName) { } + } + public sealed partial class V7TarEntry : System.Formats.Tar.TarEntry + { + public V7TarEntry(System.Formats.Tar.TarEntryType entryType, string entryName) { } + } +} diff --git a/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.csproj b/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.csproj new file mode 100644 index 0000000000000..6dc2adf4bd0d8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.csproj @@ -0,0 +1,13 @@ + + + $(NetCoreAppCurrent) + enable + + + + + + + + + diff --git a/src/libraries/System.Formats.Tar/src/Resources/Strings.resx b/src/libraries/System.Formats.Tar/src/Resources/Strings.resx new file mode 100644 index 0000000000000..3140ec6831a83 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/Resources/Strings.resx @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Specified file length was too large for the file system. + + + The argument '{0}' contains invalid path characters. + + + Could not determine the file type of '0'. + + + Cannot create '{0}' because a file or directory with the same name already exists. + + + Creating block or character device files is not supported on the current platform. + + + The file '{0}' already exists. + + + Unable to find the specified file. + + + Could not find file '{0}'. + + + Creating fifo files is not supported on the current platform. + + + The stream does not support reading. + + + The stream does not support seeking. + + + The stream does not support writing. + + + Could not find a part of the path. + + + Could not find a part of the path '{0}'. + + + The specified file name or path is too long, or a component of the specified path is too long. + + + The path '{0}' is too long, or a component of the specified path is too long. + + + SetLength requires a stream that supports seeking and writing. + + + The process cannot access the file '{0}' because it is being used by another process. + + + The process cannot access the file because it is being used by another process. + + + Cannot access a disposed stream. + + + The stream is not empty. + + + System.Formats.Tar is not supported on this platform. + + + SetLength requires a stream that supports seeking and writing. + + + The entry '{0}' has a duplicate extended attribute. + + + An entry in '{0}' format was found in an archive where other entries of format '{1}' have been found. + + + Cannot set the 'DeviceMajor' or 'DeviceMinor' fields on an entry that does not represent a block or character device. + + + The entry '{0}' has a '{1}' type, which does not support setting a data stream. + + + Cannot set the LinkName field on an entry that does not represent a hard link or a symbolic link. + + + The entry is a symbolic link or a hard link but the LinkName field is null or empty. + + + Entry type '{0}' not supported in format '{1}'. + + + Entry type '{0}' not supported for extraction. + + + Extracting the Tar entry '{0}' would have resulted in a file outside the specified destination directory: '{1}' + + + Extracting the Tar entry '{0}' would have resulted in a link target outside the specified destination directory: '{1}' + + + Entry '{0}' was expected to be in the GNU format, but did not have the expected version data. + + + Cannot create a hard link '{0}' because the specified target file '{1}' does not exist. + + + Cannot create the hard link '{0}' targeting the directory '{1}'. + + + The archive format is invalid: '{0}' + + + A POSIX format was expected (Ustar or PAX), but could not be reliably determined for entry '{0}'. + + + The size field is negative in the tar entry '{0}'. + + + The value of the size field for the current entry of type '{0}' is beyond the expected length. + + + Cannot create the symbolic link '{0}' because the specified target '{1}' does not exist. + + + The archive has more than one global extended attributes entry. + + + A metadata entry of type '{0}' was unexpectedly found after a metadata entry of type '{1}'. + + + The file '{0}' is a type of file not supported for tar archiving. + + + Access to the path is denied. + + + Access to the path '{0}' is denied. + + diff --git a/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj b/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj new file mode 100644 index 0000000000000..2cef71d5e33df --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj @@ -0,0 +1,74 @@ + + + true + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix;$(NetCoreAppCurrent) + enable + + + + + $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) + SR.PlatformNotSupported_SystemFormatsTar + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLengths.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLengths.cs new file mode 100644 index 0000000000000..20cf9fa8c3992 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLengths.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + // Specifies the expected lengths of all the header fields in the supported formats. + internal static class FieldLengths + { + private const ushort Path = 100; + + // Common attributes + + internal const ushort Name = Path; + internal const ushort Mode = 8; + internal const ushort Uid = 8; + internal const ushort Gid = 8; + internal const ushort Size = 12; + internal const ushort MTime = 12; + internal const ushort Checksum = 8; + internal const ushort TypeFlag = 1; + internal const ushort LinkName = Path; + + // POSIX and GNU shared attributes + + internal const ushort Magic = 6; + internal const ushort Version = 2; + internal const ushort UName = 32; + internal const ushort GName = 32; + internal const ushort DevMajor = 8; + internal const ushort DevMinor = 8; + + // POSIX attributes + + internal const ushort Prefix = 155; + + // GNU attributes + + internal const ushort ATime = 12; + internal const ushort CTime = 12; + internal const ushort Offset = 12; + internal const ushort LongNames = 4; + internal const ushort Unused = 1; + internal const ushort Sparse = 4 * (12 + 12); + internal const ushort IsExtended = 1; + internal const ushort RealSize = 12; + + // Padding lengths depending on format + + internal const ushort V7Padding = 255; + internal const ushort PosixPadding = 12; + + internal const int AllGnuUnused = Offset + LongNames + Unused + Sparse + IsExtended + RealSize; + + internal const ushort GnuPadding = 17; + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLocations.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLocations.cs new file mode 100644 index 0000000000000..90856330aa2ef --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/FieldLocations.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + // Specifies the position of the first byte of each header field. + internal static class FieldLocations + { + // Common attributes + + internal const ushort Name = 0; + internal const ushort Mode = FieldLengths.Name; + internal const ushort Uid = Mode + FieldLengths.Mode; + internal const ushort Gid = Uid + FieldLengths.Uid; + internal const ushort Size = Gid + FieldLengths.Gid; + internal const ushort MTime = Size + FieldLengths.Size; + internal const ushort Checksum = MTime + FieldLengths.MTime; + internal const ushort TypeFlag = Checksum + FieldLengths.Checksum; + internal const ushort LinkName = TypeFlag + FieldLengths.TypeFlag; + + // POSIX and GNU shared attributes + + internal const ushort Magic = LinkName + FieldLengths.LinkName; + internal const ushort Version = Magic + FieldLengths.Magic; + internal const ushort UName = Version + FieldLengths.Version; + internal const ushort GName = UName + FieldLengths.UName; + internal const ushort DevMajor = GName + FieldLengths.GName; + internal const ushort DevMinor = DevMajor + FieldLengths.DevMajor; + + // POSIX attributes + + internal const ushort Prefix = DevMinor + FieldLengths.DevMinor; + + // GNU attributes + + internal const ushort ATime = DevMinor + FieldLengths.DevMinor; + internal const ushort CTime = ATime + FieldLengths.ATime; + internal const ushort Offset = CTime + FieldLengths.CTime; + internal const ushort LongNames = Offset + FieldLengths.Offset; + internal const ushort Unused = LongNames + FieldLengths.LongNames; + internal const ushort Sparse = Unused + FieldLengths.Unused; + internal const ushort IsExtended = Sparse + FieldLengths.Sparse; + internal const ushort RealSize = IsExtended + FieldLengths.IsExtended; + + internal const ushort GnuUnused = CTime + FieldLengths.CTime; + + // Padding lengths depending on format + + internal const ushort V7Padding = LinkName + FieldLengths.LinkName; + internal const ushort PosixPadding = Prefix + FieldLengths.Prefix; + internal const ushort GnuPadding = RealSize + FieldLengths.RealSize; + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/GnuTarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/GnuTarEntry.cs new file mode 100644 index 0000000000000..252a95fe378fe --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/GnuTarEntry.cs @@ -0,0 +1,73 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Represents a tar entry from an archive of the GNU format. + /// + /// Even though the format is not POSIX compatible, it implements and supports the Unix-specific fields that were defined in the POSIX IEEE P1003.1 standard from 1988: devmajor, devminor, gname and uname. + public sealed class GnuTarEntry : PosixTarEntry + { + // Constructor used when reading an existing archive. + internal GnuTarEntry(TarHeader header, TarReader readerOfOrigin) + : base(header, readerOfOrigin) + { + } + + /// + /// Initializes a new instance with the specified entry type and entry name. + /// + /// The type of the entry. + /// A string with the path and file name of this entry. + /// is null or empty. + /// The entry type is not supported for creating an entry. + /// When creating an instance using the constructor, only the following entry types are supported: + /// + /// In all platforms: , , , . + /// In Unix platforms only: , and . + /// + /// + public GnuTarEntry(TarEntryType entryType, string entryName) + : base(entryType, entryName, TarFormat.Gnu) + { + } + + /// + /// A timestamp that represents the last time the file represented by this entry was accessed. + /// + /// In Unix platforms, this timestamp is commonly known as atime. + public DateTimeOffset AccessTime + { + get => _header._aTime; + set + { + if (value < DateTimeOffset.UnixEpoch) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _header._aTime = value; + } + } + + /// + /// A timestamp that represents the last time the metadata of the file represented by this entry was changed. + /// + /// In Unix platforms, this timestamp is commonly known as ctime. + public DateTimeOffset ChangeTime + { + get => _header._cTime; + set + { + if (value < DateTimeOffset.UnixEpoch) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _header._cTime = value; + } + } + + // Determines if the current instance's entry type supports setting a data stream. + internal override bool IsDataStreamSetterSupported() => EntryType is TarEntryType.RegularFile; + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs new file mode 100644 index 0000000000000..54b44f71a55b2 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics; + +namespace System.Formats.Tar +{ + /// + /// Represents a tar entry from an archive of the PAX format. + /// + public sealed class PaxTarEntry : PosixTarEntry + { + private ReadOnlyDictionary? _readOnlyExtendedAttributes; + + // Constructor used when reading an existing archive. + internal PaxTarEntry(TarHeader header, TarReader readerOfOrigin) + : base(header, readerOfOrigin) + { + _header._extendedAttributes ??= new Dictionary(); + _readOnlyExtendedAttributes = null; + } + + /// + /// Initializes a new instance with the specified entry type, entry name, and the default extended attributes. + /// + /// The type of the entry. + /// A string with the path and file name of this entry. + /// is null or empty. + /// The entry type is not supported for creating an entry. + /// When creating an instance using the constructor, only the following entry types are supported: + /// + /// In all platforms: , , , . + /// In Unix platforms only: , and . + /// + /// Use the constructor to include additional extended attributes when creating the entry. + /// The following entries are always found in the Extended Attributes dictionary of any PAX entry: + /// + /// Modification time, under the name mtime, as a number. + /// Access time, under the name atime, as a number. + /// Change time, under the name ctime, as a number. + /// Path, under the name path, as a string. + /// + /// The following entries are only found in the Extended Attributes dictionary of a PAX entry if certain conditions are met: + /// + /// Group name, under the name gname, as a string, if it is larger than 32 bytes. + /// User name, under the name uname, as a string, if it is larger than 32 bytes. + /// File length, under the name size, as an , if the string representation of the number is larger than 12 bytes. + /// + /// + public PaxTarEntry(TarEntryType entryType, string entryName) + : base(entryType, entryName, TarFormat.Pax) + { + } + + /// + /// Initializes a new instance with the specified entry type, entry name and Extended Attributes enumeration. + /// + /// The type of the entry. + /// A string with the path and file name of this entry. + /// An enumeration of string key-value pairs that represents the metadata to include in the Extended Attributes entry that precedes the current entry. + /// is . + /// is null or empty. + /// The entry type is not supported for creating an entry. + /// When creating an instance using the constructor, only the following entry types are supported: + /// + /// In all platforms: , , , . + /// In Unix platforms only: , and . + /// + /// The specified get appended to the default attributes, unless the specified enumeration overrides any of them. + /// The following entries are always found in the Extended Attributes dictionary of any PAX entry: + /// + /// Modification time, under the name mtime, as a number. + /// Access time, under the name atime, as a number. + /// Change time, under the name ctime, as a number. + /// Path, under the name path, as a string. + /// + /// The following entries are only found in the Extended Attributes dictionary of a PAX entry if certain conditions are met: + /// + /// Group name, under the name gname, as a string, if it is larger than 32 bytes. + /// User name, under the name uname, as a string, if it is larger than 32 bytes. + /// File length, under the name size, as an , if the string representation of the number is larger than 12 bytes. + /// + /// + public PaxTarEntry(TarEntryType entryType, string entryName, IEnumerable> extendedAttributes) + : base(entryType, entryName, TarFormat.Pax) + { + ArgumentNullException.ThrowIfNull(extendedAttributes); + _header.ReplaceNormalAttributesWithExtended(extendedAttributes); + } + + /// + /// Returns the extended attributes for this entry. + /// + /// The extended attributes are specified when constructing an entry. Use to append your own enumeration of extended attributes to the current entry on top of the default ones. Use to only use the default extended attributes. + /// The following entries are always found in the Extended Attributes dictionary of any PAX entry: + /// + /// Modification time, under the name mtime, as a number. + /// Access time, under the name atime, as a number. + /// Change time, under the name ctime, as a number. + /// Path, under the name path, as a string. + /// + /// The following entries are only found in the Extended Attributes dictionary of a PAX entry if certain conditions are met: + /// + /// Group name, under the name gname, as a string, if it is larger than 32 bytes. + /// User name, under the name uname, as a string, if it is larger than 32 bytes. + /// File length, under the name size, as an , if the string representation of the number is larger than 12 bytes. + /// + /// + public IReadOnlyDictionary ExtendedAttributes + { + get + { + Debug.Assert(_header._extendedAttributes != null); + return _readOnlyExtendedAttributes ??= _header._extendedAttributes.AsReadOnly(); + } + } + + // Determines if the current instance's entry type supports setting a data stream. + internal override bool IsDataStreamSetterSupported() => EntryType == TarEntryType.RegularFile; + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs new file mode 100644 index 0000000000000..0b682454f3870 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Abstract class that represents a tar entry from an archive of a format that is based on the POSIX IEEE P1003.1 standard from 1988. This includes the formats (represented by the class), (represented by the class) and (represented by the class). + /// + /// Formats that implement the POSIX IEEE P1003.1 standard from 1988, support the following header fields: devmajor, devminor, gname and uname. + /// Even though the format is not POSIX compatible, it implements and supports the Unix-specific fields that were defined in that POSIX standard. + public abstract partial class PosixTarEntry : TarEntry + { + // Constructor used when reading an existing archive. + internal PosixTarEntry(TarHeader header, TarReader readerOfOrigin) + : base(header, readerOfOrigin) + { + } + + // Constructor called when creating a new 'TarEntry*' instance that can be passed to a TarWriter. + internal PosixTarEntry(TarEntryType entryType, string entryName, TarFormat format) + : base(entryType, entryName, format) + { + } + + /// + /// When the current entry represents a character device or a block device, the major number identifies the driver associated with the device. + /// + /// Character and block devices are Unix-specific entry types. + /// The entry does not represent a block device or a character device. + /// The value is negative, or larger than 2097151. + public int DeviceMajor + { + get => _header._devMajor; + set + { + if (_header._typeFlag is not TarEntryType.BlockDevice and not TarEntryType.CharacterDevice) + { + throw new InvalidOperationException(SR.TarEntryBlockOrCharacterExpected); + } + + if (value < 0 || value > 2097151) // 7777777 in octal + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _header._devMajor = value; + } + } + + /// + /// When the current entry represents a character device or a block device, the minor number is used by the driver to distinguish individual devices it controls. + /// + /// Character and block devices are Unix-specific entry types. + /// The entry does not represent a block device or a character device. + /// The value is negative, or larger than 2097151. + public int DeviceMinor + { + get => _header._devMinor; + set + { + if (_header._typeFlag is not TarEntryType.BlockDevice and not TarEntryType.CharacterDevice) + { + throw new InvalidOperationException(SR.TarEntryBlockOrCharacterExpected); + } + if (value < 0 || value > 2097151) // 7777777 in octal + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _header._devMinor = value; + } + } + + /// + /// Represents the name of the group that owns this entry. + /// + /// Cannot set a null group name. + /// is only used in Unix platforms. + public string GroupName + { + get => _header._gName; + set + { + ArgumentNullException.ThrowIfNull(value); + _header._gName = value; + } + } + + /// + /// Represents the name of the user that owns this entry. + /// + /// is only used in Unix platforms. + /// Cannot set a null user name. + public string UserName + { + get => _header._uName; + set + { + ArgumentNullException.ThrowIfNull(value); + _header._uName = value; + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/SeekableSubReadStream.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/SeekableSubReadStream.cs new file mode 100644 index 0000000000000..391cde00bc66a --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/SeekableSubReadStream.cs @@ -0,0 +1,111 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Formats.Tar +{ + // Stream that allows wrapping a super stream and specify the lower and upper limits that can be read from it. + // It is meant to be used when the super stream is seekable. + // Does not support writing. + internal sealed class SeekableSubReadStream : SubReadStream + { + public SeekableSubReadStream(Stream superStream, long startPosition, long maxLength) + : base(superStream, startPosition, maxLength) + { + if (!superStream.CanSeek) + { + throw new InvalidOperationException(SR.IO_NotSupported_UnseekableStream); + } + } + + public override bool CanSeek => !_isDisposed; + + public override long Position + { + get + { + ThrowIfDisposed(); + return _positionInSuperStream - _startInSuperStream; + } + set + { + ThrowIfDisposed(); + if (value < 0 || value >= _endInSuperStream) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _positionInSuperStream = _startInSuperStream + value; + } + } + + public override int Read(Span destination) + { + ThrowIfDisposed(); + VerifyPositionInSuperStream(); + + // parameter validation sent to _superStream.Read + int origCount = destination.Length; + int count = destination.Length; + + if (_positionInSuperStream + count > _endInSuperStream) + { + count = (int)(_endInSuperStream - _positionInSuperStream); + } + + Debug.Assert(count >= 0); + Debug.Assert(count <= origCount); + + if (count > 0) + { + int bytesRead = _superStream.Read(destination.Slice(0, count)); + _positionInSuperStream += bytesRead; + return bytesRead; + } + + return 0; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + VerifyPositionInSuperStream(); + return ReadAsyncCore(buffer, cancellationToken); + } + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + + long newPosition = origin switch + { + SeekOrigin.Begin => _startInSuperStream + offset, + SeekOrigin.Current => _positionInSuperStream + offset, + SeekOrigin.End => _endInSuperStream + offset, + _ => throw new ArgumentOutOfRangeException(nameof(origin)), + }; + if (newPosition < _startInSuperStream || newPosition > _endInSuperStream) + { + throw new IndexOutOfRangeException(nameof(offset)); + } + + _superStream.Position = newPosition; + _positionInSuperStream = newPosition; + + return _superStream.Position; + } + + private void VerifyPositionInSuperStream() + { + if (_positionInSuperStream != _superStream.Position) + { + // Since we can seek, if the stream had its position pointer moved externally, + // we must bring it back to the last read location on this stream + _superStream.Seek(_positionInSuperStream, SeekOrigin.Begin); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/SubReadStream.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/SubReadStream.cs new file mode 100644 index 0000000000000..e7c5d29d96c96 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/SubReadStream.cs @@ -0,0 +1,180 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; + +namespace System.Formats.Tar +{ + // Stream that allows wrapping a super stream and specify the lower and upper limits that can be read from it. + // It is meant to be used when the super stream is unseekable. + // Does not support writing. + internal class SubReadStream : Stream + { + protected bool _hasReachedEnd; + protected readonly long _startInSuperStream; + protected long _positionInSuperStream; + protected readonly long _endInSuperStream; + protected readonly Stream _superStream; + protected bool _isDisposed; + + public SubReadStream(Stream superStream, long startPosition, long maxLength) + { + if (!superStream.CanRead) + { + throw new InvalidOperationException(SR.IO_NotSupported_UnreadableStream); + } + _startInSuperStream = startPosition; + _positionInSuperStream = startPosition; + _endInSuperStream = startPosition + maxLength; + _superStream = superStream; + _isDisposed = false; + _hasReachedEnd = false; + } + + public override long Length + { + get + { + ThrowIfDisposed(); + return _endInSuperStream - _startInSuperStream; + } + } + + public override long Position + { + get + { + ThrowIfDisposed(); + return _positionInSuperStream - _startInSuperStream; + } + set + { + ThrowIfDisposed(); + throw new InvalidOperationException(SR.IO_NotSupported_UnseekableStream); + } + } + + public override bool CanRead => !_isDisposed; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + internal bool HasReachedEnd + { + get + { + if (!_hasReachedEnd && _positionInSuperStream > _endInSuperStream) + { + _hasReachedEnd = true; + } + return _hasReachedEnd; + } + set + { + if (value) // Don't allow revert to false + { + _hasReachedEnd = true; + } + } + } + + protected void ThrowIfDisposed() + { + if (_isDisposed) + { + throw new ObjectDisposedException(GetType().ToString(), SR.IO_StreamDisposed); + } + } + + private void ThrowIfBeyondEndOfStream() + { + if (HasReachedEnd) + { + throw new EndOfStreamException(); + } + } + + public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); + + public override int Read(Span destination) + { + ThrowIfDisposed(); + ThrowIfBeyondEndOfStream(); + + // parameter validation sent to _superStream.Read + int origCount = destination.Length; + int count = destination.Length; + + if (_positionInSuperStream + count > _endInSuperStream) + { + count = (int)(_endInSuperStream - _positionInSuperStream); + } + + Debug.Assert(count >= 0); + Debug.Assert(count <= origCount); + + int ret = _superStream.Read(destination.Slice(0, count)); + + _positionInSuperStream += ret; + return ret; + } + + public override int ReadByte() + { + byte b = default; + return Read(MemoryMarshal.CreateSpan(ref b, 1)) == 1 ? b : -1; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return ReadAsync(new Memory(buffer, offset, count), cancellationToken).AsTask(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + ThrowIfBeyondEndOfStream(); + return ReadAsyncCore(buffer, cancellationToken); + } + + protected async ValueTask ReadAsyncCore(Memory buffer, CancellationToken cancellationToken) + { + Debug.Assert(!_hasReachedEnd); + + if (_positionInSuperStream > _endInSuperStream - buffer.Length) + { + buffer = buffer.Slice(0, (int)(_endInSuperStream - _positionInSuperStream)); + } + + int ret = await _superStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + + _positionInSuperStream += ret; + return ret; + } + + public override long Seek(long offset, SeekOrigin origin) => throw new InvalidOperationException(SR.IO_NotSupported_UnseekableStream); + + public override void SetLength(long value) => throw new InvalidOperationException(SR.IO_NotSupported_UnseekableStream); + + public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException(SR.IO_NotSupported_UnwritableStream); + + public override void Flush() => throw new InvalidOperationException(SR.IO_NotSupported_UnwritableStream); + + // Close the stream for reading. Note that this does NOT close the superStream (since + // the substream is just 'a chunk' of the super-stream + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + _isDisposed = true; + } + base.Dispose(disposing); + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs new file mode 100644 index 0000000000000..bd492019fbc53 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Win32.SafeHandles; + +namespace System.Formats.Tar +{ + // Unix specific methods for the TarEntry class. + public abstract partial class TarEntry + { + // Unix specific implementation of the method that extracts the current entry as a block device. + partial void ExtractAsBlockDevice(string destinationFileName) + { + Debug.Assert(EntryType is TarEntryType.BlockDevice); + Interop.CheckIo(Interop.Sys.CreateBlockDevice(destinationFileName, (uint)Mode, (uint)_header._devMajor, (uint)_header._devMinor), destinationFileName); + } + + // Unix specific implementation of the method that extracts the current entry as a character device. + partial void ExtractAsCharacterDevice(string destinationFileName) + { + Debug.Assert(EntryType is TarEntryType.CharacterDevice); + Interop.CheckIo(Interop.Sys.CreateCharacterDevice(destinationFileName, (uint)Mode, (uint)_header._devMajor, (uint)_header._devMinor), destinationFileName); + } + + // Unix specific implementation of the method that extracts the current entry as a fifo file. + partial void ExtractAsFifo(string destinationFileName) + { + Debug.Assert(EntryType is TarEntryType.Fifo); + Interop.CheckIo(Interop.Sys.MkFifo(destinationFileName, (uint)Mode), destinationFileName); + } + + // Unix specific implementation of the method that extracts the current entry as a hard link. + partial void ExtractAsHardLink(string targetFilePath, string hardLinkFilePath) + { + Debug.Assert(EntryType is TarEntryType.HardLink); + Debug.Assert(!string.IsNullOrEmpty(targetFilePath)); + Debug.Assert(!string.IsNullOrEmpty(hardLinkFilePath)); + Interop.CheckIo(Interop.Sys.Link(targetFilePath, hardLinkFilePath), hardLinkFilePath); + } + + // Unix specific implementation of the method that specifies the file permissions of the extracted file. + partial void SetModeOnFile(SafeFileHandle handle, string destinationFileName) + { + // Only extract USR, GRP, and OTH file permissions, and ignore + // S_ISUID, S_ISGID, and S_ISVTX bits. + // It is off by default because it's possible that a file in an archive could have + // one of these bits set and, unknown to the person extracting, could allow others to + // execute the file as the user or group. + const int ExtractPermissionMask = 0x1FF; + int permissions = (int)Mode & ExtractPermissionMask; + + // If the permissions weren't set at all, don't write the file's permissions. + if (permissions != 0) + { + Interop.CheckIo(Interop.Sys.FChMod(handle, permissions), destinationFileName); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs new file mode 100644 index 0000000000000..45f8a1a0b8eac --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using Microsoft.Win32.SafeHandles; + +namespace System.Formats.Tar +{ + // Windows specific methods for the TarEntry class. + public abstract partial class TarEntry + { + // Throws on Windows. Block devices are not supported on this platform. + partial void ExtractAsBlockDevice(string destinationFileName) + { + Debug.Assert(EntryType is TarEntryType.BlockDevice or TarEntryType.CharacterDevice); + throw new InvalidOperationException(SR.IO_DeviceFiles_NotSupported); + } + + // Throws on Windows. Character devices are not supported on this platform. + partial void ExtractAsCharacterDevice(string destinationFileName) + { + Debug.Assert(EntryType is TarEntryType.BlockDevice or TarEntryType.CharacterDevice); + throw new InvalidOperationException(SR.IO_DeviceFiles_NotSupported); + } + + // Throws on Windows. Fifo files are not supported on this platform. + partial void ExtractAsFifo(string destinationFileName) + { + Debug.Assert(EntryType is TarEntryType.Fifo); + throw new InvalidOperationException(SR.IO_FifoFiles_NotSupported); + } + + // Windows specific implementation of the method that extracts the current entry as a hard link. + partial void ExtractAsHardLink(string targetFilePath, string hardLinkFilePath) + { + Debug.Assert(EntryType is TarEntryType.HardLink); + Debug.Assert(!string.IsNullOrEmpty(targetFilePath)); + Debug.Assert(!string.IsNullOrEmpty(hardLinkFilePath)); + Interop.Kernel32.CreateHardLink(hardLinkFilePath, targetFilePath); + } + + // Mode is not used on Windows. +#pragma warning disable CA1822 // Member 'SetModeOnFile' does not access instance data and can be marked as static + partial void SetModeOnFile(SafeFileHandle handle, string destinationFileName) +#pragma warning restore CA1822 + { + // TODO: Verify that executables get their 'executable' permission applied on Windows when extracted, if applicable. https://github.com/dotnet/runtime/issues/68230 + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs new file mode 100644 index 0000000000000..0800f39ecf028 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs @@ -0,0 +1,470 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Microsoft.Win32.SafeHandles; + +namespace System.Formats.Tar +{ + /// + /// Abstract class that represents a tar entry from an archive. + /// + /// All the properties exposed by this class are supported by the , , and formats. + public abstract partial class TarEntry + { + internal TarHeader _header; + // Used to access the data section of this entry in an unseekable file + private TarReader? _readerOfOrigin; + + // Constructor used when reading an existing archive. + internal TarEntry(TarHeader header, TarReader readerOfOrigin) + { + _header = header; + _readerOfOrigin = readerOfOrigin; + } + + // Constructor called when creating a new 'TarEntry*' instance that can be passed to a TarWriter. + internal TarEntry(TarEntryType entryType, string entryName, TarFormat format) + { + ArgumentException.ThrowIfNullOrEmpty(entryName); + + // Throws if format is unknown or out of range + TarHelpers.VerifyEntryTypeIsSupported(entryType, format, forWriting: false); + + _readerOfOrigin = null; + + _header = default; + + _header._extendedAttributes = new Dictionary(); + + _header._name = entryName; + _header._linkName = string.Empty; + _header._typeFlag = entryType; + _header._mode = (int)TarHelpers.DefaultMode; + + _header._gName = string.Empty; + _header._uName = string.Empty; + + DateTimeOffset now = DateTimeOffset.Now; + _header._mTime = now; + _header._aTime = now; + _header._cTime = now; + } + + /// + /// The checksum of all the fields in this entry. The value is non-zero either when the entry is read from an existing archive, or after the entry is written to a new archive. + /// + public int Checksum => _header._checksum; + + /// + /// The type of filesystem object represented by this entry. + /// + public TarEntryType EntryType => _header._typeFlag; + + /// + /// The ID of the group that owns the file represented by this entry. + /// + /// This field is only supported in Unix platforms. + public int Gid + { + get => _header._gid; + set => _header._gid = value; + } + + /// + /// A timestamps that represents the last time the contents of the file represented by this entry were modified. + /// + /// In Unix platforms, this timestamp is commonly known as mtime. + public DateTimeOffset ModificationTime + { + get => _header._mTime; + set + { + if (value < DateTimeOffset.UnixEpoch) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _header._mTime = value; + } + } + + /// + /// When the indicates an entry that can contain data, this property returns the length in bytes of such data. + /// + /// The entry type that commonly contains data is (or in the format). Other uncommon entry types that can also contain data are: , , and . + public long Length => _header._dataStream != null ? _header._dataStream.Length : _header._size; + + /// + /// When the indicates a or a , this property returns the link target path of such link. + /// + /// Cannot set the link name if the entry type is not or . + public string LinkName + { + get => _header._linkName; + set + { + if (_header._typeFlag is not TarEntryType.HardLink and not TarEntryType.SymbolicLink) + { + throw new InvalidOperationException(SR.TarEntryHardLinkOrSymLinkExpected); + } + _header._linkName = value; + } + } + + /// + /// Represents the Unix file permissions of the file represented by this entry. + /// + /// The value in this field has no effect on Windows platforms. + public TarFileMode Mode + { + get => (TarFileMode)_header._mode; + set + { + if ((int)value is < 0 or > 4095) // 4095 in decimal is 7777 in octal + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + _header._mode = (int)value; + } + } + + /// + /// Represents the name of the entry, which includes the relative path and the filename. + /// + public string Name + { + get => _header._name; + set + { + ArgumentException.ThrowIfNullOrEmpty(value); + _header._name = value; + } + } + + /// + /// The ID of the user that owns the file represented by this entry. + /// + /// This field is only supported in Unix platforms. + public int Uid + { + get => _header._uid; + set => _header._uid = value; + } + + /// + /// Extracts the current file or directory to the filesystem. Symbolic links and hard links are not extracted. + /// + /// The path to the destination file. + /// if this method should overwrite any existing filesystem object located in the path; to prevent overwriting. + /// Files of type , or can only be extracted in Unix platforms. + /// Elevation is required to extract a or to disk. + /// Symbolic links can be recreated using , or . + /// Hard links can only be extracted when using or . + /// is or empty. + /// The parent directory of does not exist. + /// -or- + /// is and a file already exists in . + /// -or- + /// A directory exists with the same name as . + /// -or- + /// An I/O problem occurred. + /// Attempted to extract a symbolic link, a hard link or an unsupported entry type. + /// Operation not permitted due to insufficient permissions. + public void ExtractToFile(string destinationFileName, bool overwrite) + { + if (EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink) + { + throw new InvalidOperationException(string.Format(SR.TarEntryTypeNotSupportedForExtracting, EntryType)); + } + ExtractToFileInternal(destinationFileName, linkTargetPath: null, overwrite); + } + + // /// + // /// Asynchronously extracts the current entry to the filesystem. + // /// + // /// The path to the destination file. + // /// if this method should overwrite any existing filesystem object located in the path; to prevent overwriting. + // /// The token to monitor for cancellation requests. The default value is . + // /// A task that represents the asynchronous extraction operation. + // /// Files of type , or can only be extracted in Unix platforms. + // /// Elevation is required to extract a or to disk. + // /// is or empty. + // /// The parent directory of does not exist. + // /// -or- + // /// is and a file already exists in . + // /// -or- + // /// A directory exists with the same name as . + // /// -or- + // /// An I/O problem occurred. + // /// Attempted to extract an unsupported entry type. + // /// Operation not permitted due to insufficient permissions. + // public Task ExtractToFileAsync(string destinationFileName, bool overwrite, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + /// + /// The data section of this entry. If the does not support containing data, then returns . + /// + /// Gets a stream that represents the data section of this entry. + /// Sets a new stream that represents the data section, if it makes sense for the to contain data; if a stream already existed, the old stream gets disposed before substituting it with the new stream. Setting a stream is allowed. + /// If you write data to this data stream, make sure to rewind it to the desired start position before writing this entry into an archive using . + /// Setting a data section is not supported because the is not (or for an archive of format). + /// Cannot set an unreadable stream. + /// -or- + /// An I/O problem occurred. + public Stream? DataStream + { + get => _header._dataStream; + set + { + if (!IsDataStreamSetterSupported()) + { + throw new InvalidOperationException(string.Format(SR.TarEntryDoesNotSupportDataStream, Name, EntryType)); + } + + if (value != null && !value.CanRead) + { + throw new IOException(SR.IO_NotSupported_UnreadableStream); + } + + if (_readerOfOrigin != null) + { + // This entry came from a reader, so if the underlying stream is unseekable, we need to + // manually advance the stream pointer to the next header before doing the substitution + // The original stream will get disposed when the reader gets disposed. + _readerOfOrigin.AdvanceDataStreamIfNeeded(); + // We only do this once + _readerOfOrigin = null; + } + + _header._dataStream?.Dispose(); + + _header._dataStream = value; + } + } + + /// + /// A string that represents the current entry. + /// + /// The of the current entry. + public override string ToString() => Name; + + // Abstract method that determines if setting the data stream for this entry is allowed. + internal abstract bool IsDataStreamSetterSupported(); + + // Extracts the current entry to a location relative to the specified directory. + internal void ExtractRelativeToDirectory(string destinationDirectoryPath, bool overwrite) + { + Debug.Assert(!string.IsNullOrEmpty(destinationDirectoryPath)); + Debug.Assert(Path.IsPathFullyQualified(destinationDirectoryPath)); + + string destinationDirectoryFullPath = destinationDirectoryPath.EndsWith(Path.DirectorySeparatorChar) ? destinationDirectoryPath : destinationDirectoryPath + Path.DirectorySeparatorChar; + + string fileDestinationPath = GetSanitizedFullPath(destinationDirectoryFullPath, Name, SR.TarExtractingResultsFileOutside); + + string? linkTargetPath = null; + if (EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink) + { + if (string.IsNullOrEmpty(LinkName)) + { + throw new FormatException(SR.TarEntryHardLinkOrSymlinkLinkNameEmpty); + } + + linkTargetPath = GetSanitizedFullPath(destinationDirectoryFullPath, LinkName, SR.TarExtractingResultsLinkOutside); + } + + if (EntryType == TarEntryType.Directory) + { + Directory.CreateDirectory(fileDestinationPath); + } + else + { + // If it is a file, create containing directory. + Directory.CreateDirectory(Path.GetDirectoryName(fileDestinationPath)!); + ExtractToFileInternal(fileDestinationPath, linkTargetPath, overwrite); + } + + // If the path can be extracted in the specified destination directory, returns the full path with sanitized file name. Otherwise, throws. + static string GetSanitizedFullPath(string destinationDirectoryFullPath, string path, string exceptionMessage) + { + string actualPath = Path.Join(Path.GetDirectoryName(path), ArchivingUtils.SanitizeEntryFilePath(Path.GetFileName(path))); + + if (!Path.IsPathFullyQualified(actualPath)) + { + actualPath = Path.Combine(destinationDirectoryFullPath, actualPath); + } + + actualPath = Path.GetFullPath(actualPath); + + if (!actualPath.StartsWith(destinationDirectoryFullPath, PathInternal.StringComparison)) + { + throw new IOException(string.Format(exceptionMessage, path, destinationDirectoryFullPath)); + } + + return actualPath; + } + } + + // Extracts the current entry into the filesystem, regardless of the entry type. + private void ExtractToFileInternal(string filePath, string? linkTargetPath, bool overwrite) + { + ArgumentException.ThrowIfNullOrEmpty(filePath); + + VerifyPathsForEntryType(filePath, linkTargetPath, overwrite); + + switch (EntryType) + { + case TarEntryType.Directory: + case TarEntryType.DirectoryList: + Directory.CreateDirectory(filePath); + break; + + case TarEntryType.RegularFile: + case TarEntryType.V7RegularFile: + case TarEntryType.ContiguousFile: + ExtractAsRegularFile(filePath); + break; + + case TarEntryType.SymbolicLink: + Debug.Assert(!string.IsNullOrEmpty(linkTargetPath)); + FileInfo link = new(filePath); + link.CreateAsSymbolicLink(linkTargetPath); + break; + + case TarEntryType.HardLink: + Debug.Assert(!string.IsNullOrEmpty(linkTargetPath)); + ExtractAsHardLink(linkTargetPath, filePath); + break; + + case TarEntryType.BlockDevice: + ExtractAsBlockDevice(filePath); + break; + + case TarEntryType.CharacterDevice: + ExtractAsCharacterDevice(filePath); + break; + + case TarEntryType.Fifo: + ExtractAsFifo(filePath); + break; + + case TarEntryType.ExtendedAttributes: + case TarEntryType.GlobalExtendedAttributes: + case TarEntryType.LongPath: + case TarEntryType.LongLink: + Debug.Assert(false, $"Metadata entry type should not be visible: '{EntryType}'"); + break; + + case TarEntryType.MultiVolume: + case TarEntryType.RenamedOrSymlinked: + case TarEntryType.SparseFile: + case TarEntryType.TapeVolume: + default: + throw new InvalidOperationException(string.Format(SR.TarEntryTypeNotSupportedForExtracting, EntryType)); + } + } + + // Verifies if the specified paths make sense for the current type of entry. + private void VerifyPathsForEntryType(string filePath, string? linkTargetPath, bool overwrite) + { + string? directoryPath = Path.GetDirectoryName(filePath); + // If the destination contains a directory segment, need to check that it exists + if (!string.IsNullOrEmpty(directoryPath) && !Path.Exists(directoryPath)) + { + throw new IOException(string.Format(SR.IO_PathNotFound_NoPathName, filePath)); + } + + if (!Path.Exists(filePath)) + { + return; + } + + // We never want to overwrite a directory, so we always throw + if (Directory.Exists(filePath)) + { + throw new IOException(string.Format(SR.IO_AlreadyExists_Name, filePath)); + } + + // A file exists at this point + if (!overwrite) + { + throw new IOException(string.Format(SR.IO_AlreadyExists_Name, filePath)); + } + File.Delete(filePath); + + if (EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink) + { + if (!string.IsNullOrEmpty(linkTargetPath)) + { + string? targetDirectoryPath = Path.GetDirectoryName(linkTargetPath); + // If the destination target contains a directory segment, need to check that it exists + if (!string.IsNullOrEmpty(targetDirectoryPath) && !Path.Exists(targetDirectoryPath)) + { + throw new IOException(string.Format(SR.TarSymbolicLinkTargetNotExists, filePath, linkTargetPath)); + } + + if (EntryType is TarEntryType.HardLink) + { + if (!Path.Exists(linkTargetPath)) + { + throw new IOException(string.Format(SR.TarHardLinkTargetNotExists, filePath, linkTargetPath)); + } + else if (Directory.Exists(linkTargetPath)) + { + throw new IOException(string.Format(SR.TarHardLinkToDirectoryNotAllowed, filePath, linkTargetPath)); + } + } + } + else + { + throw new FormatException(SR.TarEntryHardLinkOrSymlinkLinkNameEmpty); + } + } + } + + // Extracts the current entry as a regular file into the specified destination. + // The assumption is that at this point there is no preexisting file or directory in that destination. + private void ExtractAsRegularFile(string destinationFileName) + { + Debug.Assert(!Path.Exists(destinationFileName)); + + FileStreamOptions fileStreamOptions = new FileStreamOptions() + { + Access = FileAccess.Write, + Mode = FileMode.CreateNew, + Share = FileShare.None, + PreallocationSize = Length, + }; + // Rely on FileStream's ctor for further checking destinationFileName parameter + using (FileStream fs = new FileStream(destinationFileName, fileStreamOptions)) + { + if (DataStream != null) + { + // Important: The DataStream will be written from its current position + DataStream.CopyTo(fs); + } + SetModeOnFile(fs.SafeFileHandle, destinationFileName); + } + + ArchivingUtils.AttemptSetLastWriteTime(destinationFileName, ModificationTime); + } + + // Abstract method that extracts the current entry when it is a block device. + partial void ExtractAsBlockDevice(string destinationFileName); + + // Abstract method that extracts the current entry when it is a character device. + partial void ExtractAsCharacterDevice(string destinationFileName); + + // Abstract method that extracts the current entry when it is a fifo file. + partial void ExtractAsFifo(string destinationFileName); + + // Abstract method that extracts the current entry when it is a hard link. + partial void ExtractAsHardLink(string targetFilePath, string hardLinkFilePath); + + // Abstract method that sets the file permissions of the file. + partial void SetModeOnFile(SafeFileHandle handle, string destinationFileName); + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntryType.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntryType.cs new file mode 100644 index 0000000000000..3f3e61556eb66 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntryType.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Specifies the tar entry types. + /// + /// Tar entries with a metadata entry type are not exposed to the user, they are handled internally. + public enum TarEntryType : byte + { + /// + /// Regular file. + /// This entry type is specific to the , and formats. + /// + RegularFile = (byte)'0', + /// + /// Hard link. + /// + HardLink = (byte)'1', + /// + /// Symbolic link. + /// + SymbolicLink = (byte)'2', + /// + /// Character device special file. + /// This entry type is supported only in the Unix platforms for writing. + /// + CharacterDevice = (byte)'3', + /// + /// Character device special file. + /// This entry type is supported only in the Unix platforms for writing. + /// + BlockDevice = (byte)'4', + /// + /// Directory. + /// + Directory = (byte)'5', + /// + /// FIFO special file. + /// This entry type is supported only in the Unix platforms for writing. + /// + Fifo = (byte)'6', + /// + /// GNU contiguous file + /// This entry type is specific to the format, and is treated as a entry type. + /// + // According to the GNU spec, it's extremely rare to encounter a contiguous entry. + ContiguousFile = (byte)'7', + /// + /// PAX Extended Attributes entry. + /// Metadata entry type. + /// + ExtendedAttributes = (byte)'x', + /// + /// PAX Global Extended Attributes entry. + /// Metadata entry type. + /// + GlobalExtendedAttributes = (byte)'g', + /// + /// GNU directory with a list of entries. + /// This entry type is specific to the format, and is treated as a entry type that contains a data section. + /// + DirectoryList = (byte)'D', + /// + /// GNU long link. + /// Metadata entry type. + /// + LongLink = (byte)'K', + /// + /// GNU long path. + /// Metadata entry type. + /// + LongPath = (byte)'L', + /// + /// GNU multi-volume file. + /// This entry type is specific to the format and is not supported for writing. + /// + MultiVolume = (byte)'M', + /// + /// V7 Regular file. + /// This entry type is specific to the format. + /// + V7RegularFile = (byte)'\0', + /// + /// GNU file to be renamed/symlinked. + /// This entry type is specific to the format. It is considered unsafe and is ignored by other tools. + /// + RenamedOrSymlinked = (byte)'N', + /// + /// GNU sparse file. + /// This entry type is specific to the format and is not supported for writing. + /// + SparseFile = (byte)'S', + /// + /// GNU tape volume. + /// This entry type is specific to the format and is not supported for writing. + /// + TapeVolume = (byte)'V', + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs new file mode 100644 index 0000000000000..c156edb9f1624 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs @@ -0,0 +1,274 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Diagnostics; +using System.IO; + +namespace System.Formats.Tar +{ + /// + /// Provides static methods for creating and extracting tar archives. + /// + public static class TarFile + { + /// + /// Creates a tar stream that contains all the filesystem entries from the specified directory. + /// + /// The path of the directory to archive. + /// The destination stream the archive. + /// to include the base directory name as the first segment in all the names of the archive entries. to exclude the base directory name from the archive entry names. + public static void CreateFromDirectory(string sourceDirectoryName, Stream destination, bool includeBaseDirectory) + { + ArgumentException.ThrowIfNullOrEmpty(sourceDirectoryName); + ArgumentNullException.ThrowIfNull(destination); + + if (!destination.CanWrite) + { + throw new IOException(SR.IO_NotSupported_UnwritableStream); + } + + if (!Directory.Exists(sourceDirectoryName)) + { + throw new DirectoryNotFoundException(string.Format(SR.IO_PathNotFound_Path, sourceDirectoryName)); + } + + // Rely on Path.GetFullPath for validation of paths + sourceDirectoryName = Path.GetFullPath(sourceDirectoryName); + + CreateFromDirectoryInternal(sourceDirectoryName, destination, includeBaseDirectory, leaveOpen: true); + } + + // /// + // /// Asynchronously creates a tar stream that contains all the filesystem entries from the specified directory. + // /// + // /// The path of the directory to archive. + // /// The destination stream of the archive. + // /// to include the base directory name as the first path segment in all the names of the archive entries. to exclude the base directory name from the entry name paths. + // /// The token to monitor for cancellation requests. The default value is . + // /// A task that represents the asynchronous creation operation. + // public static Task CreateFromDirectoryAsync(string sourceDirectoryName, Stream destination, bool includeBaseDirectory, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + /// + /// Creates a tar file that contains all the filesystem entries from the specified directory. + /// + /// The path of the directory to archive. + /// The path of the destination archive file. + /// to include the base directory name as the first path segment in all the names of the archive entries. to exclude the base directory name from the entry name paths. + public static void CreateFromDirectory(string sourceDirectoryName, string destinationFileName, bool includeBaseDirectory) + { + ArgumentException.ThrowIfNullOrEmpty(sourceDirectoryName); + ArgumentException.ThrowIfNullOrEmpty(destinationFileName); + + // Rely on Path.GetFullPath for validation of paths + sourceDirectoryName = Path.GetFullPath(sourceDirectoryName); + destinationFileName = Path.GetFullPath(destinationFileName); + + if (!Directory.Exists(sourceDirectoryName)) + { + throw new DirectoryNotFoundException(string.Format(SR.IO_PathNotFound_Path, sourceDirectoryName)); + } + + if (Path.Exists(destinationFileName)) + { + throw new IOException(string.Format(SR.IO_FileExists_Name, destinationFileName)); + } + + using FileStream fs = File.Create(destinationFileName, bufferSize: 0x1000, FileOptions.None); + + CreateFromDirectoryInternal(sourceDirectoryName, fs, includeBaseDirectory, leaveOpen: false); + } + + // /// + // /// Asynchronously creates a tar archive from the contents of the specified directory, and outputs them into the specified path. Can optionally include the base directory as the prefix for the the entry names. + // /// + // /// The path of the directory to archive. + // /// The path of the destination archive file. + // /// to include the base directory name as the first path segment in all the names of the archive entries. to exclude the base directory name from the entry name paths. + // /// The token to monitor for cancellation requests. The default value is . + // /// A task that represents the asynchronous creation operation. + // public static Task CreateFromDirectoryAsync(string sourceDirectoryName, string destinationFileName, bool includeBaseDirectory, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + /// + /// Extracts the contents of a stream that represents a tar archive into the specified directory. + /// + /// The stream containing the tar archive. + /// The path of the destination directory where the filesystem entries should be extracted. + /// to overwrite files and directories in ; to avoid overwriting, and throw if any files or directories are found with existing names. + /// Files of type , or can only be extracted in Unix platforms. + /// Elevation is required to extract a or to disk. + /// Operation not permitted due to insufficient permissions. + /// Extracting tar entry would have resulted in a file outside the specified destination directory. + public static void ExtractToDirectory(Stream source, string destinationDirectoryName, bool overwriteFiles) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentException.ThrowIfNullOrEmpty(destinationDirectoryName); + + if (!source.CanRead) + { + throw new IOException(SR.IO_NotSupported_UnreadableStream); + } + + if (!Directory.Exists(destinationDirectoryName)) + { + throw new DirectoryNotFoundException(string.Format(SR.IO_PathNotFound_Path, destinationDirectoryName)); + } + + // Rely on Path.GetFullPath for validation of paths + destinationDirectoryName = Path.GetFullPath(destinationDirectoryName); + + ExtractToDirectoryInternal(source, destinationDirectoryName, overwriteFiles, leaveOpen: true); + } + + // /// + // /// Asynchronously extracts the contents of a stream that represents a tar archive into the specified directory. + // /// + // /// The stream containing the tar archive. + // /// The path of the destination directory where the filesystem entries should be extracted. + // /// to overwrite files and directories in ; to avoid overwriting, and throw if any files or directories are found with existing names. + // /// The token to monitor for cancellation requests. The default value is . + // /// A task that represents the asynchronous extraction operation. + // /// Files of type , or can only be extracted in Unix platforms. + // /// Elevation is required to extract a or to disk. + // /// Operation not permitted due to insufficient permissions. + // public static Task ExtractToDirectoryAsync(Stream source, string destinationDirectoryName, bool overwriteFiles, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + /// + /// Extracts the contents of a tar file into the specified directory. + /// + /// The path of the tar file to extract. + /// The path of the destination directory where the filesystem entries should be extracted. + /// to overwrite files and directories in ; to avoid overwriting, and throw if any files or directories are found with existing names. + /// Files of type , or can only be extracted in Unix platforms. + /// Elevation is required to extract a or to disk. + /// Operation not permitted due to insufficient permissions. + public static void ExtractToDirectory(string sourceFileName, string destinationDirectoryName, bool overwriteFiles) + { + ArgumentException.ThrowIfNullOrEmpty(sourceFileName); + ArgumentException.ThrowIfNullOrEmpty(destinationDirectoryName); + + // Rely on Path.GetFullPath for validation of paths + sourceFileName = Path.GetFullPath(sourceFileName); + destinationDirectoryName = Path.GetFullPath(destinationDirectoryName); + + if (!File.Exists(sourceFileName)) + { + throw new FileNotFoundException(string.Format(SR.IO_FileNotFound, sourceFileName)); + } + + if (!Directory.Exists(destinationDirectoryName)) + { + throw new DirectoryNotFoundException(string.Format(SR.IO_PathNotFound_Path, destinationDirectoryName)); + } + + FileStreamOptions fileStreamOptions = new() + { + Access = FileAccess.Read, + BufferSize = 0x1000, + Mode = FileMode.Open, + Share = FileShare.Read + }; + + using FileStream archive = File.Open(sourceFileName, fileStreamOptions); + + ExtractToDirectoryInternal(archive, destinationDirectoryName, overwriteFiles, leaveOpen: false); + } + + // /// + // /// Asynchronously extracts the contents of a tar file into the specified directory. + // /// + // /// The path of the tar file to extract. + // /// The path of the destination directory where the filesystem entries should be extracted. + // /// to overwrite files and directories in ; to avoid overwriting, and throw if any files or directories are found with existing names. + // /// The token to monitor for cancellation requests. The default value is . + // /// A task that represents the asynchronous extraction operation. + // /// Files of type , or can only be extracted in Unix platforms. + // /// Elevation is required to extract a or to disk. + // /// Operation not permitted due to insufficient permissions. + // public static Task ExtractToDirectoryAsync(string sourceFileName, string destinationDirectoryName, bool overwriteFiles, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + // Creates an archive from the contents of a directory. + // It assumes the sourceDirectoryName is a fully qualified path, and allows choosing if the archive stream should be left open or not. + private static void CreateFromDirectoryInternal(string sourceDirectoryName, Stream destination, bool includeBaseDirectory, bool leaveOpen) + { + Debug.Assert(!string.IsNullOrEmpty(sourceDirectoryName)); + Debug.Assert(destination != null); + Debug.Assert(Path.IsPathFullyQualified(sourceDirectoryName)); + Debug.Assert(destination.CanWrite); + + using (TarWriter writer = new TarWriter(destination, TarFormat.Pax, leaveOpen)) + { + bool baseDirectoryIsEmpty = true; + DirectoryInfo di = new(sourceDirectoryName); + string basePath = di.FullName; + + if (includeBaseDirectory && di.Parent != null) + { + basePath = di.Parent.FullName; + } + + // Windows' MaxPath (260) is used as an arbitrary default capacity, as it is likely + // to be greater than the length of typical entry names from the file system, even + // on non-Windows platforms. The capacity will be increased, if needed. + const int DefaultCapacity = 260; + char[] entryNameBuffer = ArrayPool.Shared.Rent(DefaultCapacity); + + try + { + foreach (FileSystemInfo file in di.EnumerateFileSystemInfos("*", SearchOption.AllDirectories)) + { + baseDirectoryIsEmpty = false; + + int entryNameLength = file.FullName.Length - basePath.Length; + Debug.Assert(entryNameLength > 0); + + bool isDirectory = file.Attributes.HasFlag(FileAttributes.Directory); + string entryName = ArchivingUtils.EntryFromPath(file.FullName, basePath.Length, entryNameLength, ref entryNameBuffer, appendPathSeparator: isDirectory); + writer.WriteEntry(file.FullName, entryName); + } + + if (includeBaseDirectory && baseDirectoryIsEmpty) + { + string entryName = ArchivingUtils.EntryFromPath(di.Name, 0, di.Name.Length, ref entryNameBuffer, appendPathSeparator: true); + PaxTarEntry entry = new PaxTarEntry(TarEntryType.Directory, entryName); + writer.WriteEntry(entry); + } + } + finally + { + ArrayPool.Shared.Return(entryNameBuffer); + } + } + } + + // Extracts an archive into the specified directory. + // It assumes the destinationDirectoryName is a fully qualified path, and allows choosing if the archive stream should be left open or not. + private static void ExtractToDirectoryInternal(Stream source, string destinationDirectoryPath, bool overwriteFiles, bool leaveOpen) + { + Debug.Assert(source != null); + Debug.Assert(!string.IsNullOrEmpty(destinationDirectoryPath)); + Debug.Assert(Path.IsPathFullyQualified(destinationDirectoryPath)); + Debug.Assert(source.CanRead); + + using TarReader reader = new TarReader(source, leaveOpen); + + TarEntry? entry; + while ((entry = reader.GetNextEntry()) != null) + { + entry.ExtractRelativeToDirectory(destinationDirectoryPath, overwriteFiles); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFileMode.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFileMode.cs new file mode 100644 index 0000000000000..2f0dce07e26b8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFileMode.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Represents the Unix-like filesystem permissions or access modes. + /// This enumeration supports a bitwise combination of its member values. + /// + [Flags] + public enum TarFileMode + { + /// + /// No permissions. + /// + None = 0, + /// + /// Execute permission for others. + /// + OtherExecute = 1, + /// + /// Write permission for others. + /// + OtherWrite = 2, + /// + /// Read permission for others. + /// + OtherRead = 4, + /// + /// Execute permission for group. + /// + GroupExecute = 8, + /// + /// Write permission for group. + /// + GroupWrite = 16, + /// + /// Read permission for group. + /// + GroupRead = 32, + /// + /// Execute permission for user. + /// + UserExecute = 64, + /// + /// Write permission for user. + /// + UserWrite = 128, + /// + /// Read permission for user. + /// + UserRead = 256, + /// + /// Sticky bit special permission. + /// + StickyBit = 512, + /// + /// Group special permission or setgid. + /// + GroupSpecial = 1024, + /// + /// User special permission o setuid. + /// + UserSpecial = 2048, + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFormat.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFormat.cs new file mode 100644 index 0000000000000..1f4bd40327ff8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFormat.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Specifies the supported Tar formats. + /// + public enum TarFormat + { + /// + /// Tar format undetermined. + /// + Unknown, + /// + /// 1979 Version 7 AT&T Unix Tar Command Format (v7). + /// + V7, + /// + /// POSIX IEEE 1003.1-1988 Unix Standard Tar Format (ustar). + /// + Ustar, + /// + /// POSIX IEEE 1003.1-2001 ("POSIX.1") Pax Interchange Tar Format (pax). + /// + Pax, + /// + /// GNU Tar Format (gnu). + /// + Gnu, + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs new file mode 100644 index 0000000000000..f039804bffb1a --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs @@ -0,0 +1,581 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace System.Formats.Tar +{ + // Reads the header attributes from a tar archive entry. + internal partial struct TarHeader + { + private const string UstarPrefixFormat = "{0}/{1}"; // "prefix/name" + + // Attempts to read all the fields of the next header. + // Throws if end of stream is reached or if any data type conversion fails. + // Returns true if all the attributes were read successfully, false otherwise. + internal bool TryGetNextHeader(Stream archiveStream, bool copyData) + { + // The four supported formats have a header that fits in the default record size + byte[] rented = ArrayPool.Shared.Rent(minimumLength: TarHelpers.RecordSize); + + Span buffer = rented.AsSpan(0, TarHelpers.RecordSize); // minimumLength means the array could've been larger + buffer.Clear(); // Rented arrays aren't clean + + TarHelpers.ReadOrThrow(archiveStream, buffer); + + try + { + // Confirms if v7 or pax, or tentatively selects ustar + if (!TryReadCommonAttributes(buffer)) + { + return false; + } + + // Confirms if gnu, or tentatively selects ustar + ReadMagicAttribute(buffer); + + if (_format != TarFormat.V7) + { + // Confirms if gnu + ReadVersionAttribute(buffer); + + // Fields that ustar, pax and gnu share identically + ReadPosixAndGnuSharedAttributes(buffer); + + Debug.Assert(_format is TarFormat.Ustar or TarFormat.Pax or TarFormat.Gnu); + if (_format == TarFormat.Ustar) + { + ReadUstarAttributes(buffer); + } + else if (_format == TarFormat.Gnu) + { + ReadGnuAttributes(buffer); + } + // In PAX, there is nothing to read in this section (empty space) + } + + ProcessDataBlock(archiveStream, copyData); + + return true; + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + + // Reads the elements from the passed dictionary, which comes from the first global extended attributes entry, + // and inserts or replaces those elements into the current header's dictionary. + // If any of the dictionary entries use the name of a standard attribute (not all of them), that attribute's value gets replaced with the one from the dictionary. + // Unlike the historic header, numeric values in extended attributes are stored using decimal, not octal. + // Throws if any conversion from string to the expected data type fails. + internal void ReplaceNormalAttributesWithGlobalExtended(IReadOnlyDictionary gea) + { + // First step: Insert or replace all the elements in the passed dictionary into the current header's dictionary. + foreach ((string key, string value) in gea) + { + _extendedAttributes ??= new Dictionary(); + _extendedAttributes[key] = value; + } + + // Second, find only the attributes that make sense to substitute, and replace them. + if (gea.TryGetValue(PaxEaATime, out string? paxEaATime)) + { + if (TarHelpers.TryConvertToDateTimeOffset(paxEaATime, out DateTimeOffset aTime)) + { + _aTime = aTime; + } + } + if (gea.TryGetValue(PaxEaCTime, out string? paxEaCTime)) + { + if (TarHelpers.TryConvertToDateTimeOffset(paxEaCTime, out DateTimeOffset cTime)) + { + _cTime = cTime; + } + } + if (gea.TryGetValue(PaxEaMTime, out string? paxEaMTime)) + { + if (TarHelpers.TryConvertToDateTimeOffset(paxEaMTime, out DateTimeOffset mTime)) + { + _mTime = mTime; + } + } + if (gea.TryGetValue(PaxEaMode, out string? paxEaMode)) + { + _mode = Convert.ToInt32(paxEaMode); + } + if (gea.TryGetValue(PaxEaUid, out string? paxEaUid)) + { + _uid = Convert.ToInt32(paxEaUid); + } + if (gea.TryGetValue(PaxEaGid, out string? paxEaGid)) + { + _gid = Convert.ToInt32(paxEaGid); + } + if (gea.TryGetValue(PaxEaUName, out string? paxEaUName)) + { + _uName = paxEaUName; + } + if (gea.TryGetValue(PaxEaGName, out string? paxEaGName)) + { + _gName = paxEaGName; + } + } + + // Reads the elements from the passed dictionary, which comes from the previous extended attributes entry, + // and inserts or replaces those elements into the current header's dictionary. + // If any of the dictionary entries use the name of a standard attribute, that attribute's value gets replaced with the one from the dictionary. + // Unlike the historic header, numeric values in extended attributes are stored using decimal, not octal. + // Throws if any conversion from string to the expected data type fails. + internal void ReplaceNormalAttributesWithExtended(IEnumerable> extendedAttributesEnumerable) + { + Dictionary ea = new Dictionary(extendedAttributesEnumerable); + if (ea.Count == 0) + { + return; + } + _extendedAttributes ??= new Dictionary(); + + // First step: Insert or replace all the elements in the passed dictionary into the current header's dictionary. + foreach ((string key, string value) in ea) + { + _extendedAttributes[key] = value; + } + + // Second, find all the extended attributes with known names and save them in the expected standard attribute. + if (ea.TryGetValue(PaxEaName, out string? paxEaName)) + { + _name = paxEaName; + } + if (ea.TryGetValue(PaxEaLinkName, out string? paxEaLinkName)) + { + _linkName = paxEaLinkName; + } + if (ea.TryGetValue(PaxEaATime, out string? paxEaATime)) + { + if (TarHelpers.TryConvertToDateTimeOffset(paxEaATime, out DateTimeOffset aTime)) + { + _aTime = aTime; + } + } + if (ea.TryGetValue(PaxEaCTime, out string? paxEaCTime)) + { + if (TarHelpers.TryConvertToDateTimeOffset(paxEaCTime, out DateTimeOffset cTime)) + { + _cTime = cTime; + } + } + if (ea.TryGetValue(PaxEaMTime, out string? paxEaMTime)) + { + if (TarHelpers.TryConvertToDateTimeOffset(paxEaMTime, out DateTimeOffset mTime)) + { + _mTime = mTime; + } + } + if (ea.TryGetValue(PaxEaMode, out string? paxEaMode)) + { + _mode = Convert.ToInt32(paxEaMode); + } + if (ea.TryGetValue(PaxEaSize, out string? paxEaSize)) + { + _size = Convert.ToInt32(paxEaSize); + } + if (ea.TryGetValue(PaxEaUid, out string? paxEaUid)) + { + _uid = Convert.ToInt32(paxEaUid); + } + if (ea.TryGetValue(PaxEaGid, out string? paxEaGid)) + { + _gid = Convert.ToInt32(paxEaGid); + } + if (ea.TryGetValue(PaxEaUName, out string? paxEaUName)) + { + _uName = paxEaUName; + } + if (ea.TryGetValue(PaxEaGName, out string? paxEaGName)) + { + _gName = paxEaGName; + } + if (ea.TryGetValue(PaxEaDevMajor, out string? paxEaDevMajor)) + { + _devMajor = int.Parse(paxEaDevMajor); + } + if (ea.TryGetValue(PaxEaDevMinor, out string? paxEaDevMinor)) + { + _devMinor = int.Parse(paxEaDevMinor); + } + } + + // Determines what kind of stream needs to be saved for the data section. + // - Metadata typeflag entries (Extended Attributes and Global Extended Attributes in PAX, LongLink and LongPath in GNU) + // will get all the data section read and the stream pointer positioned at the beginning of the next header. + // - Block, Character, Directory, Fifo, HardLink and SymbolicLink typeflag entries have no data section so the archive stream pointer will be positioned at the beginning of the next header. + // - All other typeflag entries with a data section will generate a stream wrapping the data section: SeekableSubReadStream for seekable archive streams, and SubReadStream for unseekable archive streams. + private void ProcessDataBlock(Stream archiveStream, bool copyData) + { + bool skipBlockAlignmentPadding = true; + + switch (_typeFlag) + { + case TarEntryType.ExtendedAttributes or TarEntryType.GlobalExtendedAttributes: + ReadExtendedAttributesBlock(archiveStream); + break; + case TarEntryType.LongLink or TarEntryType.LongPath: + ReadGnuLongPathDataBlock(archiveStream); + break; + case TarEntryType.BlockDevice: + case TarEntryType.CharacterDevice: + case TarEntryType.Directory: + case TarEntryType.Fifo: + case TarEntryType.HardLink: + case TarEntryType.SymbolicLink: + // No data section + break; + case TarEntryType.RegularFile: + case TarEntryType.V7RegularFile: // Treated as regular file + case TarEntryType.ContiguousFile: // Treated as regular file + case TarEntryType.DirectoryList: // Contains the list of filesystem entries in the data section + case TarEntryType.MultiVolume: // Contains portion of a file + case TarEntryType.RenamedOrSymlinked: // Might contain data + case TarEntryType.SparseFile: // Contains portion of a file + case TarEntryType.TapeVolume: // Might contain data + default: // Unrecognized entry types could potentially have a data section + _dataStream = GetDataStream(archiveStream, copyData); + if (_dataStream is SeekableSubReadStream) + { + TarHelpers.AdvanceStream(archiveStream, _size); + } + else if (_dataStream is SubReadStream) + { + // This stream gives the user the chance to optionally read the data section + // when the underlying archive stream is unseekable + skipBlockAlignmentPadding = false; + } + + break; + } + + if (skipBlockAlignmentPadding) + { + if (_size > 0) + { + TarHelpers.SkipBlockAlignmentPadding(archiveStream, _size); + } + + if (archiveStream.CanSeek) + { + _endOfHeaderAndDataAndBlockAlignment = archiveStream.Position; + } + } + } + + // Returns a stream that represents the data section of the current header. + // If copyData is true, then a total number of _size bytes will be copied to a new MemoryStream, which is then returned. + // Otherwise, if the archive stream is seekable, returns a seekable wrapper stream. + // Otherwise, it returns an unseekable wrapper stream. + private Stream? GetDataStream(Stream archiveStream, bool copyData) + { + if (_size == 0) + { + return null; + } + + if (copyData) + { + MemoryStream copiedData = new MemoryStream(); + TarHelpers.CopyBytes(archiveStream, copiedData, _size); + return copiedData; + } + + return archiveStream.CanSeek + ? new SeekableSubReadStream(archiveStream, archiveStream.Position, _size) + : new SubReadStream(archiveStream, 0, _size); + } + + // Attempts to read the fields shared by all formats and stores them in their expected data type. + // Throws if any data type conversion fails. + // Returns true on success, false if checksum is zero. + private bool TryReadCommonAttributes(Span buffer) + { + // Start by collecting fields that need special checks that return early when data is wrong + + // Empty checksum means this is an invalid (all blank) entry, finish early + Span spanChecksum = buffer.Slice(FieldLocations.Checksum, FieldLengths.Checksum); + if (TarHelpers.IsAllNullBytes(spanChecksum)) + { + return false; + } + _checksum = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(spanChecksum); + // Zero checksum means the whole header is empty + if (_checksum == 0) + { + return false; + } + + _size = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.Size, FieldLengths.Size)); + if (_size < 0) + { + throw new FormatException(string.Format(SR.TarSizeFieldNegative, _name)); + } + + // Continue with the rest of the fields that require no special checks + + _name = TarHelpers.GetTrimmedUtf8String(buffer.Slice(FieldLocations.Name, FieldLengths.Name)); + _mode = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.Mode, FieldLengths.Mode)); + _uid = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.Uid, FieldLengths.Uid)); + _gid = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.Gid, FieldLengths.Gid)); + int mTime = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.MTime, FieldLengths.MTime)); + _mTime = TarHelpers.GetDateTimeFromSecondsSinceEpoch(mTime); + _typeFlag = (TarEntryType)buffer[FieldLocations.TypeFlag]; + _linkName = TarHelpers.GetTrimmedUtf8String(buffer.Slice(FieldLocations.LinkName, FieldLengths.LinkName)); + + if (_format == TarFormat.Unknown) + { + _format = _typeFlag switch + { + TarEntryType.ExtendedAttributes or + TarEntryType.GlobalExtendedAttributes => TarFormat.Pax, + + TarEntryType.DirectoryList or + TarEntryType.LongLink or + TarEntryType.LongPath or + TarEntryType.MultiVolume or + TarEntryType.RenamedOrSymlinked or + TarEntryType.SparseFile or + TarEntryType.TapeVolume => TarFormat.Gnu, + + // V7 is the only one that uses 'V7RegularFile'. + TarEntryType.V7RegularFile => TarFormat.V7, + + // We can quickly determine the *minimum* possible format if the entry type + // is the POSIX 'RegularFile', although later we could upgrade it to PAX or GNU + _ => (_typeFlag == TarEntryType.RegularFile) ? TarFormat.Ustar : TarFormat.V7 + }; + } + + return true; + } + + // Reads fields only found in ustar format or above and converts them to their expected data type. + // Throws if any conversion fails. + private void ReadMagicAttribute(Span buffer) + { + Span magic = buffer.Slice(FieldLocations.Magic, FieldLengths.Magic); + + // If at this point the magic value is all nulls, we definitely have a V7 + if (TarHelpers.IsAllNullBytes(magic)) + { + _format = TarFormat.V7; + return; + } + + // When the magic field is set, the archive is newer than v7. + _magic = Encoding.ASCII.GetString(magic); + + if (_magic == GnuMagic) + { + _format = TarFormat.Gnu; + } + else if (_format == TarFormat.V7 && _magic == UstarMagic) + { + // Important: Only change to ustar if we had not changed the format to pax already + _format = TarFormat.Ustar; + } + } + + // Reads the version string and determines the format depending on its value. + // Throws if converting the bytes to string fails or if an unexpected version string is found. + private void ReadVersionAttribute(Span buffer) + { + if (_format == TarFormat.V7) + { + return; + } + + Span version = buffer.Slice(FieldLocations.Version, FieldLengths.Version); + + _version = Encoding.ASCII.GetString(version); + + // The POSIX formats have a 6 byte Magic "ustar\0", followed by a 2 byte Version "00" + if ((_format is TarFormat.Ustar or TarFormat.Pax) && _version != UstarVersion) + { + throw new FormatException(string.Format(SR.TarPosixFormatExpected, _name)); + } + + // The GNU format has a Magic+Version 8 byte string "ustar \0" + if (_format == TarFormat.Gnu && _version != GnuVersion) + { + throw new FormatException(string.Format(SR.TarGnuFormatExpected, _name)); + } + } + + // Reads the attributes shared by the POSIX and GNU formats. + // Throws if converting the bytes to their expected data type fails. + private void ReadPosixAndGnuSharedAttributes(Span buffer) + { + // Convert the byte arrays + _uName = TarHelpers.GetTrimmedAsciiString(buffer.Slice(FieldLocations.UName, FieldLengths.UName)); + _gName = TarHelpers.GetTrimmedAsciiString(buffer.Slice(FieldLocations.GName, FieldLengths.GName)); + + // DevMajor and DevMinor only have values with character devices and block devices. + // For all other typeflags, the values in these fields are irrelevant. + if (_typeFlag is TarEntryType.CharacterDevice or TarEntryType.BlockDevice) + { + // Major number for a character device or block device entry. + _devMajor = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.DevMajor, FieldLengths.DevMajor)); + + // Minor number for a character device or block device entry. + _devMinor = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.DevMinor, FieldLengths.DevMinor)); + } + } + + // Reads attributes specific to the GNU format. + // Throws if any conversion fails. + private void ReadGnuAttributes(Span buffer) + { + // Convert byte arrays + int aTime = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.ATime, FieldLengths.ATime)); + _aTime = TarHelpers.GetDateTimeFromSecondsSinceEpoch(aTime); + + int cTime = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.CTime, FieldLengths.CTime)); + _cTime = TarHelpers.GetDateTimeFromSecondsSinceEpoch(cTime); + + // TODO: Read the bytes of the currently unsupported GNU fields, in case user wants to write this entry into another GNU archive, they need to be preserved. https://github.com/dotnet/runtime/issues/68230 + } + + // Reads the ustar prefix attribute. + // Throws if a conversion to an expected data type fails. + private void ReadUstarAttributes(Span buffer) + { + _prefix = TarHelpers.GetTrimmedUtf8String(buffer.Slice(FieldLocations.Prefix, FieldLengths.Prefix)); + + // In ustar, Prefix is used to store the *leading* path segments of + // Name, if the full path did not fit in the Name byte array. + if (!string.IsNullOrEmpty(_prefix)) + { + // Prefix never has a leading separator, so we add it + // it should always be a forward slash for compatibility + _name = string.Format(UstarPrefixFormat, _prefix, _name); + } + } + + // Collects the extended attributes found in the data section of a PAX entry of type 'x' or 'g'. + // Throws if end of stream is reached or if an attribute is malformed. + private void ReadExtendedAttributesBlock(Stream archiveStream) + { + Debug.Assert(_typeFlag is TarEntryType.ExtendedAttributes or TarEntryType.GlobalExtendedAttributes); + + // Regardless of the size, this entry should always have a valid dictionary object + _extendedAttributes ??= new Dictionary(); + + if (_size == 0) + { + return; + } + + // It is not expected that the extended attributes data section will be longer than int.MaxValue, considering + // 4096 is a common max path length, and also the size field is 12 bytes long, which is under int.MaxValue. + if (_size > int.MaxValue) + { + throw new InvalidOperationException(string.Format(SR.TarSizeFieldTooLargeForExtendedAttribute, _typeFlag.ToString())); + } + + byte[] buffer = new byte[(int)_size]; + if (archiveStream.Read(buffer.AsSpan()) != _size) + { + throw new EndOfStreamException(); + } + + string dataAsString = TarHelpers.GetTrimmedUtf8String(buffer); + + using StringReader reader = new(dataAsString); + + while (TryGetNextExtendedAttribute(reader, out string? key, out string? value)) + { + _extendedAttributes ??= new Dictionary(); + + if (_extendedAttributes.ContainsKey(key)) + { + throw new FormatException(string.Format(SR.TarDuplicateExtendedAttribute, _name)); + } + _extendedAttributes.Add(key, value); + } + } + + // Reads the long path found in the data section of a GNU entry of type 'K' or 'L' + // and replaces Name or LinkName, respectively, with the found string. + // Throws if end of stream is reached. + private void ReadGnuLongPathDataBlock(Stream archiveStream) + { + Debug.Assert(_typeFlag is TarEntryType.LongLink or TarEntryType.LongPath); + + if (_size == 0) + { + return; + } + + byte[] buffer = new byte[(int)_size]; + + if (archiveStream.Read(buffer.AsSpan()) != _size) + { + throw new EndOfStreamException(); + } + + string longPath = TarHelpers.GetTrimmedUtf8String(buffer); + + if (_typeFlag == TarEntryType.LongLink) + { + _linkName = longPath; + } + else if (_typeFlag == TarEntryType.LongPath) + { + _name = longPath; + } + } + + // Tries to collect the next extended attribute from the string wrapped by the specified reader. + // Extended attributes are saved in the ISO/IEC 10646-1:2000 standard UTF-8 encoding format. + // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html + // "LENGTH KEY=VALUE\n" + // Where LENGTH is the total number of bytes of that line, from LENGTH itself to the endline, inclusive. + // Throws if end of stream is reached or if an attribute is malformed. + private static bool TryGetNextExtendedAttribute( + StringReader reader, + [NotNullWhen(returnValue: true)] out string? key, + [NotNullWhen(returnValue: true)] out string? value) + { + key = null; + value = null; + + string? nextLine = reader.ReadLine(); + if (string.IsNullOrWhiteSpace(nextLine)) + { + return false; + } + + StringSplitOptions splitOptions = StringSplitOptions.RemoveEmptyEntries; + + string[] attributeArray = nextLine.Split(' ', 2, splitOptions); + if (attributeArray.Length != 2) + { + return false; + } + + string[] keyAndValueArray = attributeArray[1].Split('=', 2, splitOptions); + if (keyAndValueArray.Length != 2) + { + return false; + } + + key = keyAndValueArray[0]; + value = keyAndValueArray[1]; + + return true; + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs new file mode 100644 index 0000000000000..0c2cf88c9c7e8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs @@ -0,0 +1,631 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; + +namespace System.Formats.Tar +{ + // Writes header attributes of a tar archive entry. + internal partial struct TarHeader + { + private static ReadOnlySpan PaxMagicBytes => new byte[] { 0x75, 0x73, 0x74, 0x61, 0x72, 0x0 }; // "ustar\0" + private static ReadOnlySpan PaxVersionBytes => new byte[] { TarHelpers.ZeroChar, TarHelpers.ZeroChar }; // "00" + + private static ReadOnlySpan GnuMagicBytes => new byte[] { 0x75, 0x73, 0x74, 0x61, 0x72, TarHelpers.SpaceChar }; // "ustar " + private static ReadOnlySpan GnuVersionBytes => new byte[] { TarHelpers.SpaceChar, 0x0 }; // " \0" + + // Extended Attribute entries have a special format in the Name field: + // "{dirName}/PaxHeaders.{processId}/{fileName}{trailingSeparator}" + private const string PaxHeadersFormat = "{0}/PaxHeaders.{1}/{2}{3}"; + + // Global Extended Attribute entries have a special format in the Name field: + // "{tmpFolder}/GlobalHead.{processId}.1" + private const string GlobalHeadFormat = "{0}/GlobalHead.{1}.1"; + + // Predefined text for the Name field of a GNU long metadata entry. Applies for both LongPath ('L') and LongLink ('K'). + private const string GnuLongMetadataName = "././@LongLink"; + + // Creates a PAX Global Extended Attributes header and writes it into the specified archive stream. + internal static void WriteGlobalExtendedAttributesHeader(Stream archiveStream, Span buffer, IEnumerable> globalExtendedAttributes) + { + TarHeader geaHeader = default; + geaHeader._name = GenerateGlobalExtendedAttributeName(); + geaHeader._mode = (int)TarHelpers.DefaultMode; + geaHeader._typeFlag = TarEntryType.GlobalExtendedAttributes; + geaHeader._linkName = string.Empty; + geaHeader._magic = string.Empty; + geaHeader._version = string.Empty; + geaHeader._gName = string.Empty; + geaHeader._uName = string.Empty; + geaHeader.WriteAsPaxExtendedAttributes(archiveStream, buffer, globalExtendedAttributes, isGea: true); + } + + // Writes the current header as a V7 entry into the archive stream. + internal void WriteAsV7(Stream archiveStream, Span buffer) + { + long actualLength = GetTotalDataBytesToWrite(); + TarEntryType actualEntryType = GetCorrectTypeFlagForFormat(TarFormat.V7); + + int checksum = WriteName(buffer, out _); + checksum += WriteCommonFields(buffer, actualLength, actualEntryType); + WriteChecksum(checksum, buffer); + + archiveStream.Write(buffer); + + if (_dataStream != null) + { + WriteData(archiveStream, _dataStream, actualLength); + } + } + + // Writes the current header as a Ustar entry into the archive stream. + internal void WriteAsUstar(Stream archiveStream, Span buffer) + { + long actualLength = GetTotalDataBytesToWrite(); + TarEntryType actualEntryType = GetCorrectTypeFlagForFormat(TarFormat.Ustar); + + int checksum = WritePosixName(buffer); + checksum += WriteCommonFields(buffer, actualLength, actualEntryType); + checksum += WritePosixMagicAndVersion(buffer); + checksum += WritePosixAndGnuSharedFields(buffer); + WriteChecksum(checksum, buffer); + + archiveStream.Write(buffer); + + if (_dataStream != null) + { + WriteData(archiveStream, _dataStream, actualLength); + } + } + + // Writes the current header as a PAX entry into the archive stream. + // Makes sure to add the preceding exteded attributes entry before the actual entry. + internal void WriteAsPax(Stream archiveStream, Span buffer) + { + // First, we write the preceding extended attributes header + TarHeader extendedAttributesHeader = default; + // Fill the current header's dict + CollectExtendedAttributesFromStandardFieldsIfNeeded(); + // And pass them to the extended attributes header for writing + extendedAttributesHeader.WriteAsPaxExtendedAttributes(archiveStream, buffer, _extendedAttributes, isGea: false); + + buffer.Clear(); // Reset it to reuse it + // Second, we write this header as a normal one + WriteAsPaxInternal(archiveStream, buffer); + } + + // Writes the current header as a Gnu entry into the archive stream. + // Makes sure to add the preceding LongLink and/or LongPath entries if necessary, before the actual entry. + internal void WriteAsGnu(Stream archiveStream, Span buffer) + { + // First, we determine if we need a preceding LongLink, and write it if needed + if (_linkName.Length > FieldLengths.LinkName) + { + TarHeader longLinkHeader = GetGnuLongMetadataHeader(TarEntryType.LongLink, _linkName); + longLinkHeader.WriteAsGnuInternal(archiveStream, buffer); + buffer.Clear(); // Reset it to reuse it + } + + // Second, we determine if we need a preceding LongPath, and write it if needed + if (_name.Length > FieldLengths.Name) + { + TarHeader longPathHeader = GetGnuLongMetadataHeader(TarEntryType.LongPath, _name); + longPathHeader.WriteAsGnuInternal(archiveStream, buffer); + buffer.Clear(); // Reset it to reuse it + } + + // Third, we write this header as a normal one + WriteAsGnuInternal(archiveStream, buffer); + } + + // Creates and returns a GNU long metadata header, with the specified long text written into its data stream. + private static TarHeader GetGnuLongMetadataHeader(TarEntryType entryType, string longText) + { + Debug.Assert((entryType is TarEntryType.LongPath && longText.Length > FieldLengths.Name) || + (entryType is TarEntryType.LongLink && longText.Length > FieldLengths.LinkName)); + + TarHeader longMetadataHeader = default; + + longMetadataHeader._name = GnuLongMetadataName; // Same name for both longpath or longlink + longMetadataHeader._mode = (int)TarHelpers.DefaultMode; + longMetadataHeader._uid = 0; + longMetadataHeader._gid = 0; + longMetadataHeader._mTime = DateTimeOffset.MinValue; // 0 + longMetadataHeader._typeFlag = entryType; + + longMetadataHeader._dataStream = new MemoryStream(); + longMetadataHeader._dataStream.Write(Encoding.UTF8.GetBytes(longText)); + longMetadataHeader._dataStream.Seek(0, SeekOrigin.Begin); // Ensure it gets written into the archive from the beginning + + return longMetadataHeader; + } + + // Writes the current header as a GNU entry into the archive stream. + internal void WriteAsGnuInternal(Stream archiveStream, Span buffer) + { + // Unused GNU fields: offset, longnames, unused, sparse struct, isextended and realsize + // If this header came from another archive, it will have a value + // If it was constructed by the user, it will be an empty array + _gnuUnusedBytes ??= new byte[FieldLengths.AllGnuUnused]; + + long actualLength = GetTotalDataBytesToWrite(); + TarEntryType actualEntryType = GetCorrectTypeFlagForFormat(TarFormat.Gnu); + + int checksum = WriteName(buffer, out _); + checksum += WriteCommonFields(buffer, actualLength, actualEntryType); + checksum += WriteGnuMagicAndVersion(buffer); + checksum += WritePosixAndGnuSharedFields(buffer); + checksum += WriteGnuFields(buffer); + WriteChecksum(checksum, buffer); + + archiveStream.Write(buffer); + + if (_dataStream != null) + { + WriteData(archiveStream, _dataStream, actualLength); + } + } + + // Writes the current header as a PAX Extended Attributes entry into the archive stream. + private void WriteAsPaxExtendedAttributes(Stream archiveStream, Span buffer, IEnumerable> extendedAttributes, bool isGea) + { + // The ustar fields (uid, gid, linkName, uname, gname, devmajor, devminor) do not get written. + // The mode gets the default value. + _name = GenerateExtendedAttributeName(); + _mode = (int)TarHelpers.DefaultMode; + _typeFlag = isGea ? TarEntryType.GlobalExtendedAttributes : TarEntryType.ExtendedAttributes; + _linkName = string.Empty; + _magic = string.Empty; + _version = string.Empty; + _gName = string.Empty; + _uName = string.Empty; + + _dataStream = GenerateExtendedAttributesDataStream(extendedAttributes); + + WriteAsPaxInternal(archiveStream, buffer); + } + + // Both the Extended Attributes and Global Extended Attributes entry headers are written in a similar way, just the data changes + // This method writes an entry as both entries require, using the data from the current header instance. + private void WriteAsPaxInternal(Stream archiveStream, Span buffer) + { + long actualLength = GetTotalDataBytesToWrite(); + TarEntryType actualEntryType = GetCorrectTypeFlagForFormat(TarFormat.Pax); + + int checksum = WritePosixName(buffer); + checksum += WriteCommonFields(buffer, actualLength, actualEntryType); + checksum += WritePosixMagicAndVersion(buffer); + checksum += WritePosixAndGnuSharedFields(buffer); + WriteChecksum(checksum, buffer); + + archiveStream.Write(buffer); + + if (_dataStream != null) + { + WriteData(archiveStream, _dataStream, actualLength); + } + } + + // All formats save in the name byte array only the ASCII bytes that fit. The full string is returned in the out byte array. + private int WriteName(Span buffer, out byte[] fullNameBytes) + { + fullNameBytes = Encoding.ASCII.GetBytes(_name); + int nameBytesLength = Math.Min(fullNameBytes.Length, FieldLengths.Name); + int checksum = WriteLeftAlignedBytesAndGetChecksum(fullNameBytes.AsSpan(0, nameBytesLength), buffer.Slice(FieldLocations.Name, FieldLengths.Name)); + return checksum; + } + + // Ustar and PAX save in the name byte array only the ASCII bytes that fit, and the rest of that string is saved in the prefix field. + private int WritePosixName(Span buffer) + { + int checksum = WriteName(buffer, out byte[] fullNameBytes); + if (fullNameBytes.Length > FieldLengths.Name) + { + int prefixBytesLength = Math.Min(fullNameBytes.Length - FieldLengths.Name, FieldLengths.Name); + checksum += WriteLeftAlignedBytesAndGetChecksum(fullNameBytes.AsSpan(FieldLengths.Name, prefixBytesLength), buffer.Slice(FieldLocations.Prefix, FieldLengths.Prefix)); + } + return checksum; + } + + // Writes all the common fields shared by all formats into the specified spans. + private int WriteCommonFields(Span buffer, long actualLength, TarEntryType actualEntryType) + { + int checksum = 0; + + if (_mode > 0) + { + checksum += WriteAsOctal(_mode, buffer, FieldLocations.Mode, FieldLengths.Mode); + } + + if (_uid > 0) + { + checksum += WriteAsOctal(_uid, buffer, FieldLocations.Uid, FieldLengths.Uid); + } + + if (_gid > 0) + { + checksum += WriteAsOctal(_gid, buffer, FieldLocations.Gid, FieldLengths.Gid); + } + + _size = actualLength; + + if (_size > 0) + { + checksum += WriteAsOctal(_size, buffer, FieldLocations.Size, FieldLengths.Size); + } + + checksum += WriteAsTimestamp(_mTime, buffer, FieldLocations.MTime, FieldLengths.MTime); + + char typeFlagChar = (char)actualEntryType; + buffer[FieldLocations.TypeFlag] = (byte)typeFlagChar; + checksum += typeFlagChar; + + if (!string.IsNullOrEmpty(_linkName)) + { + checksum += WriteAsAsciiString(_linkName, buffer, FieldLocations.LinkName, FieldLengths.LinkName); + } + + return checksum; + } + + // When writing an entry that came from an archive of a different format, if its entry type happens to + // be an incompatible regular file entry type, convert it to the compatible one. + // No change for all other entry types. + private TarEntryType GetCorrectTypeFlagForFormat(TarFormat format) + { + if (format is TarFormat.V7) + { + if (_typeFlag is TarEntryType.RegularFile) + { + return TarEntryType.V7RegularFile; + } + } + else if (_typeFlag is TarEntryType.V7RegularFile) + { + return TarEntryType.RegularFile; + } + + return _typeFlag; + } + + // Calculates how many data bytes should be written, depending on the position pointer of the stream. + private long GetTotalDataBytesToWrite() + { + if (_dataStream != null) + { + long length = _dataStream.Length; + long position = _dataStream.Position; + if (position < length) + { + return length - position; + } + } + return 0; + } + + // Writes the magic and version fields of a ustar or pax entry into the specified spans. + private static int WritePosixMagicAndVersion(Span buffer) + { + int checksum = WriteLeftAlignedBytesAndGetChecksum(PaxMagicBytes, buffer.Slice(FieldLocations.Magic, FieldLengths.Magic)); + checksum += WriteLeftAlignedBytesAndGetChecksum(PaxVersionBytes, buffer.Slice(FieldLocations.Version, FieldLengths.Version)); + return checksum; + } + + // Writes the magic and vresion fields of a gnu entry into the specified spans. + private static int WriteGnuMagicAndVersion(Span buffer) + { + int checksum = WriteLeftAlignedBytesAndGetChecksum(GnuMagicBytes, buffer.Slice(FieldLocations.Magic, FieldLengths.Magic)); + checksum += WriteLeftAlignedBytesAndGetChecksum(GnuVersionBytes, buffer.Slice(FieldLocations.Version, FieldLengths.Version)); + return checksum; + } + + // Writes the posix fields shared by ustar, pax and gnu, into the specified spans. + private int WritePosixAndGnuSharedFields(Span buffer) + { + int checksum = 0; + + if (!string.IsNullOrEmpty(_uName)) + { + checksum += WriteAsAsciiString(_uName, buffer, FieldLocations.UName, FieldLengths.UName); + } + + if (!string.IsNullOrEmpty(_gName)) + { + checksum += WriteAsAsciiString(_gName, buffer, FieldLocations.GName, FieldLengths.GName); + } + + if (_devMajor > 0) + { + checksum += WriteAsOctal(_devMajor, buffer, FieldLocations.DevMajor, FieldLengths.DevMajor); + } + + if (_devMinor > 0) + { + checksum += WriteAsOctal(_devMinor, buffer, FieldLocations.DevMinor, FieldLengths.DevMinor); + } + + return checksum; + } + + // Saves the gnu-specific fields into the specified spans. + private int WriteGnuFields(Span buffer) + { + int checksum = WriteAsTimestamp(_aTime, buffer, FieldLocations.ATime, FieldLengths.ATime); + checksum += WriteAsTimestamp(_cTime, buffer, FieldLocations.CTime, FieldLengths.CTime); + + if (_gnuUnusedBytes != null) + { + checksum += WriteLeftAlignedBytesAndGetChecksum(_gnuUnusedBytes, buffer.Slice(FieldLocations.GnuUnused, FieldLengths.AllGnuUnused)); + } + + return checksum; + } + + // Writes the current header's data stream into the archive stream. + private static void WriteData(Stream archiveStream, Stream dataStream, long actualLength) + { + dataStream.CopyTo(archiveStream); // The data gets copied from the current position + int paddingAfterData = TarHelpers.CalculatePadding(actualLength); + archiveStream.Write(new byte[paddingAfterData]); + } + + // Dumps into the archive stream an extended attribute entry containing metadata of the entry it precedes. + private static Stream? GenerateExtendedAttributesDataStream(IEnumerable> extendedAttributes) + { + MemoryStream? dataStream = null; + foreach ((string attribute, string value) in extendedAttributes) + { + // Need to do this because IEnumerable has no Count property + dataStream ??= new MemoryStream(); + + byte[] entryBytes = GenerateExtendedAttributeKeyValuePairAsByteArray(Encoding.UTF8.GetBytes(attribute), Encoding.UTF8.GetBytes(value)); + dataStream.Write(entryBytes); + } + dataStream?.Seek(0, SeekOrigin.Begin); // Ensure it gets written into the archive from the beginning + return dataStream; + } + + // Some fields that have a reserved spot in the header, may not fit in such field anymore, but they can fit in the + // extended attributes. They get collected and saved in that dictionary, with no restrictions. + private void CollectExtendedAttributesFromStandardFieldsIfNeeded() + { + _extendedAttributes.Add(PaxEaName, _name); + + AddTimestampAsUnixSeconds(_extendedAttributes, PaxEaATime, _aTime); + AddTimestampAsUnixSeconds(_extendedAttributes, PaxEaCTime, _cTime); + AddTimestampAsUnixSeconds(_extendedAttributes, PaxEaMTime, _mTime); + TryAddStringField(_extendedAttributes, PaxEaGName, _gName, FieldLengths.GName); + TryAddStringField(_extendedAttributes, PaxEaUName, _uName, FieldLengths.UName); + + if (!string.IsNullOrEmpty(_linkName)) + { + _extendedAttributes.Add(PaxEaLinkName, _linkName); + } + + if (_size > 99_999_999) + { + _extendedAttributes.Add(PaxEaSize, _size.ToString()); + } + + // Adds the specified datetime to the dictionary as a decimal number. + static void AddTimestampAsUnixSeconds(Dictionary extendedAttributes, string key, DateTimeOffset value) + { + // Avoid overwriting if the user already added it before + if (!extendedAttributes.ContainsKey(key)) + { + double unixTimeSeconds = ((double)(value.UtcDateTime - DateTime.UnixEpoch).Ticks) / TimeSpan.TicksPerSecond; + extendedAttributes.Add(key, unixTimeSeconds.ToString("F6", CultureInfo.InvariantCulture)); // 6 decimals, no commas + } + } + + // Adds the specified string to the dictionary if it's longer than the specified max byte length. + static void TryAddStringField(Dictionary extendedAttributes, string key, string value, int maxLength) + { + if (Encoding.UTF8.GetByteCount(value) > maxLength) + { + extendedAttributes.Add(key, value); + } + } + } + + // Generates an extended attribute key value pair string saved into a byte array, following the ISO/IEC 10646-1:2000 standard UTF-8 encoding format. + // https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html + private static byte[] GenerateExtendedAttributeKeyValuePairAsByteArray(byte[] keyBytes, byte[] valueBytes) + { + // Assuming key="ab" and value="cdef" + + // The " ab=cdef\n" attribute string has a length of 9 chars + int suffixByteCount = 3 + // leading space, equals sign and trailing newline + keyBytes.Length + valueBytes.Length; + + // The count string "9" has a length of 1 char + string suffixByteCountString = suffixByteCount.ToString(); + int firstTotalByteCount = Encoding.ASCII.GetByteCount(suffixByteCountString); + + // If we prepend the count string length to the attribute string, + // the total length increases to 10, which has one more digit + // "9 abc=def\n" + int firstPrefixAndSuffixByteCount = firstTotalByteCount + suffixByteCount; + + // The new count string "10" has an increased length of 2 chars + string prefixAndSuffixByteCountString = firstPrefixAndSuffixByteCount.ToString(); + int realTotalCharCount = Encoding.ASCII.GetByteCount(prefixAndSuffixByteCountString); + + byte[] finalTotalCharCountBytes = Encoding.ASCII.GetBytes(prefixAndSuffixByteCountString); + + // The final string should contain the correct total length now + List bytesList = new(); + + bytesList.AddRange(finalTotalCharCountBytes); + bytesList.Add(TarHelpers.SpaceChar); + bytesList.AddRange(keyBytes); + bytesList.Add(TarHelpers.EqualsChar); + bytesList.AddRange(valueBytes); + bytesList.Add(TarHelpers.NewLineChar); + + Debug.Assert(bytesList.Count == (realTotalCharCount + suffixByteCount)); + + return bytesList.ToArray(); + } + + // The checksum accumulator first adds up the byte values of eight space chars, then the final number + // is written on top of those spaces on the specified span as ascii. + // At the end, it's saved in the header field. + internal void WriteChecksum(int checksum, Span buffer) + { + // The checksum field is also counted towards the total sum + // but as an array filled with spaces + checksum += TarHelpers.SpaceChar * 8; + + Span converted = stackalloc byte[FieldLengths.Checksum]; + WriteAsOctal(checksum, converted, 0, converted.Length); + + Span destination = buffer.Slice(FieldLocations.Checksum, FieldLengths.Checksum); + + // Checksum field ends with a null and a space + destination[^1] = TarHelpers.SpaceChar; // ' ' + destination[^2] = 0; // '\0' + + int i = destination.Length - 3; + int j = converted.Length - 1; + + while (i >= 0) + { + if (j >= 0) + { + destination[i] = converted[j]; + j--; + } + else + { + destination[i] = TarHelpers.ZeroChar; // Leading zero chars '0' + } + i--; + } + + _checksum = checksum; + } + + // Writes the specified bytes into the specified destination, aligned to the left. Returns the sum of the value of all the bytes that were written. + private static int WriteLeftAlignedBytesAndGetChecksum(ReadOnlySpan bytesToWrite, Span destination) + { + Debug.Assert(destination.Length > 1); + + int checksum = 0; + + for (int i = 0, j = 0; i < destination.Length && j < bytesToWrite.Length; i++, j++) + { + destination[i] = bytesToWrite[j]; + checksum += destination[i]; + } + + return checksum; + } + + // Writes the specified bytes aligned to the right, filling all the leading bytes with the zero char 0x30, + // ensuring a null terminator is included at the end of the specified span. + private static int WriteRightAlignedBytesAndGetChecksum(ReadOnlySpan bytesToWrite, Span destination) + { + int checksum = 0; + int i = destination.Length - 1; + int j = bytesToWrite.Length - 1; + + while (i >= 0) + { + if (i == destination.Length - 1) + { + destination[i] = 0; // null terminated + } + else if (j >= 0) + { + destination[i] = bytesToWrite[j]; + j--; + } + else + { + destination[i] = TarHelpers.ZeroChar; // leading zeros + } + checksum += destination[i]; + i--; + } + + return checksum; + } + + // Writes the specified decimal number as a right-aligned octal number and returns its checksum. + internal static int WriteAsOctal(long tenBaseNumber, Span destination, int location, int length) + { + long octal = TarHelpers.ConvertDecimalToOctal(tenBaseNumber); + byte[] bytes = Encoding.ASCII.GetBytes(octal.ToString()); + return WriteRightAlignedBytesAndGetChecksum(bytes.AsSpan(), destination.Slice(location, length)); + } + + // Writes the specified DateTimeOffset's Unix time seconds as a right-aligned octal number, and returns its checksum. + private static int WriteAsTimestamp(DateTimeOffset timestamp, Span destination, int location, int length) + { + long unixTimeSeconds = timestamp.ToUnixTimeSeconds(); + return WriteAsOctal(unixTimeSeconds, destination, location, length); + } + + // Writes the specified text as an ASCII string aligned to the left, and returns its checksum. + private static int WriteAsAsciiString(string str, Span buffer, int location, int length) + { + byte[] bytes = Encoding.ASCII.GetBytes(str); + return WriteLeftAlignedBytesAndGetChecksum(bytes.AsSpan(), buffer.Slice(location, length)); + } + + // Gets the special name for the 'name' field in an extended attribute entry. + // Format: "%d/PaxHeaders.%p/%f" + // - %d: The directory name of the file, equivalent to the result of the dirname utility on the translated pathname. + // - %p: The current process ID. + // - %f: The filename of the file, equivalent to the result of the basename utility on the translated pathname. + private string GenerateExtendedAttributeName() + { + string? dirName = Path.GetDirectoryName(_name); + dirName = string.IsNullOrEmpty(dirName) ? "." : dirName; + + int processId = Environment.ProcessId; + + string? fileName = Path.GetFileName(_name); + fileName = string.IsNullOrEmpty(fileName) ? "." : fileName; + + string trailingSeparator = (_typeFlag is TarEntryType.Directory or TarEntryType.DirectoryList) ? + $"{Path.DirectorySeparatorChar}" : string.Empty; + + return string.Format(PaxHeadersFormat, dirName, processId, fileName, trailingSeparator); + } + + // Gets the special name for the 'name' field in a global extended attribute entry. + // Format: "%d/GlobalHead.%p/%f" + // - %d: The path of the $TMPDIR variable, if found. Otherwise, the value is '/tmp'. + // - %p: The current process ID. + // - %n: The sequence number of the global extended header record of the archive, starting at 1. In our case, since we only generate one, the value is always 1. + // If the path of $TMPDIR makes the final string too long to fit in the 'name' field, + // then the TMPDIR='/tmp' is used. + private static string GenerateGlobalExtendedAttributeName() + { + string? tmpDir = Environment.GetEnvironmentVariable("TMPDIR"); + if (string.IsNullOrWhiteSpace(tmpDir)) + { + tmpDir = "/tmp"; + } + else if (Path.EndsInDirectorySeparator(tmpDir)) + { + tmpDir = Path.TrimEndingDirectorySeparator(tmpDir); + } + int processId = Environment.ProcessId; + + string result = string.Format(GlobalHeadFormat, tmpDir, processId); + if (result.Length >= FieldLengths.Name) + { + result = string.Format(GlobalHeadFormat, "/tmp", processId); + } + + return result; + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs new file mode 100644 index 0000000000000..aa0bab5aae02c --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; + +namespace System.Formats.Tar +{ + // Describes the header attributes from a tar archive entry. + // Supported formats: + // - 1979 Version 7 AT&T Unix Tar Command Format (v7). + // - POSIX IEEE 1003.1-1988 Unix Standard Tar Format (ustar). + // - POSIX IEEE 1003.1-2001 ("POSIX.1") Pax Interchange Tar Format (pax). + // - GNU Tar Format (gnu). + // Documentation: https://www.freebsd.org/cgi/man.cgi?query=tar&sektion=5 + internal partial struct TarHeader + { + // POSIX fields (shared by Ustar and PAX) + private const string UstarMagic = "ustar\0"; + private const string UstarVersion = "00"; + + // GNU-specific fields + private const string GnuMagic = "ustar "; + private const string GnuVersion = " \0"; + + // Names of PAX extended attributes commonly found fields + private const string PaxEaName = "path"; + private const string PaxEaLinkName = "linkpath"; + private const string PaxEaMode = "mode"; + private const string PaxEaGName = "gname"; + private const string PaxEaUName = "uname"; + private const string PaxEaGid = "gid"; + private const string PaxEaUid = "uid"; + private const string PaxEaATime = "atime"; + private const string PaxEaCTime = "ctime"; + private const string PaxEaMTime = "mtime"; + private const string PaxEaSize = "size"; + private const string PaxEaDevMajor = "devmajor"; + private const string PaxEaDevMinor = "devminor"; + + internal Stream? _dataStream; + + // Position in the stream where the data ends in this header. + internal long _endOfHeaderAndDataAndBlockAlignment; + + internal TarFormat _format; + + // Common attributes + + internal string _name; + internal int _mode; + internal int _uid; + internal int _gid; + internal long _size; + internal DateTimeOffset _mTime; + internal int _checksum; + internal TarEntryType _typeFlag; + internal string _linkName; + + // POSIX and GNU shared attributes + + internal string _magic; + internal string _version; + internal string _gName; + internal string _uName; + internal int _devMajor; + internal int _devMinor; + + // POSIX attributes + + internal string _prefix; + + // PAX attributes + + internal Dictionary _extendedAttributes; + + // GNU attributes + + internal DateTimeOffset _aTime; + internal DateTimeOffset _cTime; + + // If the archive is GNU and the offset, longnames, unused, sparse, isextended and realsize + // fields have data, we store it to avoid data loss, but we don't yet expose it publicly. + internal byte[]? _gnuUnusedBytes; + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHelpers.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHelpers.cs new file mode 100644 index 0000000000000..f5bf799b89558 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHelpers.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Globalization; +using System.IO; +using System.Text; + +namespace System.Formats.Tar +{ + // Static class containing a variety of helper methods. + internal static class TarHelpers + { + internal const short RecordSize = 512; + internal const int MaxBufferLength = 4096; + + internal const int ZeroChar = 0x30; + internal const byte SpaceChar = 0x20; + internal const byte EqualsChar = 0x3d; + internal const byte NewLineChar = 0xa; + + internal const TarFileMode DefaultMode = // 644 in octal + TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.GroupRead | TarFileMode.OtherRead; + + // Helps advance the stream a total number of bytes larger than int.MaxValue. + internal static void AdvanceStream(Stream archiveStream, long bytesToDiscard) + { + if (archiveStream.CanSeek) + { + archiveStream.Position += bytesToDiscard; + } + else if (bytesToDiscard > 0) + { + byte[] buffer = ArrayPool.Shared.Rent(minimumLength: MaxBufferLength); + while (bytesToDiscard > 0) + { + int currentLengthToRead = (int)Math.Min(MaxBufferLength, bytesToDiscard); + if (archiveStream.Read(buffer.AsSpan(0, currentLengthToRead)) != currentLengthToRead) + { + throw new EndOfStreamException(); + } + bytesToDiscard -= currentLengthToRead; + } + ArrayPool.Shared.Return(buffer); + } + } + + // Helps copy a specific number of bytes from one stream into another. + internal static void CopyBytes(Stream origin, Stream destination, long bytesToCopy) + { + byte[] buffer = ArrayPool.Shared.Rent(minimumLength: MaxBufferLength); + while (bytesToCopy > 0) + { + int currentLengthToRead = (int)Math.Min(MaxBufferLength, bytesToCopy); + if (origin.Read(buffer.AsSpan(0, currentLengthToRead)) != currentLengthToRead) + { + throw new EndOfStreamException(); + } + destination.Write(buffer.AsSpan(0, currentLengthToRead)); + bytesToCopy -= currentLengthToRead; + } + ArrayPool.Shared.Return(buffer); + } + + // Returns the number of bytes until the next multiple of the record size. + internal static int CalculatePadding(long size) + { + long ceilingMultipleOfRecordSize = ((RecordSize - 1) | (size - 1)) + 1; + int padding = (int)(ceilingMultipleOfRecordSize - size); + return padding; + } + + // Returns the specified 8-base number as a 10-base number. + internal static int ConvertDecimalToOctal(int value) + { + int multiplier = 1; + int accum = value; + int actual = 0; + while (accum != 0) + { + actual += (accum % 8) * multiplier; + accum /= 8; + multiplier *= 10; + } + return actual; + } + + // Returns the specified 10-base number as an 8-base number. + internal static long ConvertDecimalToOctal(long value) + { + long multiplier = 1; + long accum = value; + long actual = 0; + while (accum != 0) + { + actual += (accum % 8) * multiplier; + accum /= 8; + multiplier *= 10; + } + return actual; + } + + // Returns true if all the bytes in the specified array are nulls, false otherwise. + internal static bool IsAllNullBytes(Span buffer) + { + for (int i = 0; i < buffer.Length; i++) + { + if (buffer[i] != 0) + { + return false; + } + } + return true; + } + + // Returns a DateTimeOffset instance representing the number of seconds that have passed since the Unix Epoch. + internal static DateTimeOffset GetDateTimeFromSecondsSinceEpoch(double secondsSinceUnixEpoch) + { + DateTimeOffset offset = new DateTimeOffset((long)(secondsSinceUnixEpoch * TimeSpan.TicksPerSecond) + DateTime.UnixEpoch.Ticks, TimeSpan.Zero); + return offset; + } + + // Receives a byte array that represents an ASCII string containing a number in octal base. + // Converts the array to an octal base number, then transforms it to ten base and returns it. + internal static int GetTenBaseNumberFromOctalAsciiChars(Span buffer) + { + string str = GetTrimmedAsciiString(buffer); + return string.IsNullOrEmpty(str) ? 0 : Convert.ToInt32(str, fromBase: 8); + } + + // Returns the string contained in the specified buffer of bytes, + // in the specified encoding, removing the trailing null or space chars. + private static string GetTrimmedString(ReadOnlySpan buffer, Encoding encoding) + { + int trimmedLength = buffer.Length; + while (trimmedLength > 0 && IsByteNullOrSpace(buffer[trimmedLength - 1])) + { + trimmedLength--; + } + + return trimmedLength == 0 ? string.Empty : encoding.GetString(buffer.Slice(0, trimmedLength)); + + static bool IsByteNullOrSpace(byte c) => c is 0 or 32; + } + + // Returns the ASCII string contained in the specified buffer of bytes, + // removing the trailing null or space chars. + internal static string GetTrimmedAsciiString(ReadOnlySpan buffer) => GetTrimmedString(buffer, Encoding.ASCII); + + // Returns the UTF8 string contained in the specified buffer of bytes, + // removing the trailing null or space chars. + internal static string GetTrimmedUtf8String(ReadOnlySpan buffer) => GetTrimmedString(buffer, Encoding.UTF8); + + // Reads the specified number of bytes and stores it in the byte buffer passed by reference. + // Throws if end of stream is reached. + internal static void ReadOrThrow(Stream archiveStream, Span buffer) + { + int totalRead = 0; + while (totalRead < buffer.Length) + { + int bytesRead = archiveStream.Read(buffer.Slice(totalRead)); + if (bytesRead == 0) + { + throw new EndOfStreamException(); + } + totalRead += bytesRead; + } + } + + // Returns true if it successfully converts the specified string to a DateTimeOffset, false otherwise. + internal static bool TryConvertToDateTimeOffset(string value, out DateTimeOffset timestamp) + { + timestamp = default; + if (!string.IsNullOrEmpty(value)) + { + if (!double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out double doubleTime)) + { + return false; + } + + timestamp = GetDateTimeFromSecondsSinceEpoch(doubleTime); + } + return timestamp != default; + } + + // After the file contents, there may be zero or more null characters, + // which exist to ensure the data is aligned to the record size. Skip them and + // set the stream position to the first byte of the next entry. + internal static int SkipBlockAlignmentPadding(Stream archiveStream, long size) + { + int bytesToSkip = CalculatePadding(size); + AdvanceStream(archiveStream, bytesToSkip); + return bytesToSkip; + } + + // Throws if the specified entry type is not supported for the specified format. + // If 'forWriting' is true, an incompatible 'Regular File' entry type is allowed. It will be converted to the compatible version before writing. + internal static void VerifyEntryTypeIsSupported(TarEntryType entryType, TarFormat archiveFormat, bool forWriting) + { + switch (archiveFormat) + { + case TarFormat.V7: + if (entryType is + TarEntryType.Directory or + TarEntryType.HardLink or + TarEntryType.V7RegularFile or + TarEntryType.SymbolicLink) + { + return; + } + if (forWriting && entryType is TarEntryType.RegularFile) + { + return; + } + break; + + case TarFormat.Ustar: + if (entryType is + TarEntryType.BlockDevice or + TarEntryType.CharacterDevice or + TarEntryType.Directory or + TarEntryType.Fifo or + TarEntryType.HardLink or + TarEntryType.RegularFile or + TarEntryType.SymbolicLink) + { + return; + } + if (forWriting && entryType is TarEntryType.V7RegularFile) + { + return; + } + break; + + case TarFormat.Pax: + if (entryType is + TarEntryType.BlockDevice or + TarEntryType.CharacterDevice or + TarEntryType.Directory or + TarEntryType.Fifo or + TarEntryType.HardLink or + TarEntryType.RegularFile or + TarEntryType.SymbolicLink) + { + // Not supported for writing - internally autogenerated: + // - ExtendedAttributes + // - GlobalExtendedAttributes + return; + } + if (forWriting && entryType is TarEntryType.V7RegularFile) + { + return; + } + break; + + case TarFormat.Gnu: + if (entryType is + TarEntryType.BlockDevice or + TarEntryType.CharacterDevice or + TarEntryType.Directory or + TarEntryType.Fifo or + TarEntryType.HardLink or + TarEntryType.RegularFile or + TarEntryType.SymbolicLink) + { + // Not supported for writing: + // - ContiguousFile + // - DirectoryList + // - MultiVolume + // - RenamedOrSymlinked + // - SparseFile + // - TapeVolume + + // Also not supported for writing - internally autogenerated: + // - LongLink + // - LongPath + return; + } + if (forWriting && entryType is TarEntryType.V7RegularFile) + { + return; + } + break; + + case TarFormat.Unknown: + default: + throw new FormatException(string.Format(SR.TarInvalidFormat, archiveFormat)); + } + + throw new InvalidOperationException(string.Format(SR.TarEntryTypeNotSupported, entryType, archiveFormat)); + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarReader.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarReader.cs new file mode 100644 index 0000000000000..187b5f43c4872 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarReader.cs @@ -0,0 +1,433 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace System.Formats.Tar +{ + /// + /// Reads a tar archive from a stream. + /// + public sealed class TarReader : IDisposable + { + private bool _isDisposed; + private readonly bool _leaveOpen; + private TarEntry? _previouslyReadEntry; + private List? _dataStreamsToDispose; + private bool _readFirstEntry; + private bool _reachedEndMarkers; + + internal Stream _archiveStream; + + /// + /// Initializes a instance that can read tar entries from the specified stream, and can optionally leave the stream open upon disposal of this instance. + /// + /// The stream to read from. + /// to dispose the when this instance is disposed; to leave the stream open. + /// is unreadable. + public TarReader(Stream archiveStream, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(archiveStream); + + if (!archiveStream.CanRead) + { + throw new IOException(SR.IO_NotSupported_UnreadableStream); + } + + _archiveStream = archiveStream; + _leaveOpen = leaveOpen; + + _previouslyReadEntry = null; + GlobalExtendedAttributes = null; + Format = TarFormat.Unknown; + _isDisposed = false; + _readFirstEntry = false; + _reachedEndMarkers = false; + } + + /// + /// The format of the archive. It is initially . The archive format is detected after the first call to . + /// + public TarFormat Format { get; private set; } + + /// + /// If the archive format is , returns a read-only dictionary containing the string key-value pairs of the Global Extended Attributes in the first entry of the archive. + /// If there is no Global Extended Attributes entry at the beginning of the archive, this returns an empty read-only dictionary. + /// If the first entry has not been read by calling , this returns . + /// + public IReadOnlyDictionary? GlobalExtendedAttributes { get; private set; } + + /// + /// Disposes the current instance, and disposes the streams of all the entries that were read from the archive. + /// + /// The property of any entry can be replaced with a new stream. If the user decides to replace it on a instance that was obtained using a , the underlying stream gets disposed immediately, freeing the of origin from the responsibility of having to dispose it. + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + // /// + // /// Asynchronously disposes the current instance, and disposes the streams of all the entries that were read from the archive. + // /// + // /// The property of any entry can be replaced with a new stream. If the user decides to replace it on a instance that was obtained using a , the underlying stream gets disposed immediately, freeing the of origin from the responsibility of having to dispose it. + // public ValueTask DisposeAsync() + // { + // throw new NotImplementedException(); + // } + + /// + /// Retrieves the next entry from the archive stream. + /// + /// Set it to to copy the data of the entry into a new . This is helpful when the underlying archive stream is unseekable, and the data needs to be accessed later. + /// Set it to if the data should not be copied into a new stream. If the underlying stream is unseekable, the user has the responsibility of reading and processing the immediately after calling this method. + /// The default value is . + /// A instance if a valid entry was found, or if the end of the archive has been reached. + /// The archive is malformed. + /// -or- + /// The archive contains entries in different formats. + /// -or- + /// More than one Global Extended Attributes Entry was found in the current archive. + /// -or- + /// Two or more Extended Attributes entries were found consecutively in the current archive. + /// An I/O problem ocurred. + public TarEntry? GetNextEntry(bool copyData = false) + { + if (_reachedEndMarkers) + { + // Avoid advancing the stream if we already found the end of the archive. + return null; + } + + Debug.Assert(_archiveStream.CanRead); + + if (_archiveStream.CanSeek && _archiveStream.Length == 0) + { + // Attempting to get the next entry on an empty tar stream + return null; + } + + AdvanceDataStreamIfNeeded(); + + if (TryGetNextEntryHeader(out TarHeader header, copyData)) + { + if (!_readFirstEntry) + { + Debug.Assert(Format == TarFormat.Unknown); + Format = header._format; + _readFirstEntry = true; + } + else if (header._format != Format) + { + throw new FormatException(string.Format(SR.TarEntriesInDifferentFormats, header._format, Format)); + } + + TarEntry entry = Format switch + { + TarFormat.Pax => new PaxTarEntry(header, this), + TarFormat.Gnu => new GnuTarEntry(header, this), + TarFormat.Ustar => new UstarTarEntry(header, this), + TarFormat.V7 or TarFormat.Unknown or _ => new V7TarEntry(header, this), + }; + + _previouslyReadEntry = entry; + PreserveDataStreamForDisposalIfNeeded(entry); + return entry; + } + + _reachedEndMarkers = true; + return null; + } + + // /// + // /// Asynchronously retrieves the next entry from the archive stream. + // /// + // /// Set it to to copy the data of the entry into a new . This is helpful when the underlying archive stream is unseekable, and the data needs to be accessed later. + // /// Set it to if the data should not be copied into a new stream. If the underlying stream is unseekable, the user has the responsibility of reading and processing the immediately after calling this method. + // /// The default value is . + // /// The token to monitor for cancellation requests. The default value is . + // /// A value task containing a instance if a valid entry was found, or if the end of the archive has been reached. + // public ValueTask GetNextEntryAsync(bool copyData = false, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + // Moves the underlying archive stream position pointer to the beginning of the next header. + internal void AdvanceDataStreamIfNeeded() + { + if (_previouslyReadEntry == null) + { + return; + } + + if (_archiveStream.CanSeek) + { + Debug.Assert(_previouslyReadEntry._header._endOfHeaderAndDataAndBlockAlignment > 0); + _archiveStream.Position = _previouslyReadEntry._header._endOfHeaderAndDataAndBlockAlignment; + } + else if (_previouslyReadEntry._header._size > 0) + { + // When working with seekable streams, every time we return an entry, we avoid advancing the pointer beyond the data section + // This is so the user can read the data if desired. But if the data was not read by the user, we need to advance the pointer + // here until it's located at the beginning of the next entry header. + // This should only be done if the previous entry came from a TarReader and it still had its original SubReadStream or SeekableSubReadStream. + + if (_previouslyReadEntry._header._dataStream is not SubReadStream dataStream) + { + return; + } + + if (!dataStream.HasReachedEnd) + { + // If the user did not advance the position, we need to make sure the position + // pointer is located at the beginning of the next header. + if (dataStream.Position < (_previouslyReadEntry._header._size - 1)) + { + long bytesToSkip = _previouslyReadEntry._header._size - dataStream.Position; + TarHelpers.AdvanceStream(_archiveStream, bytesToSkip); + TarHelpers.SkipBlockAlignmentPadding(_archiveStream, _previouslyReadEntry._header._size); + dataStream.HasReachedEnd = true; // Now the pointer is beyond the limit, so any read attempts should throw + } + } + } + } + + // Disposes the current instance. + // If 'disposing' is 'false', the method was called from the finalizer. + private void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + try + { + if (!_leaveOpen && _dataStreamsToDispose?.Count > 0) + { + foreach (Stream s in _dataStreamsToDispose) + { + s.Dispose(); + } + } + } + finally + { + _isDisposed = true; + } + } + } + + // Attempts to read the next tar archive entry header. + // Returns true if an entry header was collected successfully, false otherwise. + // An entry header represents any typeflag that is contains metadata. + // Metadata typeflags: ExtendedAttributes, GlobalExtendedAttributes, LongLink, LongPath. + // Metadata typeflag entries get handled internally by this method until a valid header entry can be returned. + private bool TryGetNextEntryHeader(out TarHeader header, bool copyData) + { + Debug.Assert(!_reachedEndMarkers); + + header = default; + + // Set the initial format that is expected to be retrieved when calling TarHeader.TryReadAttributes. + // If the archive format is set to unknown here, it means this is the first entry we read and the value will be changed as fields get discovered. + // If the archive format is initially detected as pax, then any subsequent entries detected as ustar will be assumed to be pax. + header._format = Format; + + if (!header.TryGetNextHeader(_archiveStream, copyData)) + { + return false; + } + + // Special case: First header. Collect GEA from data section, then get next entry. + if (header._typeFlag is TarEntryType.GlobalExtendedAttributes) + { + if (GlobalExtendedAttributes != null) + { + // We can only have one extended attributes entry. + throw new FormatException(SR.TarTooManyGlobalExtendedAttributesEntries); + } + + GlobalExtendedAttributes = header._extendedAttributes?.AsReadOnly(); + + header = default; + header._format = TarFormat.Pax; + try + { + if (!header.TryGetNextHeader(_archiveStream, copyData)) + { + return false; + } + } + catch (EndOfStreamException) + { + // Edge case: The only entry in the archive was a Global Extended Attributes entry + Format = TarFormat.Pax; + return false; + } + if (header._typeFlag == TarEntryType.GlobalExtendedAttributes) + { + throw new FormatException(SR.TarTooManyGlobalExtendedAttributesEntries); + } + } + + // If a metadata typeflag entry is retrieved, handle it here, then read the next entry + + // PAX metadata + if (header._typeFlag is TarEntryType.ExtendedAttributes) + { + if (!TryProcessExtendedAttributesHeader(header, copyData, out TarHeader mainHeader)) + { + return false; + } + header = mainHeader; + } + // GNU metadata + else if (header._typeFlag is TarEntryType.LongLink or TarEntryType.LongPath) + { + if (!TryProcessGnuMetadataHeader(header, copyData, out TarHeader mainHeader)) + { + return false; + } + header = mainHeader; + } + + // Common fields should always acquire a value + Debug.Assert(header._name != null); + Debug.Assert(header._linkName != null); + + // Initialize non-common string fields if necessary + header._magic ??= string.Empty; + header._version ??= string.Empty; + header._gName ??= string.Empty; + header._uName ??= string.Empty; + header._prefix ??= string.Empty; + + return true; + } + + private bool TryProcessExtendedAttributesHeader(TarHeader firstHeader, bool copyData, out TarHeader secondHeader) + { + secondHeader = default; + secondHeader._format = TarFormat.Pax; + + // Now get the actual entry + if (!secondHeader.TryGetNextHeader(_archiveStream, copyData)) + { + return false; + } + + // Should never read a GEA entry at this point + if (secondHeader._typeFlag == TarEntryType.GlobalExtendedAttributes) + { + throw new FormatException(SR.TarTooManyGlobalExtendedAttributesEntries); + } + + // Can't have two metadata entries in a row, no matter the archive format + if (secondHeader._typeFlag is TarEntryType.ExtendedAttributes) + { + throw new FormatException(string.Format(SR.TarUnexpectedMetadataEntry, TarEntryType.ExtendedAttributes, TarEntryType.ExtendedAttributes)); + } + + Debug.Assert(firstHeader._extendedAttributes != null); + if (GlobalExtendedAttributes != null) + { + // First, replace some of the entry's standard attributes with the global ones + secondHeader.ReplaceNormalAttributesWithGlobalExtended(GlobalExtendedAttributes); + } + // Then replace all the standard attributes with the extended attributes ones, + // overwriting the previous global replacements if needed + secondHeader.ReplaceNormalAttributesWithExtended(firstHeader._extendedAttributes); + + return true; + } + + private bool TryProcessGnuMetadataHeader(TarHeader header, bool copyData, out TarHeader finalHeader) + { + finalHeader = default; + + TarHeader secondHeader = default; + secondHeader._format = TarFormat.Gnu; + + // Get the second entry, which is the actual entry + if (!secondHeader.TryGetNextHeader(_archiveStream, copyData)) + { + return false; + } + + // Can't have two identical metadata entries in a row + if (secondHeader._typeFlag == header._typeFlag) + { + throw new FormatException(string.Format(SR.TarUnexpectedMetadataEntry, secondHeader._typeFlag, header._typeFlag)); + } + + // It's possible to have the two different metadata entries in a row + if ((header._typeFlag is TarEntryType.LongLink && secondHeader._typeFlag is TarEntryType.LongPath) || + (header._typeFlag is TarEntryType.LongPath && secondHeader._typeFlag is TarEntryType.LongLink)) + { + TarHeader thirdHeader = default; + thirdHeader._format = TarFormat.Gnu; + + // Get the third entry, which is the actual entry + if (!thirdHeader.TryGetNextHeader(_archiveStream, copyData)) + { + return false; + } + + // Can't have three GNU metadata entries in a row + if (thirdHeader._typeFlag is TarEntryType.LongLink or TarEntryType.LongPath) + { + throw new FormatException(string.Format(SR.TarUnexpectedMetadataEntry, thirdHeader._typeFlag, secondHeader._typeFlag)); + } + + if (header._typeFlag is TarEntryType.LongLink) + { + Debug.Assert(header._linkName != null); + Debug.Assert(secondHeader._name != null); + + thirdHeader._linkName = header._linkName; + thirdHeader._name = secondHeader._name; + } + else if (header._typeFlag is TarEntryType.LongPath) + { + Debug.Assert(header._name != null); + Debug.Assert(secondHeader._linkName != null); + thirdHeader._name = header._name; + thirdHeader._linkName = secondHeader._linkName; + } + + finalHeader = thirdHeader; + } + // Only one metadata entry was found + else + { + if (header._typeFlag is TarEntryType.LongLink) + { + Debug.Assert(header._linkName != null); + secondHeader._linkName = header._linkName; + } + else if (header._typeFlag is TarEntryType.LongPath) + { + Debug.Assert(header._name != null); + secondHeader._name = header._name; + } + + finalHeader = secondHeader; + } + + return true; + } + + // If the current entry contains a non-null DataStream, that stream gets added to an internal + // list of streams that need to be disposed when this TarReader instance gets disposed. + private void PreserveDataStreamForDisposalIfNeeded(TarEntry entry) + { + // Only dispose the data stream if it was the original one from the archive + // The user can substitute it anytime, and the setter disposes the original stream upon substitution + if (entry._header._dataStream is SubReadStream dataStream) + { + _dataStreamsToDispose ??= new List(); + _dataStreamsToDispose.Add(dataStream); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs new file mode 100644 index 0000000000000..dffa8525a9405 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs @@ -0,0 +1,96 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO; + +namespace System.Formats.Tar +{ + // Unix specific methods for the TarWriter class. + public sealed partial class TarWriter : IDisposable + { + // Unix specific implementation of the method that reads an entry from disk and writes it into the archive stream. + partial void ReadFileFromDiskAndWriteToArchiveStreamAsEntry(string fullPath, string entryName) + { + Interop.Sys.FileStatus status = default; + status.Mode = default; + status.Dev = default; + Interop.CheckIo(Interop.Sys.LStat(fullPath, out status)); + + TarEntryType entryType = (status.Mode & (uint)Interop.Sys.FileTypes.S_IFMT) switch + { + // Hard links are treated as regular files. + // Unix socket files do not get added to tar files. + Interop.Sys.FileTypes.S_IFBLK => TarEntryType.BlockDevice, + Interop.Sys.FileTypes.S_IFCHR => TarEntryType.CharacterDevice, + Interop.Sys.FileTypes.S_IFIFO => TarEntryType.Fifo, + Interop.Sys.FileTypes.S_IFLNK => TarEntryType.SymbolicLink, + Interop.Sys.FileTypes.S_IFREG => Format is TarFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile, + Interop.Sys.FileTypes.S_IFDIR => TarEntryType.Directory, + _ => throw new IOException(string.Format(SR.TarUnsupportedFile, fullPath)), + }; + + FileSystemInfo info = entryType is TarEntryType.Directory ? new DirectoryInfo(fullPath) : new FileInfo(fullPath); + + TarEntry entry = Format switch + { + TarFormat.V7 => new V7TarEntry(entryType, entryName), + TarFormat.Ustar => new UstarTarEntry(entryType, entryName), + TarFormat.Pax => new PaxTarEntry(entryType, entryName), + TarFormat.Gnu => new GnuTarEntry(entryType, entryName), + _ => throw new FormatException(string.Format(SR.TarInvalidFormat, Format)), + }; + + if ((entryType is TarEntryType.BlockDevice or TarEntryType.CharacterDevice) && status.Dev > 0) + { + uint major; + uint minor; + unsafe + { + Interop.CheckIo(Interop.Sys.GetDeviceIdentifiers((ulong)status.Dev, &major, &minor)); + } + + entry._header._devMajor = (int)major; + entry._header._devMinor = (int)minor; + } + + entry._header._mTime = TarHelpers.GetDateTimeFromSecondsSinceEpoch(status.MTime); + entry._header._aTime = TarHelpers.GetDateTimeFromSecondsSinceEpoch(status.ATime); + entry._header._cTime = TarHelpers.GetDateTimeFromSecondsSinceEpoch(status.CTime); + + entry._header._mode = (status.Mode & 4095); // First 12 bits + + entry.Uid = (int)status.Uid; + entry.Gid = (int)status.Gid; + + // TODO: Add these p/invokes https://github.com/dotnet/runtime/issues/68230 + entry._header._uName = "";// Interop.Sys.GetUName(); + entry._header._gName = "";// Interop.Sys.GetGName(); + + if (entry.EntryType == TarEntryType.SymbolicLink) + { + entry.LinkName = info.LinkTarget ?? string.Empty; + } + + if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile) + { + FileStreamOptions options = new() + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.None + }; + + Debug.Assert(entry._header._dataStream == null); + entry._header._dataStream = File.Open(fullPath, options); + } + + WriteEntry(entry); + if (entry._header._dataStream != null) + { + entry._header._dataStream.Dispose(); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs new file mode 100644 index 0000000000000..5fdd3b97d85c8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.IO; + +namespace System.Formats.Tar +{ + // Windows specific methods for the TarWriter class. + public sealed partial class TarWriter : IDisposable + { + // Creating archives in Windows always sets the mode to 777 + private const TarFileMode DefaultWindowsMode = TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.UserExecute | TarFileMode.GroupRead | TarFileMode.GroupWrite | TarFileMode.GroupExecute | TarFileMode.OtherRead | TarFileMode.OtherWrite | TarFileMode.UserExecute; + + // Windows specific implementation of the method that reads an entry from disk and writes it into the archive stream. + partial void ReadFileFromDiskAndWriteToArchiveStreamAsEntry(string fullPath, string entryName) + { + TarEntryType entryType; + FileAttributes attributes = File.GetAttributes(fullPath); + + if (attributes.HasFlag(FileAttributes.ReparsePoint)) + { + entryType = TarEntryType.SymbolicLink; + } + else if (attributes.HasFlag(FileAttributes.Directory)) + { + entryType = TarEntryType.Directory; + } + else if (attributes.HasFlag(FileAttributes.Normal) || attributes.HasFlag(FileAttributes.Archive)) + { + entryType = Format is TarFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile; + } + else + { + throw new IOException(string.Format(SR.TarUnsupportedFile, fullPath)); + } + + TarEntry entry = Format switch + { + TarFormat.V7 => new V7TarEntry(entryType, entryName), + TarFormat.Ustar => new UstarTarEntry(entryType, entryName), + TarFormat.Pax => new PaxTarEntry(entryType, entryName), + TarFormat.Gnu => new GnuTarEntry(entryType, entryName), + _ => throw new FormatException(string.Format(SR.TarInvalidFormat, Format)), + }; + + FileSystemInfo info = attributes.HasFlag(FileAttributes.Directory) ? new DirectoryInfo(fullPath) : new FileInfo(fullPath); + + entry._header._mTime = new DateTimeOffset(info.LastWriteTimeUtc); + entry._header._aTime = new DateTimeOffset(info.LastAccessTimeUtc); + entry._header._cTime = new DateTimeOffset(info.LastWriteTimeUtc); // There is no "change time" property + + entry.Mode = DefaultWindowsMode; + + if (entry.EntryType == TarEntryType.SymbolicLink) + { + entry.LinkName = info.LinkTarget ?? string.Empty; + } + + if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile) + { + FileStreamOptions options = new() + { + Mode = FileMode.Open, + Access = FileAccess.Read, + Share = FileShare.Read, + Options = FileOptions.None + }; + + Debug.Assert(entry._header._dataStream == null); + entry._header._dataStream = File.Open(fullPath, options); + } + + WriteEntry(entry); + if (entry._header._dataStream != null) + { + entry._header._dataStream.Dispose(); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs new file mode 100644 index 0000000000000..a9bf0d3a9ad13 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs @@ -0,0 +1,320 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace System.Formats.Tar +{ + /// + /// Writes a tar archive into a stream. + /// + public sealed partial class TarWriter : IDisposable + { + private bool _wroteGEA; + private bool _wroteEntries; + private bool _isDisposed; + private readonly bool _leaveOpen; + private readonly Stream _archiveStream; + private readonly IEnumerable>? _globalExtendedAttributes; + + /// + /// Initializes a instance that can write tar entries to the specified stream, optionally leave the stream open upon disposal of this instance, and can optionally add a Global Extended Attributes entry at the beginning of the archive. When using this constructor, the format of the resulting archive is . + /// + /// The stream to write to. + /// An optional enumeration of string key-value pairs that represent Global Extended Attributes metadata that should apply to all subsquent entries. If , then no Global Extended Attributes entry is written. If an empty instance is passed, a Global Extended Attributes entry is written with default values. + /// to dispose the when this instance is disposed; to leave the stream open. + public TarWriter(Stream archiveStream, IEnumerable>? globalExtendedAttributes = null, bool leaveOpen = false) + : this(archiveStream, TarFormat.Pax, leaveOpen) + { + _globalExtendedAttributes = globalExtendedAttributes; + } + + /// + /// Initializes a instance that can write tar entries to the specified stream, optionally leave the stream open upon disposal of this instance, and can specify the format of the underlying archive. + /// + /// The stream to write to. + /// The format of the archive. + /// to dispose the when this instance is disposed; to leave the stream open. + /// If the selected is , no Global Extended Attributes entry is written. To write a PAX archive with a Global Extended Attributes entry inserted at the beginning of the archive, use the constructor instead. + /// The recommended format is for its flexibility. + /// is . + /// is unwritable. + /// is either , or not one of the other enum values. + public TarWriter(Stream archiveStream, TarFormat archiveFormat, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(archiveStream); + + if (!archiveStream.CanWrite) + { + throw new IOException(SR.IO_NotSupported_UnwritableStream); + } + + if (archiveFormat is not TarFormat.V7 and not TarFormat.Ustar and not TarFormat.Pax and not TarFormat.Gnu) + { + throw new ArgumentOutOfRangeException(nameof(archiveFormat)); + } + + _archiveStream = archiveStream; + Format = archiveFormat; + _leaveOpen = leaveOpen; + _isDisposed = false; + _wroteEntries = false; + _wroteGEA = false; + _globalExtendedAttributes = null; + } + + /// + /// The format of the archive. + /// + public TarFormat Format { get; private set; } + + /// + /// Disposes the current instance, and closes the archive stream if the leaveOpen argument was set to in the constructor. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + // /// + // /// Asynchronously disposes the current instance, and closes the archive stream if the leaveOpen argument was set to in the constructor. + // /// + // public ValueTask DisposeAsync() + // { + // throw new NotImplementedException(); + // } + + /// + /// Writes the specified file into the archive stream as a tar entry. + /// + /// The path to the file to write to the archive. + /// The name of the file as it should be represented in the archive. It should include the optional relative path and the filename. + /// The archive stream is disposed. + /// or is or empty. + /// An I/O problem ocurred. + public void WriteEntry(string fileName, string? entryName) + { + ThrowIfDisposed(); + + ArgumentException.ThrowIfNullOrEmpty(fileName); + + string fullPath = Path.GetFullPath(fileName); + + if (string.IsNullOrEmpty(entryName)) + { + entryName = Path.GetFileName(fileName); + } + + if (Format is TarFormat.Pax) + { + WriteGlobalExtendedAttributesEntryIfNeeded(); + } + + ReadFileFromDiskAndWriteToArchiveStreamAsEntry(fullPath, entryName); + } + + // /// + // /// Asynchronously writes the specified file into the archive stream as a tar entry. + // /// + // /// The path to the file to write to the archive. + // /// The name of the file as it should be represented in the archive. It should include the optional relative path and the filename. + // /// The token to monitor for cancellation requests. The default value is . + // public Task WriteEntryAsync(string fileName, string? entryName, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + /// + /// Writes the specified entry into the archive stream. + /// + /// The tar entry to write. + /// Before writing an entry to the archive, if you wrote data into the entry's , make sure to rewind it to the desired start position. + /// These are the entry types supported for writing on each format: + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// , and + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// The archive stream is disposed. + /// The entry type of the is not supported for writing. + /// An I/O problem ocurred. + public void WriteEntry(TarEntry entry) + { + ThrowIfDisposed(); + + TarHelpers.VerifyEntryTypeIsSupported(entry.EntryType, Format, forWriting: true); + + WriteGlobalExtendedAttributesEntryIfNeeded(); + + byte[] rented = ArrayPool.Shared.Rent(minimumLength: TarHelpers.RecordSize); + Span buffer = rented.AsSpan(0, TarHelpers.RecordSize); // minimumLength means the array could've been larger + buffer.Clear(); // Rented arrays aren't clean + try + { + switch (Format) + { + case TarFormat.V7: + entry._header.WriteAsV7(_archiveStream, buffer); + break; + case TarFormat.Ustar: + entry._header.WriteAsUstar(_archiveStream, buffer); + break; + case TarFormat.Pax: + entry._header.WriteAsPax(_archiveStream, buffer); + break; + case TarFormat.Gnu: + entry._header.WriteAsGnu(_archiveStream, buffer); + break; + case TarFormat.Unknown: + default: + throw new FormatException(string.Format(SR.TarInvalidFormat, Format)); + } + } + finally + { + ArrayPool.Shared.Return(rented); + } + + _wroteEntries = true; + } + + // /// + // /// Asynchronously writes the specified entry into the archive stream. + // /// + // /// The tar entry to write. + // /// The token to monitor for cancellation requests. The default value is . + // /// Before writing an entry to the archive, if you wrote data into the entry's , make sure to rewind it to the desired start position. + // /// These are the entry types supported for writing on each format: + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// , and + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // /// + // public Task WriteEntryAsync(TarEntry entry, CancellationToken cancellationToken = default) + // { + // throw new NotImplementedException(); + // } + + // Disposes the current instance. + // If 'disposing' is 'false', the method was called from the finalizer. + private void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + try + { + WriteGlobalExtendedAttributesEntryIfNeeded(); + + if (_wroteEntries) + { + WriteFinalRecords(); + } + + + if (!_leaveOpen) + { + _archiveStream.Dispose(); + } + } + finally + { + _isDisposed = true; + } + } + } + + // If the underlying archive stream is disposed, throws 'ObjectDisposedException'. + private void ThrowIfDisposed() + { + if (_isDisposed) + { + throw new ObjectDisposedException(GetType().ToString()); + } + } + + // Writes a Global Extended Attributes entry at the beginning of the archive. + private void WriteGlobalExtendedAttributesEntryIfNeeded() + { + Debug.Assert(!_isDisposed); + + if (_wroteGEA || Format != TarFormat.Pax) + { + return; + } + + Debug.Assert(!_wroteEntries); // The GEA entry can only be the first entry + + if (_globalExtendedAttributes != null) + { + byte[] rented = ArrayPool.Shared.Rent(minimumLength: TarHelpers.RecordSize); + try + { + Span buffer = rented.AsSpan(0, TarHelpers.RecordSize); + buffer.Clear(); // Rented arrays aren't clean + // Write the GEA entry regardless if it has values or not + TarHeader.WriteGlobalExtendedAttributesHeader(_archiveStream, buffer, _globalExtendedAttributes); + } + finally + { + ArrayPool.Shared.Return(rented); + } + } + _wroteGEA = true; + } + + // The spec indicates that the end of the archive is indicated + // by two records consisting entirely of zero bytes. + private void WriteFinalRecords() + { + byte[] emptyRecord = new byte[TarHelpers.RecordSize]; + _archiveStream.Write(emptyRecord); + _archiveStream.Write(emptyRecord); + _archiveStream.SetLength(_archiveStream.Position); + } + + // Partial method for reading an entry from disk and writing it into the archive stream. + partial void ReadFileFromDiskAndWriteToArchiveStreamAsEntry(string fullPath, string entryName); + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/UstarTarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/UstarTarEntry.cs new file mode 100644 index 0000000000000..13e1d9c358a69 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/UstarTarEntry.cs @@ -0,0 +1,38 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Represents a tar entry from an archive of the Ustar format. + /// + public sealed class UstarTarEntry : PosixTarEntry + { + // Constructor used when reading an existing archive. + internal UstarTarEntry(TarHeader header, TarReader readerOfOrigin) + : base(header, readerOfOrigin) + { + } + + /// + /// Initializes a new instance with the specified entry type and entry name. + /// + /// The type of the entry. + /// A string with the path and file name of this entry. + /// is null or empty. + /// The entry type is not supported for creating an entry. + /// When creating an instance using the constructor, only the following entry types are supported: + /// + /// In all platforms: , , , . + /// In Unix platforms only: , and . + /// + /// + public UstarTarEntry(TarEntryType entryType, string entryName) + : base(entryType, entryName, TarFormat.Ustar) + { + } + + // Determines if the current instance's entry type supports setting a data stream. + internal override bool IsDataStreamSetterSupported() => EntryType == TarEntryType.RegularFile; + } +} diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/V7TarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/V7TarEntry.cs new file mode 100644 index 0000000000000..84389365c7372 --- /dev/null +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/V7TarEntry.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Formats.Tar +{ + /// + /// Represents a tar entry from an archive of the V7 format. + /// + public sealed class V7TarEntry : TarEntry + { + // Constructor used when reading an existing archive. + internal V7TarEntry(TarHeader header, TarReader readerOfOrigin) + : base(header, readerOfOrigin) + { + } + + /// + /// Initializes a new instance with the specified entry type and entry name. + /// + /// The type of the entry. + /// A string with the path and file name of this entry. + /// is null or empty. + /// The entry type is not supported for creating an entry. + /// When creating an instance using the constructor, only the following entry types are supported: , , and . + public V7TarEntry(TarEntryType entryType, string entryName) + : base(entryType, entryName, TarFormat.V7) + { + } + + // Determines if the current instance's entry type supports setting a data stream. + internal override bool IsDataStreamSetterSupported() => EntryType == TarEntryType.V7RegularFile; + } +} diff --git a/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj b/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj new file mode 100644 index 0000000000000..63240f7bd9094 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj @@ -0,0 +1,72 @@ + + + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix + true + $(LibrariesProjectRoot)/Common/tests/Resources/Strings.resx + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryGnu.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryGnu.Tests.cs new file mode 100644 index 0000000000000..5ff711b5ee6ad --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryGnu.Tests.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class GnuTarEntry_Tests : TarTestsBase + { + [Fact] + public void Constructor_InvalidEntryName() + { + Assert.Throws(() => new GnuTarEntry(TarEntryType.RegularFile, entryName: null)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.RegularFile, entryName: string.Empty)); + } + + [Fact] + public void Constructor_UnsupportedEntryTypes() + { + Assert.Throws(() => new GnuTarEntry((TarEntryType)byte.MaxValue, InitialEntryName)); + + Assert.Throws(() => new GnuTarEntry(TarEntryType.ExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.GlobalExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.V7RegularFile, InitialEntryName)); + + // These are specific to GNU, but currently the user cannot create them manually + Assert.Throws(() => new GnuTarEntry(TarEntryType.ContiguousFile, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.DirectoryList, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.MultiVolume, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.RenamedOrSymlinked, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.SparseFile, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.TapeVolume, InitialEntryName)); + + // The user should not create these entries manually + Assert.Throws(() => new GnuTarEntry(TarEntryType.LongLink, InitialEntryName)); + Assert.Throws(() => new GnuTarEntry(TarEntryType.LongPath, InitialEntryName)); + } + + [Fact] + public void SupportedEntryType_RegularFile() + { + GnuTarEntry regularFile = new GnuTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + } + + [Fact] + public void SupportedEntryType_Directory() + { + GnuTarEntry directory = new GnuTarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + } + + [Fact] + public void SupportedEntryType_HardLink() + { + GnuTarEntry hardLink = new GnuTarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + } + + [Fact] + public void SupportedEntryType_SymbolicLink() + { + GnuTarEntry symbolicLink = new GnuTarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + } + + [Fact] + public void SupportedEntryType_BlockDevice() + { + GnuTarEntry blockDevice = new GnuTarEntry(TarEntryType.BlockDevice, InitialEntryName); + SetBlockDevice(blockDevice); + VerifyBlockDevice(blockDevice); + } + + [Fact] + public void SupportedEntryType_CharacterDevice() + { + GnuTarEntry characterDevice = new GnuTarEntry(TarEntryType.CharacterDevice, InitialEntryName); + SetCharacterDevice(characterDevice); + VerifyCharacterDevice(characterDevice); + } + + [Fact] + public void SupportedEntryType_Fifo() + { + GnuTarEntry fifo = new GnuTarEntry(TarEntryType.Fifo, InitialEntryName); + SetFifo(fifo); + VerifyFifo(fifo); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Mismatch_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(Path.GetPathRoot(root.Path), "dir", "file.txt"); + + GnuTarEntry entry = new GnuTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match_AdditionalSubdirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "dir", "file.txt"); + + GnuTarEntry entry = new GnuTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "file.txt"); + + GnuTarEntry entry = new GnuTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + entry.ExtractToFile(fullPath, overwrite: false); + + Assert.True(File.Exists(fullPath)); + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void ExtractToFile_Link_Throws(TarEntryType entryType) + { + using TempDirectory root = new TempDirectory(); + string fileName = "mylink"; + string fullPath = Path.Join(root.Path, fileName); + + string linkTarget = PlatformDetection.IsWindows ? @"C:\Windows\system32\notepad.exe" : "/usr/bin/nano"; + + GnuTarEntry entry = new GnuTarEntry(entryType, fileName); + entry.LinkName = linkTarget; + + Assert.Throws(() => entry.ExtractToFile(fileName, overwrite: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(root.Path).Count()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryPax.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryPax.Tests.cs new file mode 100644 index 0000000000000..9729f05789f88 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryPax.Tests.cs @@ -0,0 +1,168 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class PaxTarEntry_Tests : TarTestsBase + { + [Fact] + public void Constructor_InvalidEntryName() + { + Assert.Throws(() => new PaxTarEntry(TarEntryType.RegularFile, entryName: null)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.RegularFile, entryName: string.Empty)); + } + + [Fact] + public void Constructor_UnsupportedEntryTypes() + { + Assert.Throws(() => new PaxTarEntry((TarEntryType)byte.MaxValue, InitialEntryName)); + + Assert.Throws(() => new PaxTarEntry(TarEntryType.ContiguousFile, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.DirectoryList, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.LongLink, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.LongPath, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.MultiVolume, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.V7RegularFile, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.RenamedOrSymlinked, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.SparseFile, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.TapeVolume, InitialEntryName)); + + // The user should not be creating these entries manually in pax + Assert.Throws(() => new PaxTarEntry(TarEntryType.ExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new PaxTarEntry(TarEntryType.GlobalExtendedAttributes, InitialEntryName)); + } + + [Fact] + public void SupportedEntryType_RegularFile() + { + PaxTarEntry regularFile = new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + } + + [Fact] + public void SupportedEntryType_Directory() + { + PaxTarEntry directory = new PaxTarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + } + + [Fact] + public void SupportedEntryType_HardLink() + { + PaxTarEntry hardLink = new PaxTarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + } + + [Fact] + public void SupportedEntryType_SymbolicLink() + { + PaxTarEntry symbolicLink = new PaxTarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + } + + [Fact] + public void SupportedEntryType_BlockDevice() + { + PaxTarEntry blockDevice = new PaxTarEntry(TarEntryType.BlockDevice, InitialEntryName); + SetBlockDevice(blockDevice); + VerifyBlockDevice(blockDevice); + } + + [Fact] + public void SupportedEntryType_CharacterDevice() + { + PaxTarEntry characterDevice = new PaxTarEntry(TarEntryType.CharacterDevice, InitialEntryName); + SetCharacterDevice(characterDevice); + VerifyCharacterDevice(characterDevice); + } + + [Fact] + public void SupportedEntryType_Fifo() + { + PaxTarEntry fifo = new PaxTarEntry(TarEntryType.Fifo, InitialEntryName); + SetFifo(fifo); + VerifyFifo(fifo); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Mismatch_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(Path.GetPathRoot(root.Path), "dir", "file.txt"); + + PaxTarEntry entry = new PaxTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match_AdditionalSubdirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "dir", "file.txt"); + + PaxTarEntry entry = new PaxTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "file.txt"); + + PaxTarEntry entry = new PaxTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + entry.ExtractToFile(fullPath, overwrite: false); + + Assert.True(File.Exists(fullPath)); + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void ExtractToFile_Link_Throws(TarEntryType entryType) + { + using TempDirectory root = new TempDirectory(); + string fileName = "mylink"; + string fullPath = Path.Join(root.Path, fileName); + + string linkTarget = PlatformDetection.IsWindows ? @"C:\Windows\system32\notepad.exe" : "/usr/bin/nano"; + + PaxTarEntry entry = new PaxTarEntry(entryType, fileName); + entry.LinkName = linkTarget; + + Assert.Throws(() => entry.ExtractToFile(fileName, overwrite: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(root.Path).Count()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryUstar.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryUstar.Tests.cs new file mode 100644 index 0000000000000..461c278b7f67e --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryUstar.Tests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class UstarTarEntry_Tests : TarTestsBase + { + [Fact] + public void Constructor_InvalidEntryName() + { + Assert.Throws(() => new UstarTarEntry(TarEntryType.RegularFile, entryName: null)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.RegularFile, entryName: string.Empty)); + } + + [Fact] + public void Constructor_UnsupportedEntryTypes() + { + Assert.Throws(() => new UstarTarEntry((TarEntryType)byte.MaxValue, InitialEntryName)); + + Assert.Throws(() => new UstarTarEntry(TarEntryType.ContiguousFile, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.DirectoryList, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.ExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.GlobalExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.LongLink, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.LongPath, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.MultiVolume, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.V7RegularFile, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.RenamedOrSymlinked, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.SparseFile, InitialEntryName)); + Assert.Throws(() => new UstarTarEntry(TarEntryType.TapeVolume, InitialEntryName)); + } + + [Fact] + public void SupportedEntryType_RegularFile() + { + UstarTarEntry regularFile = new UstarTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + } + + [Fact] + public void SupportedEntryType_Directory() + { + UstarTarEntry directory = new UstarTarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + } + + [Fact] + public void SupportedEntryType_HardLink() + { + UstarTarEntry hardLink = new UstarTarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + } + + [Fact] + public void SupportedEntryType_SymbolicLink() + { + UstarTarEntry symbolicLink = new UstarTarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + } + + [Fact] + public void SupportedEntryType_BlockDevice() + { + UstarTarEntry blockDevice = new UstarTarEntry(TarEntryType.BlockDevice, InitialEntryName); + SetBlockDevice(blockDevice); + VerifyBlockDevice(blockDevice); + } + + [Fact] + public void SupportedEntryType_CharacterDevice() + { + UstarTarEntry characterDevice = new UstarTarEntry(TarEntryType.CharacterDevice, InitialEntryName); + SetCharacterDevice(characterDevice); + VerifyCharacterDevice(characterDevice); + } + + [Fact] + public void SupportedEntryType_Fifo() + { + UstarTarEntry fifo = new UstarTarEntry(TarEntryType.Fifo, InitialEntryName); + SetFifo(fifo); + VerifyFifo(fifo); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Mismatch_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(Path.GetPathRoot(root.Path), "dir", "file.txt"); + + UstarTarEntry entry = new UstarTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match_AdditionalSubdirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "dir", "file.txt"); + + UstarTarEntry entry = new UstarTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "file.txt"); + + UstarTarEntry entry = new UstarTarEntry(TarEntryType.RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + entry.ExtractToFile(fullPath, overwrite: false); + + Assert.True(File.Exists(fullPath)); + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void ExtractToFile_Link_Throws(TarEntryType entryType) + { + using TempDirectory root = new TempDirectory(); + string fileName = "mylink"; + string fullPath = Path.Join(root.Path, fileName); + + string linkTarget = PlatformDetection.IsWindows ? @"C:\Windows\system32\notepad.exe" : "/usr/bin/nano"; + + UstarTarEntry entry = new UstarTarEntry(entryType, fileName); + entry.LinkName = linkTarget; + + Assert.Throws(() => entry.ExtractToFile(fileName, overwrite: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(root.Path).Count()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryV7.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryV7.Tests.cs new file mode 100644 index 0000000000000..ff1e6bb8c14ce --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntryV7.Tests.cs @@ -0,0 +1,145 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class V7TarEntry_Tests : TarTestsBase + { + [Fact] + public void Constructor_InvalidEntryName() + { + Assert.Throws(() => new V7TarEntry(TarEntryType.V7RegularFile, entryName: null)); + Assert.Throws(() => new V7TarEntry(TarEntryType.V7RegularFile, entryName: string.Empty)); + } + + [Fact] + public void Constructor_UnsupportedEntryTypes() + { + Assert.Throws(() => new V7TarEntry((TarEntryType)byte.MaxValue, InitialEntryName)); + + Assert.Throws(() => new V7TarEntry(TarEntryType.BlockDevice, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.CharacterDevice, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.ContiguousFile, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.DirectoryList, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.ExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.Fifo, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.GlobalExtendedAttributes, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.LongLink, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.LongPath, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.MultiVolume, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.RegularFile, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.RenamedOrSymlinked, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.SparseFile, InitialEntryName)); + Assert.Throws(() => new V7TarEntry(TarEntryType.TapeVolume, InitialEntryName)); + } + + [Fact] + public void SupportedEntryType_V7RegularFile() + { + V7TarEntry oldRegularFile = new V7TarEntry(TarEntryType.V7RegularFile, InitialEntryName); + SetRegularFile(oldRegularFile); + VerifyRegularFile(oldRegularFile, isWritable: true); + } + + [Fact] + public void SupportedEntryType_Directory() + { + V7TarEntry directory = new V7TarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + } + + [Fact] + public void SupportedEntryType_HardLink() + { + V7TarEntry hardLink = new V7TarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + } + + [Fact] + public void SupportedEntryType_SymbolicLink() + { + V7TarEntry symbolicLink = new V7TarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Mismatch_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(Path.GetPathRoot(root.Path), "dir", "file.txt"); + + V7TarEntry entry = new V7TarEntry(TarEntryType.V7RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match_AdditionalSubdirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "dir", "file.txt"); + + V7TarEntry entry = new V7TarEntry(TarEntryType.V7RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + Assert.Throws(() => entry.ExtractToFile(root.Path, overwrite: false)); + + Assert.False(File.Exists(fullPath)); + } + + [Fact] + public void Constructor_Name_FullPath_DestinationDirectory_Match() + { + using TempDirectory root = new TempDirectory(); + + string fullPath = Path.Join(root.Path, "file.txt"); + + V7TarEntry entry = new V7TarEntry(TarEntryType.V7RegularFile, fullPath); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + entry.ExtractToFile(fullPath, overwrite: false); + + Assert.True(File.Exists(fullPath)); + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void ExtractToFile_Link_Throws(TarEntryType entryType) + { + using TempDirectory root = new TempDirectory(); + string fileName = "mylink"; + string fullPath = Path.Join(root.Path, fileName); + + string linkTarget = PlatformDetection.IsWindows ? @"C:\Windows\system32\notepad.exe" : "/usr/bin/nano"; + + V7TarEntry entry = new V7TarEntry(entryType, fileName); + entry.LinkName = linkTarget; + + Assert.Throws(() => entry.ExtractToFile(fileName, overwrite: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(root.Path).Count()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.File.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.File.Tests.cs new file mode 100644 index 0000000000000..d2fe36deaaa3c --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.File.Tests.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarFile_CreateFromDirectory_File_Tests : TarTestsBase + { + [Fact] + public void InvalidPaths_Throw() + { + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: null,destinationFileName: "path", includeBaseDirectory: false)); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: string.Empty,destinationFileName: "path", includeBaseDirectory: false)); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: "path",destinationFileName: null, includeBaseDirectory: false)); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: "path",destinationFileName: string.Empty, includeBaseDirectory: false)); + } + + [Fact] + public void NonExistentDirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + + string dirPath = Path.Join(root.Path, "dir"); + string filePath = Path.Join(root.Path, "file.tar"); + + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: "IDontExist", destinationFileName: filePath, includeBaseDirectory: false)); + } + + [Fact] + public void DestinationExists_Throws() + { + using TempDirectory root = new TempDirectory(); + + string dirPath = Path.Join(root.Path, "dir"); + Directory.CreateDirectory(dirPath); + + string filePath = Path.Join(root.Path, "file.tar"); + File.Create(filePath).Dispose(); + + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: dirPath, destinationFileName: filePath, includeBaseDirectory: false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void VerifyIncludeBaseDirectory(bool includeBaseDirectory) + { + using TempDirectory source = new TempDirectory(); + using TempDirectory destination = new TempDirectory(); + + string fileName1 = "file1.txt"; + string filePath1 = Path.Join(source.Path, fileName1); + File.Create(filePath1).Dispose(); + + string subDirectoryName = "dir/"; // The trailing separator is preserved in the TarEntry.Name + string subDirectoryPath = Path.Join(source.Path, subDirectoryName); + Directory.CreateDirectory(subDirectoryPath); + + string fileName2 = "file2.txt"; + string filePath2 = Path.Join(subDirectoryPath, fileName2); + File.Create(filePath2).Dispose(); + + string destinationArchiveFileName = Path.Join(destination.Path, "output.tar"); + TarFile.CreateFromDirectory(source.Path, destinationArchiveFileName, includeBaseDirectory); + + using FileStream fileStream = File.OpenRead(destinationArchiveFileName); + using TarReader reader = new TarReader(fileStream); + + List entries = new List(); + + TarEntry entry; + while ((entry = reader.GetNextEntry()) != null) + { + entries.Add(entry); + } + + Assert.Equal(3, entries.Count); + + string prefix = includeBaseDirectory ? Path.GetFileName(source.Path) + '/' : string.Empty; + + TarEntry entry1 = entries.FirstOrDefault(x => + x.EntryType == TarEntryType.RegularFile && + x.Name == prefix + fileName1); + Assert.NotNull(entry1); + + TarEntry directory = entries.FirstOrDefault(x => + x.EntryType == TarEntryType.Directory && + x.Name == prefix + subDirectoryName); + Assert.NotNull(directory); + + string actualFileName2 = subDirectoryName + fileName2; // Notice the trailing separator in subDirectoryName + TarEntry entry2 = entries.FirstOrDefault(x => + x.EntryType == TarEntryType.RegularFile && + x.Name == prefix + actualFileName2); + Assert.NotNull(entry2); + } + + [Fact] + public void IncludeBaseDirectoryIfEmpty() + { + using TempDirectory source = new TempDirectory(); + using TempDirectory destination = new TempDirectory(); + + string destinationArchiveFileName = Path.Join(destination.Path, "output.tar"); + TarFile.CreateFromDirectory(source.Path, destinationArchiveFileName, includeBaseDirectory: true); + + using FileStream fileStream = File.OpenRead(destinationArchiveFileName); + using (TarReader reader = new TarReader(fileStream)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Equal(TarEntryType.Directory, entry.EntryType); + Assert.Equal(Path.GetFileName(source.Path) + '/', entry.Name); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void IncludeAllSegmentsOfPath(bool includeBaseDirectory) + { + using TempDirectory source = new TempDirectory(); + using TempDirectory destination = new TempDirectory(); + + string segment1 = Path.Join(source.Path, "segment1"); + Directory.CreateDirectory(segment1); + string segment2 = Path.Join(segment1, "segment2"); + Directory.CreateDirectory(segment2); + string textFile = Path.Join(segment2, "file.txt"); + File.Create(textFile).Dispose(); + + string destinationArchiveFileName = Path.Join(destination.Path, "output.tar"); + + TarFile.CreateFromDirectory(source.Path, destinationArchiveFileName, includeBaseDirectory); + + using FileStream fileStream = File.OpenRead(destinationArchiveFileName); + using TarReader reader = new TarReader(fileStream); + + string prefix = includeBaseDirectory ? Path.GetFileName(source.Path) + '/' : string.Empty; + + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Equal(TarEntryType.Directory, entry.EntryType); + Assert.Equal(prefix + "segment1/", entry.Name); + + entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Equal(TarEntryType.Directory, entry.EntryType); + Assert.Equal(prefix + "segment1/segment2/", entry.Name); + + entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + Assert.Equal(prefix + "segment1/segment2/file.txt", entry.Name); + + Assert.Null(reader.GetNextEntry()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.Stream.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.Stream.Tests.cs new file mode 100644 index 0000000000000..ca0cb57fab783 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.Stream.Tests.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarFile_CreateFromDirectory_Stream_Tests : TarTestsBase + { + [Fact] + public void InvalidPath_Throws() + { + using MemoryStream archive = new MemoryStream(); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: null,destination: archive, includeBaseDirectory: false)); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: string.Empty,destination: archive, includeBaseDirectory: false)); + } + + [Fact] + public void NullStream_Throws() + { + using MemoryStream archive = new MemoryStream(); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: "path",destination: null, includeBaseDirectory: false)); + } + + [Fact] + public void UnwritableStream_Throws() + { + using MemoryStream archive = new MemoryStream(); + using WrappedStream unwritable = new WrappedStream(archive, canRead: true, canWrite: false, canSeek: true); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: "path",destination: unwritable, includeBaseDirectory: false)); + } + + [Fact] + public void NonExistentDirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + string dirPath = Path.Join(root.Path, "dir"); + + using MemoryStream archive = new MemoryStream(); + Assert.Throws(() => TarFile.CreateFromDirectory(sourceDirectoryName: dirPath, destination: archive, includeBaseDirectory: false)); + } + } +} \ No newline at end of file diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs new file mode 100644 index 0000000000000..adc1b09a23670 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public partial class TarFile_ExtractToDirectory_File_Tests : TarTestsBase + { + + [Fact] + public void Extract_SpecialFiles_Unix_Unelevated_ThrowsUnauthorizedAccess() + { + string originalFileName = GetTarFilePath(CompressionMethod.Uncompressed, TestTarFormat.ustar, "specialfiles"); + using TempDirectory root = new TempDirectory(); + + string archive = Path.Join(root.Path, "input.tar"); + string destination = Path.Join(root.Path, "dir"); + + // Copying the tar to reduce the chance of other tests failing due to being used by another process + File.Copy(originalFileName, archive); + + Directory.CreateDirectory(destination); + + Assert.Throws(() => TarFile.ExtractToDirectory(archive, destination, overwriteFiles: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(destination).Count()); + } + } +} \ No newline at end of file diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs new file mode 100644 index 0000000000000..9976bb4b7bd00 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public partial class TarFile_ExtractToDirectory_File_Tests : TarTestsBase + { + + [Fact] + public void Extract_SpecialFiles_Windows_ThrowsInvalidOperation() + { + string originalFileName = GetTarFilePath(CompressionMethod.Uncompressed, TestTarFormat.ustar, "specialfiles"); + using TempDirectory root = new TempDirectory(); + + string archive = Path.Join(root.Path, "input.tar"); + string destination = Path.Join(root.Path, "dir"); + + // Copying the tar to reduce the chance of other tests failing due to being used by another process + File.Copy(originalFileName, archive); + + Directory.CreateDirectory(destination); + + Assert.Throws(() => TarFile.ExtractToDirectory(archive, destination, overwriteFiles: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(destination).Count()); + } + } +} \ No newline at end of file diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs new file mode 100644 index 0000000000000..67466ca344404 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs @@ -0,0 +1,143 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public partial class TarFile_ExtractToDirectory_File_Tests : TarTestsBase + { + [Fact] + public void InvalidPaths_Throw() + { + Assert.Throws(() => TarFile.ExtractToDirectory(sourceFileName: null, destinationDirectoryName: "path", overwriteFiles: false)); + Assert.Throws(() => TarFile.ExtractToDirectory(sourceFileName: string.Empty, destinationDirectoryName: "path", overwriteFiles: false)); + Assert.Throws(() => TarFile.ExtractToDirectory(sourceFileName: "path", destinationDirectoryName: null, overwriteFiles: false)); + Assert.Throws(() => TarFile.ExtractToDirectory(sourceFileName: "path", destinationDirectoryName: string.Empty, overwriteFiles: false)); + } + + [Fact] + public void NonExistentFile_Throws() + { + using TempDirectory root = new TempDirectory(); + + string filePath = Path.Join(root.Path, "file.tar"); + string dirPath = Path.Join(root.Path, "dir"); + + Directory.CreateDirectory(dirPath); + + Assert.Throws(() => TarFile.ExtractToDirectory(sourceFileName: filePath, destinationDirectoryName: dirPath, overwriteFiles: false)); + } + + [Fact] + public void NonExistentDirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + + string filePath = Path.Join(root.Path, "file.tar"); + string dirPath = Path.Join(root.Path, "dir"); + + File.Create(filePath).Dispose(); + + Assert.Throws(() => TarFile.ExtractToDirectory(sourceFileName: filePath, destinationDirectoryName: dirPath, overwriteFiles: false)); + } + + [Theory] + [InlineData(TestTarFormat.v7)] + [InlineData(TestTarFormat.ustar)] + [InlineData(TestTarFormat.pax)] + [InlineData(TestTarFormat.pax_gea)] + [InlineData(TestTarFormat.gnu)] + [InlineData(TestTarFormat.oldgnu)] + public void Extract_Archive_File(TestTarFormat testFormat) + { + string sourceArchiveFileName = GetTarFilePath(CompressionMethod.Uncompressed, testFormat, "file"); + + using TempDirectory destination = new TempDirectory(); + + string filePath = Path.Join(destination.Path, "file.txt"); + + TarFile.ExtractToDirectory(sourceArchiveFileName, destination.Path, overwriteFiles: false); + + Assert.True(File.Exists(filePath)); + } + + [Fact] + public void Extract_Archive_File_OverwriteTrue() + { + string testCaseName = "file"; + string archivePath = GetTarFilePath(CompressionMethod.Uncompressed, TestTarFormat.pax, testCaseName); + + using TempDirectory destination = new TempDirectory(); + + string filePath = Path.Join(destination.Path, "file.txt"); + using (FileStream fileStream = File.Create(filePath)) + { + using StreamWriter writer = new StreamWriter(fileStream, leaveOpen: false); + writer.WriteLine("Original text"); + } + + TarFile.ExtractToDirectory(archivePath, destination.Path, overwriteFiles: true); + + Assert.True(File.Exists(filePath)); + + using (FileStream fileStream = File.Open(filePath, FileMode.Open)) + { + using StreamReader reader = new StreamReader(fileStream); + string actualContents = reader.ReadLine(); + Assert.Equal($"Hello {testCaseName}", actualContents); // Confirm overwrite + } + } + + [Fact] + public void Extract_Archive_File_OverwriteFalse() + { + string sourceArchiveFileName = GetTarFilePath(CompressionMethod.Uncompressed, TestTarFormat.pax, "file"); + + using TempDirectory destination = new TempDirectory(); + + string filePath = Path.Join(destination.Path, "file.txt"); + + using (StreamWriter writer = File.CreateText(filePath)) + { + writer.WriteLine("My existence should cause an exception"); + } + + Assert.Throws(() => TarFile.ExtractToDirectory(sourceArchiveFileName, destination.Path, overwriteFiles: false)); + } + + [Fact] + public void Extract_AllSegmentsOfPath() + { + using TempDirectory source = new TempDirectory(); + using TempDirectory destination = new TempDirectory(); + + string archivePath = Path.Join(source.Path, "archive.tar"); + using FileStream archiveStream = File.Create(archivePath); + using (TarWriter writer = new TarWriter(archiveStream)) + { + PaxTarEntry segment1 = new PaxTarEntry(TarEntryType.Directory, "segment1"); + writer.WriteEntry(segment1); + + PaxTarEntry segment2 = new PaxTarEntry(TarEntryType.Directory, "segment1/segment2"); + writer.WriteEntry(segment2); + + PaxTarEntry file = new PaxTarEntry(TarEntryType.RegularFile, "segment1/segment2/file.txt"); + writer.WriteEntry(file); + } + + TarFile.ExtractToDirectory(archivePath, destination.Path, overwriteFiles: false); + + string segment1Path = Path.Join(destination.Path, "segment1"); + Assert.True(Directory.Exists(segment1Path), $"{segment1Path}' does not exist."); + + string segment2Path = Path.Join(segment1Path, "segment2"); + Assert.True(Directory.Exists(segment2Path), $"{segment2Path}' does not exist."); + + string filePath = Path.Join(segment2Path, "file.txt"); + Assert.True(File.Exists(filePath), $"{filePath}' does not exist."); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs new file mode 100644 index 0000000000000..978dc56e86aef --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarFile_ExtractToDirectory_Stream_Tests : TarTestsBase + { + [Fact] + public void NullStream_Throws() + { + Assert.Throws(() => TarFile.ExtractToDirectory(source: null, destinationDirectoryName: "path", overwriteFiles: false)); + } + + [Fact] + public void InvalidPath_Throws() + { + using MemoryStream archive = new MemoryStream(); + Assert.Throws(() => TarFile.ExtractToDirectory(archive, destinationDirectoryName: null, overwriteFiles: false)); + Assert.Throws(() => TarFile.ExtractToDirectory(archive, destinationDirectoryName: string.Empty, overwriteFiles: false)); + } + + [Fact] + public void UnreadableStream_Throws() + { + using MemoryStream archive = new MemoryStream(); + using WrappedStream unreadable = new WrappedStream(archive, canRead: false, canWrite: true, canSeek: true); + Assert.Throws(() => TarFile.ExtractToDirectory(unreadable, destinationDirectoryName: "path", overwriteFiles: false)); + } + + [Fact] + public void NonExistentDirectory_Throws() + { + using TempDirectory root = new TempDirectory(); + string dirPath = Path.Join(root.Path, "dir"); + + using MemoryStream archive = new MemoryStream(); + Assert.Throws(() => TarFile.ExtractToDirectory(archive, destinationDirectoryName: dirPath, overwriteFiles: false)); + } + + [Fact] + public void ExtractEntry_ManySubfolderSegments_NoPrecedingDirectoryEntries() + { + using TempDirectory root = new TempDirectory(); + + string firstSegment = "a"; + string secondSegment = Path.Join(firstSegment, "b"); + string fileWithTwoSegments = Path.Join(secondSegment, "c.txt"); + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + // No preceding directory entries for the segments + UstarTarEntry entry = new UstarTarEntry(TarEntryType.RegularFile, fileWithTwoSegments); + + entry.DataStream = new MemoryStream(); + entry.DataStream.Write(new byte[] { 0x1 }); + entry.DataStream.Seek(0, SeekOrigin.Begin); + + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + TarFile.ExtractToDirectory(archive, root.Path, overwriteFiles: false); + + Assert.True(Directory.Exists(Path.Join(root.Path, firstSegment))); + Assert.True(Directory.Exists(Path.Join(root.Path, secondSegment))); + Assert.True(File.Exists(Path.Join(root.Path, fileWithTwoSegments))); + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void Extract_LinkEntry_TargetOutsideDirectory(TarEntryType entryType) + { + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry = new UstarTarEntry(entryType, "link"); + entry.LinkName = PlatformDetection.IsWindows ? @"C:\Windows\System32\notepad.exe" : "/usr/bin/nano"; + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + + using TempDirectory root = new TempDirectory(); + + Assert.Throws(() => TarFile.ExtractToDirectory(archive, root.Path, overwriteFiles: false)); + + Assert.Equal(0, Directory.GetFileSystemEntries(root.Path).Count()); + } + + [ConditionalFact(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + public void Extract_SymbolicLinkEntry_TargetInsideDirectory() => Extract_LinkEntry_TargetInsideDirectory_Internal(TarEntryType.SymbolicLink); + + [Fact] + public void Extract_HardLinkEntry_TargetInsideDirectory() => Extract_LinkEntry_TargetInsideDirectory_Internal(TarEntryType.HardLink); + + private void Extract_LinkEntry_TargetInsideDirectory_Internal(TarEntryType entryType) + { + using TempDirectory root = new TempDirectory(); + + string linkName = "link"; + string targetName = "target"; + string targetPath = Path.Join(root.Path, targetName); + + File.Create(targetPath).Dispose(); + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry = new UstarTarEntry(entryType, linkName); + entry.LinkName = targetPath; + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + + TarFile.ExtractToDirectory(archive, root.Path, overwriteFiles: false); + + Assert.Equal(2, Directory.GetFileSystemEntries(root.Path).Count()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.ExtractToFile.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.ExtractToFile.Tests.cs new file mode 100644 index 0000000000000..bf5b6a110b570 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.ExtractToFile.Tests.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarReader_ExtractToFile_Tests : TarTestsBase + { + [Fact] + public void ExtractToFile_SpecialFile_Unelevated_Throws() + { + using TempDirectory root = new TempDirectory(); + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, TestTarFormat.ustar, "specialfiles"); + + using TarReader reader = new TarReader(ms); + + string path = Path.Join(root.Path, "output"); + + // Block device requires elevation for writing + PosixTarEntry blockDevice = reader.GetNextEntry() as PosixTarEntry; + Assert.NotNull(blockDevice); + Assert.Throws(() => blockDevice.ExtractToFile(path, overwrite: false)); + Assert.False(File.Exists(path)); + + // Character device requires elevation for writing + PosixTarEntry characterDevice = reader.GetNextEntry() as PosixTarEntry; + Assert.NotNull(characterDevice); + Assert.Throws(() => characterDevice.ExtractToFile(path, overwrite: false)); + Assert.False(File.Exists(path)); + + // Fifo does not require elevation, should succeed + PosixTarEntry fifo = reader.GetNextEntry() as PosixTarEntry; + Assert.NotNull(fifo); + fifo.ExtractToFile(path, overwrite: false); + Assert.True(File.Exists(path)); + + Assert.Null(reader.GetNextEntry()); + } + } +} \ No newline at end of file diff --git a/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.File.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.File.Tests.cs new file mode 100644 index 0000000000000..fa9ea86ed9d43 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.File.Tests.cs @@ -0,0 +1,806 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarReader_File_Tests : TarTestsBase + { + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_File(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "file"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry file = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "file.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_File_HardLink(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "file_hardlink"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry file = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "file.txt", $"Hello {testCaseName}"); + + TarEntry hardLink = reader.GetNextEntry(); + // The 'tar' tool detects hardlinks as regular files and saves them as such in the archives, for all formats + Verify_Archive_RegularFile(hardLink, format, reader.GlobalExtendedAttributes, "hardlink.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_File_SymbolicLink(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "file_symlink"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry file = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "file.txt", $"Hello {testCaseName}"); + + TarEntry symbolicLink = reader.GetNextEntry(); + Verify_Archive_SymbolicLink(symbolicLink, reader.GlobalExtendedAttributes, "link.txt", "file.txt"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_Folder_File(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "folder_file"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry directory = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_Directory(directory, reader.GlobalExtendedAttributes, "folder/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "folder/file.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_Folder_File_Utf8(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "folder_file_utf8"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry directory = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_Directory(directory, reader.GlobalExtendedAttributes, "földër/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "földër/áöñ.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_Folder_Subfolder_File(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "folder_subfolder_file"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry parent = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_Directory(parent, reader.GlobalExtendedAttributes, "parent/"); + + TarEntry child = reader.GetNextEntry(); + Verify_Archive_Directory(child, reader.GlobalExtendedAttributes, "parent/child/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "parent/child/file.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_FolderSymbolicLink_Folder_Subfolder_File(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "foldersymlink_folder_subfolder_file"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry childlink = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_SymbolicLink(childlink, reader.GlobalExtendedAttributes, "childlink", "parent/child"); + + TarEntry parent = reader.GetNextEntry(); + Verify_Archive_Directory(parent, reader.GlobalExtendedAttributes, "parent/"); + + TarEntry child = reader.GetNextEntry(); + Verify_Archive_Directory(child, reader.GlobalExtendedAttributes, "parent/child/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "parent/child/file.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + [InlineData(TarFormat.V7, TestTarFormat.v7)] + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_Many_Small_Files(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "many_small_files"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + + List entries = new List(); + TarEntry entry; + bool isFirstEntry = true; + while ((entry = reader.GetNextEntry()) != null) + { + if (isFirstEntry) + { + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + isFirstEntry = false; + } + entries.Add(entry); + } + + int directoriesCount = entries.Count(e => e.EntryType == TarEntryType.Directory); + Assert.Equal(10, directoriesCount); + + TarEntryType regularFileEntryType = format == TarFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile; + for (int i = 0; i < 10; i++) + { + int filesCount = entries.Count(e => e.EntryType == regularFileEntryType && e.Name.StartsWith($"{i}/")); + Assert.Equal(10, filesCount); + } + } + + [Theory] + // V7 does not support longer filenames + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_LongPath_Splitable_Under255(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "longpath_splitable_under255"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry directory = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_Directory(directory, reader.GlobalExtendedAttributes, "00000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, $"00000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999/00000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + // V7 does not support block devices, character devices or fifos + [InlineData(TarFormat.Ustar, TestTarFormat.ustar)] + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_SpecialFiles(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "specialfiles"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + PosixTarEntry blockDevice = reader.GetNextEntry() as PosixTarEntry; + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_BlockDevice(blockDevice, reader.GlobalExtendedAttributes, AssetBlockDeviceFileName); + + PosixTarEntry characterDevice = reader.GetNextEntry() as PosixTarEntry; + Verify_Archive_CharacterDevice(characterDevice, reader.GlobalExtendedAttributes, AssetCharacterDeviceFileName); + + PosixTarEntry fifo = reader.GetNextEntry() as PosixTarEntry; + Verify_Archive_Fifo(fifo, reader.GlobalExtendedAttributes, "fifofile"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + // Neither V7 not Ustar can handle links with long target filenames + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_File_LongSymbolicLink(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "file_longsymlink"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry directory = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_Directory(directory, reader.GlobalExtendedAttributes, "000000000011111111112222222222333333333344444444445555555555666666666677777777778888888888999999999900000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "000000000011111111112222222222333333333344444444445555555555666666666677777777778888888888999999999900000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555/00000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555555556666666666777777777788888888889999999999000000000011111111112222222222333333333344444444445.txt", $"Hello {testCaseName}"); + + TarEntry symbolicLink = reader.GetNextEntry(); + Verify_Archive_SymbolicLink(symbolicLink, reader.GlobalExtendedAttributes, "link.txt", "000000000011111111112222222222333333333344444444445555555555666666666677777777778888888888999999999900000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555/00000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555555556666666666777777777788888888889999999999000000000011111111112222222222333333333344444444445.txt"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + // Neither V7 not Ustar can handle a path that does not have separators that can be split under 100 bytes + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_LongFileName_Over100_Under255(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "longfilename_over100_under255"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry file = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "000000000011111111112222222222333333333344444444445555555555666666666677777777778888888888999999999900000000001111111111222222222233333333334444444444.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + [Theory] + // Neither V7 not Ustar can handle path lenghts waaaay beyond name+prefix length + [InlineData(TarFormat.Pax, TestTarFormat.pax)] + [InlineData(TarFormat.Pax, TestTarFormat.pax_gea)] + [InlineData(TarFormat.Gnu, TestTarFormat.gnu)] + [InlineData(TarFormat.Gnu, TestTarFormat.oldgnu)] + public void Read_Archive_LongPath_Over255(TarFormat format, TestTarFormat testFormat) + { + string testCaseName = "longpath_over255"; + using MemoryStream ms = GetTarMemoryStream(CompressionMethod.Uncompressed, testFormat, testCaseName); + + using TarReader reader = new TarReader(ms); + if (testFormat == TestTarFormat.pax_gea) + { + // The GEA are collected after reading the first entry, not on the constructor + Assert.Null(reader.GlobalExtendedAttributes); + } + + // Format is determined after reading the first entry, not on the constructor + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry directory = reader.GetNextEntry(); + + Assert.Equal(format, reader.Format); + if (testFormat == TestTarFormat.pax_gea) + { + Assert.NotNull(reader.GlobalExtendedAttributes); + Assert.True(reader.GlobalExtendedAttributes.Any()); + Assert.Contains(AssetPaxGeaKey, reader.GlobalExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, reader.GlobalExtendedAttributes[AssetPaxGeaKey]); + } + + Verify_Archive_Directory(directory, reader.GlobalExtendedAttributes, "000000000011111111112222222222333333333344444444445555555555666666666677777777778888888888999999999900000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555/"); + + TarEntry file = reader.GetNextEntry(); + Verify_Archive_RegularFile(file, format, reader.GlobalExtendedAttributes, "000000000011111111112222222222333333333344444444445555555555666666666677777777778888888888999999999900000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555/00000000001111111111222222222233333333334444444444555555555566666666667777777777888888888899999999990000000000111111111122222222223333333333444444444455555555556666666666777777777788888888889999999999000000000011111111112222222222333333333344444444445.txt", $"Hello {testCaseName}"); + + Assert.Null(reader.GetNextEntry()); + } + + private void Verify_Archive_RegularFile(TarEntry file, TarFormat format, IReadOnlyDictionary gea, string expectedFileName, string expectedContents) + { + Assert.NotNull(file); + + Assert.True(file.Checksum > 0); + Assert.NotNull(file.DataStream); + Assert.True(file.DataStream.Length > 0); + Assert.True(file.DataStream.CanRead); + Assert.True(file.DataStream.CanSeek); + file.DataStream.Seek(0, SeekOrigin.Begin); + using (StreamReader reader = new StreamReader(file.DataStream, leaveOpen: true)) + { + string contents = reader.ReadLine(); + Assert.Equal(expectedContents, contents); + } + + TarEntryType expectedEntryType = format == TarFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile; + Assert.Equal(expectedEntryType, file.EntryType); + + Assert.Equal(AssetGid, file.Gid); + Assert.Equal(file.Length, file.DataStream.Length); + Assert.Equal(DefaultLinkName, file.LinkName); + Assert.Equal(AssetMode, file.Mode); + Assert.True(file.ModificationTime > DateTimeOffset.UnixEpoch); + Assert.Equal(expectedFileName, file.Name); + Assert.Equal(AssetUid, file.Uid); + + if (file is PosixTarEntry posix) + { + Assert.Equal(DefaultDeviceMajor, posix.DeviceMajor); + Assert.Equal(DefaultDeviceMinor, posix.DeviceMinor); + Assert.Equal(AssetGName, posix.GroupName); + Assert.Equal(AssetUName, posix.UserName); + + if (posix is PaxTarEntry pax) + { + VerifyAssetExtendedAttributes(pax, gea); + } + else if (posix is GnuTarEntry gnu) + { + Assert.True(gnu.AccessTime >= DateTimeOffset.UnixEpoch); + Assert.True(gnu.ChangeTime >= DateTimeOffset.UnixEpoch); + } + } + } + + private void VerifyAssetExtendedAttributes(PaxTarEntry pax, IReadOnlyDictionary gea) + { + Assert.NotNull(pax.ExtendedAttributes); + Assert.True(pax.ExtendedAttributes.Count() >= 3); // Expect to at least collect mtime, ctime and atime + if (gea != null && gea.Any()) + { + Assert.Contains(AssetPaxGeaKey, pax.ExtendedAttributes); + Assert.Equal(AssetPaxGeaValue, pax.ExtendedAttributes[AssetPaxGeaKey]); + } + + Assert.Contains("mtime", pax.ExtendedAttributes); + Assert.Contains("atime", pax.ExtendedAttributes); + Assert.Contains("ctime", pax.ExtendedAttributes); + + Assert.True(double.TryParse(pax.ExtendedAttributes["mtime"], NumberStyles.Any, CultureInfo.InvariantCulture, out double mtimeSecondsSinceEpoch)); + Assert.True(mtimeSecondsSinceEpoch > 0); + + Assert.True(double.TryParse(pax.ExtendedAttributes["atime"], NumberStyles.Any, CultureInfo.InvariantCulture, out double atimeSecondsSinceEpoch)); + Assert.True(atimeSecondsSinceEpoch > 0); + + Assert.True(double.TryParse(pax.ExtendedAttributes["ctime"], NumberStyles.Any, CultureInfo.InvariantCulture, out double ctimeSecondsSinceEpoch)); + Assert.True(ctimeSecondsSinceEpoch > 0); + } + + private void Verify_Archive_SymbolicLink(TarEntry symbolicLink, IReadOnlyDictionary gea, string expectedFileName, string expectedTargetName) + { + Assert.NotNull(symbolicLink); + + Assert.True(symbolicLink.Checksum > 0); + Assert.Null(symbolicLink.DataStream); + + Assert.Equal(TarEntryType.SymbolicLink, symbolicLink.EntryType); + + Assert.Equal(AssetGid, symbolicLink.Gid); + Assert.Equal(0, symbolicLink.Length); + Assert.Equal(expectedTargetName, symbolicLink.LinkName); + Assert.Equal(AssetSymbolicLinkMode, symbolicLink.Mode); + Assert.True(symbolicLink.ModificationTime > DateTimeOffset.UnixEpoch); + Assert.Equal(expectedFileName, symbolicLink.Name); + Assert.Equal(AssetUid, symbolicLink.Uid); + + if (symbolicLink is PosixTarEntry posix) + { + Assert.Equal(DefaultDeviceMajor, posix.DeviceMajor); + Assert.Equal(DefaultDeviceMinor, posix.DeviceMinor); + Assert.Equal(AssetGName, posix.GroupName); + Assert.Equal(AssetUName, posix.UserName); + } + + if (symbolicLink is PaxTarEntry pax) + { + // TODO: Check ext attrs https://github.com/dotnet/runtime/issues/68230 + } + else if (symbolicLink is GnuTarEntry gnu) + { + Assert.True(gnu.AccessTime >= DateTimeOffset.UnixEpoch); + Assert.True(gnu.ChangeTime >= DateTimeOffset.UnixEpoch); + } + } + + private void Verify_Archive_Directory(TarEntry directory, IReadOnlyDictionary gea, string expectedFileName) + { + Assert.NotNull(directory); + + Assert.True(directory.Checksum > 0); + Assert.Null(directory.DataStream); + + Assert.Equal(TarEntryType.Directory, directory.EntryType); + + Assert.Equal(AssetGid, directory.Gid); + Assert.Equal(0, directory.Length); + Assert.Equal(DefaultLinkName, directory.LinkName); + Assert.Equal(AssetMode, directory.Mode); + Assert.True(directory.ModificationTime > DateTimeOffset.UnixEpoch); + Assert.Equal(expectedFileName, directory.Name); + Assert.Equal(AssetUid, directory.Uid); + + if (directory is PosixTarEntry posix) + { + Assert.Equal(DefaultDeviceMajor, posix.DeviceMajor); + Assert.Equal(DefaultDeviceMinor, posix.DeviceMinor); + Assert.Equal(AssetGName, posix.GroupName); + Assert.Equal(AssetUName, posix.UserName); + } + + if (directory is PaxTarEntry pax) + { + // TODO: Check ext attrs https://github.com/dotnet/runtime/issues/68230 + } + else if (directory is GnuTarEntry gnu) + { + Assert.True(gnu.AccessTime >= DateTimeOffset.UnixEpoch); + Assert.True(gnu.ChangeTime >= DateTimeOffset.UnixEpoch); + } + } + + private void Verify_Archive_BlockDevice(PosixTarEntry blockDevice, IReadOnlyDictionary gea, string expectedFileName) + { + Assert.NotNull(blockDevice); + Assert.Equal(TarEntryType.BlockDevice, blockDevice.EntryType); + + Assert.True(blockDevice.Checksum > 0); + Assert.Null(blockDevice.DataStream); + + Assert.Equal(AssetGid, blockDevice.Gid); + Assert.Equal(0, blockDevice.Length); + Assert.Equal(DefaultLinkName, blockDevice.LinkName); + Assert.Equal(AssetSpecialFileMode, blockDevice.Mode); + Assert.True(blockDevice.ModificationTime > DateTimeOffset.UnixEpoch); + Assert.Equal(expectedFileName, blockDevice.Name); + Assert.Equal(AssetUid, blockDevice.Uid); + + // TODO: Figure out why the numbers don't match https://github.com/dotnet/runtime/issues/68230 + // Assert.Equal(AssetBlockDeviceMajor, blockDevice.DeviceMajor); + // Assert.Equal(AssetBlockDeviceMinor, blockDevice.DeviceMinor); + // Remove these two temporary checks when the above is fixed + Assert.True(blockDevice.DeviceMajor > 0); + Assert.True(blockDevice.DeviceMinor > 0); + Assert.Equal(AssetGName, blockDevice.GroupName); + Assert.Equal(AssetUName, blockDevice.UserName); + + if (blockDevice is PaxTarEntry pax) + { + // TODO: Check ext attrs https://github.com/dotnet/runtime/issues/68230 + } + else if (blockDevice is GnuTarEntry gnu) + { + Assert.True(gnu.AccessTime >= DateTimeOffset.UnixEpoch); + Assert.True(gnu.ChangeTime >= DateTimeOffset.UnixEpoch); + } + } + + private void Verify_Archive_CharacterDevice(PosixTarEntry characterDevice, IReadOnlyDictionary gea, string expectedFileName) + { + Assert.NotNull(characterDevice); + Assert.Equal(TarEntryType.CharacterDevice, characterDevice.EntryType); + + Assert.True(characterDevice.Checksum > 0); + Assert.Null(characterDevice.DataStream); + + Assert.Equal(AssetGid, characterDevice.Gid); + Assert.Equal(0, characterDevice.Length); + Assert.Equal(DefaultLinkName, characterDevice.LinkName); + Assert.Equal(AssetSpecialFileMode, characterDevice.Mode); + Assert.True(characterDevice.ModificationTime > DateTimeOffset.UnixEpoch); + Assert.Equal(expectedFileName, characterDevice.Name); + Assert.Equal(AssetUid, characterDevice.Uid); + + // TODO: Figure out why the numbers don't match https://github.com/dotnet/runtime/issues/68230 + //Assert.Equal(AssetBlockDeviceMajor, characterDevice.DeviceMajor); + //Assert.Equal(AssetBlockDeviceMinor, characterDevice.DeviceMinor); + // Remove these two temporary checks when the above is fixed + Assert.True(characterDevice.DeviceMajor > 0); + Assert.True(characterDevice.DeviceMinor > 0); + Assert.Equal(AssetGName, characterDevice.GroupName); + Assert.Equal(AssetUName, characterDevice.UserName); + + if (characterDevice is PaxTarEntry pax) + { + // TODO: Check ext attrs https://github.com/dotnet/runtime/issues/68230 + } + else if (characterDevice is GnuTarEntry gnu) + { + Assert.True(gnu.AccessTime >= DateTimeOffset.UnixEpoch); + Assert.True(gnu.ChangeTime >= DateTimeOffset.UnixEpoch); + } + } + + private void Verify_Archive_Fifo(PosixTarEntry fifo, IReadOnlyDictionary gea, string expectedFileName) + { + Assert.NotNull(fifo); + + Assert.True(fifo.Checksum > 0); + Assert.Null(fifo.DataStream); + + Assert.Equal(TarEntryType.Fifo, fifo.EntryType); + + Assert.Equal(AssetGid, fifo.Gid); + Assert.Equal(0, fifo.Length); + Assert.Equal(DefaultLinkName, fifo.LinkName); + Assert.Equal(AssetSpecialFileMode, fifo.Mode); + Assert.True(fifo.ModificationTime > DateTimeOffset.UnixEpoch); + Assert.Equal(expectedFileName, fifo.Name); + Assert.Equal(AssetUid, fifo.Uid); + + Assert.Equal(DefaultDeviceMajor, fifo.DeviceMajor); + Assert.Equal(DefaultDeviceMinor, fifo.DeviceMinor); + Assert.Equal(AssetGName, fifo.GroupName); + Assert.Equal(AssetUName, fifo.UserName); + + if (fifo is PaxTarEntry pax) + { + // TODO: Check ext attrs https://github.com/dotnet/runtime/issues/68230 + } + else if (fifo is GnuTarEntry gnu) + { + Assert.True(gnu.AccessTime >= DateTimeOffset.UnixEpoch); + Assert.True(gnu.ChangeTime >= DateTimeOffset.UnixEpoch); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs new file mode 100644 index 0000000000000..a9eccc625c038 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs @@ -0,0 +1,254 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarReader_GetNextEntry_Tests : TarTestsBase + { + [Fact] + public void MalformedArchive_TooSmall() + { + using MemoryStream malformed = new MemoryStream(); + byte[] buffer = new byte[] { 0x1 }; + malformed.Write(buffer); + malformed.Seek(0, SeekOrigin.Begin); + + using TarReader reader = new TarReader(malformed); + Assert.Throws(() => reader.GetNextEntry()); + } + + [Fact] + public void MalformedArchive_HeaderSize() + { + using MemoryStream malformed = new MemoryStream(); + byte[] buffer = new byte[512]; // Minimum length of any header + Array.Fill(buffer, 0x1); + malformed.Write(buffer); + malformed.Seek(0, SeekOrigin.Begin); + + using TarReader reader = new TarReader(malformed); + Assert.Throws(() => reader.GetNextEntry()); + } + + [Fact] + public void EmptyArchive() + { + using MemoryStream empty = new MemoryStream(); + + using TarReader reader = new TarReader(empty); + Assert.Null(reader.GetNextEntry()); + } + + + [Fact] + public void LongEndMarkers_DoNotAdvanceStream() + { + using MemoryStream archive = new MemoryStream(); + + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry = new UstarTarEntry(TarEntryType.Directory, "dir"); + writer.WriteEntry(entry); + } + + byte[] buffer = new byte[2048]; // Four additional end markers (512 each) + Array.Fill(buffer, 0x0); + archive.Write(buffer); + archive.Seek(0, SeekOrigin.Begin); + + using TarReader reader = new TarReader(archive); + Assert.NotNull(reader.GetNextEntry()); + Assert.Null(reader.GetNextEntry()); + long expectedPosition = archive.Position; // After reading the first null entry, should not advance more + Assert.Null(reader.GetNextEntry()); + Assert.Equal(expectedPosition, archive.Position); + } + + [Fact] + public void GetNextEntry_CopyDataTrue_SeekableArchive() + { + string expectedText = "Hello world!"; + MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry1 = new UstarTarEntry(TarEntryType.RegularFile, "file.txt"); + entry1.DataStream = new MemoryStream(); + using (StreamWriter streamWriter = new StreamWriter(entry1.DataStream, leaveOpen: true)) + { + streamWriter.WriteLine(expectedText); + } + entry1.DataStream.Seek(0, SeekOrigin.Begin); // Rewind to ensure it gets written from the beginning + writer.WriteEntry(entry1); + + UstarTarEntry entry2 = new UstarTarEntry(TarEntryType.Directory, "dir"); + writer.WriteEntry(entry2); + } + + archive.Seek(0, SeekOrigin.Begin); + + UstarTarEntry entry; + using (TarReader reader = new TarReader(archive)) // Seekable + { + entry = reader.GetNextEntry(copyData: true) as UstarTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + + // Force reading the next entry to advance the underlying stream position + Assert.NotNull(reader.GetNextEntry()); + Assert.Null(reader.GetNextEntry()); + + entry.DataStream.Seek(0, SeekOrigin.Begin); // Should not throw: This is a new stream, not the archive's disposed stream + using (StreamReader streamReader = new StreamReader(entry.DataStream)) + { + string actualText = streamReader.ReadLine(); + Assert.Equal(expectedText, actualText); + } + + } + + // The reader must stay alive because it's in charge of disposing all the entries it collected + Assert.Throws(() => entry.DataStream.Read(new byte[1])); + } + + [Fact] + public void GetNextEntry_CopyDataTrue_UnseekableArchive() + { + string expectedText = "Hello world!"; + MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry1 = new UstarTarEntry(TarEntryType.RegularFile, "file.txt"); + entry1.DataStream = new MemoryStream(); + using (StreamWriter streamWriter = new StreamWriter(entry1.DataStream, leaveOpen: true)) + { + streamWriter.WriteLine(expectedText); + } + entry1.DataStream.Seek(0, SeekOrigin.Begin); + writer.WriteEntry(entry1); + + UstarTarEntry entry2 = new UstarTarEntry(TarEntryType.Directory, "dir"); + writer.WriteEntry(entry2); + } + + archive.Seek(0, SeekOrigin.Begin); + using WrappedStream wrapped = new WrappedStream(archive, canRead: true, canWrite: false, canSeek: false); + + UstarTarEntry entry; + using (TarReader reader = new TarReader(wrapped, leaveOpen: true)) // Unseekable + { + entry = reader.GetNextEntry(copyData: true) as UstarTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + + // Force reading the next entry to advance the underlying stream position + Assert.NotNull(reader.GetNextEntry()); + Assert.Null(reader.GetNextEntry()); + + Assert.NotNull(entry.DataStream); + entry.DataStream.Seek(0, SeekOrigin.Begin); // Should not throw: This is a new stream, not the archive's disposed stream + using (StreamReader streamReader = new StreamReader(entry.DataStream)) + { + string actualText = streamReader.ReadLine(); + Assert.Equal(expectedText, actualText); + } + + } + + // The reader must stay alive because it's in charge of disposing all the entries it collected + Assert.Throws(() => entry.DataStream.Read(new byte[1])); + } + + [Fact] + public void GetNextEntry_CopyDataFalse_UnseekableArchive_Exceptions() + { + MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry1 = new UstarTarEntry(TarEntryType.RegularFile, "file.txt"); + entry1.DataStream = new MemoryStream(); + using (StreamWriter streamWriter = new StreamWriter(entry1.DataStream, leaveOpen: true)) + { + streamWriter.WriteLine("Hello world!"); + } + entry1.DataStream.Seek(0, SeekOrigin.Begin); // Rewind to ensure it gets written from the beginning + writer.WriteEntry(entry1); + + UstarTarEntry entry2 = new UstarTarEntry(TarEntryType.Directory, "dir"); + writer.WriteEntry(entry2); + } + + archive.Seek(0, SeekOrigin.Begin); + using WrappedStream wrapped = new WrappedStream(archive, canRead: true, canWrite: false, canSeek: false); + UstarTarEntry entry; + using (TarReader reader = new TarReader(wrapped)) // Unseekable + { + entry = reader.GetNextEntry(copyData: false) as UstarTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + entry.DataStream.ReadByte(); // Reading is possible as long as we don't move to the next entry + + // Attempting to read the next entry should automatically move the position pointer to the beginning of the next header + Assert.NotNull(reader.GetNextEntry()); + Assert.Null(reader.GetNextEntry()); + + // This is not possible because the position of the main stream is already past the data + Assert.Throws(() => entry.DataStream.Read(new byte[1])); + } + + // The reader must stay alive because it's in charge of disposing all the entries it collected + Assert.Throws(() => entry.DataStream.Read(new byte[1])); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetNextEntry_UnseekableArchive_ReplaceDataStream_ExcludeFromDisposing(bool copyData) + { + MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry entry1 = new UstarTarEntry(TarEntryType.RegularFile, "file.txt"); + entry1.DataStream = new MemoryStream(); + using (StreamWriter streamWriter = new StreamWriter(entry1.DataStream, leaveOpen: true)) + { + streamWriter.WriteLine("Hello world!"); + } + entry1.DataStream.Seek(0, SeekOrigin.Begin); // Rewind to ensure it gets written from the beginning + writer.WriteEntry(entry1); + + UstarTarEntry entry2 = new UstarTarEntry(TarEntryType.Directory, "dir"); + writer.WriteEntry(entry2); + } + + archive.Seek(0, SeekOrigin.Begin); + using WrappedStream wrapped = new WrappedStream(archive, canRead: true, canWrite: false, canSeek: false); + UstarTarEntry entry; + Stream oldStream; + using (TarReader reader = new TarReader(wrapped)) // Unseekable + { + entry = reader.GetNextEntry(copyData) as UstarTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + + oldStream = entry.DataStream; + + entry.DataStream = new MemoryStream(); // Substitution, setter should dispose the previous stream + using(StreamWriter streamWriter = new StreamWriter(entry.DataStream, leaveOpen: true)) + { + streamWriter.WriteLine("Substituted"); + } + } // Disposing reader should not dispose the substituted DataStream + + Assert.Throws(() => oldStream.Read(new byte[1])); + + entry.DataStream.Seek(0, SeekOrigin.Begin); + using (StreamReader streamReader = new StreamReader(entry.DataStream)) + { + Assert.Equal("Substituted", streamReader.ReadLine()); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.Gnu.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Gnu.cs new file mode 100644 index 0000000000000..94753c80e3cbd --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Gnu.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public abstract partial class TarTestsBase : FileCleanupTestBase + { + protected void SetRegularFile(GnuTarEntry regularFile) + { + SetCommonRegularFile(regularFile); + SetPosixProperties(regularFile); + SetGnuProperties(regularFile); + } + + protected void SetDirectory(GnuTarEntry directory) + { + SetCommonDirectory(directory); + SetPosixProperties(directory); + SetGnuProperties(directory); + } + + protected void SetHardLink(GnuTarEntry hardLink) + { + SetCommonHardLink(hardLink); + SetPosixProperties(hardLink); + SetGnuProperties(hardLink); + } + + protected void SetSymbolicLink(GnuTarEntry symbolicLink) + { + SetCommonSymbolicLink(symbolicLink); + SetPosixProperties(symbolicLink); + SetGnuProperties(symbolicLink); + } + + protected void SetCharacterDevice(GnuTarEntry characterDevice) + { + SetCharacterDeviceProperties(characterDevice); + SetGnuProperties(characterDevice); + } + + protected void SetBlockDevice(GnuTarEntry blockDevice) + { + SetBlockDeviceProperties(blockDevice); + SetGnuProperties(blockDevice); + } + + protected void SetFifo(GnuTarEntry fifo) + { + SetFifoProperties(fifo); + SetGnuProperties(fifo); + } + + protected void SetGnuProperties(GnuTarEntry entry) + { + DateTimeOffset approxNow = DateTimeOffset.Now.Subtract(TimeSpan.FromHours(6)); + + // ATime: Verify the default value was approximately "now" + Assert.True(entry.AccessTime > approxNow); + Assert.Throws(() => entry.AccessTime = DateTimeOffset.MinValue); + entry.AccessTime = TestAccessTime; + + // CTime: Verify the default value was approximately "now" + Assert.True(entry.ChangeTime > approxNow); + Assert.Throws(() => entry.ChangeTime = DateTimeOffset.MinValue); + entry.ChangeTime = TestChangeTime; + } + + protected void VerifyRegularFile(GnuTarEntry regularFile, bool isWritable) + { + VerifyPosixRegularFile(regularFile, isWritable); + VerifyGnuProperties(regularFile); + } + + protected void VerifyDirectory(GnuTarEntry directory) + { + VerifyPosixDirectory(directory); + VerifyGnuProperties(directory); + } + + protected void VerifyHardLink(GnuTarEntry hardLink) + { + VerifyPosixHardLink(hardLink); + VerifyGnuProperties(hardLink); + } + + protected void VerifySymbolicLink(GnuTarEntry symbolicLink) + { + VerifyPosixSymbolicLink(symbolicLink); + VerifyGnuProperties(symbolicLink); + } + + protected void VerifyCharacterDevice(GnuTarEntry characterDevice) + { + VerifyPosixCharacterDevice(characterDevice); + VerifyGnuProperties(characterDevice); + } + + protected void VerifyBlockDevice(GnuTarEntry blockDevice) + { + VerifyPosixBlockDevice(blockDevice); + VerifyGnuProperties(blockDevice); + } + + protected void VerifyFifo(GnuTarEntry fifo) + { + VerifyPosixFifo(fifo); + VerifyGnuProperties(fifo); + } + + protected void VerifyGnuProperties(GnuTarEntry entry) + { + Assert.Equal(TestAccessTime, entry.AccessTime); + Assert.Equal(TestChangeTime, entry.ChangeTime); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.Pax.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Pax.cs new file mode 100644 index 0000000000000..87ba82cc2f5a9 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Pax.cs @@ -0,0 +1,118 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public abstract partial class TarTestsBase : FileCleanupTestBase + { + protected void SetRegularFile(PaxTarEntry regularFile) + { + SetCommonRegularFile(regularFile); + SetPosixProperties(regularFile); + } + + protected void SetDirectory(PaxTarEntry directory) + { + SetCommonDirectory(directory); + SetPosixProperties(directory); + } + + protected void SetHardLink(PaxTarEntry hardLink) + { + SetCommonHardLink(hardLink); + SetPosixProperties(hardLink); + } + + protected void SetSymbolicLink(PaxTarEntry symbolicLink) + { + SetCommonSymbolicLink(symbolicLink); + SetPosixProperties(symbolicLink); + } + + protected void SetCharacterDevice(PaxTarEntry characterDevice) + { + SetCharacterDeviceProperties(characterDevice); + } + + protected void SetBlockDevice(PaxTarEntry blockDevice) + { + SetBlockDeviceProperties(blockDevice); + } + + protected void SetFifo(PaxTarEntry fifo) + { + SetFifoProperties(fifo); + } + + protected void VerifyRegularFile(PaxTarEntry regularFile, bool isWritable) + { + VerifyPosixRegularFile(regularFile, isWritable); + } + + protected void VerifyDirectory(PaxTarEntry directory) + { + VerifyPosixDirectory(directory); + } + + protected void VerifyHardLink(PaxTarEntry hardLink) + { + VerifyPosixHardLink(hardLink); + } + + protected void VerifySymbolicLink(PaxTarEntry symbolicLink) + { + VerifyPosixSymbolicLink(symbolicLink); + } + + protected void VerifyCharacterDevice(PaxTarEntry characterDevice) + { + VerifyPosixCharacterDevice(characterDevice); + } + + protected void VerifyBlockDevice(PaxTarEntry blockDevice) + { + VerifyPosixBlockDevice(blockDevice); + } + + protected void VerifyFifo(PaxTarEntry fifo) + { + VerifyPosixFifo(fifo); + } + + protected DateTimeOffset ConvertDoubleToDateTimeOffset(double value) + { + return new DateTimeOffset((long)(value * TimeSpan.TicksPerSecond) + DateTime.UnixEpoch.Ticks, TimeSpan.Zero); + } + + protected double ConvertDateTimeOffsetToDouble(DateTimeOffset value) + { + return ((double)(value.UtcDateTime - DateTime.UnixEpoch).Ticks)/TimeSpan.TicksPerSecond; + } + + protected void VerifyExtendedAttributeTimestamp(PaxTarEntry entry, string name, DateTimeOffset expected = default) + { + Assert.Contains(name, entry.ExtendedAttributes); + + // As regular header fields, timestamps are saved as integer numbers that fit in 12 bytes + // But as extended attributes, they should always be saved as doubles with decimal precision + Assert.Contains(".", entry.ExtendedAttributes[name]); + + Assert.True(double.TryParse(entry.ExtendedAttributes[name], NumberStyles.Any, CultureInfo.InvariantCulture, out double doubleTime)); // Force the parsing to use '.' as decimal separator + DateTimeOffset timestamp = ConvertDoubleToDateTimeOffset(doubleTime); + + if (expected != default) + { + Assert.Equal(expected, timestamp); + } + else + { + Assert.True(timestamp > DateTimeOffset.UnixEpoch); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.Posix.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Posix.cs new file mode 100644 index 0000000000000..498612de473d6 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Posix.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public abstract partial class TarTestsBase : FileCleanupTestBase + { + protected void SetPosixProperties(PosixTarEntry entry) + { + Assert.Equal(DefaultGName, entry.GroupName); + entry.GroupName = TestGName; + + Assert.Equal(DefaultUName, entry.UserName); + entry.UserName = TestUName; + } + + private void SetBlockDeviceProperties(PosixTarEntry device) + { + Assert.NotNull(device); + Assert.Equal(TarEntryType.BlockDevice, device.EntryType); + SetCommonProperties(device); + SetPosixProperties(device); + + // DeviceMajor + Assert.Equal(DefaultDeviceMajor, device.DeviceMajor); + Assert.Throws(() => device.DeviceMajor = -1); + Assert.Throws(() => device.DeviceMajor = 2097152); + device.DeviceMajor = TestBlockDeviceMajor; + + // DeviceMinor + Assert.Equal(DefaultDeviceMinor, device.DeviceMinor); + Assert.Throws(() => device.DeviceMinor = -1); + Assert.Throws(() => device.DeviceMinor = 2097152); + device.DeviceMinor = TestBlockDeviceMinor; + } + + private void SetCharacterDeviceProperties(PosixTarEntry device) + { + Assert.NotNull(device); + Assert.Equal(TarEntryType.CharacterDevice, device.EntryType); + SetCommonProperties(device); + SetPosixProperties(device); + + // DeviceMajor + Assert.Equal(DefaultDeviceMajor, device.DeviceMajor); + Assert.Throws(() => device.DeviceMajor = -1); + Assert.Throws(() => device.DeviceMajor = 2097152); + device.DeviceMajor = TestCharacterDeviceMajor; + + // DeviceMinor + Assert.Equal(DefaultDeviceMinor, device.DeviceMinor); + Assert.Throws(() => device.DeviceMinor = -1); + Assert.Throws(() => device.DeviceMinor = 2097152); + device.DeviceMinor = TestCharacterDeviceMinor; + } + + private void SetFifoProperties(PosixTarEntry fifo) + { + Assert.NotNull(fifo); + Assert.Equal(TarEntryType.Fifo, fifo.EntryType); + SetCommonProperties(fifo); + SetPosixProperties(fifo); + } + + protected void VerifyPosixProperties(PosixTarEntry entry) + { + entry.GroupName = TestGName; + Assert.Equal(TestGName, entry.GroupName); + + entry.UserName = TestUName; + Assert.Equal(TestUName, entry.UserName); + } + + protected void VerifyPosixRegularFile(PosixTarEntry regularFile, bool isWritable) + { + VerifyCommonRegularFile(regularFile, isWritable); + VerifyUnsupportedDeviceProperties(regularFile); + } + + protected void VerifyPosixDirectory(PosixTarEntry directory) + { + VerifyCommonDirectory(directory); + VerifyUnsupportedDeviceProperties(directory); + } + + protected void VerifyPosixHardLink(PosixTarEntry hardLink) + { + VerifyCommonHardLink(hardLink); + VerifyUnsupportedDeviceProperties(hardLink); + } + + protected void VerifyPosixSymbolicLink(PosixTarEntry symbolicLink) + { + VerifyCommonSymbolicLink(symbolicLink); + VerifyUnsupportedDeviceProperties(symbolicLink); + } + + protected void VerifyPosixCharacterDevice(PosixTarEntry device) + { + Assert.NotNull(device); + Assert.Equal(TarEntryType.CharacterDevice, device.EntryType); + VerifyCommonProperties(device); + VerifyUnsupportedLinkProperty(device); + VerifyUnsupportedDataStream(device); + + Assert.Equal(TestCharacterDeviceMajor, device.DeviceMajor); + Assert.Equal(TestCharacterDeviceMinor, device.DeviceMinor); + } + + protected void VerifyPosixBlockDevice(PosixTarEntry device) + { + Assert.NotNull(device); + Assert.Equal(TarEntryType.BlockDevice, device.EntryType); + VerifyCommonProperties(device); + VerifyUnsupportedLinkProperty(device); + VerifyUnsupportedDataStream(device); + + Assert.Equal(TestBlockDeviceMajor, device.DeviceMajor); + Assert.Equal(TestBlockDeviceMinor, device.DeviceMinor); + } + + protected void VerifyPosixFifo(PosixTarEntry fifo) + { + Assert.NotNull(fifo); + Assert.Equal(TarEntryType.Fifo, fifo.EntryType); + VerifyCommonProperties(fifo); + VerifyPosixProperties(fifo); + VerifyUnsupportedDeviceProperties(fifo); + VerifyUnsupportedLinkProperty(fifo); + VerifyUnsupportedDataStream(fifo); + } + + protected void VerifyUnsupportedDeviceProperties(PosixTarEntry entry) + { + Assert.True(entry.EntryType is not TarEntryType.CharacterDevice and not TarEntryType.BlockDevice); + Assert.Equal(0, entry.DeviceMajor); + Assert.Throws(() => entry.DeviceMajor = 5); + Assert.Equal(0, entry.DeviceMajor); // No change + + Assert.Equal(0, entry.DeviceMinor); + Assert.Throws(() => entry.DeviceMinor = 5); + Assert.Equal(0, entry.DeviceMinor); // No change + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.Ustar.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Ustar.cs new file mode 100644 index 0000000000000..b88a9833bbe3f --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.Ustar.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace System.Formats.Tar.Tests +{ + public abstract partial class TarTestsBase : FileCleanupTestBase + { + protected void SetRegularFile(UstarTarEntry regularFile) + { + SetCommonRegularFile(regularFile); + SetPosixProperties(regularFile); + } + + protected void SetDirectory(UstarTarEntry directory) + { + SetCommonDirectory(directory); + SetPosixProperties(directory); + } + + protected void SetHardLink(UstarTarEntry hardLink) + { + SetCommonHardLink(hardLink); + SetPosixProperties(hardLink); + } + + protected void SetSymbolicLink(UstarTarEntry symbolicLink) + { + SetCommonSymbolicLink(symbolicLink); + SetPosixProperties(symbolicLink); + } + + protected void SetCharacterDevice(UstarTarEntry characterDevice) + { + SetCharacterDeviceProperties(characterDevice); + } + + protected void SetBlockDevice(UstarTarEntry blockDevice) + { + SetBlockDeviceProperties(blockDevice); + } + + protected void SetFifo(UstarTarEntry fifo) + { + SetFifoProperties(fifo); + } + + protected void VerifyRegularFile(UstarTarEntry regularFile, bool isWritable) + { + VerifyPosixRegularFile(regularFile, isWritable); + } + + protected void VerifyDirectory(UstarTarEntry directory) + { + VerifyPosixDirectory(directory); + } + + protected void VerifyHardLink(UstarTarEntry hardLink) + { + VerifyPosixHardLink(hardLink); + } + + protected void VerifySymbolicLink(UstarTarEntry symbolicLink) + { + VerifyPosixSymbolicLink(symbolicLink); + } + + protected void VerifyCharacterDevice(UstarTarEntry characterDevice) + { + VerifyPosixCharacterDevice(characterDevice); + } + + protected void VerifyBlockDevice(UstarTarEntry blockDevice) + { + VerifyPosixBlockDevice(blockDevice); + } + + protected void VerifyFifo(UstarTarEntry fifo) + { + VerifyPosixFifo(fifo); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.V7.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.V7.cs new file mode 100644 index 0000000000000..e90785dfb45a8 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.V7.cs @@ -0,0 +1,26 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace System.Formats.Tar.Tests +{ + public abstract partial class TarTestsBase : FileCleanupTestBase + { + protected void SetRegularFile(V7TarEntry regularFile) => SetCommonRegularFile(regularFile, isV7RegularFile: true); + + protected void SetDirectory(V7TarEntry directory) => SetCommonDirectory(directory); + + protected void SetHardLink(V7TarEntry hardLink) => SetCommonHardLink(hardLink); + + protected void SetSymbolicLink(V7TarEntry symbolicLink) => SetCommonSymbolicLink(symbolicLink); + + protected void VerifyRegularFile(V7TarEntry regularFile, bool isWritable) => VerifyCommonRegularFile(regularFile, isWritable, isV7RegularFile: true); + + protected void VerifyDirectory(V7TarEntry directory) => VerifyCommonDirectory(directory); + + protected void VerifyHardLink(V7TarEntry hardLink) => VerifyCommonHardLink(hardLink); + + protected void VerifySymbolicLink(V7TarEntry symbolicLink) => VerifyCommonSymbolicLink(symbolicLink); + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs new file mode 100644 index 0000000000000..3df9bf89cc1fe --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs @@ -0,0 +1,285 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public abstract partial class TarTestsBase : FileCleanupTestBase + { + protected const string InitialEntryName = "InitialEntryName.ext"; + protected readonly string ModifiedEntryName = "ModifiedEntryName.ext"; + + // Default values are what a TarEntry created with its constructor will set + protected const TarFileMode DefaultMode = TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.GroupRead | TarFileMode.OtherRead; // 644 in octal, internally used as default + protected const TarFileMode DefaultWindowsMode = TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.UserExecute | TarFileMode.GroupRead | TarFileMode.GroupWrite | TarFileMode.GroupExecute | TarFileMode.OtherRead | TarFileMode.OtherWrite | TarFileMode.UserExecute; // Creating archives in Windows always sets the mode to 777 + protected const int DefaultGid = 0; + protected const int DefaultUid = 0; + protected const int DefaultDeviceMajor = 0; + protected const int DefaultDeviceMinor = 0; + protected readonly string DefaultLinkName = string.Empty; + protected readonly string DefaultGName = string.Empty; + protected readonly string DefaultUName = string.Empty; + + // Values to which properties will be modified in tests + protected const int TestGid = 1234; + protected const int TestUid = 5678; + protected const int TestBlockDeviceMajor = 61; + protected const int TestBlockDeviceMinor = 65; + protected const int TestCharacterDeviceMajor = 51; + protected const int TestCharacterDeviceMinor = 42; + protected readonly DateTimeOffset TestModificationTime = new DateTimeOffset(2003, 3, 3, 3, 33, 33, TimeSpan.Zero); + protected readonly DateTimeOffset TestAccessTime = new DateTimeOffset(2022, 2, 2, 2, 22, 22, TimeSpan.Zero); + protected readonly DateTimeOffset TestChangeTime = new DateTimeOffset(2011, 11, 11, 11, 11, 11, TimeSpan.Zero); + protected readonly string TestLinkName = "TestLinkName"; + protected const TarFileMode TestMode = TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.GroupRead | TarFileMode.GroupWrite | TarFileMode.OtherRead | TarFileMode.OtherWrite; + protected readonly DateTimeOffset TestTimestamp = DateTimeOffset.Now; + protected const string TestGName = "group"; + protected const string TestUName = "user"; + + // The metadata of the entries inside the asset archives are all set to these values + protected const int AssetGid = 3579; + protected const int AssetUid = 7913; + protected const string AssetBlockDeviceFileName = "blockdev"; + protected const string AssetCharacterDeviceFileName = "chardev"; + protected const int AssetBlockDeviceMajor = 71; + protected const int AssetBlockDeviceMinor = 53; + protected const int AssetCharacterDeviceMajor = 49; + protected const int AssetCharacterDeviceMinor = 86; + protected const TarFileMode AssetMode = TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.UserExecute | TarFileMode.GroupRead | TarFileMode.OtherRead; + protected const TarFileMode AssetSpecialFileMode = TarFileMode.UserRead | TarFileMode.UserWrite | TarFileMode.GroupRead | TarFileMode.OtherRead; + protected const TarFileMode AssetSymbolicLinkMode = TarFileMode.OtherExecute | TarFileMode.OtherWrite | TarFileMode.OtherRead | TarFileMode.GroupExecute | TarFileMode.GroupWrite | TarFileMode.GroupRead | TarFileMode.UserExecute | TarFileMode.UserWrite | TarFileMode.UserRead; + protected const string AssetGName = "devdiv"; + protected const string AssetUName = "dotnet"; + protected const string AssetPaxGeaKey = "globexthdr.MyGlobalExtendedAttribute"; + protected const string AssetPaxGeaValue = "hello"; + + protected enum CompressionMethod + { + // Archiving only, no compression + Uncompressed, + // Archive compressed with Gzip + GZip, + } + + // Names match the testcase foldername + public enum TestTarFormat + { + // V7 formatted files. + v7, + // UStar formatted files. + ustar, + // PAX formatted files. + pax, + // PAX formatted files that include a single Global Extended Attributes entry in the first position. + pax_gea, + // Old GNU formatted files. Format used by GNU tar of versions prior to 1.12. + oldgnu, + // GNU formatted files. Format used by GNU tar versions up to 1.13.25. + gnu + } + + protected static string GetTestCaseUnarchivedFolderPath(string testCaseName) => + Path.Join(Directory.GetCurrentDirectory(), "unarchived", testCaseName); + + protected static string GetTarFilePath(CompressionMethod compressionMethod, TestTarFormat format, string testCaseName) + { + (string compressionMethodFolder, string fileExtension) = compressionMethod switch + { + CompressionMethod.Uncompressed => ("tar", ".tar"), + CompressionMethod.GZip => ("targz", ".tar.gz"), + _ => throw new InvalidOperationException($"Unexpected compression method: {compressionMethod}"), + }; + + return Path.Join(Directory.GetCurrentDirectory(), compressionMethodFolder, format.ToString(), testCaseName + fileExtension); + } + + // MemoryStream containing the copied contents of the specified file. Meant for reading and writing. + protected static MemoryStream GetTarMemoryStream(CompressionMethod compressionMethod, TestTarFormat format, string testCaseName) + { + string path = GetTarFilePath(compressionMethod, format, testCaseName); + FileStreamOptions options = new() + { + Access = FileAccess.Read, + Mode = FileMode.Open, + Share = FileShare.Read + + }; + MemoryStream ms = new(); + using (FileStream fs = new FileStream(path, options)) + { + fs.CopyTo(ms); + } + ms.Seek(0, SeekOrigin.Begin); + return ms; + } + + protected void SetCommonRegularFile(TarEntry regularFile, bool isV7RegularFile = false) + { + Assert.NotNull(regularFile); + TarEntryType entryType = isV7RegularFile ? TarEntryType.V7RegularFile : TarEntryType.RegularFile; + + Assert.Equal(entryType, regularFile.EntryType); + SetCommonProperties(regularFile); + + // Data stream + Assert.Null(regularFile.DataStream); + } + + protected void SetCommonDirectory(TarEntry directory) + { + Assert.NotNull(directory); + Assert.Equal(TarEntryType.Directory, directory.EntryType); + SetCommonProperties(directory); + } + + protected void SetCommonHardLink(TarEntry hardLink) + { + Assert.NotNull(hardLink); + Assert.Equal(TarEntryType.HardLink, hardLink.EntryType); + SetCommonProperties(hardLink); + + // LinkName + Assert.Equal(DefaultLinkName, hardLink.LinkName); + hardLink.LinkName = TestLinkName; + } + + protected void SetCommonSymbolicLink(TarEntry symbolicLink) + { + Assert.NotNull(symbolicLink); + Assert.Equal(TarEntryType.SymbolicLink, symbolicLink.EntryType); + SetCommonProperties(symbolicLink); + + // LinkName + Assert.Equal(DefaultLinkName, symbolicLink.LinkName); + symbolicLink.LinkName = TestLinkName; + } + + protected void SetCommonProperties(TarEntry entry) + { + // Length (Data is checked outside this method) + Assert.Equal(0, entry.Length); + + // Checksum + Assert.Equal(0, entry.Checksum); + + // Gid + Assert.Equal(DefaultGid, entry.Gid); + entry.Gid = TestGid; + + // Mode + Assert.Equal(DefaultMode, entry.Mode); + entry.Mode = TestMode; + + // MTime: Verify the default value was approximately "now" by default + DateTimeOffset approxNow = DateTimeOffset.Now.Subtract(TimeSpan.FromHours(6)); + Assert.True(entry.ModificationTime > approxNow); + + Assert.Throws(() => entry.ModificationTime = DateTime.MinValue); // Minimum allowed is UnixEpoch, not MinValue + entry.ModificationTime = TestModificationTime; + + // Name + Assert.Equal(InitialEntryName, entry.Name); + entry.Name = ModifiedEntryName; + + // Uid + Assert.Equal(DefaultUid, entry.Uid); + entry.Uid = TestUid; + } + + protected void VerifyCommonRegularFile(TarEntry regularFile, bool isFromWriter, bool isV7RegularFile = false) + { + Assert.NotNull(regularFile); + TarEntryType entryType = isV7RegularFile ? TarEntryType.V7RegularFile : TarEntryType.RegularFile; + Assert.Equal(entryType, regularFile.EntryType); + VerifyCommonProperties(regularFile); + VerifyUnsupportedLinkProperty(regularFile); + VerifyDataStream(regularFile, isFromWriter); + } + + protected void VerifyCommonDirectory(TarEntry directory) + { + Assert.NotNull(directory); + Assert.Equal(TarEntryType.Directory, directory.EntryType); + VerifyCommonProperties(directory); + VerifyUnsupportedLinkProperty(directory); + VerifyUnsupportedDataStream(directory); + } + + protected void VerifyCommonHardLink(TarEntry hardLink) + { + Assert.NotNull(hardLink); + Assert.Equal(TarEntryType.HardLink, hardLink.EntryType); + VerifyCommonProperties(hardLink); + VerifyUnsupportedDataStream(hardLink); + Assert.Equal(TestLinkName, hardLink.LinkName); + } + + protected void VerifyCommonSymbolicLink(TarEntry symbolicLink) + { + Assert.NotNull(symbolicLink); + Assert.Equal(TarEntryType.SymbolicLink, symbolicLink.EntryType); + VerifyCommonProperties(symbolicLink); + VerifyUnsupportedDataStream(symbolicLink); + Assert.Equal(TestLinkName, symbolicLink.LinkName); + } + + protected void VerifyCommonProperties(TarEntry entry) + { + Assert.Equal(TestGid, entry.Gid); + Assert.Equal(TestMode, entry.Mode); + Assert.Equal(TestModificationTime, entry.ModificationTime); + Assert.Equal(ModifiedEntryName, entry.Name); + Assert.Equal(TestUid, entry.Uid); + } + + protected void VerifyUnsupportedLinkProperty(TarEntry entry) + { + Assert.Equal(DefaultLinkName, entry.LinkName); + Assert.Throws(() => entry.LinkName = "NotSupported"); + Assert.Equal(DefaultLinkName, entry.LinkName); + } + + protected void VerifyUnsupportedDataStream(TarEntry entry) + { + Assert.Null(entry.DataStream); + using (MemoryStream dataStream = new MemoryStream()) + { + Assert.Throws(() => entry.DataStream = dataStream); + } + } + + protected void VerifyDataStream(TarEntry entry, bool isFromWriter) + { + if (isFromWriter) + { + Assert.Null(entry.DataStream); + entry.DataStream = new MemoryStream(); + // Verify it is not modified or wrapped in any way + Assert.True(entry.DataStream.CanRead); + Assert.True(entry.DataStream.CanWrite); + + entry.DataStream.WriteByte(1); + Assert.Equal(1, entry.DataStream.Length); + Assert.Equal(1, entry.Length); + entry.DataStream.Dispose(); + Assert.Throws(() => entry.DataStream.WriteByte(1)); + + entry.DataStream = new MemoryStream(); + Assert.Equal(0, entry.DataStream.Length); + entry.DataStream.WriteByte(1); + Assert.Equal(1, entry.Length); + Assert.Equal(1, entry.DataStream.Length); + entry.DataStream.Seek(0, SeekOrigin.Begin); + } + else + { + // Reader should always set it + Assert.NotNull(entry.DataStream); + Assert.True(entry.DataStream.CanRead); + Assert.False(entry.DataStream.CanWrite); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs new file mode 100644 index 0000000000000..6b75ac5655d2d --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarWriter_Tests : TarTestsBase + { + [Fact] + public void Constructors_NullStream() + { + Assert.Throws(() => new TarWriter(archiveStream: null)); + Assert.Throws(() => new TarWriter(archiveStream: null, TarFormat.V7)); + } + + [Fact] + public void Constructors_LeaveOpen() + { + using MemoryStream archiveStream = new MemoryStream(); + + TarWriter writer1 = new TarWriter(archiveStream, leaveOpen: true); + writer1.Dispose(); + archiveStream.WriteByte(0); // Should succeed because stream was not closed + + TarWriter writer2 = new TarWriter(archiveStream, leaveOpen: false); + writer2.Dispose(); + Assert.Throws(() => archiveStream.WriteByte(0)); // Should fail because stream was closed + } + + [Fact] + public void Constructor_Format() + { + using MemoryStream archiveStream = new MemoryStream(); + + using TarWriter writerDefault = new TarWriter(archiveStream, leaveOpen: true); + Assert.Equal(TarFormat.Pax, writerDefault.Format); + + using TarWriter writerV7 = new TarWriter(archiveStream, TarFormat.V7, leaveOpen: true); + Assert.Equal(TarFormat.V7, writerV7.Format); + + using TarWriter writerUstar = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true); + Assert.Equal(TarFormat.Ustar, writerUstar.Format); + + using TarWriter writerPax = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true); + Assert.Equal(TarFormat.Pax, writerPax.Format); + + using TarWriter writerGnu = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true); + Assert.Equal(TarFormat.Gnu, writerGnu.Format); + + using TarWriter writerNullGeaDefaultPax = new TarWriter(archiveStream, leaveOpen: true, globalExtendedAttributes: null); + Assert.Equal(TarFormat.Pax, writerNullGeaDefaultPax.Format); + + using TarWriter writerValidGeaDefaultPax = new TarWriter(archiveStream, leaveOpen: true, globalExtendedAttributes: new Dictionary()); + Assert.Equal(TarFormat.Pax, writerValidGeaDefaultPax.Format); + + Assert.Throws(() => new TarWriter(archiveStream, TarFormat.Unknown)); + Assert.Throws(() => new TarWriter(archiveStream, (TarFormat)int.MinValue)); + Assert.Throws(() => new TarWriter(archiveStream, (TarFormat)int.MaxValue)); + } + + [Fact] + public void Constructors_UnwritableStream_Throws() + { + using MemoryStream archiveStream = new MemoryStream(); + using WrappedStream wrappedStream = new WrappedStream(archiveStream, canRead: true, canWrite: false, canSeek: false); + Assert.Throws(() => new TarWriter(wrappedStream)); + Assert.Throws(() => new TarWriter(wrappedStream, TarFormat.V7)); + } + + [Fact] + public void Constructor_NoEntryInsertion_WritesNothing() + { + using MemoryStream archiveStream = new MemoryStream(); + TarWriter writer = new TarWriter(archiveStream, leaveOpen: true); + writer.Dispose(); // No entries inserted, should write no empty records + Assert.Equal(0, archiveStream.Length); + } + + [Fact] + public void VerifyChecksumV7() + { + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarFormat.V7, leaveOpen: true)) + { + V7TarEntry entry = new V7TarEntry( + // '\0' = 0 + TarEntryType.V7RegularFile, + // 'a.b' = 97 + 46 + 98 = 241 + entryName: "a.b"); + + // '0000744\0' = 48 + 48 + 48 + 48 + 55 + 52 + 52 + 0 = 351 + entry.Mode = AssetMode; // octal 744 = u+rxw, g+r, o+r + + // '0017351\0' = 48 + 48 + 49 + 55 + 51 + 53 + 49 + 0 = 353 + entry.Uid = AssetUid; // decimal 7913, octal 17351 + + // '0006773\0' = 48 + 48 + 48 + 54 + 55 + 55 + 51 + 0 = 359 + entry.Gid = AssetGid; // decimal 3579, octal 6773 + + // '14164217674\0' = 49 + 52 + 49 + 54 + 52 + 50 + 49 + 55 + 54 + 55 + 52 + 0 = 571 + DateTimeOffset mtime = new DateTimeOffset(2022, 1, 2, 3, 45, 00, TimeSpan.Zero); // ToUnixTimeSeconds() = decimal 1641095100, octal 14164217674 + entry.ModificationTime = mtime; + + entry.DataStream = new MemoryStream(); + byte[] buffer = new byte[] { 72, 101, 108, 108, 111 }; + + // '0000000005\0' = 48 + 48 + 48 + 48 + 48 + 48 + 48 + 48 + 48 + 48 + 53 + 0 = 533 + entry.DataStream.Write(buffer); // Data length: decimal 5 + entry.DataStream.Seek(0, SeekOrigin.Begin); // Rewind to ensure it gets written from the beginning + + // Sum so far: 0 + 241 + 351 + 353 + 359 + 571 + 533 = decimal 2408 + // Add 8 spaces to the sum: 2408 + (8 x 32) = octal 5150, decimal 2664 (final) + // Checksum: '005150\0 ' + + writer.WriteEntry(entry); + + Assert.Equal(2664, entry.Checksum); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.Equal(2664, entry.Checksum); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Gnu.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Gnu.Tests.cs new file mode 100644 index 0000000000000..da5cc1117f124 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Gnu.Tests.cs @@ -0,0 +1,254 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + // Tests specific to Gnu format. + public class TarWriter_WriteEntry_Gnu_Tests : TarTestsBase + { + [Fact] + public void Write_V7RegularFileEntry_As_RegularFileEntry() + { + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, archiveFormat: TarFormat.Gnu, leaveOpen: true)) + { + V7TarEntry entry = new V7TarEntry(TarEntryType.V7RegularFile, InitialEntryName); + + // Should be written as RegularFile + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + GnuTarEntry entry = reader.GetNextEntry() as GnuTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Fact] + public void WriteRegularFile() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry regularFile = new GnuTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + writer.WriteEntry(regularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry regularFile = reader.GetNextEntry() as GnuTarEntry; + VerifyRegularFile(regularFile, isWritable: false); + } + } + + [Fact] + public void WriteHardLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry hardLink = new GnuTarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + writer.WriteEntry(hardLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry hardLink = reader.GetNextEntry() as GnuTarEntry; + VerifyHardLink(hardLink); + } + } + + [Fact] + public void WriteSymbolicLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry symbolicLink = new GnuTarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + writer.WriteEntry(symbolicLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry symbolicLink = reader.GetNextEntry() as GnuTarEntry; + VerifySymbolicLink(symbolicLink); + } + } + + [Fact] + public void WriteDirectory() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry directory = new GnuTarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + writer.WriteEntry(directory); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry directory = reader.GetNextEntry() as GnuTarEntry; + VerifyDirectory(directory); + } + } + + [Fact] + public void WriteCharacterDevice() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry charDevice = new GnuTarEntry(TarEntryType.CharacterDevice, InitialEntryName); + SetCharacterDevice(charDevice); + VerifyCharacterDevice(charDevice); + writer.WriteEntry(charDevice); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry charDevice = reader.GetNextEntry() as GnuTarEntry; + VerifyCharacterDevice(charDevice); + } + } + + [Fact] + public void WriteBlockDevice() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry blockDevice = new GnuTarEntry(TarEntryType.BlockDevice, InitialEntryName); + SetBlockDevice(blockDevice); + VerifyBlockDevice(blockDevice); + writer.WriteEntry(blockDevice); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry blockDevice = reader.GetNextEntry() as GnuTarEntry; + VerifyBlockDevice(blockDevice); + } + } + + [Fact] + public void WriteFifo() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry fifo = new GnuTarEntry(TarEntryType.Fifo, InitialEntryName); + SetFifo(fifo); + VerifyFifo(fifo); + writer.WriteEntry(fifo); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry fifo = reader.GetNextEntry() as GnuTarEntry; + VerifyFifo(fifo); + } + } + + [Theory] + [InlineData(TarEntryType.RegularFile)] + [InlineData(TarEntryType.Directory)] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void Write_Long_Name(TarEntryType entryType) + { + // Name field in header only fits 100 bytes + string longName = new string('a', 101); + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry entry = new GnuTarEntry(entryType, longName); + writer.WriteEntry(entry); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry entry = reader.GetNextEntry() as GnuTarEntry; + Assert.Equal(entryType, entry.EntryType); + Assert.Equal(longName, entry.Name); + } + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void Write_LongLinKName(TarEntryType entryType) + { + // LinkName field in header only fits 100 bytes + string longLinkName = new string('a', 101); + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry entry = new GnuTarEntry(entryType, "file.txt"); + entry.LinkName = longLinkName; + writer.WriteEntry(entry); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry entry = reader.GetNextEntry() as GnuTarEntry; + Assert.Equal(entryType, entry.EntryType); + Assert.Equal("file.txt", entry.Name); + Assert.Equal(longLinkName, entry.LinkName); + } + } + + [Theory] + [InlineData(TarEntryType.SymbolicLink)] + [InlineData(TarEntryType.HardLink)] + public void Write_LongName_And_LongLinKName(TarEntryType entryType) + { + // Both the Name and LinkName fields in header only fit 100 bytes + string longName = new string('a', 101); + string longLinkName = new string('a', 101); + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Gnu, leaveOpen: true)) + { + GnuTarEntry entry = new GnuTarEntry(entryType, longName); + entry.LinkName = longLinkName; + writer.WriteEntry(entry); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + GnuTarEntry entry = reader.GetNextEntry() as GnuTarEntry; + Assert.Equal(entryType, entry.EntryType); + Assert.Equal(longName, entry.Name); + Assert.Equal(longLinkName, entry.LinkName); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs new file mode 100644 index 0000000000000..a746c609d23db --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs @@ -0,0 +1,293 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + // Tests specific to PAX format. + public class TarWriter_WriteEntry_Pax_Tests : TarTestsBase + { + [Fact] + public void Write_V7RegularFileEntry_As_RegularFileEntry() + { + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, archiveFormat: TarFormat.Pax, leaveOpen: true)) + { + V7TarEntry entry = new V7TarEntry(TarEntryType.V7RegularFile, InitialEntryName); + + // Should be written as RegularFile + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + PaxTarEntry entry = reader.GetNextEntry() as PaxTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Fact] + public void WriteRegularFile() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry regularFile = new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + writer.WriteEntry(regularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry regularFile = reader.GetNextEntry() as PaxTarEntry; + VerifyRegularFile(regularFile, isWritable: false); + } + } + + [Fact] + public void WriteHardLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry hardLink = new PaxTarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + writer.WriteEntry(hardLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry hardLink = reader.GetNextEntry() as PaxTarEntry; + VerifyHardLink(hardLink); + } + } + + [Fact] + public void WriteSymbolicLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry symbolicLink = new PaxTarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + writer.WriteEntry(symbolicLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry symbolicLink = reader.GetNextEntry() as PaxTarEntry; + VerifySymbolicLink(symbolicLink); + } + } + + [Fact] + public void WriteDirectory() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry directory = new PaxTarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + writer.WriteEntry(directory); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry directory = reader.GetNextEntry() as PaxTarEntry; + VerifyDirectory(directory); + } + } + + [Fact] + public void WriteCharacterDevice() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry charDevice = new PaxTarEntry(TarEntryType.CharacterDevice, InitialEntryName); + SetCharacterDevice(charDevice); + VerifyCharacterDevice(charDevice); + writer.WriteEntry(charDevice); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry charDevice = reader.GetNextEntry() as PaxTarEntry; + VerifyCharacterDevice(charDevice); + } + } + + [Fact] + public void WriteBlockDevice() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry blockDevice = new PaxTarEntry(TarEntryType.BlockDevice, InitialEntryName); + SetBlockDevice(blockDevice); + VerifyBlockDevice(blockDevice); + writer.WriteEntry(blockDevice); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry blockDevice = reader.GetNextEntry() as PaxTarEntry; + VerifyBlockDevice(blockDevice); + } + } + + [Fact] + public void WriteFifo() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry fifo = new PaxTarEntry(TarEntryType.Fifo, InitialEntryName); + SetFifo(fifo); + VerifyFifo(fifo); + writer.WriteEntry(fifo); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry fifo = reader.GetNextEntry() as PaxTarEntry; + VerifyFifo(fifo); + } + } + + [Fact] + public void WritePaxAttributes_CustomAttribute() + { + string expectedKey = "MyExtendedAttributeKey"; + string expectedValue = "MyExtendedAttributeValue"; + + Dictionary extendedAttributes = new(); + extendedAttributes.Add(expectedKey, expectedValue); + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry regularFile = new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName, extendedAttributes); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + writer.WriteEntry(regularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry regularFile = reader.GetNextEntry() as PaxTarEntry; + VerifyRegularFile(regularFile, isWritable: false); + + Assert.NotNull(regularFile.ExtendedAttributes); + + // path, mtime, atime and ctime are always collected by default + Assert.True(regularFile.ExtendedAttributes.Count >= 5); + + Assert.Contains("path", regularFile.ExtendedAttributes); + Assert.Contains("mtime", regularFile.ExtendedAttributes); + Assert.Contains("atime", regularFile.ExtendedAttributes); + Assert.Contains("ctime", regularFile.ExtendedAttributes); + + Assert.Contains(expectedKey, regularFile.ExtendedAttributes); + Assert.Equal(expectedValue, regularFile.ExtendedAttributes[expectedKey]); + } + } + + [Fact] + public void WritePaxAttributes_Timestamps() + { + Dictionary extendedAttributes = new(); + extendedAttributes.Add("atime", ConvertDateTimeOffsetToDouble(TestAccessTime).ToString("F6", CultureInfo.InvariantCulture)); + extendedAttributes.Add("ctime", ConvertDateTimeOffsetToDouble(TestChangeTime).ToString("F6", CultureInfo.InvariantCulture)); + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry regularFile = new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName, extendedAttributes); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + writer.WriteEntry(regularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry regularFile = reader.GetNextEntry() as PaxTarEntry; + VerifyRegularFile(regularFile, isWritable: false); + + Assert.NotNull(regularFile.ExtendedAttributes); + Assert.True(regularFile.ExtendedAttributes.Count >= 4); + + Assert.Contains("path", regularFile.ExtendedAttributes); + VerifyExtendedAttributeTimestamp(regularFile, "mtime", TestModificationTime); + VerifyExtendedAttributeTimestamp(regularFile, "atime", TestAccessTime); + VerifyExtendedAttributeTimestamp(regularFile, "ctime", TestChangeTime); + } + } + + [Fact] + public void WritePaxAttributes_LongGroupName_LongUserName() + { + string userName = "IAmAUserNameWhoseLengthIsWayBeyondTheThirtyTwoByteLimit"; + string groupName = "IAmAGroupNameWhoseLengthIsWayBeyondTheThirtyTwoByteLimit"; + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Pax, leaveOpen: true)) + { + PaxTarEntry regularFile = new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + regularFile.UserName = userName; + regularFile.GroupName = groupName; + writer.WriteEntry(regularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + PaxTarEntry regularFile = reader.GetNextEntry() as PaxTarEntry; + VerifyRegularFile(regularFile, isWritable: false); + + Assert.NotNull(regularFile.ExtendedAttributes); + + // path, mtime, atime and ctime are always collected by default + Assert.True(regularFile.ExtendedAttributes.Count >= 6); + + Assert.Contains("path", regularFile.ExtendedAttributes); + Assert.Contains("mtime", regularFile.ExtendedAttributes); + Assert.Contains("atime", regularFile.ExtendedAttributes); + Assert.Contains("ctime", regularFile.ExtendedAttributes); + + Assert.Contains("uname", regularFile.ExtendedAttributes); + Assert.Equal(userName, regularFile.ExtendedAttributes["uname"]); + + Assert.Contains("gname", regularFile.ExtendedAttributes); + Assert.Equal(groupName, regularFile.ExtendedAttributes["gname"]); + + // They should also get exposed via the regular properties + Assert.Equal(groupName, regularFile.GroupName); + Assert.Equal(userName, regularFile.UserName); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Ustar.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Ustar.Tests.cs new file mode 100644 index 0000000000000..a130ae87edc3c --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Ustar.Tests.cs @@ -0,0 +1,175 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + // Tests specific to Ustar format. + public class TarWriter_WriteEntry_Ustar_Tests : TarTestsBase + { + [Fact] + public void Write_V7RegularFileEntry_As_RegularFileEntry() + { + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, archiveFormat: TarFormat.Ustar, leaveOpen: true)) + { + V7TarEntry entry = new V7TarEntry(TarEntryType.V7RegularFile, InitialEntryName); + + // Should be written as RegularFile + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + UstarTarEntry entry = reader.GetNextEntry() as UstarTarEntry; + Assert.NotNull(entry); + Assert.Equal(TarEntryType.RegularFile, entry.EntryType); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Fact] + public void WriteRegularFile() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry regularFile = new UstarTarEntry(TarEntryType.RegularFile, InitialEntryName); + SetRegularFile(regularFile); + VerifyRegularFile(regularFile, isWritable: true); + writer.WriteEntry(regularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry regularFile = reader.GetNextEntry() as UstarTarEntry; + VerifyRegularFile(regularFile, isWritable: false); + } + } + + [Fact] + public void WriteHardLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry hardLink = new UstarTarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + writer.WriteEntry(hardLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry hardLink = reader.GetNextEntry() as UstarTarEntry; + VerifyHardLink(hardLink); + } + } + + [Fact] + public void WriteSymbolicLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry symbolicLink = new UstarTarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + writer.WriteEntry(symbolicLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry symbolicLink = reader.GetNextEntry() as UstarTarEntry; + VerifySymbolicLink(symbolicLink); + } + } + + [Fact] + public void WriteDirectory() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry directory = new UstarTarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + writer.WriteEntry(directory); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry directory = reader.GetNextEntry() as UstarTarEntry; + VerifyDirectory(directory); + } + } + + [Fact] + public void WriteCharacterDevice() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry charDevice = new UstarTarEntry(TarEntryType.CharacterDevice, InitialEntryName); + SetCharacterDevice(charDevice); + VerifyCharacterDevice(charDevice); + writer.WriteEntry(charDevice); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry charDevice = reader.GetNextEntry() as UstarTarEntry; + VerifyCharacterDevice(charDevice); + } + } + + [Fact] + public void WriteBlockDevice() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry blockDevice = new UstarTarEntry(TarEntryType.BlockDevice, InitialEntryName); + SetBlockDevice(blockDevice); + VerifyBlockDevice(blockDevice); + writer.WriteEntry(blockDevice); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry blockDevice = reader.GetNextEntry() as UstarTarEntry; + VerifyBlockDevice(blockDevice); + } + } + + [Fact] + public void WriteFifo() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.Ustar, leaveOpen: true)) + { + UstarTarEntry fifo = new UstarTarEntry(TarEntryType.Fifo, InitialEntryName); + SetFifo(fifo); + VerifyFifo(fifo); + writer.WriteEntry(fifo); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + UstarTarEntry fifo = reader.GetNextEntry() as UstarTarEntry; + VerifyFifo(fifo); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.V7.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.V7.Tests.cs new file mode 100644 index 0000000000000..7b74d769fa13e --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.V7.Tests.cs @@ -0,0 +1,151 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + // Tests specific to V7 format. + public class TarWriter_WriteEntry_V7_Tests : TarTestsBase + { + [Fact] + public void ThrowIf_WriteEntry_UnsupportedFile() + { + // Verify that entry types that can be manually constructed in other types, cannot be inserted in a v7 writer + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, archiveFormat: TarFormat.V7, leaveOpen: true)) + { + // Entry types supported in ustar but not in v7 + Assert.Throws(() => writer.WriteEntry(new UstarTarEntry(TarEntryType.BlockDevice, InitialEntryName))); + Assert.Throws(() => writer.WriteEntry(new UstarTarEntry(TarEntryType.CharacterDevice, InitialEntryName))); + Assert.Throws(() => writer.WriteEntry(new UstarTarEntry(TarEntryType.Fifo, InitialEntryName))); + + // Entry types supported in pax but not in v7 + Assert.Throws(() => writer.WriteEntry(new PaxTarEntry(TarEntryType.BlockDevice, InitialEntryName))); + Assert.Throws(() => writer.WriteEntry(new PaxTarEntry(TarEntryType.CharacterDevice, InitialEntryName))); + Assert.Throws(() => writer.WriteEntry(new PaxTarEntry(TarEntryType.Fifo, InitialEntryName))); + + // Entry types supported in gnu but not in v7 + Assert.Throws(() => writer.WriteEntry(new GnuTarEntry(TarEntryType.BlockDevice, InitialEntryName))); + Assert.Throws(() => writer.WriteEntry(new GnuTarEntry(TarEntryType.CharacterDevice, InitialEntryName))); + Assert.Throws(() => writer.WriteEntry(new GnuTarEntry(TarEntryType.Fifo, InitialEntryName))); + } + // Verify nothing was written, not even the empty records + Assert.Equal(0, archiveStream.Length); + } + + [Theory] + [InlineData(TarFormat.Ustar)] + [InlineData(TarFormat.Pax)] + [InlineData(TarFormat.Gnu)] + public void Write_RegularFileEntry_As_V7RegularFileEntry(TarFormat entryFormat) + { + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, archiveFormat: TarFormat.V7, leaveOpen: true)) + { + TarEntry entry = entryFormat switch + { + TarFormat.Ustar => new UstarTarEntry(TarEntryType.RegularFile, InitialEntryName), + TarFormat.Pax => new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName), + TarFormat.Gnu => new GnuTarEntry(TarEntryType.RegularFile, InitialEntryName), + _ => throw new FormatException() + }; + + // Should be written as V7RegularFile + writer.WriteEntry(entry); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.True(entry is V7TarEntry); + Assert.Equal(TarEntryType.V7RegularFile, entry.EntryType); + + Assert.Null(reader.GetNextEntry()); + } + } + + + [Fact] + public void WriteRegularFile() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.V7, leaveOpen: true)) + { + V7TarEntry oldRegularFile = new V7TarEntry(TarEntryType.V7RegularFile, InitialEntryName); + SetRegularFile(oldRegularFile); + VerifyRegularFile(oldRegularFile, isWritable: true); + writer.WriteEntry(oldRegularFile); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + V7TarEntry oldRegularFile = reader.GetNextEntry() as V7TarEntry; + VerifyRegularFile(oldRegularFile, isWritable: false); + } + } + + [Fact] + public void WriteHardLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.V7, leaveOpen: true)) + { + V7TarEntry hardLink = new V7TarEntry(TarEntryType.HardLink, InitialEntryName); + SetHardLink(hardLink); + VerifyHardLink(hardLink); + writer.WriteEntry(hardLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + V7TarEntry hardLink = reader.GetNextEntry() as V7TarEntry; + VerifyHardLink(hardLink); + } + } + + [Fact] + public void WriteSymbolicLink() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.V7, leaveOpen: true)) + { + V7TarEntry symbolicLink = new V7TarEntry(TarEntryType.SymbolicLink, InitialEntryName); + SetSymbolicLink(symbolicLink); + VerifySymbolicLink(symbolicLink); + writer.WriteEntry(symbolicLink); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + V7TarEntry symbolicLink = reader.GetNextEntry() as V7TarEntry; + VerifySymbolicLink(symbolicLink); + } + } + + [Fact] + public void WriteDirectory() + { + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, TarFormat.V7, leaveOpen: true)) + { + V7TarEntry directory = new V7TarEntry(TarEntryType.Directory, InitialEntryName); + SetDirectory(directory); + VerifyDirectory(directory); + writer.WriteEntry(directory); + } + + archiveStream.Position = 0; + using (TarReader reader = new TarReader(archiveStream)) + { + V7TarEntry directory = reader.GetNextEntry() as V7TarEntry; + VerifyDirectory(directory); + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs new file mode 100644 index 0000000000000..3dba03a5b29f6 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.DotNet.RemoteExecutor; +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public partial class TarWriter_WriteEntry_File_Tests : TarTestsBase + { + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(TarFormat.Ustar)] + [InlineData(TarFormat.Pax)] + [InlineData(TarFormat.Gnu)] + public void Add_Fifo(TarFormat format) + { + RemoteExecutor.Invoke((string strFormat) => + { + TarFormat expectedFormat = Enum.Parse(strFormat); + + using TempDirectory root = new TempDirectory(); + string fifoName = "fifofile"; + string fifoPath = Path.Join(root.Path, fifoName); + + Interop.CheckIo(Interop.Sys.MkFifo(fifoPath, (int)DefaultMode)); + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, expectedFormat, leaveOpen: true)) + { + writer.WriteEntry(fileName: fifoPath, entryName: fifoName); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + Assert.Equal(TarFormat.Unknown, reader.Format); + PosixTarEntry entry = reader.GetNextEntry() as PosixTarEntry; + Assert.Equal(expectedFormat, reader.Format); + + Assert.NotNull(entry); + Assert.Equal(fifoName, entry.Name); + Assert.Equal(DefaultLinkName, entry.LinkName); + Assert.Equal(TarEntryType.Fifo, entry.EntryType); + Assert.Null(entry.DataStream); + + VerifyPlatformSpecificMetadata(fifoPath, entry); + + Assert.Null(reader.GetNextEntry()); + } + + }, format.ToString(), new RemoteInvokeOptions { RunAsSudo = true }).Dispose(); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(TarFormat.Ustar)] + [InlineData(TarFormat.Pax)] + [InlineData(TarFormat.Gnu)] + public void Add_BlockDevice(TarFormat format) + { + RemoteExecutor.Invoke((string strFormat) => + { + TarFormat expectedFormat = Enum.Parse(strFormat); + + using TempDirectory root = new TempDirectory(); + string blockDevicePath = Path.Join(root.Path, AssetBlockDeviceFileName); + + // Creating device files needs elevation + Interop.CheckIo(Interop.Sys.CreateBlockDevice(blockDevicePath, (int)DefaultMode, TestBlockDeviceMajor, TestBlockDeviceMinor)); + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, expectedFormat, leaveOpen: true)) + { + writer.WriteEntry(fileName: blockDevicePath, entryName: AssetBlockDeviceFileName); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + Assert.Equal(TarFormat.Unknown, reader.Format); + PosixTarEntry entry = reader.GetNextEntry() as PosixTarEntry; + Assert.Equal(expectedFormat, reader.Format); + + Assert.NotNull(entry); + Assert.Equal(AssetBlockDeviceFileName, entry.Name); + Assert.Equal(DefaultLinkName, entry.LinkName); + Assert.Equal(TarEntryType.BlockDevice, entry.EntryType); + Assert.Null(entry.DataStream); + + VerifyPlatformSpecificMetadata(blockDevicePath, entry); + + // TODO: Fix how these values are collected, the numbers don't match even though https://github.com/dotnet/runtime/issues/68230 + // they come from stat's dev and from the major/minor syscalls + // Assert.Equal(TestBlockDeviceMajor, entry.DeviceMajor); + // Assert.Equal(TestBlockDeviceMinor, entry.DeviceMinor); + + Assert.Null(reader.GetNextEntry()); + } + + }, format.ToString(), new RemoteInvokeOptions { RunAsSudo = true }).Dispose(); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(TarFormat.Ustar)] + [InlineData(TarFormat.Pax)] + [InlineData(TarFormat.Gnu)] + public void Add_CharacterDevice(TarFormat format) + { + RemoteExecutor.Invoke((string strFormat) => + { + TarFormat expectedFormat = Enum.Parse(strFormat); + using TempDirectory root = new TempDirectory(); + string characterDevicePath = Path.Join(root.Path, AssetCharacterDeviceFileName); + + // Creating device files needs elevation + Interop.CheckIo(Interop.Sys.CreateCharacterDevice(characterDevicePath, (int)DefaultMode, TestCharacterDeviceMajor, TestCharacterDeviceMinor)); + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, expectedFormat, leaveOpen: true)) + { + writer.WriteEntry(fileName: characterDevicePath, entryName: AssetCharacterDeviceFileName); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + Assert.Equal(TarFormat.Unknown, reader.Format); + PosixTarEntry entry = reader.GetNextEntry() as PosixTarEntry; + Assert.Equal(expectedFormat, reader.Format); + + Assert.NotNull(entry); + Assert.Equal(AssetCharacterDeviceFileName, entry.Name); + Assert.Equal(DefaultLinkName, entry.LinkName); + Assert.Equal(TarEntryType.CharacterDevice, entry.EntryType); + Assert.Null(entry.DataStream); + + VerifyPlatformSpecificMetadata(characterDevicePath, entry); + + // TODO: Fix how these values are collected, the numbers don't match even though https://github.com/dotnet/runtime/issues/68230 + // they come from stat's dev and from the major/minor syscalls + // Assert.Equal(TestCharacterDeviceMajor, entry.DeviceMajor); + // Assert.Equal(TestCharacterDeviceMinor, entry.DeviceMinor); + + Assert.Null(reader.GetNextEntry()); + } + + }, format.ToString(), new RemoteInvokeOptions { RunAsSudo = true }).Dispose(); + } + + partial void VerifyPlatformSpecificMetadata(string filePath, TarEntry entry) + { + Interop.Sys.FileStatus status = default; + status.Mode = default; + status.Dev = default; + Interop.CheckIo(Interop.Sys.LStat(filePath, out status)); + + Assert.Equal((int)status.Uid, entry.Uid); + Assert.Equal((int)status.Gid, entry.Gid); + + if (entry is PosixTarEntry posix) + { + Assert.Equal(DefaultGName, posix.GroupName); + Assert.Equal(DefaultUName, posix.UserName); + + if (entry.EntryType is not TarEntryType.BlockDevice and not TarEntryType.CharacterDevice) + { + Assert.Equal(DefaultDeviceMajor, posix.DeviceMajor); + Assert.Equal(DefaultDeviceMinor, posix.DeviceMinor); + } + } + + if (entry.EntryType is not TarEntryType.Directory) + { + TarFileMode expectedMode = (TarFileMode)(status.Mode & 4095); // First 12 bits + DateTimeOffset expectedMTime = DateTimeOffset.FromUnixTimeSeconds(status.MTime); + DateTimeOffset expectedATime = DateTimeOffset.FromUnixTimeSeconds(status.ATime); + DateTimeOffset expectedCTime = DateTimeOffset.FromUnixTimeSeconds(status.CTime); + + Assert.Equal(expectedMode, entry.Mode); + Assert.Equal(expectedMTime, entry.ModificationTime); + + if (entry is PaxTarEntry pax) + { + Assert.NotNull(pax.ExtendedAttributes); + Assert.True(pax.ExtendedAttributes.Count >= 4); + Assert.Contains("path", pax.ExtendedAttributes); + VerifyExtendedAttributeTimestamp(pax, "mtime"); + VerifyExtendedAttributeTimestamp(pax, "atime"); + VerifyExtendedAttributeTimestamp(pax, "ctime"); + } + else if (entry is GnuTarEntry gnu) + { + Assert.Equal(expectedATime, gnu.AccessTime); + Assert.Equal(expectedCTime, gnu.ChangeTime); + } + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Windows.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Windows.cs new file mode 100644 index 0000000000000..7108affb7d27a --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Windows.cs @@ -0,0 +1,79 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public partial class TarWriter_WriteEntry_File_Tests : TarTestsBase + { + partial void VerifyPlatformSpecificMetadata(string filePath, TarEntry entry) + { + FileSystemInfo info; + if (entry.EntryType == TarEntryType.Directory) + { + info = new DirectoryInfo(filePath); + } + else + { + info = new FileInfo(filePath); + } + + VerifyTimestamp(info.LastWriteTimeUtc, entry.ModificationTime); + + // Archives created in Windows always set mode to 777 + Assert.Equal(DefaultWindowsMode, entry.Mode); + + Assert.Equal(DefaultUid, entry.Uid); + Assert.Equal(DefaultGid, entry.Gid); + + if (entry is PosixTarEntry posix) + { + Assert.Equal(DefaultGName, posix.GroupName); + Assert.Equal(DefaultUName, posix.UserName); + + Assert.Equal(DefaultDeviceMajor, posix.DeviceMajor); + Assert.Equal(DefaultDeviceMinor, posix.DeviceMinor); + + if (entry is PaxTarEntry pax) + { + Assert.True(pax.ExtendedAttributes.Count >= 4); + Assert.Contains("path", pax.ExtendedAttributes); + Assert.Contains("mtime", pax.ExtendedAttributes); + Assert.Contains("atime", pax.ExtendedAttributes); + Assert.Contains("ctime", pax.ExtendedAttributes); + + Assert.True(double.TryParse(pax.ExtendedAttributes["mtime"], NumberStyles.Any, CultureInfo.InvariantCulture, out double doubleMTime)); + DateTimeOffset actualMTime = ConvertDoubleToDateTimeOffset(doubleMTime); + VerifyTimestamp(info.LastAccessTimeUtc, actualMTime); + + Assert.True(double.TryParse(pax.ExtendedAttributes["atime"], NumberStyles.Any, CultureInfo.InvariantCulture, out double doubleATime)); + DateTimeOffset actualATime = ConvertDoubleToDateTimeOffset(doubleATime); + VerifyTimestamp(info.LastAccessTimeUtc, actualATime); + + Assert.True(double.TryParse(pax.ExtendedAttributes["ctime"], NumberStyles.Any, CultureInfo.InvariantCulture, out double doubleCTime)); + DateTimeOffset actualCTime = ConvertDoubleToDateTimeOffset(doubleCTime); + VerifyTimestamp(info.LastAccessTimeUtc, actualCTime); + } + + if (entry is GnuTarEntry gnu) + { + VerifyTimestamp(info.LastAccessTimeUtc, gnu.AccessTime); + VerifyTimestamp(info.CreationTimeUtc, gnu.ChangeTime); + } + } + } + + private void VerifyTimestamp(DateTime expected, DateTimeOffset actual) + { + // TODO: Find out best way to compare DateTime vs DateTimeOffset, + // because DateTime seems to truncate the miliseconds https://github.com/dotnet/runtime/issues/68230 + Assert.Equal(expected.Date, actual.Date); + Assert.Equal(expected.Hour, actual.Hour); + Assert.Equal(expected.Minute, actual.Minute); + Assert.Equal(expected.Second, actual.Second); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs new file mode 100644 index 0000000000000..298509f1c92a4 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs @@ -0,0 +1,255 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public partial class TarWriter_WriteEntry_File_Tests : TarTestsBase + { + [Fact] + public void ThrowIf_AddFile_AfterDispose() + { + using MemoryStream archiveStream = new MemoryStream(); + TarWriter writer = new TarWriter(archiveStream); + writer.Dispose(); + + Assert.Throws(() => writer.WriteEntry("fileName", "entryName")); + } + + [Fact] + public void FileName_NullOrEmpty() + { + using MemoryStream archiveStream = new MemoryStream(); + using TarWriter writer = new TarWriter(archiveStream); + + Assert.Throws(() => writer.WriteEntry(null, "entryName")); + Assert.Throws(() => writer.WriteEntry(string.Empty, "entryName")); + } + + [Fact] + public void EntryName_NullOrEmpty() + { + using TempDirectory root = new TempDirectory(); + + string file1Name = "file1.txt"; + string file2Name = "file2.txt"; + + string file1Path = Path.Join(root.Path, file1Name); + string file2Path = Path.Join(root.Path, file2Name); + + File.Create(file1Path).Dispose(); + File.Create(file2Path).Dispose(); + + using MemoryStream archiveStream = new MemoryStream(); + using (TarWriter writer = new TarWriter(archiveStream, leaveOpen: true)) + { + writer.WriteEntry(file1Path, null); + writer.WriteEntry(file2Path, string.Empty); + } + + archiveStream.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archiveStream)) + { + TarEntry first = reader.GetNextEntry(); + Assert.NotNull(first); + Assert.Equal(file1Name, first.Name); + + TarEntry second = reader.GetNextEntry(); + Assert.NotNull(second); + Assert.Equal(file2Name, second.Name); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Theory] + [InlineData(TarFormat.V7)] + [InlineData(TarFormat.Ustar)] + [InlineData(TarFormat.Pax)] + [InlineData(TarFormat.Gnu)] + public void Add_File(TarFormat format) + { + using TempDirectory root = new TempDirectory(); + string fileName = "file.txt"; + string filePath = Path.Join(root.Path, fileName); + string fileContents = "Hello world"; + + using (StreamWriter streamWriter = File.CreateText(filePath)) + { + streamWriter.Write(fileContents); + } + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, format, leaveOpen: true)) + { + writer.WriteEntry(fileName: filePath, entryName: fileName); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Equal(format, reader.Format); + Assert.Equal(fileName, entry.Name); + TarEntryType expectedEntryType = format is TarFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile; + Assert.Equal(expectedEntryType, entry.EntryType); + Assert.True(entry.Length > 0); + Assert.NotNull(entry.DataStream); + + entry.DataStream.Seek(0, SeekOrigin.Begin); + using StreamReader dataReader = new StreamReader(entry.DataStream); + string dataContents = dataReader.ReadLine(); + + Assert.Equal(fileContents, dataContents); + + VerifyPlatformSpecificMetadata(filePath, entry); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Theory] + [InlineData(TarFormat.V7, false)] + [InlineData(TarFormat.V7, true)] + [InlineData(TarFormat.Ustar, false)] + [InlineData(TarFormat.Ustar, true)] + [InlineData(TarFormat.Pax, false)] + [InlineData(TarFormat.Pax, true)] + [InlineData(TarFormat.Gnu, false)] + [InlineData(TarFormat.Gnu, true)] + public void Add_Directory(TarFormat format, bool withContents) + { + using TempDirectory root = new TempDirectory(); + string dirName = "dir"; + string dirPath = Path.Join(root.Path, dirName); + Directory.CreateDirectory(dirPath); + + if (withContents) + { + // Add a file inside the directory, we need to ensure the contents + // of the directory are ignored when using AddFile + File.Create(Path.Join(dirPath, "file.txt")).Dispose(); + } + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, format, leaveOpen: true)) + { + writer.WriteEntry(fileName: dirPath, entryName: dirName); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry entry = reader.GetNextEntry(); + Assert.Equal(format, reader.Format); + + Assert.NotNull(entry); + Assert.Equal(dirName, entry.Name); + Assert.Equal(TarEntryType.Directory, entry.EntryType); + Assert.Null(entry.DataStream); + + VerifyPlatformSpecificMetadata(dirPath, entry); + + Assert.Null(reader.GetNextEntry()); // If the dir had contents, they should've been excluded + } + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [InlineData(TarFormat.V7, false)] + [InlineData(TarFormat.V7, true)] + [InlineData(TarFormat.Ustar, false)] + [InlineData(TarFormat.Ustar, true)] + [InlineData(TarFormat.Pax, false)] + [InlineData(TarFormat.Pax, true)] + [InlineData(TarFormat.Gnu, false)] + [InlineData(TarFormat.Gnu, true)] + public void Add_SymbolicLink(TarFormat format, bool createTarget) + { + using TempDirectory root = new TempDirectory(); + string targetName = "file.txt"; + string linkName = "link.txt"; + string targetPath = Path.Join(root.Path, targetName); + string linkPath = Path.Join(root.Path, linkName); + + if (createTarget) + { + File.Create(targetPath).Dispose(); + } + + FileInfo linkInfo = new FileInfo(linkPath); + linkInfo.CreateAsSymbolicLink(targetName); + + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, format, leaveOpen: true)) + { + writer.WriteEntry(fileName: linkPath, entryName: linkName); + } + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + Assert.Equal(TarFormat.Unknown, reader.Format); + TarEntry entry = reader.GetNextEntry(); + Assert.Equal(format, reader.Format); + + Assert.NotNull(entry); + Assert.Equal(linkName, entry.Name); + Assert.Equal(targetName, entry.LinkName); + Assert.Equal(TarEntryType.SymbolicLink, entry.EntryType); + Assert.Null(entry.DataStream); + + VerifyPlatformSpecificMetadata(linkPath, entry); + + Assert.Null(reader.GetNextEntry()); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Add_PaxGlobalExtendedAttributes_NoEntries(bool withAttributes) + { + using MemoryStream archive = new MemoryStream(); + + Dictionary globalExtendedAttributes = new Dictionary(); + + if (withAttributes) + { + globalExtendedAttributes.Add("hello", "world"); + } + + using (TarWriter writer = new TarWriter(archive, globalExtendedAttributes, leaveOpen: true)) + { + } // Dispose with no entries + + archive.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(archive)) + { + // Unknown until reading first entry + Assert.Equal(TarFormat.Unknown, reader.Format); + Assert.Null(reader.GlobalExtendedAttributes); + + Assert.Null(reader.GetNextEntry()); + + Assert.Equal(TarFormat.Pax, reader.Format); + Assert.NotNull(reader.GlobalExtendedAttributes); + + int expectedCount = withAttributes ? 1 : 0; + Assert.Equal(expectedCount, reader.GlobalExtendedAttributes.Count); + + if (expectedCount > 0) + { + Assert.Equal("world", reader.GlobalExtendedAttributes["hello"]); + } + } + } + + partial void VerifyPlatformSpecificMetadata(string filePath, TarEntry entry); + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Tests.cs new file mode 100644 index 0000000000000..c68690c0a7fa4 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Tests.cs @@ -0,0 +1,59 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + // Tests that are independent of the archive format. + public class TarWriter_WriteEntry_Tests : TarTestsBase + { + [Fact] + public void WriteEntry_AfterDispose_Throws() + { + using MemoryStream archiveStream = new MemoryStream(); + TarWriter writer = new TarWriter(archiveStream); + writer.Dispose(); + + PaxTarEntry entry = new PaxTarEntry(TarEntryType.RegularFile, InitialEntryName); + Assert.Throws(() => writer.WriteEntry(entry)); + } + + [Fact] + public void WriteEntry_FromUnseekableStream_AdvanceDataStream_WriteFromThatPosition() + { + using MemoryStream source = GetTarMemoryStream(CompressionMethod.Uncompressed, TestTarFormat.ustar, "file"); + using WrappedStream unseekable = new WrappedStream(source, canRead: true, canWrite: true, canSeek: false); + + using MemoryStream destination = new MemoryStream(); + + using (TarReader reader = new TarReader(unseekable)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.NotNull(entry.DataStream); + entry.DataStream.ReadByte(); // Advance one byte, now the expected string would be "ello file" + + using (TarWriter writer = new TarWriter(destination, TarFormat.Ustar, leaveOpen: true)) + { + writer.WriteEntry(entry); + } + } + + destination.Seek(0, SeekOrigin.Begin); + using (TarReader reader = new TarReader(destination)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.NotNull(entry.DataStream); + + using (StreamReader streamReader = new StreamReader(entry.DataStream, leaveOpen: true)) + { + string contents = streamReader.ReadLine(); + Assert.Equal("ello file", contents); + } + } + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/WrappedStream.cs b/src/libraries/System.Formats.Tar/tests/WrappedStream.cs new file mode 100644 index 0000000000000..5697f7c09ba55 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/WrappedStream.cs @@ -0,0 +1,132 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; + +namespace System.Formats.Tar +{ + public class WrappedStream : Stream + { + private readonly Stream _baseStream; + private readonly EventHandler _onClosed; + private bool _canRead, _canWrite, _canSeek; + + public WrappedStream(Stream baseStream, bool canRead, bool canWrite, bool canSeek, EventHandler onClosed = null) + { + _baseStream = baseStream; + _onClosed = onClosed; + _canRead = canRead; + _canSeek = canSeek; + _canWrite = canWrite; + } + + public override void Flush() => _baseStream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) + { + if (CanRead) + { + try + { + return _baseStream.Read(buffer, offset, count); + } + catch (ObjectDisposedException ex) + { + throw new InvalidOperationException("This stream does not support reading", ex); + } + } + else throw new InvalidOperationException("This stream does not support reading"); + } + + public override long Seek(long offset, SeekOrigin origin) + { + if (CanSeek) + { + try + { + return _baseStream.Seek(offset, origin); + } + catch (ObjectDisposedException ex) + { + throw new InvalidOperationException("This stream does not support seeking", ex); + } + } + else throw new InvalidOperationException("This stream does not support seeking"); + } + + public override void SetLength(long value) { _baseStream.SetLength(value); } + + public override void Write(byte[] buffer, int offset, int count) + { + if (CanWrite) + { + try + { + _baseStream.Write(buffer, offset, count); + } + catch (ObjectDisposedException ex) + { + throw new InvalidOperationException("This stream does not support writing", ex); + } + } + else throw new InvalidOperationException("This stream does not support writing"); + } + + public override bool CanRead => _canRead && _baseStream.CanRead; + + public override bool CanSeek => _canSeek && _baseStream.CanSeek; + + public override bool CanWrite => _canWrite && _baseStream.CanWrite; + + public override long Length + { + get + { + if (!CanSeek) + { + throw new InvalidOperationException("This stream does not support seeking."); + } + return _baseStream.Length; + } + } + + public override long Position + { + get + { + if (!CanSeek) + { + throw new InvalidOperationException("This stream does not support seeking"); + } + return _baseStream.Position; + } + set + { + if (CanSeek) + { + try + { + _baseStream.Position = value; + } + catch (ObjectDisposedException ex) + { + throw new InvalidOperationException("This stream does not support seeking", ex); + } + } + else throw new InvalidOperationException("This stream does not support seeking"); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _onClosed?.Invoke(this, null); + _canRead = false; + _canWrite = false; + _canSeek = false; + } + base.Dispose(disposing); + } + } +} \ No newline at end of file diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj index 32e34d7d6493f..1b322f68a4720 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj +++ b/src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj @@ -7,18 +7,20 @@ - + - + @@ -34,7 +36,8 @@ Link="Common\Interop\Unix\System.Native\Interop.FChMod.cs" /> - + diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs index b363d12311173..4370cd16878b1 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFile.Create.cs @@ -393,17 +393,17 @@ private static void DoCreateFromDirectory(string sourceDirectoryName, string des if (file is FileInfo) { // Create entry for file: - string entryName = ZipFileUtils.EntryFromPath(file.FullName, basePath.Length, entryNameLength, ref entryNameBuffer); + string entryName = ArchivingUtils.EntryFromPath(file.FullName, basePath.Length, entryNameLength, ref entryNameBuffer); ZipFileExtensions.DoCreateEntryFromFile(archive, file.FullName, entryName, compressionLevel); } else { // Entry marking an empty dir: - if (file is DirectoryInfo possiblyEmpty && ZipFileUtils.IsDirEmpty(possiblyEmpty)) + if (file is DirectoryInfo possiblyEmpty && ArchivingUtils.IsDirEmpty(possiblyEmpty)) { // FullName never returns a directory separator character on the end, // but Zip archives require it to specify an explicit directory: - string entryName = ZipFileUtils.EntryFromPath(file.FullName, basePath.Length, entryNameLength, ref entryNameBuffer, appendPathSeparator: true); + string entryName = ArchivingUtils.EntryFromPath(file.FullName, basePath.Length, entryNameLength, ref entryNameBuffer, appendPathSeparator: true); archive.CreateEntry(entryName); } } @@ -411,7 +411,7 @@ private static void DoCreateFromDirectory(string sourceDirectoryName, string des // If no entries create an empty root directory entry: if (includeBaseDirectory && directoryIsEmpty) - archive.CreateEntry(ZipFileUtils.EntryFromPath(di.Name, 0, di.Name.Length, ref entryNameBuffer, appendPathSeparator: true)); + archive.CreateEntry(ArchivingUtils.EntryFromPath(di.Name, 0, di.Name.Length, ref entryNameBuffer, appendPathSeparator: true)); } finally { diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs index f3cf37712c54a..5fa679ca1363e 100644 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs +++ b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs @@ -73,14 +73,7 @@ public static void ExtractToFile(this ZipArchiveEntry source!!, string destinati ExtractExternalAttributes(fs, source); } - try - { - File.SetLastWriteTime(destinationFileName, source.LastWriteTime.DateTime); - } - catch - { - // some OSes like Android (#35374) might not support setting the last write time, the extraction should not fail because of that - } + ArchivingUtils.AttemptSetLastWriteTime(destinationFileName, source.LastWriteTime); } static partial void ExtractExternalAttributes(FileStream fs, ZipArchiveEntry entry); @@ -96,7 +89,7 @@ internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source!!, s if (!destinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar)) destinationDirectoryFullPath += Path.DirectorySeparatorChar; - string fileDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, SanitizeZipFilePath(source.FullName))); + string fileDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectoryFullPath, ArchivingUtils.SanitizeEntryFilePath(source.FullName))); if (!fileDestinationPath.StartsWith(destinationDirectoryFullPath, PathInternal.StringComparison)) throw new IOException(SR.IO_ExtractingResultsInOutside); diff --git a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidFullName_Unix.cs b/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidFullName_Unix.cs deleted file mode 100644 index 210739d3b3aa8..0000000000000 --- a/src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileValidFullName_Unix.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text; - -namespace System.IO.Compression -{ - public static partial class ZipFileExtensions - { - internal static string SanitizeZipFilePath(string zipPath) - { - return zipPath.Replace('\0', '_'); - } - } -} diff --git a/src/native/libs/Common/pal_config.h.in b/src/native/libs/Common/pal_config.h.in index 1814c53499ad9..57fa017578bd0 100644 --- a/src/native/libs/Common/pal_config.h.in +++ b/src/native/libs/Common/pal_config.h.in @@ -135,6 +135,8 @@ #cmakedefine01 HAVE_MALLOC_USABLE_SIZE #cmakedefine01 HAVE_MALLOC_USABLE_SIZE_NP #cmakedefine01 HAVE_POSIX_MEMALIGN +#cmakedefine01 HAVE_MAKEDEV_FILEH +#cmakedefine01 HAVE_MAKEDEV_SYSMACROSH // Mac OS X has stat64, but it is deprecated since plain stat now // provides the same 64-bit aware struct when targeting OS X > 10.5 diff --git a/src/native/libs/System.Native/entrypoints.c b/src/native/libs/System.Native/entrypoints.c index 7b764d5451777..c707d0fcc207d 100644 --- a/src/native/libs/System.Native/entrypoints.c +++ b/src/native/libs/System.Native/entrypoints.c @@ -83,6 +83,9 @@ static const Entry s_sysNative[] = DllImportEntry(SystemNative_LSeek) DllImportEntry(SystemNative_Link) DllImportEntry(SystemNative_SymLink) + DllImportEntry(SystemNative_MkNod) + DllImportEntry(SystemNative_GetDeviceIdentifiers) + DllImportEntry(SystemNative_MkFifo) DllImportEntry(SystemNative_MksTemps) DllImportEntry(SystemNative_MMap) DllImportEntry(SystemNative_MUnmap) diff --git a/src/native/libs/System.Native/pal_io.c b/src/native/libs/System.Native/pal_io.c index e05f57a5fec17..a0714a778f196 100644 --- a/src/native/libs/System.Native/pal_io.c +++ b/src/native/libs/System.Native/pal_io.c @@ -22,6 +22,9 @@ #include #include #include +#if !HAVE_MAKEDEV_FILEH && HAVE_MAKEDEV_SYSMACROSH +#include +#endif #include #include #include @@ -123,6 +126,7 @@ c_static_assert(PAL_S_ISGID == S_ISGID); // accordingly. c_static_assert(PAL_S_IFMT == S_IFMT); c_static_assert(PAL_S_IFIFO == S_IFIFO); +c_static_assert(PAL_S_IFBLK == S_IFBLK); c_static_assert(PAL_S_IFCHR == S_IFCHR); c_static_assert(PAL_S_IFDIR == S_IFDIR); c_static_assert(PAL_S_IFREG == S_IFREG); @@ -766,6 +770,35 @@ int32_t SystemNative_SymLink(const char* target, const char* linkPath) return result; } +int32_t SystemNative_GetDeviceIdentifiers(uint64_t dev, uint32_t* majorNumber, uint32_t* minorNumber) +{ + dev_t castedDev = (dev_t)dev; + *majorNumber = (uint32_t)major(castedDev); + *minorNumber = (uint32_t)minor(castedDev); + return ConvertErrorPlatformToPal(errno); +} + +int32_t SystemNative_MkNod(const char* pathName, uint32_t mode, uint32_t major, uint32_t minor) +{ + dev_t dev = (dev_t)makedev(major, minor); + + if (errno > 0) + { + return -1; + } + + int32_t result; + while ((result = mknod(pathName, (mode_t)mode, dev)) < 0 && errno == EINTR); + return result; +} + +int32_t SystemNative_MkFifo(const char* pathName, uint32_t mode) +{ + int32_t result; + while ((result = mkfifo(pathName, (mode_t)mode)) < 0 && errno == EINTR); + return result; +} + intptr_t SystemNative_MksTemps(char* pathTemplate, int32_t suffixLength) { intptr_t result; diff --git a/src/native/libs/System.Native/pal_io.h b/src/native/libs/System.Native/pal_io.h index 6ebde79230af9..1ace89303642a 100644 --- a/src/native/libs/System.Native/pal_io.h +++ b/src/native/libs/System.Native/pal_io.h @@ -121,6 +121,7 @@ enum { PAL_S_IFMT = 0xF000, // Type of file (apply as mask to FileStatus.Mode and one of S_IF*) PAL_S_IFIFO = 0x1000, // FIFO (named pipe) + PAL_S_IFBLK = 0x6000, // Block special PAL_S_IFCHR = 0x2000, // Character special PAL_S_IFDIR = 0x4000, // Directory PAL_S_IFREG = 0x8000, // Regular file @@ -540,6 +541,26 @@ PALEXPORT int32_t SystemNative_Link(const char* source, const char* linkTarget); */ PALEXPORT int32_t SystemNative_SymLink(const char* target, const char* linkPath); +/** + * Given a device ID, extracts the major and minor and components and returns them. + * Return 0 on success; otherwise, returns -1 and errno is set. + */ +PALEXPORT int32_t SystemNative_GetDeviceIdentifiers(uint64_t dev, uint32_t* majorNumber, uint32_t* minorNumber); + +/** + * Creates a special or ordinary file. + * + * Returns 0 on success; otherwise, returns -1 and errno is set. + */ +PALEXPORT int32_t SystemNative_MkNod(const char* pathName, uint32_t mode, uint32_t major, uint32_t minor); + +/** + * Creates a FIFO special file (named pipe). + * + * Returns 0 on success; otherwise, returns -1 and errno is set. + */ +PALEXPORT int32_t SystemNative_MkFifo(const char* pathName, uint32_t mode); + /** * Creates a file name that adheres to the specified template, creates the file on disk with * 0600 permissions, and returns an open r/w File Descriptor on the file. diff --git a/src/native/libs/configure.cmake b/src/native/libs/configure.cmake index 237f88c07ea20..1941f3543d5ea 100644 --- a/src/native/libs/configure.cmake +++ b/src/native/libs/configure.cmake @@ -1122,6 +1122,20 @@ check_c_source_compiles( " HAVE_BUILTIN_MUL_OVERFLOW) +check_symbol_exists( + makedev + sys/file.h + HAVE_MAKEDEV_FILEH) + +check_symbol_exists( + makedev + sys/sysmacros.h + HAVE_MAKEDEV_SYSMACROSH) + +if (NOT HAVE_MAKEDEV_FILEH AND NOT HAVE_MAKEDEV_SYSMACROSH) + message(FATAL_ERROR "Cannot find the makedev function on this platform.") +endif() + configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/Common/pal_config.h.in ${CMAKE_CURRENT_BINARY_DIR}/Common/pal_config.h)