Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Tar timestamp conversion from/to string and DateTimeOffset #71038

Merged
merged 12 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ private bool TryReadCommonAttributes(Span<byte> buffer)
_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));
long mTime = TarHelpers.GetTenBaseLongFromOctalAsciiChars(buffer.Slice(FieldLocations.MTime, FieldLengths.MTime));
_mTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(mTime);
_typeFlag = (TarEntryType)buffer[FieldLocations.TypeFlag];
_linkName = TarHelpers.GetTrimmedUtf8String(buffer.Slice(FieldLocations.LinkName, FieldLengths.LinkName));
Expand Down Expand Up @@ -383,10 +383,10 @@ private void ReadPosixAndGnuSharedAttributes(Span<byte> buffer)
private void ReadGnuAttributes(Span<byte> buffer)
{
// Convert byte arrays
int aTime = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.ATime, FieldLengths.ATime));
long aTime = TarHelpers.GetTenBaseLongFromOctalAsciiChars(buffer.Slice(FieldLocations.ATime, FieldLengths.ATime));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to create a test that has a date time in 2039 to make sure we can handle dates past 2038.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests for the epochalypse and the max upper limit in octal.

_aTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(aTime);

int cTime = TarHelpers.GetTenBaseNumberFromOctalAsciiChars(buffer.Slice(FieldLocations.CTime, FieldLengths.CTime));
long cTime = TarHelpers.GetTenBaseLongFromOctalAsciiChars(buffer.Slice(FieldLocations.CTime, FieldLengths.CTime));
_cTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;

Expand Down Expand Up @@ -390,20 +389,6 @@ private void CollectExtendedAttributesFromStandardFieldsIfNeeded()
_extendedAttributes ??= new Dictionary<string, string>();
_extendedAttributes.Add(PaxEaName, _name);

