Skip to content

Commit

Permalink
Compression.ZipFile support for Unix Permissions (#55531)
Browse files Browse the repository at this point in the history
* Compression.ZipFile support for Unix Permissions

When running on Unix, capture the file's permissions on ZipFile Create and write the captured file permissions on ZipFile Extract.

Fix #1548
  • Loading branch information
eerhardt authored Jul 14, 2021
1 parent 6a7603e commit 5b0c6dd
Show file tree
Hide file tree
Showing 9 changed files with 299 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,46 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ArgumentOutOfRange_FileLengthTooBig" xml:space="preserve">
<value>Specified file length was too large for the file system.</value>
</data>
<data name="IO_DirectoryNameWithData" xml:space="preserve">
<value>Zip entry name ends in directory separator character but contains data.</value>
</data>
<data name="IO_ExtractingResultsInOutside" xml:space="preserve">
<value>Extracting Zip entry would have resulted in a file outside the specified destination directory.</value>
</data>
</root>
<data name="IO_FileExists_Name" xml:space="preserve">
<value>The file '{0}' already exists.</value>
</data>
<data name="IO_FileNotFound" xml:space="preserve">
<value>Unable to find the specified file.</value>
</data>
<data name="IO_FileNotFound_FileName" xml:space="preserve">
<value>Could not find file '{0}'.</value>
</data>
<data name="IO_PathNotFound_NoPathName" xml:space="preserve">
<value>Could not find a part of the path.</value>
</data>
<data name="IO_PathNotFound_Path" xml:space="preserve">
<value>Could not find a part of the path '{0}'.</value>
</data>
<data name="IO_PathTooLong" xml:space="preserve">
<value>The specified file name or path is too long, or a component of the specified path is too long.</value>
</data>
<data name="IO_PathTooLong_Path" xml:space="preserve">
<value>The path '{0}' is too long, or a component of the specified path is too long.</value>
</data>
<data name="IO_SharingViolation_File" xml:space="preserve">
<value>The process cannot access the file '{0}' because it is being used by another process.</value>
</data>
<data name="IO_SharingViolation_NoFileName" xml:space="preserve">
<value>The process cannot access the file because it is being used by another process.</value>
</data>
<data name="UnauthorizedAccess_IODenied_NoPathName" xml:space="preserve">
<value>Access to the path is denied.</value>
</data>
<data name="UnauthorizedAccess_IODenied_Path" xml:space="preserve">
<value>Access to the path '{0}' is denied.</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<TargetFrameworks>$(NetCoreAppCurrent)</TargetFrameworks>
<TargetFrameworks>$(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix;$(NetCoreAppCurrent)-Browser</TargetFrameworks>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
Expand All @@ -14,10 +14,26 @@
<Compile Include="$(CommonPath)System\IO\PathInternal.CaseSensitivity.cs"
Link="Common\System\IO\PathInternal.CaseSensitivity.cs" />
</ItemGroup>
<!-- Unix specific files -->
<ItemGroup Condition=" '$(TargetsUnix)' == 'true' or '$(TargetsBrowser)' == 'true' ">
<Compile Include="System\IO\Compression\ZipFileExtensions.ZipArchive.Create.Unix.cs" />
<Compile Include="System\IO\Compression\ZipFileExtensions.ZipArchiveEntry.Extract.Unix.cs" />
<Compile Include="$(CommonPath)Interop\Unix\Interop.IOErrors.cs"
Link="Common\Interop\Unix\Interop.IOErrors.cs" />
<Compile Include="$(CommonPath)Interop\Unix\Interop.Libraries.cs"
Link="Common\Interop\Unix\Interop.Libraries.cs" />
<Compile Include="$(CommonPath)Interop\Unix\Interop.Errors.cs"
Link="Common\Interop\Unix\System.Native\Interop.Errors.cs" />
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.FChMod.cs"
Link="Common\Interop\Unix\System.Native\Interop.FChMod.cs" />
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Stat.cs"
Link="Common\Interop\Unix\System.Native\Interop.Stat.cs" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.IO.Compression" />
<Reference Include="System.IO.FileSystem" />
<Reference Include="System.Runtime" />
<Reference Include="System.Runtime.Extensions" />
<Reference Include="System.Runtime.InteropServices" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.IO.Compression
{
public static partial class ZipFileExtensions
{
static partial void SetExternalAttributes(FileStream fs, ZipArchiveEntry entry)
{
Interop.Sys.FileStatus status;
Interop.CheckIo(Interop.Sys.FStat(fs.SafeFileHandle, out status), fs.Name);

entry.ExternalAttributes |= status.Mode << 16;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio

// Argument checking gets passed down to FileStream's ctor and CreateEntry

using (Stream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 0x1000, useAsync: false))
using (FileStream fs = new FileStream(sourceFileName, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 0x1000, useAsync: false))
{
ZipArchiveEntry entry = compressionLevel.HasValue
? destination.CreateEntry(entryName, compressionLevel.Value)
Expand All @@ -109,11 +109,15 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio

entry.LastWriteTime = lastWrite;

SetExternalAttributes(fs, entry);

using (Stream es = entry.Open())
fs.CopyTo(es);

return entry;
}
}

static partial void SetExternalAttributes(FileStream fs, ZipArchiveEntry entry);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace System.IO.Compression
{
public static partial class ZipFileExtensions
{
static partial void ExtractExternalAttributes(FileStream fs, ZipArchiveEntry entry)
{
// Only extract USR, GRP, and OTH file permissions, and ignore
// S_ISUID, S_ISGID, and S_ISVTX bits. This matches unzip's default behavior.
// It is off by default because of this comment:

// "It's possible that a file in an archive could have one of these bits set
// and, unknown to the person unzipping, could allow others to execute the
// file as the user or group. The new option -K bypasses this check."
const int ExtractPermissionMask = 0x1FF;
int permissions = (entry.ExternalAttributes >> 16) & ExtractPermissionMask;

// If the permissions weren't set at all, don't write the file's permissions,
// since the .zip could have been made using a previous version of .NET, which didn't
// include the permissions, or was made on Windows.
if (permissions != 0)
{
Interop.CheckIo(Interop.Sys.FChMod(fs.SafeFileHandle, permissions), fs.Name);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +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.ComponentModel;

namespace System.IO.Compression
{
public static partial class ZipFileExtensions
Expand Down Expand Up @@ -75,15 +73,19 @@ public static void ExtractToFile(this ZipArchiveEntry source, string destination
// Rely on FileStream's ctor for further checking destinationFileName parameter
FileMode fMode = overwrite ? FileMode.Create : FileMode.CreateNew;

using (Stream fs = new FileStream(destinationFileName, fMode, FileAccess.Write, FileShare.None, bufferSize: 0x1000, useAsync: false))
using (FileStream fs = new FileStream(destinationFileName, fMode, FileAccess.Write, FileShare.None, bufferSize: 0x1000, useAsync: false))
{
using (Stream es = source.Open())
es.CopyTo(fs);

ExtractExternalAttributes(fs, source);
}

File.SetLastWriteTime(destinationFileName, source.LastWriteTime.DateTime);
}

static partial void ExtractExternalAttributes(FileStream fs, ZipArchiveEntry entry);

internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName) =>
ExtractRelativeToDirectory(source, destinationDirectoryName, overwrite: false);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(NetCoreAppCurrent)</TargetFrameworks>
<TargetFrameworks>$(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix;$(NetCoreAppCurrent)-Browser</TargetFrameworks>
</PropertyGroup>
<ItemGroup>
<Compile Include="ZipFile.Create.cs" />
Expand All @@ -23,6 +23,12 @@
<Compile Include="$(CommonTestPath)System\IO\Compression\ZipTestHelper.cs"
Link="Common\System\IO\Compression\ZipTestHelper.cs" />
</ItemGroup>
<ItemGroup Condition=" '$(TargetsUnix)' == 'true' or '$(TargetsBrowser)' == 'true' ">
<Compile Include="ZipFile.Unix.cs" />
<Compile Include="$(CommonPath)Interop\Unix\Interop.Libraries.cs" Link="Interop\Unix\Interop.Libraries.cs" />
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.ChMod.cs" Link="Interop\Unix\System.Native\Interop.ChMod.cs" />
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Stat.cs" Link="Interop\Unix\System.Native\Interop.Stat.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Compression.TestData" Version="$(SystemIOCompressionTestDataVersion)" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,5 +450,28 @@ private static async Task UpdateArchive(ZipArchive archive, string installFile,
}
}
}

[Fact]
public void CreateSetsExternalAttributesCorrectly()
{
string folderName = zfolder("normal");
string filepath = GetTestFilePath();
ZipFile.CreateFromDirectory(folderName, filepath);

using (ZipArchive archive = ZipFile.Open(filepath, ZipArchiveMode.Read))
{
foreach (ZipArchiveEntry entry in archive.Entries)
{
if (OperatingSystem.IsWindows())
{
Assert.Equal(0, entry.ExternalAttributes);
}
else
{
Assert.NotEqual(0, entry.ExternalAttributes);
}
}
}
}
}
}
159 changes: 159 additions & 0 deletions src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Unix.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text;
using Xunit;

namespace System.IO.Compression.Tests
{
public class ZipFile_Unix : ZipFileTestBase
{
[Fact]
public void UnixCreateSetsPermissionsInExternalAttributes()
{
// '7600' tests that S_ISUID, S_ISGID, and S_ISVTX bits get preserved in ExternalAttributes
string[] testPermissions = new[] { "777", "755", "644", "600", "7600" };

using (var tempFolder = new TempDirectory(Path.Combine(GetTestFilePath(), "testFolder")))
{
foreach (string permission in testPermissions)
{
CreateFile(tempFolder.Path, permission);
}

string archivePath = GetTestFilePath();
ZipFile.CreateFromDirectory(tempFolder.Path, archivePath);

using (ZipArchive archive = ZipFile.OpenRead(archivePath))
{
Assert.Equal(5, archive.Entries.Count);

foreach (ZipArchiveEntry entry in archive.Entries)
{
Assert.EndsWith(".txt", entry.Name, StringComparison.Ordinal);
EnsureExternalAttributes(entry.Name.Substring(0, entry.Name.Length - 4), entry);
}

void EnsureExternalAttributes(string permissions, ZipArchiveEntry entry)
{
Assert.Equal(Convert.ToInt32(permissions, 8), (entry.ExternalAttributes >> 16) & 0xFFF);
}
}

// test that round tripping the archive has the same file permissions
using (var extractFolder = new TempDirectory(Path.Combine(GetTestFilePath(), "extract")))
{
ZipFile.ExtractToDirectory(archivePath, extractFolder.Path);

foreach (string permission in testPermissions)
{
string filename = Path.Combine(extractFolder.Path, permission + ".txt");
Assert.True(File.Exists(filename));

EnsureFilePermissions(filename, permission);
}
}
}
}

[Fact]
public void UnixExtractSetsFilePermissionsFromExternalAttributes()
{
// '7600' tests that S_ISUID, S_ISGID, and S_ISVTX bits don't get extracted to file permissions
string[] testPermissions = new[] { "777", "755", "644", "754", "7600" };
byte[] contents = Encoding.UTF8.GetBytes("contents");

string archivePath = GetTestFilePath();
using (FileStream fileStream = new FileStream(archivePath, FileMode.CreateNew))
using (ZipArchive archive = new ZipArchive(fileStream, ZipArchiveMode.Create))
{
foreach (string permission in testPermissions)
{
ZipArchiveEntry entry = archive.CreateEntry(permission + ".txt");
entry.ExternalAttributes = Convert.ToInt32(permission, 8) << 16;
using Stream stream = entry.Open();
stream.Write(contents);
stream.Flush();
}
}

using (var tempFolder = new TempDirectory(GetTestFilePath()))
{
ZipFile.ExtractToDirectory(archivePath, tempFolder.Path);

foreach (string permission in testPermissions)
{
string filename = Path.Combine(tempFolder.Path, permission + ".txt");
Assert.True(File.Exists(filename));

EnsureFilePermissions(filename, permission);
}
}
}

private static void CreateFile(string folderPath, string permissions)
{
string filename = Path.Combine(folderPath, $"{permissions}.txt");
File.WriteAllText(filename, "contents");

Assert.Equal(0, Interop.Sys.ChMod(filename, Convert.ToInt32(permissions, 8)));
}

private static void EnsureFilePermissions(string filename, string permissions)
{
Interop.Sys.FileStatus status;
Assert.Equal(0, Interop.Sys.Stat(filename, out status));

// note that we don't extract S_ISUID, S_ISGID, and S_ISVTX bits,
// so only use the last 3 numbers of permissions to verify the file permissions
permissions = permissions.Length > 3 ? permissions.Substring(permissions.Length - 3) : permissions;
Assert.Equal(Convert.ToInt32(permissions, 8), status.Mode & 0xFFF);
}

[Theory]
[InlineData("sharpziplib.zip", null)] // ExternalAttributes are not set in this .zip, use the system default
[InlineData("Linux_RW_RW_R__.zip", "664")]
[InlineData("Linux_RWXRW_R__.zip", "764")]
[InlineData("OSX_RWXRW_R__.zip", "764")]
public void UnixExtractFilePermissionsCompat(string zipName, string expectedPermissions)
{
expectedPermissions = GetExpectedPermissions(expectedPermissions);

string zipFileName = compat(zipName);
using (var tempFolder = new TempDirectory(GetTestFilePath()))
{
ZipFile.ExtractToDirectory(zipFileName, tempFolder.Path);

using ZipArchive archive = ZipFile.Open(zipFileName, ZipArchiveMode.Read);
foreach (ZipArchiveEntry entry in archive.Entries)
{
string filename = Path.Combine(tempFolder.Path, entry.FullName);
Assert.True(File.Exists(filename), $"File '{filename}' should exist");

EnsureFilePermissions(filename, expectedPermissions);
}
}
}

private static string GetExpectedPermissions(string expectedPermissions)
{
if (string.IsNullOrEmpty(expectedPermissions))
{
// Create a new file, and get its permissions to get the current system default permissions

using (var tempFolder = new TempDirectory())
{
string filename = Path.Combine(tempFolder.Path, Path.GetRandomFileName());
File.WriteAllText(filename, "contents");

Interop.Sys.FileStatus status;
Assert.Equal(0, Interop.Sys.Stat(filename, out status));

expectedPermissions = Convert.ToString(status.Mode & 0xFFF, 8);
}
}

return expectedPermissions;
}
}
}

0 comments on commit 5b0c6dd

Please sign in to comment.