Skip to content

Commit b4a9d88

Browse files
authored
TarEntry: remove some unneeded checks when extracting symbolic and hard links. (#85378)
* TarEntry: remove some unneeded checks when extracting symbolic and hard links. * Add test. * Fix linkTargetPath for hard links. The LinkName for hard links is a path relative to the archive root. * Add the trailing separator early on. * Sanitize the complete Name and LinkName. * Preserve the drive colon when sanitizing paths on Windows. * Add some comments. * PR feedback.
1 parent 3a72640 commit b4a9d88

11 files changed

+131
-89
lines changed

src/libraries/Common/src/System/IO/Archiving.Utils.Unix.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ namespace System.IO
55
{
66
internal static partial class ArchivingUtils
77
{
8-
internal static string SanitizeEntryFilePath(string entryPath) => entryPath.Replace('\0', '_');
8+
#pragma warning disable IDE0060 // preserveDriveRoot is unused.
9+
internal static string SanitizeEntryFilePath(string entryPath, bool preserveDriveRoot = false) => entryPath.Replace('\0', '_');
10+
#pragma warning restore IDE0060
911

1012
public static unsafe string EntryFromPath(ReadOnlySpan<char> path, bool appendPathSeparator = false)
1113
{

src/libraries/Common/src/System/IO/Archiving.Utils.Windows.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,23 @@ internal static partial class ArchivingUtils
1313
"\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F" +
1414
"\"*:<>?|");
1515

16-
internal static string SanitizeEntryFilePath(string entryPath)
16+
internal static string SanitizeEntryFilePath(string entryPath, bool preserveDriveRoot = false)
1717
{
18+
// When preserveDriveRoot is set, preserve the colon in 'c:\'.
19+
int offset = 0;
20+
if (preserveDriveRoot && entryPath.Length >= 3 && entryPath[1] == ':' && Path.IsPathFullyQualified(entryPath))
21+
{
22+
offset = 3;
23+
}
24+
1825
// Find the first illegal character in the entry path.
19-
int i = entryPath.AsSpan().IndexOfAny(s_illegalChars);
26+
int i = entryPath.AsSpan(offset).IndexOfAny(s_illegalChars);
2027
if (i < 0)
2128
{
2229
// There weren't any characters to sanitize. Just return the original string.
2330
return entryPath;
2431
}
32+
i += offset;
2533

2634
// We found at least one character that needs to be replaced.
2735
return string.Create(entryPath.Length, (i, entryPath), static (dest, state) =>

src/libraries/System.Formats.Tar/src/Resources/Strings.resx

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,6 @@
160160
<data name="TarGnuFormatExpected" xml:space="preserve">
161161
<value>Entry '{0}' was expected to be in the GNU format, but did not have the expected version data.</value>
162162
</data>
163-
<data name="TarHardLinkTargetNotExists" xml:space="preserve">
164-
<value>Cannot create a hard link '{0}' because the specified target file '{1}' does not exist.</value>
165-
</data>
166-
<data name="TarHardLinkToDirectoryNotAllowed" xml:space="preserve">
167-
<value>Cannot create the hard link '{0}' targeting the directory '{1}'.</value>
168-
</data>
169163
<data name="TarInvalidFormat" xml:space="preserve">
170164
<value>The archive format is invalid: '{0}'</value>
171165
</data>
@@ -178,9 +172,6 @@
178172
<data name="TarSizeFieldTooLargeForEntryType" xml:space="preserve">
179173
<value>The value of the size field for the current entry of type '{0}' is greater than the expected length.</value>
180174
</data>
181-
<data name="TarSymbolicLinkTargetNotExists" xml:space="preserve">
182-
<value>Cannot create the symbolic link '{0}' because the specified target '{1}' does not exist.</value>
183-
</data>
184175
<data name="TarUnexpectedMetadataEntry" xml:space="preserve">
185176
<value>A metadata entry of type '{0}' was unexpectedly found after a metadata entry of type '{1}'.</value>
186177
</data>

src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs

Lines changed: 41 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -330,59 +330,65 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b
330330
Debug.Assert(!string.IsNullOrEmpty(destinationDirectoryPath));
331331
Debug.Assert(Path.IsPathFullyQualified(destinationDirectoryPath));
332332

333-
destinationDirectoryPath = Path.TrimEndingDirectorySeparator(destinationDirectoryPath);
334-
335-
string? fileDestinationPath = GetSanitizedFullPath(destinationDirectoryPath, Name);
333+
string name = ArchivingUtils.SanitizeEntryFilePath(Name, preserveDriveRoot: true);
334+
string? fileDestinationPath = GetFullDestinationPath(
335+
destinationDirectoryPath,
336+
Path.IsPathFullyQualified(name) ? name : Path.Join(Path.GetDirectoryName(destinationDirectoryPath), name));
336337
if (fileDestinationPath == null)
337338
{
338-
throw new IOException(SR.Format(SR.TarExtractingResultsFileOutside, Name, destinationDirectoryPath));
339+
throw new IOException(SR.Format(SR.TarExtractingResultsFileOutside, name, destinationDirectoryPath));
339340
}
340341

341342
string? linkTargetPath = null;
342-
if (EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink)
343-
{
344-
if (string.IsNullOrEmpty(LinkName))
343+
if (EntryType is TarEntryType.SymbolicLink)
344+
{
345+
// LinkName is an absolute path, or path relative to the fileDestinationPath directory.
346+
// We don't check if the LinkName is empty. In that case, creation of the link will fail because link targets can't be empty.
347+
string linkName = ArchivingUtils.SanitizeEntryFilePath(LinkName, preserveDriveRoot: true);
348+
string? linkDestination = GetFullDestinationPath(
349+
destinationDirectoryPath,
350+
Path.IsPathFullyQualified(linkName) ? linkName : Path.Join(Path.GetDirectoryName(fileDestinationPath), linkName));
351+
if (linkDestination is null)
345352
{
346-
throw new InvalidDataException(SR.TarEntryHardLinkOrSymlinkLinkNameEmpty);
353+
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkName, destinationDirectoryPath));
347354
}
348-
349-
linkTargetPath = GetSanitizedFullPath(destinationDirectoryPath,
350-
Path.IsPathFullyQualified(LinkName) ? LinkName : Path.Join(Path.GetDirectoryName(fileDestinationPath), LinkName));
351-
352-
if (linkTargetPath == null)
355+
// Use the linkName for creating the symbolic link.
356+
linkTargetPath = linkName;
357+
}
358+
else if (EntryType is TarEntryType.HardLink)
359+
{
360+
// LinkName is path relative to the destinationDirectoryPath.
361+
// We don't check if the LinkName is empty. In that case, creation of the link will fail because a hard link can't target a directory.
362+
string linkName = ArchivingUtils.SanitizeEntryFilePath(LinkName, preserveDriveRoot: false);
363+
string? linkDestination = GetFullDestinationPath(
364+
destinationDirectoryPath,
365+
Path.Join(destinationDirectoryPath, linkName));
366+
if (linkDestination is null)
353367
{
354-
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, LinkName, destinationDirectoryPath));
368+
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkName, destinationDirectoryPath));
355369
}
356-
357-
// after TarExtractingResultsLinkOutside validation, preserve the original
358-
// symlink target path (to match behavior of other utilities).
359-
linkTargetPath = LinkName;
370+
// Use the target path for creating the hard link.
371+
linkTargetPath = linkDestination;
360372
}
361373