bool containsATime = _extendedAttributes.ContainsKey(PaxEaATime);
bool containsCTime = _extendedAttributes.ContainsKey(PaxEaATime);
if (!containsATime || !containsCTime)
{
DateTimeOffset now = DateTimeOffset.UtcNow;
if (!containsATime)
{
AddTimestampAsUnixSeconds(_extendedAttributes, PaxEaATime, now);
}
if (!containsCTime)
{
AddTimestampAsUnixSeconds(_extendedAttributes, PaxEaCTime, now);
}
}
if (!_extendedAttributes.ContainsKey(PaxEaMTime))
{
AddTimestampAsUnixSeconds(_extendedAttributes, PaxEaMTime, _mTime);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,20 @@ internal static DateTimeOffset GetDateTimeOffsetFromSecondsSinceEpoch(long secon
new DateTimeOffset((secondsSinceUnixEpoch * TimeSpan.TicksPerSecond) + DateTime.UnixEpoch.Ticks, TimeSpan.Zero);

// Converts the specified number of seconds that have passed since the Unix Epoch to a DateTimeOffset.
internal static DateTimeOffset GetDateTimeOffsetFromSecondsSinceEpoch(double secondsSinceUnixEpoch) =>
private static DateTimeOffset GetDateTimeOffsetFromSecondsSinceEpoch(decimal secondsSinceUnixEpoch) =>
new DateTimeOffset((long)(secondsSinceUnixEpoch * TimeSpan.TicksPerSecond) + DateTime.UnixEpoch.Ticks, TimeSpan.Zero);

// Converts the specified DateTimeOffset to the number of seconds that have passed since the Unix Epoch.
internal static double GetSecondsSinceEpochFromDateTimeOffset(DateTimeOffset dateTimeOffset) =>
((double)(dateTimeOffset.UtcDateTime - DateTime.UnixEpoch).Ticks) / TimeSpan.TicksPerSecond;
private static decimal GetSecondsSinceEpochFromDateTimeOffset(DateTimeOffset dateTimeOffset) =>
((decimal)(dateTimeOffset.UtcDateTime - DateTime.UnixEpoch).Ticks) / TimeSpan.TicksPerSecond;

// If the specified fieldName is found in the provided dictionary and it is a valid double number, returns true and sets the value in 'dateTimeOffset'.
// If the specified fieldName is found in the provided dictionary and it is a valid decimal number, returns true and sets the value in 'dateTimeOffset'.
internal static bool TryGetDateTimeOffsetFromTimestampString(Dictionary<string, string>? dict, string fieldName, out DateTimeOffset dateTimeOffset)
{
dateTimeOffset = default;
if (dict != null &&
dict.TryGetValue(fieldName, out string? value) &&
double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out double secondsSinceEpoch))
decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal secondsSinceEpoch))
{
dateTimeOffset = GetDateTimeOffsetFromSecondsSinceEpoch(secondsSinceEpoch);
return true;
Expand All @@ -143,8 +143,10 @@ internal static bool TryGetDateTimeOffsetFromTimestampString(Dictionary<string,
// Converts the specified DateTimeOffset to the string representation of seconds since the Unix Epoch.
internal static string GetTimestampStringFromDateTimeOffset(DateTimeOffset timestamp)
{
double secondsSinceEpoch = GetSecondsSinceEpochFromDateTimeOffset(timestamp);
return secondsSinceEpoch.ToString("F9", CultureInfo.InvariantCulture); // 6 decimals, no commas
decimal secondsSinceEpoch = GetSecondsSinceEpochFromDateTimeOffset(timestamp);

// Use 'G' to ensure the decimals get preserved (avoid losing precision).
return secondsSinceEpoch.ToString("G", CultureInfo.InvariantCulture);
}

// If the specified fieldName is found in the provided dictionary and is a valid string representation of a number, returns true and sets the value in 'baseTenInteger'.
Expand Down Expand Up @@ -179,6 +181,14 @@ internal static int GetTenBaseNumberFromOctalAsciiChars(Span<byte> buffer)
return string.IsNullOrEmpty(str) ? 0 : Convert.ToInt32(str, fromBase: 8);
}

// 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 long GetTenBaseLongFromOctalAsciiChars(Span<byte> buffer)
{
string str = GetTrimmedAsciiString(buffer);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to create an intermediate string just to parse it into an integer?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to use System.Buffers.Text.Utf8Parser

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a Utf8Parser API that parses the integer from an "octal" string. I see it supports x - hexstring.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that a new allocation is not needed. This code can be improved to instead of returning a string, it returns an int representing the length of the ROS for slicing.

The method checks that the last character(s) in the ROS are either a 0 (null char) or a 32 (space). All other characters are not trimmed. Which means that if an unexpected non-numeric character is found, it will cause the conversion to fail.

Do you mind if I address this request later? I'd like to get this PR merged just for the DateTimeOffsets.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mind if I address this request later?

I think that is fine.

return string.IsNullOrEmpty(str) ? 0 : Convert.ToInt64(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<byte> buffer, Encoding encoding)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,10 @@ partial void ReadFileFromDiskAndWriteToArchiveStreamAsEntry(string fullPath, str
entry._header._devMinor = (int)minor;
}

entry._header._mTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(status.MTime);
entry._header._aTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(status.ATime);
entry._header._cTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(status.CTime);
entry._header._mTime = info.LastWriteTimeUtc;
entry._header._aTime = info.LastAccessTimeUtc;
// FileSystemInfo does not have ChangeTime, but LastWriteTime and LastAccessTime make sure to add nanoseconds, so we should do the same here
entry._header._cTime = DateTimeOffset.FromUnixTimeSeconds(status.CTime).AddTicks(status.CTimeNsec / 100 /* nanoseconds per tick */);

entry._header._mode = (status.Mode & 4095); // First 12 bits

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ partial void ReadFileFromDiskAndWriteToArchiveStreamAsEntry(string fullPath, str

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._header._mTime = info.LastWriteTimeUtc;
entry._header._aTime = info.LastAccessTimeUtc;
entry._header._cTime = info.LastWriteTimeUtc; // There is no "change time" property

entry.Mode = DefaultWindowsMode;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,89 @@ public class PaxTarEntry_Conversion_Tests : TarTestsConversionBase
[Fact]
public void Constructor_ConversionFromGnu_CharacterDevice() => TestConstructionConversion(TarEntryType.CharacterDevice, TarEntryFormat.Gnu, TarEntryFormat.Pax);

[Theory]
[InlineData(TarEntryFormat.V7)]
[InlineData(TarEntryFormat.Ustar)]
[InlineData(TarEntryFormat.Pax)]
[InlineData(TarEntryFormat.Gnu)]
public void Constructor_ConversionFromV7_Write(TarEntryFormat originalEntryFormat)
{
string name = "file.txt";
string contents = "Hello world";

TarEntry originalEntry = InvokeTarEntryCreationConstructor(originalEntryFormat, GetTarEntryTypeForTarEntryFormat(TarEntryType.RegularFile, originalEntryFormat), name);

using MemoryStream dataStream = new MemoryStream();
using (StreamWriter streamWriter = new StreamWriter(dataStream, leaveOpen: true))
{
streamWriter.WriteLine(contents);
}
dataStream.Position = 0;
originalEntry.DataStream = dataStream;

DateTimeOffset expectedATime;
DateTimeOffset expectedCTime;

if (originalEntryFormat is TarEntryFormat.Pax or TarEntryFormat.Gnu)
{
// The constructor should've set the atime and ctime automatically to the same value of mtime
expectedATime = originalEntry.ModificationTime;
expectedCTime = originalEntry.ModificationTime;
}
else
{
// ustar and v7 do not have atime and ctime, so the expected values of atime and ctime should be
// larger than mtime, because the conversion constructor sets those values automatically
DateTimeOffset now = DateTimeOffset.UtcNow;
expectedATime = now;
expectedCTime = now;
}

TarEntry convertedEntry = InvokeTarEntryConversionConstructor(TarEntryFormat.Pax, originalEntry);

using MemoryStream archiveStream = new MemoryStream();
using (TarWriter writer = new TarWriter(archiveStream, leaveOpen: true))
{
writer.WriteEntry(convertedEntry);
}

archiveStream.Position = 0;
using (TarReader reader = new TarReader(archiveStream))
{
PaxTarEntry paxEntry = reader.GetNextEntry() as PaxTarEntry;
Assert.NotNull(paxEntry);
Assert.Equal(TarEntryFormat.Pax, paxEntry.Format);
Assert.Equal(TarEntryType.RegularFile, paxEntry.EntryType);
Assert.Equal(name, paxEntry.Name);

Assert.NotNull(paxEntry.DataStream);

using (StreamReader streamReader = new StreamReader(paxEntry.DataStream, leaveOpen: true))
{
Assert.Equal(contents, streamReader.ReadLine());
}

// atime and ctime should've been added automatically in the conversion constructor
// and should not be equal to the value of mtime, which was set on the original entry constructor

Assert.Contains(PaxEaATime, paxEntry.ExtendedAttributes);
Assert.Contains(PaxEaCTime, paxEntry.ExtendedAttributes);
DateTimeOffset atime = GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes[PaxEaATime]);
DateTimeOffset ctime = GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes[PaxEaCTime]);

if (originalEntryFormat is TarEntryFormat.Pax or TarEntryFormat.Gnu)
{
Assert.Equal(expectedATime, atime);
Assert.Equal(expectedCTime, ctime);
}
else
{
AssertExtensions.GreaterThanOrEqualTo(atime, expectedATime);
AssertExtensions.GreaterThanOrEqualTo(ctime, expectedCTime);
}
}
}

[Theory]
[InlineData(TarEntryFormat.V7)]
[InlineData(TarEntryFormat.Ustar)]
Expand All @@ -72,7 +155,7 @@ public void Constructor_ConversionFromV7_From_UnseekableTarReader(TarEntryFormat
using WrappedStream wrappedSource = new WrappedStream(source, canRead: true, canWrite: false, canSeek: false);

using TarReader sourceReader = new TarReader(wrappedSource, leaveOpen: true);
V7TarEntry v7Entry = sourceReader.GetNextEntry(copyData: false) as V7TarEntry;
V7TarEntry v7Entry = sourceReader.GetNextEntry(copyData: false) as V7TarEntry; // Preserve the connection to the unseekable stream
PaxTarEntry paxEntry = new PaxTarEntry(other: v7Entry); // Convert, and avoid advancing wrappedSource position

using MemoryStream destination = new MemoryStream();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ private TarEntry GetFirstEntry(MemoryStream dataStream, TarEntryType entryType,
PaxTarEntry paxEntry = firstEntry as PaxTarEntry;
Assert.Contains("atime", paxEntry.ExtendedAttributes);
Assert.Contains("ctime", paxEntry.ExtendedAttributes);
CompareDateTimeOffsets(firstEntry.ModificationTime, GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, "atime"));
CompareDateTimeOffsets(firstEntry.ModificationTime, GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, "ctime"));
Assert.Equal(firstEntry.ModificationTime, GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, "atime"));
Assert.Equal(firstEntry.ModificationTime, GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, "ctime"));
}
else if (format is TarEntryFormat.Gnu)
{
GnuTarEntry gnuEntry = firstEntry as GnuTarEntry;
CompareDateTimeOffsets(firstEntry.ModificationTime, gnuEntry.AccessTime);
CompareDateTimeOffsets(firstEntry.ModificationTime, gnuEntry.ChangeTime);
Assert.Equal(firstEntry.ModificationTime, gnuEntry.AccessTime);
Assert.Equal(firstEntry.ModificationTime, gnuEntry.ChangeTime);
}

