diff --git a/src/libraries/Common/src/System/IO/PathInternal.Unix.cs b/src/libraries/Common/src/System/IO/PathInternal.Unix.cs index 5bb8ab93fd9b23..4914071a448717 100644 --- a/src/libraries/Common/src/System/IO/PathInternal.Unix.cs +++ b/src/libraries/Common/src/System/IO/PathInternal.Unix.cs @@ -17,6 +17,7 @@ internal static partial class PathInternal internal const string DirectorySeparatorCharAsString = "/"; internal const string ParentDirectoryPrefix = @"../"; internal const string DirectorySeparators = DirectorySeparatorCharAsString; + internal static ReadOnlySpan Utf8DirectorySeparators => "/"u8; internal static int GetRootLength(ReadOnlySpan path) { diff --git a/src/libraries/Common/src/System/IO/PathInternal.Windows.cs b/src/libraries/Common/src/System/IO/PathInternal.Windows.cs index 3bc5b493c37845..e0aff52855590c 100644 --- a/src/libraries/Common/src/System/IO/PathInternal.Windows.cs +++ b/src/libraries/Common/src/System/IO/PathInternal.Windows.cs @@ -55,6 +55,7 @@ internal static partial class PathInternal internal const string DevicePathPrefix = @"\\.\"; internal const string ParentDirectoryPrefix = @"..\"; internal const string DirectorySeparators = @"\/"; + internal static ReadOnlySpan Utf8DirectorySeparators => @"\/"u8; internal const int MaxShortPath = 260; internal const int MaxShortDirectoryPath = 248; diff --git a/src/libraries/System.Formats.Tar/src/Resources/Strings.resx b/src/libraries/System.Formats.Tar/src/Resources/Strings.resx index 5308e9153c9791..90001cf8bcc603 100644 --- a/src/libraries/System.Formats.Tar/src/Resources/Strings.resx +++ b/src/libraries/System.Formats.Tar/src/Resources/Strings.resx @@ -1,17 +1,17 @@  - @@ -261,4 +261,7 @@ Unable to parse number. - + + The field '{0}' exceeds the maximum allowed length for this format. + + \ No newline at end of file diff --git a/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj b/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj index c0487caf28049e..33a9aa8c1dc0cb 100644 --- a/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj +++ b/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj @@ -65,6 +65,7 @@ + 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 index ce37a74c304c80..c1eacf8da7c727 100644 --- 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 @@ -517,8 +517,8 @@ private void ReadVersionAttribute(Span buffer) 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)); + _uName = TarHelpers.GetTrimmedUtf8String(buffer.Slice(FieldLocations.UName, FieldLengths.UName)); + _gName = TarHelpers.GetTrimmedUtf8String(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. 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 index 43fe79ce7dc0a6..c63e19bd5cd0bf 100644 --- 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 @@ -5,9 +5,7 @@ using System.Buffers.Text; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Numerics; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -25,6 +23,7 @@ internal sealed partial class TarHeader // 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"; + private const string ArgNameEntry = "entry"; // Writes the current header as a V7 entry into the archive stream. internal void WriteAsV7(Stream archiveStream, Span buffer) @@ -101,7 +100,7 @@ private long WriteUstarFieldsToBuffer(Span buffer) long actualLength = GetTotalDataBytesToWrite(); TarEntryType actualEntryType = TarHelpers.GetCorrectTypeFlagForFormat(TarEntryFormat.Ustar, _typeFlag); - int tmpChecksum = WritePosixName(buffer); + int tmpChecksum = WriteUstarName(buffer); tmpChecksum += WriteCommonFields(buffer, actualLength, actualEntryType); tmpChecksum += WritePosixMagicAndVersion(buffer); tmpChecksum += WritePosixAndGnuSharedFields(buffer); @@ -178,7 +177,7 @@ internal async Task WriteAsPaxAsync(Stream archiveStream, Memory buffer, C 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) + if (_linkName != null && Encoding.UTF8.GetByteCount(_linkName) > FieldLengths.LinkName) { TarHeader longLinkHeader = GetGnuLongMetadataHeader(TarEntryType.LongLink, _linkName); longLinkHeader.WriteAsGnuInternal(archiveStream, buffer); @@ -186,7 +185,7 @@ internal void WriteAsGnu(Stream archiveStream, Span buffer) } // Second, we determine if we need a preceding LongPath, and write it if needed - if (_name.Length > FieldLengths.Name) + if (Encoding.UTF8.GetByteCount(_name) > FieldLengths.Name) { TarHeader longPathHeader = GetGnuLongMetadataHeader(TarEntryType.LongPath, _name); longPathHeader.WriteAsGnuInternal(archiveStream, buffer); @@ -204,7 +203,7 @@ internal async Task WriteAsGnuAsync(Stream archiveStream, Memory buffer, C cancellationToken.ThrowIfCancellationRequested(); // First, we determine if we need a preceding LongLink, and write it if needed - if (_linkName?.Length > FieldLengths.LinkName) + if (_linkName != null && Encoding.UTF8.GetByteCount(_linkName) > FieldLengths.LinkName) { TarHeader longLinkHeader = GetGnuLongMetadataHeader(TarEntryType.LongLink, _linkName); await longLinkHeader.WriteAsGnuInternalAsync(archiveStream, buffer, cancellationToken).ConfigureAwait(false); @@ -212,7 +211,7 @@ internal async Task WriteAsGnuAsync(Stream archiveStream, Memory buffer, C } // Second, we determine if we need a preceding LongPath, and write it if needed - if (_name.Length > FieldLengths.Name) + if (Encoding.UTF8.GetByteCount(_name) > FieldLengths.Name) { TarHeader longPathHeader = GetGnuLongMetadataHeader(TarEntryType.LongPath, _name); await longPathHeader.WriteAsGnuInternalAsync(archiveStream, buffer, cancellationToken).ConfigureAwait(false); @@ -226,8 +225,7 @@ internal async Task WriteAsGnuAsync(Stream archiveStream, Memory buffer, C // 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)); + Debug.Assert(entryType is TarEntryType.LongPath or TarEntryType.LongLink); TarHeader longMetadataHeader = new(TarEntryFormat.Gnu); @@ -350,7 +348,7 @@ private void WriteAsPaxSharedInternal(Span buffer, out long actualLength) { actualLength = GetTotalDataBytesToWrite(); - int tmpChecksum = WritePosixName(buffer); + int tmpChecksum = WriteName(buffer); tmpChecksum += WriteCommonFields(buffer, actualLength, TarHelpers.GetCorrectTypeFlagForFormat(TarEntryFormat.Pax, _typeFlag)); tmpChecksum += WritePosixMagicAndVersion(buffer); tmpChecksum += WritePosixAndGnuSharedFields(buffer); @@ -358,31 +356,93 @@ private void WriteAsPaxSharedInternal(Span buffer, out long actualLength) _checksum = WriteChecksum(tmpChecksum, buffer); } - // All formats save in the name byte array only the ASCII bytes that fit. + // Gnu and pax save in the name byte array only the UTF8 bytes that fit. + // V7 does not support more than 100 bytes so it throws. private int WriteName(Span buffer) { - ReadOnlySpan src = _name.AsSpan(0, Math.Min(_name.Length, FieldLengths.Name)); - Span dest = buffer.Slice(FieldLocations.Name, FieldLengths.Name); - int encoded = Encoding.ASCII.GetBytes(src, dest); - return Checksum(dest.Slice(0, encoded)); + ReadOnlySpan name = _name; + int encodedLength = GetUtf8TextLength(name); + + if (encodedLength > FieldLengths.Name) + { + if (_format is TarEntryFormat.V7) + { + throw new ArgumentException(SR.Format(SR.TarEntryFieldExceedsMaxLength, nameof(TarEntry.Name)), ArgNameEntry); + } + + int utf16NameTruncatedLength = GetUtf16TruncatedTextLength(name, FieldLengths.Name); + name = name.Slice(0, utf16NameTruncatedLength); + } + + return WriteAsUtf8String(name, buffer.Slice(FieldLocations.Name, FieldLengths.Name)); } - // 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) + // 'https://www.freebsd.org/cgi/man.cgi?tar(5)' + // If the path name is too long to fit in the 100 bytes provided by the standard format, + // it can be split at any / character with the first portion going into the prefix field. + private int WriteUstarName(Span buffer) { - int checksum = WriteName(buffer); + // We can have a path name as big as 256, prefix + '/' + name, + // the separator in between can be neglected as the reader will append it when it joins both fields. + const int MaxPathName = FieldLengths.Prefix + 1 + FieldLengths.Name; + + if (GetUtf8TextLength(_name) > MaxPathName) + { + throw new ArgumentException(SR.Format(SR.TarEntryFieldExceedsMaxLength, nameof(TarEntry.Name)), ArgNameEntry); + } + + Span encodingBuffer = stackalloc byte[MaxPathName]; + int encoded = Encoding.UTF8.GetBytes(_name, encodingBuffer); + ReadOnlySpan pathNameBytes = encodingBuffer.Slice(0, encoded); - if (_name.Length > FieldLengths.Name) + // If the pathname is able to fit in Name, we can write it down there and avoid calculating Prefix. + if (pathNameBytes.Length <= FieldLengths.Name) { - int prefixBytesLength = Math.Min(_name.Length - FieldLengths.Name, FieldLengths.Prefix); - Span remaining = stackalloc byte[prefixBytesLength]; - int encoded = Encoding.ASCII.GetBytes(_name.AsSpan(FieldLengths.Name, prefixBytesLength), remaining); - Debug.Assert(encoded == remaining.Length); + return WriteLeftAlignedBytesAndGetChecksum(pathNameBytes, buffer.Slice(FieldLocations.Name, FieldLengths.Name)); + } - checksum += WriteLeftAlignedBytesAndGetChecksum(remaining, buffer.Slice(FieldLocations.Prefix, FieldLengths.Prefix)); + int lastIdx = pathNameBytes.LastIndexOfAny(PathInternal.Utf8DirectorySeparators); + scoped ReadOnlySpan name; + scoped ReadOnlySpan prefix; + + if (lastIdx < 1) // splitting at the root is not allowed. + { + name = pathNameBytes; + prefix = default; + } + else + { + name = pathNameBytes.Slice(lastIdx + 1); + prefix = pathNameBytes.Slice(0, lastIdx); } - return checksum; + // At this point path name is > 100. + // Attempt to split it in a way it can use prefix. + while (prefix.Length - name.Length > FieldLengths.Prefix) + { + lastIdx = prefix.LastIndexOfAny(PathInternal.Utf8DirectorySeparators); + if (lastIdx < 1) + { + break; + } + + name = pathNameBytes.Slice(lastIdx + 1); + prefix = pathNameBytes.Slice(0, lastIdx); + } + + if (prefix.Length <= FieldLengths.Prefix && name.Length <= FieldLengths.Name) + { + Debug.Assert(prefix.Length != 1 || !PathInternal.Utf8DirectorySeparators.Contains(prefix[0])); + + int checksum = WriteLeftAlignedBytesAndGetChecksum(prefix, buffer.Slice(FieldLocations.Prefix, FieldLengths.Prefix)); + checksum += WriteLeftAlignedBytesAndGetChecksum(name, buffer.Slice(FieldLocations.Name, FieldLengths.Name)); + + return checksum; + } + else + { + throw new ArgumentException(SR.Format(SR.TarEntryFieldExceedsMaxLength, nameof(TarEntry.Name)), ArgNameEntry); + } } // Writes all the common fields shared by all formats into the specified spans. @@ -423,7 +483,20 @@ private int WriteCommonFields(Span buffer, long actualLength, TarEntryType if (!string.IsNullOrEmpty(_linkName)) { - checksum += WriteAsAsciiString(_linkName, buffer.Slice(FieldLocations.LinkName, FieldLengths.LinkName)); + ReadOnlySpan linkName = _linkName; + + if (GetUtf8TextLength(linkName) > FieldLengths.LinkName) + { + if (_format is not TarEntryFormat.Pax and not TarEntryFormat.Gnu) + { + throw new ArgumentException(SR.Format(SR.TarEntryFieldExceedsMaxLength, nameof(TarEntry.LinkName)), ArgNameEntry); + } + + int truncatedLength = GetUtf16TruncatedTextLength(linkName, FieldLengths.LinkName); + linkName = linkName.Slice(0, truncatedLength); + } + + checksum += WriteAsUtf8String(linkName, buffer.Slice(FieldLocations.LinkName, FieldLengths.LinkName)); } return checksum; @@ -467,12 +540,38 @@ private int WritePosixAndGnuSharedFields(Span buffer) if (!string.IsNullOrEmpty(_uName)) { - checksum += WriteAsAsciiString(_uName, buffer.Slice(FieldLocations.UName, FieldLengths.UName)); + ReadOnlySpan uName = _uName; + + if (GetUtf8TextLength(uName) > FieldLengths.UName) + { + if (_format is not TarEntryFormat.Pax) + { + throw new ArgumentException(SR.Format(SR.TarEntryFieldExceedsMaxLength, nameof(PaxTarEntry.UserName)), ArgNameEntry); + } + + int truncatedLength = GetUtf16TruncatedTextLength(uName, FieldLengths.UName); + uName = uName.Slice(0, truncatedLength); + } + + checksum += WriteAsUtf8String(uName, buffer.Slice(FieldLocations.UName, FieldLengths.UName)); } if (!string.IsNullOrEmpty(_gName)) { - checksum += WriteAsAsciiString(_gName, buffer.Slice(FieldLocations.GName, FieldLengths.GName)); + ReadOnlySpan gName = _gName; + + if (GetUtf8TextLength(gName) > FieldLengths.GName) + { + if (_format is not TarEntryFormat.Pax) + { + throw new ArgumentException(SR.Format(SR.TarEntryFieldExceedsMaxLength, nameof(PaxTarEntry.GroupName)), ArgNameEntry); + } + + int truncatedLength = GetUtf16TruncatedTextLength(gName, FieldLengths.GName); + gName = gName.Slice(0, truncatedLength); + } + + checksum += WriteAsUtf8String(gName, buffer.Slice(FieldLocations.GName, FieldLengths.GName)); } if (_devMajor > 0) @@ -621,39 +720,33 @@ static int CountDigits(int value) // extended attributes. They get collected and saved in that dictionary, with no restrictions. private void CollectExtendedAttributesFromStandardFieldsIfNeeded() { - ExtendedAttributes.Add(PaxEaName, _name); - - if (!ExtendedAttributes.ContainsKey(PaxEaMTime)) - { - ExtendedAttributes.Add(PaxEaMTime, TarHelpers.GetTimestampStringFromDateTimeOffset(_mTime)); - } + ExtendedAttributes[PaxEaName] = _name; + ExtendedAttributes[PaxEaMTime] = TarHelpers.GetTimestampStringFromDateTimeOffset(_mTime); - if (!string.IsNullOrEmpty(_gName)) - { - TryAddStringField(ExtendedAttributes, PaxEaGName, _gName, FieldLengths.GName); - } - - if (!string.IsNullOrEmpty(_uName)) - { - TryAddStringField(ExtendedAttributes, PaxEaUName, _uName, FieldLengths.UName); - } + TryAddStringField(ExtendedAttributes, PaxEaGName, _gName, FieldLengths.GName); + TryAddStringField(ExtendedAttributes, PaxEaUName, _uName, FieldLengths.UName); if (!string.IsNullOrEmpty(_linkName)) { - ExtendedAttributes.Add(PaxEaLinkName, _linkName); + Debug.Assert(_typeFlag is TarEntryType.SymbolicLink or TarEntryType.HardLink); + ExtendedAttributes[PaxEaLinkName] = _linkName; } if (_size > 99_999_999) { - ExtendedAttributes.Add(PaxEaSize, _size.ToString()); + ExtendedAttributes[PaxEaSize] = _size.ToString(); } - // 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) + // Sets the specified string to the dictionary if it's longer than the specified max byte length; otherwise, remove it. + static void TryAddStringField(Dictionary extendedAttributes, string key, string? value, int maxLength) { - if (Encoding.UTF8.GetByteCount(value) > maxLength) + if (string.IsNullOrEmpty(value) || GetUtf8TextLength(value) <= maxLength) + { + extendedAttributes.Remove(key); + } + else { - extendedAttributes.Add(key, value); + extendedAttributes[key] = value; } } } @@ -766,11 +859,11 @@ private static int WriteAsTimestamp(DateTimeOffset timestamp, Span destina return FormatOctal(unixTimeSeconds, destination); } - // Writes the specified text as an ASCII string aligned to the left, and returns its checksum. - private static int WriteAsAsciiString(string str, Span buffer) + // Writes the specified text as an UTF8 string aligned to the left, and returns its checksum. + private static int WriteAsUtf8String(ReadOnlySpan text, Span buffer) { - byte[] bytes = Encoding.ASCII.GetBytes(str); - return WriteLeftAlignedBytesAndGetChecksum(bytes.AsSpan(), buffer); + int encoded = Encoding.UTF8.GetBytes(text, buffer); + return WriteLeftAlignedBytesAndGetChecksum(buffer.Slice(0, encoded), buffer); } // Gets the special name for the 'name' field in an extended attribute entry. @@ -819,5 +912,32 @@ private static string GenerateGlobalExtendedAttributeName(int globalExtendedAttr return result; } + + private static int GetUtf8TextLength(ReadOnlySpan text) + => Encoding.UTF8.GetByteCount(text); + + // Returns the text's utf16 length truncated at the specified utf8 max length. + private static int GetUtf16TruncatedTextLength(ReadOnlySpan text, int utf8MaxLength) + { + Debug.Assert(GetUtf8TextLength(text) > utf8MaxLength); + + int utf8Length = 0; + int utf16TruncatedLength = 0; + + foreach (Rune rune in text.EnumerateRunes()) + { + utf8Length += rune.Utf8SequenceLength; + if (utf8Length <= utf8MaxLength) + { + utf16TruncatedLength += rune.Utf16SequenceLength; + } + else + { + break; + } + } + + return utf16TruncatedLength; + } } } 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 index c43c8dff343f23..d4741850029265 100644 --- a/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj +++ b/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj @@ -47,9 +47,11 @@ + + diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs index ae01821d62b6d6..81e5b3de3c4bd1 100644 --- a/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs @@ -1,4 +1,4 @@ -// 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.Collections.Generic; @@ -89,6 +89,11 @@ public abstract partial class TarTestsBase : FileCleanupTestBase protected const string PaxEaSize = "size"; protected const string PaxEaDevMajor = "devmajor"; protected const string PaxEaDevMinor = "devminor"; + internal const char OneByteCharacter = 'a'; + internal const char TwoBytesCharacter = '\u00F6'; + internal const string FourBytesCharacter = "\uD83D\uDE12"; + internal const char Separator = '/'; + internal const int MaxPathComponent = 255; private static readonly string[] V7TestCaseNames = new[] { @@ -476,7 +481,7 @@ protected TarEntryType GetTarEntryTypeForTarEntryFormat(TarEntryType entryType, return entryType; } - protected TarEntry InvokeTarEntryCreationConstructor(TarEntryFormat targetFormat, TarEntryType entryType, string entryName) + protected static TarEntry InvokeTarEntryCreationConstructor(TarEntryFormat targetFormat, TarEntryType entryType, string entryName) => targetFormat switch { TarEntryFormat.V7 => new V7TarEntry(entryType, entryName), @@ -622,5 +627,187 @@ public static IEnumerable GetPaxAndGnuTestCaseNames() yield return new object[] { name }; } } + + private static List GetPrefixes() + { + List prefixes = new() { "", "/a/", "./", "../" }; + + if (OperatingSystem.IsWindows()) + prefixes.Add("C:/"); + + return prefixes; + } + + internal static IEnumerable GetNamesPrefixedTestData(NameCapabilities max) + { + Assert.True(Enum.IsDefined(max)); + List prefixes = GetPrefixes(); + + foreach (string prefix in prefixes) + { + // prefix + name of length 100 + int nameLength = 100 - prefix.Length; + yield return prefix + Repeat(OneByteCharacter, nameLength); + yield return prefix + Repeat(OneByteCharacter, nameLength - 2) + TwoBytesCharacter; + yield return prefix + Repeat(OneByteCharacter, nameLength - 4) + FourBytesCharacter; + + // prefix alone + if (prefix != string.Empty) + yield return prefix; + } + + if (max == NameCapabilities.Name) + yield break; + + // maxed out name. + foreach (string prefix in prefixes) + { + yield return prefix + Repeat(OneByteCharacter, 100); + yield return prefix + Repeat(OneByteCharacter, 100 - 2) + TwoBytesCharacter; + yield return prefix + Repeat(OneByteCharacter, 100 - 4) + FourBytesCharacter; + } + + // maxed out prefix and name. + foreach (string prefix in prefixes) + { + int directoryLength = 155 - prefix.Length; + yield return prefix + Repeat(OneByteCharacter, directoryLength) + Separator + Repeat(OneByteCharacter, 100); + yield return prefix + Repeat(OneByteCharacter, directoryLength - 2) + TwoBytesCharacter + Separator + Repeat(OneByteCharacter, 100); + yield return prefix + Repeat(OneByteCharacter, directoryLength - 4) + FourBytesCharacter + Separator + Repeat(OneByteCharacter, 100); + } + + if (max == NameCapabilities.NameAndPrefix) + yield break; + + foreach (string prefix in prefixes) + { + int directoryLength = MaxPathComponent - prefix.Length; + yield return prefix + Repeat(OneByteCharacter, directoryLength) + Separator + Repeat(OneByteCharacter, MaxPathComponent); + yield return prefix + Repeat(OneByteCharacter, directoryLength - 2) + TwoBytesCharacter + Separator + Repeat(OneByteCharacter, MaxPathComponent); + yield return prefix + Repeat(OneByteCharacter, directoryLength - 4) + FourBytesCharacter + Separator + Repeat(OneByteCharacter, MaxPathComponent); + } + } + + internal static IEnumerable GetNamesNonAsciiTestData(NameCapabilities max) + { + Assert.True(Enum.IsDefined(max)); + + yield return Repeat(OneByteCharacter, 100); + yield return Repeat(TwoBytesCharacter, 100 / 2); + yield return Repeat(OneByteCharacter, 2) + Repeat(TwoBytesCharacter, 49); + + yield return Repeat(FourBytesCharacter, 100 / 4); + yield return Repeat(OneByteCharacter, 4) + Repeat(FourBytesCharacter, 24); + + if (max == NameCapabilities.Name) + yield break; + + // prefix + name + // this is 256 but is supported because prefix is not required to end in separator. + yield return Repeat(OneByteCharacter, 155) + Separator + Repeat(OneByteCharacter, 100); + + // non-ascii prefix + name + yield return Repeat(TwoBytesCharacter, 155 / 2) + Separator + Repeat(OneByteCharacter, 100); + yield return Repeat(FourBytesCharacter, 155 / 4) + Separator + Repeat(OneByteCharacter, 100); + + // prefix + non-ascii name + yield return Repeat(OneByteCharacter, 155) + Separator + Repeat(TwoBytesCharacter, 100 / 2); + yield return Repeat(OneByteCharacter, 155) + Separator + Repeat(FourBytesCharacter, 100 / 4); + + // non-ascii prefix + non-ascii name + yield return Repeat(TwoBytesCharacter, 155 / 2) + Separator + Repeat(TwoBytesCharacter, 100 / 2); + yield return Repeat(FourBytesCharacter, 155 / 4) + Separator + Repeat(FourBytesCharacter, 100 / 4); + + if (max == NameCapabilities.NameAndPrefix) + yield break; + + // Pax and Gnu support unlimited paths. + yield return Repeat(OneByteCharacter, MaxPathComponent); + yield return Repeat(TwoBytesCharacter, MaxPathComponent / 2); + yield return Repeat(FourBytesCharacter, MaxPathComponent / 4); + + yield return Repeat(OneByteCharacter, MaxPathComponent) + Separator + Repeat(OneByteCharacter, MaxPathComponent); + yield return Repeat(TwoBytesCharacter, MaxPathComponent / 2) + Separator + Repeat(TwoBytesCharacter, MaxPathComponent / 2); + yield return Repeat(FourBytesCharacter, MaxPathComponent / 4) + Separator + Repeat(FourBytesCharacter, MaxPathComponent / 4); + } + + internal static IEnumerable GetTooLongNamesTestData(NameCapabilities max) + { + Assert.True(max is NameCapabilities.Name or NameCapabilities.NameAndPrefix); + + // root directory can't be saved as prefix + yield return "/" + Repeat(OneByteCharacter, 100); + + List prefixes = GetPrefixes(); + + // 1. non-ascii last character doesn't fit in name. + foreach (string prefix in prefixes) + { + // 1.1. last character doesn't fit fully. + yield return prefix + Repeat(OneByteCharacter, 100 + 1); + yield return prefix + Repeat(OneByteCharacter, 100 - 2) + Repeat(TwoBytesCharacter, 2); + yield return prefix + Repeat(OneByteCharacter, 100 - 4) + Repeat(FourBytesCharacter, 2); + + // 1.2. last character doesn't fit by one byte. + yield return prefix + Repeat(OneByteCharacter, 100 - 2 + 1) + Repeat(TwoBytesCharacter, 1); + yield return prefix + Repeat(OneByteCharacter, 100 - 4 + 1) + Repeat(FourBytesCharacter, 1); + } + + // 2. non-ascii last character doesn't fit in prefix. + string maxedOutName = Repeat(OneByteCharacter, 100); + + // 2.1. last char doesn't fit fully. + yield return Repeat(OneByteCharacter, 155 + 1) + Separator + maxedOutName; + yield return Repeat(OneByteCharacter, 155 - 2) + Repeat(TwoBytesCharacter, 2) + Separator + maxedOutName; + yield return Repeat(OneByteCharacter, 155 - 4) + Repeat(FourBytesCharacter, 2) + Separator + maxedOutName; + + // 2.2 last char doesn't fit by one byte. + yield return Repeat(OneByteCharacter, 155 - 2 + 1) + TwoBytesCharacter + Separator + maxedOutName; + yield return Repeat(OneByteCharacter, 155 - 4 + 1) + FourBytesCharacter + Separator + maxedOutName; + + if (max is NameCapabilities.NameAndPrefix) + yield break; + + // Next cases only apply for V7 which only allows 100 length names. + foreach (string prefix in prefixes) + { + if (prefix.Length == 0) + continue; + + yield return prefix + Repeat(OneByteCharacter, 100); + yield return prefix + Repeat(TwoBytesCharacter, 100 / 2); + yield return prefix + Repeat(FourBytesCharacter, 100 / 4); + } + } + + internal static string Repeat(char c, int count) + { + return new string(c, count); + } + + internal static string Repeat(string c, int count) + { + return string.Concat(Enumerable.Repeat(c, count)); + } + + internal enum NameCapabilities + { + Name, + NameAndPrefix, + Unlimited + } + + internal static void WriteTarArchiveWithOneEntry(Stream s, TarEntryFormat entryFormat, TarEntryType entryType) + { + using TarWriter writer = new(s, leaveOpen: true); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, "foo"); + if (entryType == TarEntryType.SymbolicLink) + { + entry.LinkName = "bar"; + } + + writer.WriteEntry(entry); + } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs index 8d482af0b1dff1..b46816844b44cb 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.Tests.cs @@ -1,7 +1,6 @@ // 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; @@ -174,7 +173,7 @@ private TarEntry CreateTarEntryAndGetExpectedChecksum(TarEntryFormat format, Tar if (entryType is TarEntryType.SymbolicLink) { - expectedChecksum += GetLinkChecksum(longLink, out string linkName); + expectedChecksum += GetLinkChecksum(format, longLink, out string linkName); entry.LinkName = linkName; } @@ -195,23 +194,26 @@ private int GetNameChecksum(TarEntryFormat format, bool longPath, out string ent } else { - entryName = new string('a', 150); - // 100 * 97 = 9700 (first 100 bytes go into 'name' field) - expectedChecksum += 9700; + entryName = new string('a', 100); + expectedChecksum += 9700; // 100 * 97 = 9700 (first 100 bytes go into 'name' field) + + // V7 does not support name fields larger than 100 + if (format is not TarEntryFormat.V7) + { + entryName += "/" + new string('a', 50); + } - // - V7 does not support name fields larger than 100, writes what it can - // - Gnu writes first 100 bytes in 'name' field, then the full name is written in a LonPath entry - // that precedes this one. - if (format is TarEntryFormat.Ustar or TarEntryFormat.Pax) + // Gnu and Pax writes first 100 bytes in 'name' field, then the full name is written in a metadata entry that precedes this one. + if (format is TarEntryFormat.Ustar) { - // 50 * 97 = 4850 (rest of bytes go into 'prefix' field) - expectedChecksum += 4850; + // Ustar can write the directory into prefix. + expectedChecksum += 4850; // 50 * 97 = 4850 } } return expectedChecksum; } - private int GetLinkChecksum(bool longLink, out string linkName) + private int GetLinkChecksum(TarEntryFormat format, bool longLink, out string linkName) { int expectedChecksum = 0; if (!longLink) @@ -222,12 +224,16 @@ private int GetLinkChecksum(bool longLink, out string linkName) } else { - linkName = new string('a', 150); - // 100 * 97 = 9700 (first 100 bytes go into 'linkName' field) + linkName = new string('a', 100); // 100 * 97 = 9700 (first 100 bytes go into 'linkName' field) expectedChecksum += 9700; - // - V7 and Ustar ignore the rest of the bytes - // - Pax and Gnu write first 100 bytes in 'linkName' field, then the full link name is written in the + + // V7 and Ustar does not support name fields larger than 100 + // Pax and Gnu write first 100 bytes in 'linkName' field, then the full link name is written in the // preceding metadata entry (extended attributes for PAX, LongLink for GNU). + if (format is not TarEntryFormat.V7 and not TarEntryFormat.Ustar) + { + linkName += "/" + new string('a', 50); + } } return expectedChecksum; diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs new file mode 100644 index 00000000000000..b13863d0b0858e --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs @@ -0,0 +1,249 @@ +// 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 TarWriter_WriteEntry_Roundtrip_Tests : TarTestsBase + { + public static IEnumerable NameRoundtripsTheoryData() + { + foreach (bool unseekableStream in new[] { false, true }) + { + foreach (TarEntryType entryType in new[] { TarEntryType.RegularFile, TarEntryType.Directory }) + { + foreach (string name in GetNamesNonAsciiTestData(NameCapabilities.Name).Concat(GetNamesPrefixedTestData(NameCapabilities.Name))) + { + TarEntryType v7EntryType = entryType is TarEntryType.RegularFile ? TarEntryType.V7RegularFile : entryType; + yield return new object[] { TarEntryFormat.V7, v7EntryType, unseekableStream, name }; + } + + foreach (string name in GetNamesNonAsciiTestData(NameCapabilities.NameAndPrefix).Concat(GetNamesPrefixedTestData(NameCapabilities.NameAndPrefix))) + { + yield return new object[] { TarEntryFormat.Ustar, entryType, unseekableStream, name }; + } + + foreach (string name in GetNamesNonAsciiTestData(NameCapabilities.Unlimited).Concat(GetNamesPrefixedTestData(NameCapabilities.Unlimited))) + { + yield return new object[] { TarEntryFormat.Pax, entryType, unseekableStream, name }; + yield return new object[] { TarEntryFormat.Gnu, entryType, unseekableStream, name }; + } + } + } + } + + [Theory] + [MemberData(nameof(NameRoundtripsTheoryData))] + public void NameRoundtrips(TarEntryFormat entryFormat, TarEntryType entryType, bool unseekableStream, string name) + { + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, name); + entry.Name = name; + + MemoryStream ms = new(); + Stream s = unseekableStream ? new WrappedStream(ms, ms.CanRead, ms.CanWrite, canSeek: false) : ms; + + using (TarWriter writer = new(s, leaveOpen: true)) + { + writer.WriteEntry(entry); + } + + ms.Position = 0; + using TarReader reader = new(s); + + entry = reader.GetNextEntry(); + Assert.Null(reader.GetNextEntry()); + Assert.Equal(name, entry.Name); + } + + public static IEnumerable LinkNameRoundtripsTheoryData() + { + foreach (bool unseekableStream in new[] { false, true }) + { + foreach (TarEntryType entryType in new[] { TarEntryType.SymbolicLink, TarEntryType.HardLink }) + { + foreach (string name in GetNamesNonAsciiTestData(NameCapabilities.Name).Concat(GetNamesPrefixedTestData(NameCapabilities.Name))) + { + yield return new object[] { TarEntryFormat.V7, entryType, unseekableStream, name }; + yield return new object[] { TarEntryFormat.Ustar, entryType, unseekableStream, name }; + } + + foreach (string name in GetNamesNonAsciiTestData(NameCapabilities.Unlimited).Concat(GetNamesPrefixedTestData(NameCapabilities.Unlimited))) + { + yield return new object[] { TarEntryFormat.Pax, entryType, unseekableStream, name }; + yield return new object[] { TarEntryFormat.Gnu, entryType, unseekableStream, name }; + } + } + } + } + + [Theory] + [MemberData(nameof(LinkNameRoundtripsTheoryData))] + public void LinkNameRoundtrips(TarEntryFormat entryFormat, TarEntryType entryType, bool unseekableStream, string linkName) + { + string name = "foo"; + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, name); + entry.LinkName = linkName; + + MemoryStream ms = new(); + Stream s = unseekableStream ? new WrappedStream(ms, ms.CanRead, ms.CanWrite, canSeek: false) : ms; + + using (TarWriter writer = new(s, leaveOpen: true)) + { + writer.WriteEntry(entry); + } + + ms.Position = 0; + using TarReader reader = new(s); + + entry = reader.GetNextEntry(); + Assert.Null(reader.GetNextEntry()); + Assert.Equal(name, entry.Name); + Assert.Equal(linkName, entry.LinkName); + } + + public static IEnumerable UserNameGroupNameRoundtripsTheoryData() + { + foreach (bool unseekableStream in new[] { false, true }) + { + foreach (TarEntryFormat entryFormat in new[] { TarEntryFormat.Ustar, TarEntryFormat.Pax, TarEntryFormat.Gnu }) + { + yield return new object[] { entryFormat, unseekableStream, Repeat(OneByteCharacter, 32) }; + yield return new object[] { entryFormat, unseekableStream, Repeat(TwoBytesCharacter, 32 / 2) }; + yield return new object[] { entryFormat, unseekableStream, Repeat(FourBytesCharacter, 32 / 4) }; + } + } + } + + [Theory] + [MemberData(nameof(UserNameGroupNameRoundtripsTheoryData))] + public void UserNameGroupNameRoundtrips(TarEntryFormat entryFormat, bool unseekableStream, string userGroupName) + { + string name = "foo"; + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, TarEntryType.RegularFile, name); + PosixTarEntry posixEntry = Assert.IsAssignableFrom(entry); + posixEntry.UserName = userGroupName; + posixEntry.GroupName = userGroupName; + + MemoryStream ms = new(); + Stream s = unseekableStream ? new WrappedStream(ms, ms.CanRead, ms.CanWrite, canSeek: false) : ms; + + using (TarWriter writer = new(s, leaveOpen: true)) + { + writer.WriteEntry(posixEntry); + } + + ms.Position = 0; + using TarReader reader = new(s); + + entry = reader.GetNextEntry(); + posixEntry = Assert.IsAssignableFrom(entry); + Assert.Null(reader.GetNextEntry()); + + Assert.Equal(name, posixEntry.Name); + Assert.Equal(userGroupName, posixEntry.UserName); + Assert.Equal(userGroupName, posixEntry.GroupName); + } + + [Theory] + [InlineData(TarEntryType.RegularFile)] + [InlineData(TarEntryType.Directory)] + [InlineData(TarEntryType.HardLink)] + [InlineData(TarEntryType.SymbolicLink)] + public void PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenTheyFitOnLegacyFields(TarEntryType entryType) + { + Dictionary extendedAttributes = new(); + extendedAttributes[PaxEaName] = "ea_name"; + extendedAttributes[PaxEaGName] = "ea_gname"; + extendedAttributes[PaxEaUName] = "ea_uname"; + extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + extendedAttributes[PaxEaLinkName] = "ea_linkname"; + } + + PaxTarEntry writeEntry = new PaxTarEntry(entryType, "name", extendedAttributes); + writeEntry.Name = new string('a', 100); + // GName and UName must be longer than 32 to be written as extended attribute. + writeEntry.GroupName = new string('b', 32); + writeEntry.UserName = new string('c', 32); + // There's no limit on MTime, we just ensure it roundtrips. + writeEntry.ModificationTime = TestModificationTime.AddDays(1); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + writeEntry.LinkName = new string('d', 100); + } + + MemoryStream ms = new(); + using (TarWriter w = new(ms, leaveOpen: true)) + { + w.WriteEntry(writeEntry); + } + ms.Position = 0; + + using TarReader r = new(ms); + PaxTarEntry readEntry = Assert.IsType(r.GetNextEntry()); + Assert.Null(r.GetNextEntry()); + + Assert.Equal(writeEntry.Name, readEntry.Name); + Assert.Equal(writeEntry.GroupName, readEntry.GroupName); + Assert.Equal(writeEntry.UserName, readEntry.UserName); + Assert.Equal(writeEntry.ModificationTime, readEntry.ModificationTime); + Assert.Equal(writeEntry.LinkName, readEntry.LinkName); + } + + [Theory] + [InlineData(TarEntryType.RegularFile)] + [InlineData(TarEntryType.Directory)] + [InlineData(TarEntryType.HardLink)] + [InlineData(TarEntryType.SymbolicLink)] + public void PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenLargerThanLegacyFields(TarEntryType entryType) + { + Dictionary extendedAttributes = new(); + extendedAttributes[PaxEaName] = "ea_name"; + extendedAttributes[PaxEaGName] = "ea_gname"; + extendedAttributes[PaxEaUName] = "ea_uname"; + extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + extendedAttributes[PaxEaLinkName] = "ea_linkname"; + } + + PaxTarEntry writeEntry = new PaxTarEntry(entryType, "name", extendedAttributes); + writeEntry.Name = new string('a', MaxPathComponent); + // GName and UName must be longer than 32 to be written as extended attribute. + writeEntry.GroupName = new string('b', 32 + 1); + writeEntry.UserName = new string('c', 32 + 1); + // There's no limit on MTime, we just ensure it roundtrips. + writeEntry.ModificationTime = TestModificationTime.AddDays(1); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + writeEntry.LinkName = new string('d', 100 + 1); + } + + MemoryStream ms = new(); + using (TarWriter w = new(ms, leaveOpen: true)) + { + w.WriteEntry(writeEntry); + } + ms.Position = 0; + + using TarReader r = new(ms); + PaxTarEntry readEntry = Assert.IsType(r.GetNextEntry()); + Assert.Null(r.GetNextEntry()); + + Assert.Equal(writeEntry.Name, readEntry.Name); + Assert.Equal(writeEntry.GroupName, readEntry.GroupName); + Assert.Equal(writeEntry.UserName, readEntry.UserName); + Assert.Equal(writeEntry.ModificationTime, readEntry.ModificationTime); + Assert.Equal(writeEntry.LinkName, readEntry.LinkName); + } + } +} 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 index 2808914db4e52a..158c6fba96f4fd 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Tests.cs @@ -302,8 +302,6 @@ public void WriteTimestampsBeyondOctalLimit(TarEntryFormat format) } [Theory] - [InlineData(TarEntryFormat.V7)] - // [InlineData(TarEntryFormat.Ustar)] https://github.com/dotnet/runtime/issues/75360 [InlineData(TarEntryFormat.Pax)] [InlineData(TarEntryFormat.Gnu)] public void WriteLongName(TarEntryFormat format) @@ -352,5 +350,142 @@ string GetExpectedNameForFormat(TarEntryFormat format, string expectedName) return expectedName; } } + + public static IEnumerable WriteEntry_TooLongName_Throws_TheoryData() + { + foreach (TarEntryType entryType in new[] { TarEntryType.RegularFile, TarEntryType.Directory }) + { + foreach (string name in GetTooLongNamesTestData(NameCapabilities.Name)) + { + TarEntryType v7EntryType = entryType is TarEntryType.RegularFile ? TarEntryType.V7RegularFile : entryType; + yield return new object[] { TarEntryFormat.V7, v7EntryType, name }; + } + + foreach (string name in GetTooLongNamesTestData(NameCapabilities.NameAndPrefix)) + { + yield return new object[] { TarEntryFormat.Ustar, entryType, name }; + } + } + } + + [Theory] + [MemberData(nameof(WriteEntry_TooLongName_Throws_TheoryData))] + public void WriteEntry_TooLongName_Throws(TarEntryFormat entryFormat, TarEntryType entryType, string name) + { + using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, name); + Assert.Throws("entry", () => writer.WriteEntry(entry)); + } + + public static IEnumerable WriteEntry_TooLongLinkName_Throws_TheoryData() + { + foreach (TarEntryType entryType in new[] { TarEntryType.SymbolicLink, TarEntryType.HardLink }) + { + foreach (string name in GetTooLongNamesTestData(NameCapabilities.Name)) + { + yield return new object[] { TarEntryFormat.V7, entryType, name }; + } + + foreach (string name in GetTooLongNamesTestData(NameCapabilities.NameAndPrefix)) + { + yield return new object[] { TarEntryFormat.Ustar, entryType, name }; + } + } + } + + [Theory] + [MemberData(nameof(WriteEntry_TooLongLinkName_Throws_TheoryData))] + public void WriteEntry_TooLongLinkName_Throws(TarEntryFormat entryFormat, TarEntryType entryType, string linkName) + { + using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, "foo"); + entry.LinkName = linkName; + + Assert.Throws("entry", () => writer.WriteEntry(entry)); + } + + public static IEnumerable WriteEntry_TooLongUserGroupName_Throws_TheoryData() + { + // Not testing Pax as it supports unlimited size uname/gname. + foreach (TarEntryFormat entryFormat in new[] { TarEntryFormat.Ustar, TarEntryFormat.Gnu }) + { + // Last character doesn't fit fully. + yield return new object[] { entryFormat, Repeat(OneByteCharacter, 32 + 1) }; + yield return new object[] { entryFormat, Repeat(TwoBytesCharacter, 32 / 2 + 1) }; + yield return new object[] { entryFormat, Repeat(FourBytesCharacter, 32 / 4 + 1) }; + + // Last character doesn't fit by one byte. + yield return new object[] { entryFormat, Repeat(TwoBytesCharacter, 32 - 2 + 1) + TwoBytesCharacter }; + yield return new object[] { entryFormat, Repeat(FourBytesCharacter, 32 - 4 + 1) + FourBytesCharacter }; + } + } + + [Theory] + [MemberData(nameof(WriteEntry_TooLongUserGroupName_Throws_TheoryData))] + public void WriteEntry_TooLongUserName_Throws(TarEntryFormat entryFormat, string userName) + { + using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, TarEntryType.RegularFile, "foo"); + PosixTarEntry posixEntry = Assert.IsAssignableFrom(entry); + posixEntry.UserName = userName; + + Assert.Throws("entry", () => writer.WriteEntry(entry)); + } + + [Theory] + [MemberData(nameof(WriteEntry_TooLongUserGroupName_Throws_TheoryData))] + public void WriteEntry_TooLongGroupName_Throws(TarEntryFormat entryFormat, string groupName) + { + using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, TarEntryType.RegularFile, "foo"); + PosixTarEntry posixEntry = Assert.IsAssignableFrom(entry); + posixEntry.GroupName = groupName; + + Assert.Throws("entry", () => writer.WriteEntry(entry)); + } + + public static IEnumerable WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter_TheoryData() + { + foreach (var entryFormat in new[] { TarEntryFormat.V7, TarEntryFormat.Ustar, TarEntryFormat.Pax, TarEntryFormat.Gnu }) + { + foreach (var entryType in new[] { entryFormat == TarEntryFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile, TarEntryType.Directory, TarEntryType.SymbolicLink }) + { + foreach (bool unseekableStream in new[] { false, true }) + { + yield return new object[] { entryFormat, entryType, unseekableStream }; + } + } + } + } + + [Theory] + [MemberData(nameof(WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter_TheoryData))] + public void WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter(TarEntryFormat entryFormat, TarEntryType entryType, bool unseekableStream) + { + MemoryStream msSource = new(); + MemoryStream msDestination = new(); + + WriteTarArchiveWithOneEntry(msSource, entryFormat, entryType); + msSource.Position = 0; + + Stream source = new WrappedStream(msSource, msSource.CanRead, msSource.CanWrite, canSeek: !unseekableStream); + Stream destination = new WrappedStream(msDestination, msDestination.CanRead, msDestination.CanWrite, canSeek: !unseekableStream); + + using (TarReader reader = new(source)) + using (TarWriter writer = new(destination)) + { + TarEntry entry; + while ((entry = reader.GetNextEntry()) != null) + { + writer.WriteEntry(entry); + } + } + + AssertExtensions.SequenceEqual(msSource.ToArray(), msDestination.ToArray()); + } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs new file mode 100644 index 00000000000000..727474e50b1259 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs @@ -0,0 +1,198 @@ +// 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.Threading.Tasks; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class TarWriter_WriteEntryAsync_Roundtrip_Tests : TarTestsBase + { + public static IEnumerable NameRoundtripsAsyncTheoryData() + => TarWriter_WriteEntry_Roundtrip_Tests.NameRoundtripsTheoryData(); + + [Theory] + [MemberData(nameof(NameRoundtripsAsyncTheoryData))] + public async Task NameRoundtripsAsync(TarEntryFormat entryFormat, TarEntryType entryType, bool unseekableStream, string name) + { + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, name); + entry.Name = name; + + MemoryStream ms = new(); + Stream s = unseekableStream ? new WrappedStream(ms, ms.CanRead, ms.CanWrite, canSeek: false) : ms; + + await using (TarWriter writer = new(s, leaveOpen: true)) + { + await writer.WriteEntryAsync(entry); + } + + ms.Position = 0; + await using TarReader reader = new(s); + + entry = await reader.GetNextEntryAsync(); + Assert.Null(await reader.GetNextEntryAsync()); + Assert.Equal(name, entry.Name); + } + + public static IEnumerable LinkNameRoundtripsAsyncTheoryData() + => TarWriter_WriteEntry_Roundtrip_Tests.LinkNameRoundtripsTheoryData(); + + [Theory] + [MemberData(nameof(LinkNameRoundtripsAsyncTheoryData))] + public async Task LinkNameRoundtripsAsync(TarEntryFormat entryFormat, TarEntryType entryType, bool unseekableStream, string linkName) + { + string name = "foo"; + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, name); + entry.LinkName = linkName; + + MemoryStream ms = new(); + Stream s = unseekableStream ? new WrappedStream(ms, ms.CanRead, ms.CanWrite, canSeek: false) : ms; + + await using (TarWriter writer = new(s, leaveOpen: true)) + { + await writer.WriteEntryAsync(entry); + } + + ms.Position = 0; + await using TarReader reader = new(s); + + entry = await reader.GetNextEntryAsync(); + Assert.Null(await reader.GetNextEntryAsync()); + Assert.Equal(name, entry.Name); + Assert.Equal(linkName, entry.LinkName); + } + + public static IEnumerable UserNameGroupNameRoundtripsAsyncTheoryData() + => TarWriter_WriteEntry_Roundtrip_Tests.UserNameGroupNameRoundtripsTheoryData(); + + [Theory] + [MemberData(nameof(UserNameGroupNameRoundtripsAsyncTheoryData))] + public async Task UserNameGroupNameRoundtripsAsync(TarEntryFormat entryFormat, bool unseekableStream, string userGroupName) + { + string name = "foo"; + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, TarEntryType.RegularFile, name); + PosixTarEntry posixEntry = Assert.IsAssignableFrom(entry); + posixEntry.UserName = userGroupName; + posixEntry.GroupName = userGroupName; + + MemoryStream ms = new(); + Stream s = unseekableStream ? new WrappedStream(ms, ms.CanRead, ms.CanWrite, canSeek: false) : ms; + + await using (TarWriter writer = new(s, leaveOpen: true)) + { + await writer.WriteEntryAsync(posixEntry); + } + + ms.Position = 0; + await using TarReader reader = new(s); + + entry = await reader.GetNextEntryAsync(); + posixEntry = Assert.IsAssignableFrom(entry); + Assert.Null(await reader.GetNextEntryAsync()); + + Assert.Equal(name, posixEntry.Name); + Assert.Equal(userGroupName, posixEntry.UserName); + Assert.Equal(userGroupName, posixEntry.GroupName); + } + + [Theory] + [InlineData(TarEntryType.RegularFile)] + [InlineData(TarEntryType.Directory)] + [InlineData(TarEntryType.HardLink)] + [InlineData(TarEntryType.SymbolicLink)] + public async Task PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenTheyFitOnLegacyFieldsAsync(TarEntryType entryType) + { + Dictionary extendedAttributes = new(); + extendedAttributes[PaxEaName] = "ea_name"; + extendedAttributes[PaxEaGName] = "ea_gname"; + extendedAttributes[PaxEaUName] = "ea_uname"; + extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + extendedAttributes[PaxEaLinkName] = "ea_linkname"; + } + + PaxTarEntry writeEntry = new PaxTarEntry(entryType, "name", extendedAttributes); + writeEntry.Name = new string('a', 100); + // GName and UName must be longer than 32 to be written as extended attribute. + writeEntry.GroupName = new string('b', 32); + writeEntry.UserName = new string('c', 32); + // There's no limit on MTime, we just ensure it roundtrips. + writeEntry.ModificationTime = TestModificationTime.AddDays(1); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + writeEntry.LinkName = new string('d', 100); + } + + MemoryStream ms = new(); + await using (TarWriter w = new(ms, leaveOpen: true)) + { + await w.WriteEntryAsync(writeEntry); + } + ms.Position = 0; + + await using TarReader r = new(ms); + PaxTarEntry readEntry = Assert.IsType(await r.GetNextEntryAsync()); + Assert.Null(await r.GetNextEntryAsync()); + + Assert.Equal(writeEntry.Name, readEntry.Name); + Assert.Equal(writeEntry.GroupName, readEntry.GroupName); + Assert.Equal(writeEntry.UserName, readEntry.UserName); + Assert.Equal(writeEntry.ModificationTime, readEntry.ModificationTime); + Assert.Equal(writeEntry.LinkName, readEntry.LinkName); + } + + [Theory] + [InlineData(TarEntryType.RegularFile)] + [InlineData(TarEntryType.Directory)] + [InlineData(TarEntryType.HardLink)] + [InlineData(TarEntryType.SymbolicLink)] + public async Task PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenLargerThanLegacyFieldsAsync(TarEntryType entryType) + { + Dictionary extendedAttributes = new(); + extendedAttributes[PaxEaName] = "ea_name"; + extendedAttributes[PaxEaGName] = "ea_gname"; + extendedAttributes[PaxEaUName] = "ea_uname"; + extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + extendedAttributes[PaxEaLinkName] = "ea_linkname"; + } + + PaxTarEntry writeEntry = new PaxTarEntry(entryType, "name", extendedAttributes); + writeEntry.Name = new string('a', MaxPathComponent); + // GName and UName must be longer than 32 to be written as extended attribute. + writeEntry.GroupName = new string('b', 32 + 1); + writeEntry.UserName = new string('c', 32 + 1); + // There's no limit on MTime, we just ensure it roundtrips. + writeEntry.ModificationTime = TestModificationTime.AddDays(1); + + if (entryType is TarEntryType.HardLink or TarEntryType.SymbolicLink) + { + writeEntry.LinkName = new string('d', 100 + 1); + } + + MemoryStream ms = new(); + await using (TarWriter w = new(ms, leaveOpen: true)) + { + await w.WriteEntryAsync(writeEntry); + } + ms.Position = 0; + + await using TarReader r = new(ms); + PaxTarEntry readEntry = Assert.IsType(await r.GetNextEntryAsync()); + Assert.Null(await r.GetNextEntryAsync()); + + Assert.Equal(writeEntry.Name, readEntry.Name); + Assert.Equal(writeEntry.GroupName, readEntry.GroupName); + Assert.Equal(writeEntry.UserName, readEntry.UserName); + Assert.Equal(writeEntry.ModificationTime, readEntry.ModificationTime); + Assert.Equal(writeEntry.LinkName, readEntry.LinkName); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Tests.cs index c2eb58a7f1f24a..84ba2d8d83c2a1 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Tests.cs @@ -322,5 +322,91 @@ public async Task WriteTimestampsBeyondOctalLimit_Async(TarEntryFormat format) } } } + + public static IEnumerable WriteEntry_TooLongName_Throws_Async_TheoryData() + => TarWriter_WriteEntry_Tests.WriteEntry_TooLongName_Throws_TheoryData(); + + [Theory] + [MemberData(nameof(WriteEntry_TooLongName_Throws_Async_TheoryData))] + public async Task WriteEntry_TooLongName_Throws_Async(TarEntryFormat entryFormat, TarEntryType entryType, string name) + { + await using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, name); + await Assert.ThrowsAsync("entry", () => writer.WriteEntryAsync(entry)); + } + + public static IEnumerable WriteEntry_TooLongLinkName_Throws_Async_TheoryData() + => TarWriter_WriteEntry_Tests.WriteEntry_TooLongLinkName_Throws_TheoryData(); + + [Theory] + [MemberData(nameof(WriteEntry_TooLongLinkName_Throws_Async_TheoryData))] + public async Task WriteEntry_TooLongLinkName_Throws_Async(TarEntryFormat entryFormat, TarEntryType entryType, string linkName) + { + await using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, entryType, "foo"); + entry.LinkName = linkName; + + await Assert.ThrowsAsync("entry", () => writer.WriteEntryAsync(entry)); + } + + public static IEnumerable WriteEntry_TooLongUserGroupName_Throws_Async_TheoryData() + => TarWriter_WriteEntry_Tests.WriteEntry_TooLongUserGroupName_Throws_TheoryData(); + + [Theory] + [MemberData(nameof(WriteEntry_TooLongUserGroupName_Throws_Async_TheoryData))] + public async Task WriteEntry_TooLongUserName_Throws_Async(TarEntryFormat entryFormat, string userName) + { + await using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, TarEntryType.RegularFile, "foo"); + PosixTarEntry posixEntry = Assert.IsAssignableFrom(entry); + posixEntry.UserName = userName; + + await Assert.ThrowsAsync("entry", () => writer.WriteEntryAsync(entry)); + } + + [Theory] + [MemberData(nameof(WriteEntry_TooLongUserGroupName_Throws_Async_TheoryData))] + public async Task WriteEntry_TooLongGroupName_Throws_Async(TarEntryFormat entryFormat, string groupName) + { + await using TarWriter writer = new(new MemoryStream()); + + TarEntry entry = InvokeTarEntryCreationConstructor(entryFormat, TarEntryType.RegularFile, "foo"); + PosixTarEntry posixEntry = Assert.IsAssignableFrom(entry); + posixEntry.GroupName = groupName; + + await Assert.ThrowsAsync("entry", () => writer.WriteEntryAsync(entry)); + } + + public static IEnumerable WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter_Async_TheoryData() + => TarWriter_WriteEntry_Tests.WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter_TheoryData(); + + [Theory] + [MemberData(nameof(WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter_Async_TheoryData))] + public async Task WriteEntry_UsingTarEntry_FromTarReader_IntoTarWriter_Async(TarEntryFormat entryFormat, TarEntryType entryType, bool unseekableStream) + { + using MemoryStream msSource = new(); + using MemoryStream msDestination = new(); + + WriteTarArchiveWithOneEntry(msSource, entryFormat, entryType); + msSource.Position = 0; + + Stream source = new WrappedStream(msSource, msSource.CanRead, msSource.CanWrite, canSeek: !unseekableStream); + Stream destination = new WrappedStream(msDestination, msDestination.CanRead, msDestination.CanWrite, canSeek: !unseekableStream); + + await using (TarReader reader = new(source)) + await using (TarWriter writer = new(destination)) + { + TarEntry entry; + while ((entry = await reader.GetNextEntryAsync()) != null) + { + await writer.WriteEntryAsync(entry); + } + } + + AssertExtensions.SequenceEqual(msSource.ToArray(), msDestination.ToArray()); + } } }