362374
return (fileDestinationPath, linkTargetPath);
363375
}
364376

365-
// If the path can be extracted in the specified destination directory, returns the full path with sanitized file name. Otherwise, returns null.
366-
private static string? GetSanitizedFullPath(string destinationDirectoryFullPath, string path)
377+
// Returns the full destination path if the path is the destinationDirectory or a subpath. Otherwise, returns null.
378+
private static string? GetFullDestinationPath(string destinationDirectoryFullPath, string qualifiedPath)
367379
{
368-
destinationDirectoryFullPath = PathInternal.EnsureTrailingSeparator(destinationDirectoryFullPath);
380+
Debug.Assert(Path.IsPathFullyQualified(qualifiedPath), $"{qualifiedPath} is not qualified");
381+
Debug.Assert(PathInternal.EndsInDirectorySeparator(destinationDirectoryFullPath), "caller must ensure the path ends with a separator.");
369382

370-
string fullyQualifiedPath = Path.IsPathFullyQualified(path) ? path : Path.Combine(destinationDirectoryFullPath, path);
371-
string normalizedPath = Path.GetFullPath(fullyQualifiedPath); // Removes relative segments
372-
string? fileName = Path.GetFileName(normalizedPath);
373-
if (string.IsNullOrEmpty(fileName)) // It's a directory
374-
{
375-
fileName = PathInternal.DirectorySeparatorCharAsString;
376-
}
383+
string fullPath = Path.GetFullPath(qualifiedPath); // Removes relative segments
377384

378-
string sanitizedPath = Path.Join(Path.GetDirectoryName(normalizedPath), ArchivingUtils.SanitizeEntryFilePath(fileName));
379-
return sanitizedPath.StartsWith(destinationDirectoryFullPath, PathInternal.StringComparison) ? sanitizedPath : null;
385+
return fullPath.StartsWith(destinationDirectoryFullPath, PathInternal.StringComparison) ? fullPath : null;
380386
}
381387