return firstEntry;
Expand Down Expand Up @@ -123,18 +123,18 @@ private TarEntry ConvertAndVerifyEntry(TarEntry originalEntry, TarEntryType entr
if (formatToConvert is TarEntryFormat.Pax)
{
PaxTarEntry paxEntry = convertedEntry as PaxTarEntry;
DateTimeOffset actualAccessTime = GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, "atime");
DateTimeOffset actualChangeTime = GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, "atime");
DateTimeOffset actualAccessTime = GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, PaxEaATime);
DateTimeOffset actualChangeTime = GetDateTimeOffsetFromTimestampString(paxEntry.ExtendedAttributes, PaxEaCTime);
if (originalEntry.Format is TarEntryFormat.Pax or TarEntryFormat.Gnu)
{
GetExpectedTimestampsFromOriginalPaxOrGnu(originalEntry, out DateTimeOffset expectedATime, out DateTimeOffset expectedCTime);
CompareDateTimeOffsets(expectedATime, actualAccessTime);
CompareDateTimeOffsets(expectedCTime, actualChangeTime);
Assert.Equal(expectedATime, actualAccessTime);
Assert.Equal(expectedCTime, actualChangeTime);
}
else if (originalEntry.Format is TarEntryFormat.Ustar or TarEntryFormat.V7)
{
CompareDateTimeOffsets(initialNow, actualAccessTime);
CompareDateTimeOffsets(initialNow, actualChangeTime);
AssertExtensions.GreaterThanOrEqualTo(actualAccessTime, initialNow);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this GreaterThanOrEqualTo and not just Assert.Equal?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In these cases, the initialNow timestamp is DateTimeOffset.UtcNow, which is generated shortly before invoking the constructors that take the other entry. Since the constructors generate the mtime timestamp automatically using UtcNow, I can't know the exact expected value, but I can at least verify that the value is larger than the timestamp I saved before calling the constructors.

The cases that do an Equal comparison are the ones where the existing mtime is used as the value to store for atime and ctime, so I know the exact expected value to compare.

AssertExtensions.GreaterThanOrEqualTo(actualChangeTime, initialNow);
}
}