382388
// Extracts the current entry into the filesystem, regardless of the entry type.
383389
private void ExtractToFileInternal(string filePath, string? linkTargetPath, bool overwrite)
384390
{
385-
VerifyPathsForEntryType(filePath, linkTargetPath, overwrite);
391+
VerifyDestinationPath(filePath, overwrite);
386392

387393
if (EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.ContiguousFile)
388394
{
@@ -401,7 +407,7 @@ private Task ExtractToFileInternalAsync(string filePath, string? linkTargetPath,
401407
{
402408
return Task.FromCanceled(cancellationToken);
403409
}
404-
VerifyPathsForEntryType(filePath, linkTargetPath, overwrite);
410+
VerifyDestinationPath(filePath, overwrite);
405411

406412
if (EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile or TarEntryType.ContiguousFile)
407413
{
@@ -423,7 +429,7 @@ private void CreateNonRegularFile(string filePath, string? linkTargetPath)
423429
case TarEntryType.Directory:
424430
case TarEntryType.DirectoryList:
425431
// Mode must only be used for the leaf directory.
426-
// VerifyPathsForEntryType ensures we're only creating a leaf.
432+
// VerifyDestinationPath ensures we're only creating a leaf.
427433
Debug.Assert(Directory.Exists(Path.GetDirectoryName(filePath)));
428434
Debug.Assert(!Directory.Exists(filePath));
429435

@@ -476,8 +482,8 @@ private void CreateNonRegularFile(string filePath, string? linkTargetPath)
476482
}
477483
}
478484

479-
// Verifies if the specified paths make sense for the current type of entry.
480-
private void VerifyPathsForEntryType(string filePath, string? linkTargetPath, bool overwrite)
485+
// Verifies there's a writable destination.
486+
private static void VerifyDestinationPath(string filePath, bool overwrite)
481487
{
482488
string? directoryPath = Path.GetDirectoryName(filePath);
483489
// If the destination contains a directory segment, need to check that it exists
@@ -503,35 +509,6 @@ private void VerifyPathsForEntryType(string filePath, string? linkTargetPath, bo
503509
throw new IOException(SR.Format(SR.IO_AlreadyExists_Name, filePath));
504510
}
505511
File.Delete(filePath);
506-
507-
if (EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink)
508-
{
509-
if (!string.IsNullOrEmpty(linkTargetPath))
510-
{
511-
string? targetDirectoryPath = Path.GetDirectoryName(linkTargetPath);
512-
// If the destination target contains a directory segment, need to check that it exists
513-
if (!string.IsNullOrEmpty(targetDirectoryPath) && !Path.Exists(targetDirectoryPath))
514-
{
515-
throw new IOException(SR.Format(SR.TarSymbolicLinkTargetNotExists, filePath, linkTargetPath));
516-
}
517-
518-
if (EntryType is TarEntryType.HardLink)
519-
{
520-
if (!Path.Exists(linkTargetPath))
521-
{
522-
throw new IOException(SR.Format(SR.TarHardLinkTargetNotExists, filePath, linkTargetPath));
523-
}
524-
else if (Directory.Exists(linkTargetPath))
525-
{
526-
throw new IOException(SR.Format(SR.TarHardLinkToDirectoryNotAllowed, filePath, linkTargetPath));
527-
}
528-
}
529-
}
530-
else
531-
{
532-
throw new InvalidDataException(SR.TarEntryHardLinkOrSymlinkLinkNameEmpty);
533-
}
534-
}
535512
}
536513

537514
// Extracts the current entry as a regular file into the specified destination.

src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ public static void ExtractToDirectory(Stream source, string destinationDirectory
185185

186186
// Rely on Path.GetFullPath for validation of paths
187187
destinationDirectoryName = Path.GetFullPath(destinationDirectoryName);
188+
destinationDirectoryName = PathInternal.EnsureTrailingSeparator(destinationDirectoryName);
188189

189190
ExtractToDirectoryInternal(source, destinationDirectoryName, overwriteFiles, leaveOpen: true);
190191
}
@@ -229,6 +230,7 @@ public static Task ExtractToDirectoryAsync(Stream source, string destinationDire
229230

230231
// Rely on Path.GetFullPath for validation of paths
231232
destinationDirectoryName = Path.GetFullPath(destinationDirectoryName);
233+
destinationDirectoryName = PathInternal.EnsureTrailingSeparator(destinationDirectoryName);
232234

233235
return ExtractToDirectoryInternalAsync(source, destinationDirectoryName, overwriteFiles, leaveOpen: true, cancellationToken);
234236
}
@@ -257,6 +259,7 @@ public static void ExtractToDirectory(string sourceFileName, string destinationD
257259
// Rely on Path.GetFullPath for validation of paths
258260
sourceFileName = Path.GetFullPath(sourceFileName);
259261
destinationDirectoryName = Path.GetFullPath(destinationDirectoryName);
262+
destinationDirectoryName = PathInternal.EnsureTrailingSeparator(destinationDirectoryName);
260263

261264
if (!File.Exists(sourceFileName))
262265
{
@@ -303,6 +306,7 @@ public static Task ExtractToDirectoryAsync(string sourceFileName, string destina
303306
// Rely on Path.GetFullPath for validation of paths
304307
sourceFileName = Path.GetFullPath(sourceFileName);
305308
destinationDirectoryName = Path.GetFullPath(destinationDirectoryName);
309+
destinationDirectoryName = PathInternal.EnsureTrailingSeparator(destinationDirectoryName);
306310

307311
if (!File.Exists(sourceFileName))
308312
{

src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectory.File.Roundtrip.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ public void SymlinkRelativeTargets_OutsideTheArchive_Fails(string symlinkTargetP
7373
using FileStream archiveStream = File.OpenRead(destinationArchive);
7474
Exception exception = Assert.Throws<IOException>(() => TarFile.ExtractToDirectory(archiveStream, destinationDirectoryName, overwriteFiles: true));
7575

76-
Assert.Equal(SR.Format(SR.TarExtractingResultsLinkOutside, symlinkTargetPath, destinationDirectoryName), exception.Message);
76+
Assert.Equal(SR.Format(SR.TarExtractingResultsLinkOutside, symlinkTargetPath, $"{destinationDirectoryName}{Path.DirectorySeparatorChar}"), exception.Message);
7777
}
7878
}
7979
}

src/libraries/System.Formats.Tar/tests/TarFile/TarFile.CreateFromDirectoryAsync.File.Roundtrip.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public async Task SymlinkRelativeTargets_OutsideTheArchive_Fails_Async(string sy
7474
using FileStream archiveStream = File.OpenRead(destinationArchive);
7575
Exception exception = await Assert.ThrowsAsync<IOException>(() => TarFile.ExtractToDirectoryAsync(archiveStream, destinationDirectoryName, overwriteFiles: true));
7676

77-
Assert.Equal(SR.Format(SR.TarExtractingResultsLinkOutside, symlinkTargetPath, destinationDirectoryName), exception.Message);
77+
Assert.Equal(SR.Format(SR.TarExtractingResultsLinkOutside, symlinkTargetPath, $"{destinationDirectoryName}{Path.DirectorySeparatorChar}"), exception.Message);
7878
}
7979
}
8080
}

src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,5 +296,34 @@ public void UnixFileModes_RestrictiveParentDir(bool overwrite)
296296
Assert.True(File.Exists(filePath), $"{filePath}' does not exist.");
297297
AssertFileModeEquals(filePath, TestPermission1);
298298
}
299+
300+
[Fact]
301+
public void LinkBeforeTarget()
302+
{
303+
using TempDirectory source = new TempDirectory();
304+
using TempDirectory destination = new TempDirectory();
305+
306+
string archivePath = Path.Join(source.Path, "archive.tar");
307+
using FileStream archiveStream = File.Create(archivePath);
308+
using (TarWriter writer = new TarWriter(archiveStream))
309+
{
310+
PaxTarEntry link = new PaxTarEntry(TarEntryType.SymbolicLink, "link");
311+
link.LinkName = "dir/file";
312+
writer.WriteEntry(link);
313+
314+
PaxTarEntry file = new PaxTarEntry(TarEntryType.RegularFile, "dir/file");
315+
writer.WriteEntry(file);
316+
}
317+
318+
string filePath = Path.Join(destination.Path, "dir", "file");
319+
string linkPath = Path.Join(destination.Path, "link");
320+
321+
File.WriteAllText(linkPath, "");
322+
323+
TarFile.ExtractToDirectory(archivePath, destination.Path, overwriteFiles: true);
324+
325+
Assert.True(File.Exists(filePath), $"{filePath}' does not exist.");
326+
Assert.True(File.Exists(linkPath), $"{linkPath}' does not exist.");
327+
}
299328
}
300329
}

src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ public void Extract_LinkEntry_TargetOutsideDirectory(TarEntryType entryType)
9090

9191
using TempDirectory root = new TempDirectory();
9292

93-
Assert.Throws<IOException>(() => TarFile.ExtractToDirectory(archive, root.Path, overwriteFiles: false));
93+
Assert.ThrowsAny<IOException>(() => TarFile.ExtractToDirectory(archive, root.Path, overwriteFiles: false));
9494

9595
Assert.Equal(0, Directory.GetFileSystemEntries(root.Path).Count());
9696
}
@@ -123,19 +123,20 @@ private void Extract_LinkEntry_TargetInsideDirectory_Internal(TarEntryType entry
123123
{
124124
using TempDirectory root = new TempDirectory();
125125

126-
string baseDir = string.IsNullOrEmpty(subfolder) ? root.Path : Path.Join(root.Path, subfolder);
126+
string baseDir = root.Path;
127127
Directory.CreateDirectory(baseDir);
128128

129129
string linkName = "link";
130130
string targetName = "target";
131-
string targetPath = Path.Join(baseDir, targetName);
132-
133-
File.Create(targetPath).Dispose();
131+
string targetPath = string.IsNullOrEmpty(subfolder) ? targetName : Path.Join(subfolder, targetName);
134132

135133
using MemoryStream archive = new MemoryStream();
136134
using (TarWriter writer = new TarWriter(archive, format, leaveOpen: true))
137135
{
138-
TarEntry entry= InvokeTarEntryCreationConstructor(format, entryType, linkName);
136+
TarEntry fileEntry = InvokeTarEntryCreationConstructor(format, TarEntryType.RegularFile, targetPath);
137+
writer.WriteEntry(fileEntry);
138+
139+
TarEntry entry = InvokeTarEntryCreationConstructor(format, entryType, linkName);
139140
entry.LinkName = targetPath;
140141
writer.WriteEntry(entry);
141142
}

0 commit comments

Comments
 (0)