Expand All @@ -144,13 +144,13 @@ private TarEntry ConvertAndVerifyEntry(TarEntry originalEntry, TarEntryType entr
if (originalEntry.Format is TarEntryFormat.Pax or TarEntryFormat.Gnu)
{
GetExpectedTimestampsFromOriginalPaxOrGnu(originalEntry, out DateTimeOffset expectedATime, out DateTimeOffset expectedCTime);
CompareDateTimeOffsets(expectedATime, gnuEntry.AccessTime);
CompareDateTimeOffsets(expectedCTime, gnuEntry.ChangeTime);
AssertExtensions.GreaterThanOrEqualTo(gnuEntry.AccessTime, expectedATime);
AssertExtensions.GreaterThanOrEqualTo(gnuEntry.ChangeTime, expectedCTime);
}
else if (originalEntry.Format is TarEntryFormat.Ustar or TarEntryFormat.V7)
{
CompareDateTimeOffsets(initialNow, gnuEntry.AccessTime);
CompareDateTimeOffsets(initialNow, gnuEntry.ChangeTime);
AssertExtensions.GreaterThanOrEqualTo(gnuEntry.AccessTime, initialNow);
AssertExtensions.GreaterThanOrEqualTo(gnuEntry.ChangeTime, initialNow);
}
}

Expand Down
Loading