Skip to content

Commit 5b0c6dd

Browse files
authored
Compression.ZipFile support for Unix Permissions (#55531)
* 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
1 parent 6a7603e commit 5b0c6dd

9 files changed

Lines changed: 299 additions & 8 deletions

src/libraries/System.IO.Compression.ZipFile/src/Resources/Strings.resx

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,46 @@
5757
<resheader name="writer">
5858
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
5959
</resheader>
60+
<data name="ArgumentOutOfRange_FileLengthTooBig" xml:space="preserve">
61+
<value>Specified file length was too large for the file system.</value>
62+
</data>
6063
<data name="IO_DirectoryNameWithData" xml:space="preserve">
6164
<value>Zip entry name ends in directory separator character but contains data.</value>
6265
</data>
6366
<data name="IO_ExtractingResultsInOutside" xml:space="preserve">
6467
<value>Extracting Zip entry would have resulted in a file outside the specified destination directory.</value>
6568
</data>
66-
</root>
69+
<data name="IO_FileExists_Name" xml:space="preserve">
70+
<value>The file '{0}' already exists.</value>
71+
</data>
72+
<data name="IO_FileNotFound" xml:space="preserve">
73+
<value>Unable to find the specified file.</value>
74+
</data>
75+
<data name="IO_FileNotFound_FileName" xml:space="preserve">
76+
<value>Could not find file '{0}'.</value>
77+
</data>
78+
<data name="IO_PathNotFound_NoPathName" xml:space="preserve">
79+
<value>Could not find a part of the path.</value>
80+
</data>
81+
<data name="IO_PathNotFound_Path" xml:space="preserve">
82+
<value>Could not find a part of the path '{0}'.</value>
83+
</data>
84+
<data name="IO_PathTooLong" xml:space="preserve">
85+
<value>The specified file name or path is too long, or a component of the specified path is too long.</value>
86+
</data>
87+
<data name="IO_PathTooLong_Path" xml:space="preserve">
88+
<value>The path '{0}' is too long, or a component of the specified path is too long.</value>
89+
</data>
90+
<data name="IO_SharingViolation_File" xml:space="preserve">
91+
<value>The process cannot access the file '{0}' because it is being used by another process.</value>
92+
</data>
93+
<data name="IO_SharingViolation_NoFileName" xml:space="preserve">
94+
<value>The process cannot access the file because it is being used by another process.</value>
95+
</data>
96+
<data name="UnauthorizedAccess_IODenied_NoPathName" xml:space="preserve">
97+
<value>Access to the path is denied.</value>
98+
</data>
99+
<data name="UnauthorizedAccess_IODenied_Path" xml:space="preserve">
100+
<value>Access to the path '{0}' is denied.</value>
101+
</data>
102+
</root>

src/libraries/System.IO.Compression.ZipFile/src/System.IO.Compression.ZipFile.csproj

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
33
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
4-
<TargetFrameworks>$(NetCoreAppCurrent)</TargetFrameworks>
4+
<TargetFrameworks>$(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix;$(NetCoreAppCurrent)-Browser</TargetFrameworks>
55
<Nullable>enable</Nullable>
66
</PropertyGroup>
77
<ItemGroup>
@@ -14,10 +14,26 @@
1414
<Compile Include="$(CommonPath)System\IO\PathInternal.CaseSensitivity.cs"
1515
Link="Common\System\IO\PathInternal.CaseSensitivity.cs" />
1616
</ItemGroup>
17+
<!-- Unix specific files -->
18+
<ItemGroup Condition=" '$(TargetsUnix)' == 'true' or '$(TargetsBrowser)' == 'true' ">
19+
<Compile Include="System\IO\Compression\ZipFileExtensions.ZipArchive.Create.Unix.cs" />
20+
<Compile Include="System\IO\Compression\ZipFileExtensions.ZipArchiveEntry.Extract.Unix.cs" />
21+
<Compile Include="$(CommonPath)Interop\Unix\Interop.IOErrors.cs"
22+
Link="Common\Interop\Unix\Interop.IOErrors.cs" />
23+
<Compile Include="$(CommonPath)Interop\Unix\Interop.Libraries.cs"
24+
Link="Common\Interop\Unix\Interop.Libraries.cs" />
25+
<Compile Include="$(CommonPath)Interop\Unix\Interop.Errors.cs"
26+
Link="Common\Interop\Unix\System.Native\Interop.Errors.cs" />
27+
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.FChMod.cs"
28+
Link="Common\Interop\Unix\System.Native\Interop.FChMod.cs" />
29+
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Stat.cs"
30+
Link="Common\Interop\Unix\System.Native\Interop.Stat.cs" />
31+
</ItemGroup>
1732
<ItemGroup>
1833
<Reference Include="System.IO.Compression" />
1934
<Reference Include="System.IO.FileSystem" />
2035
<Reference Include="System.Runtime" />
2136
<Reference Include="System.Runtime.Extensions" />
37+
<Reference Include="System.Runtime.InteropServices" />
2238
</ItemGroup>
2339
</Project>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace System.IO.Compression
5+
{
6+
public static partial class ZipFileExtensions
7+
{
8+
static partial void SetExternalAttributes(FileStream fs, ZipArchiveEntry entry)
9+
{
10+
Interop.Sys.FileStatus status;
11+
Interop.CheckIo(Interop.Sys.FStat(fs.SafeFileHandle, out status), fs.Name);
12+
13+
entry.ExternalAttributes |= status.Mode << 16;
14+
}
15+
}
16+
}

src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchive.Create.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ internal static ZipArchiveEntry DoCreateEntryFromFile(this ZipArchive destinatio
9494

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

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

110110
entry.LastWriteTime = lastWrite;
111111

112+
SetExternalAttributes(fs, entry);
113+
112114
using (Stream es = entry.Open())
113115
fs.CopyTo(es);
114116

115117
return entry;
116118
}
117119
}
120+
121+
static partial void SetExternalAttributes(FileStream fs, ZipArchiveEntry entry);
118122
}
119123
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace System.IO.Compression
5+
{
6+
public static partial class ZipFileExtensions
7+
{
8+
static partial void ExtractExternalAttributes(FileStream fs, ZipArchiveEntry entry)
9+
{
10+
// Only extract USR, GRP, and OTH file permissions, and ignore
11+
// S_ISUID, S_ISGID, and S_ISVTX bits. This matches unzip's default behavior.
12+
// It is off by default because of this comment:
13+
14+
// "It's possible that a file in an archive could have one of these bits set
15+
// and, unknown to the person unzipping, could allow others to execute the
16+
// file as the user or group. The new option -K bypasses this check."
17+
const int ExtractPermissionMask = 0x1FF;
18+
int permissions = (entry.ExternalAttributes >> 16) & ExtractPermissionMask;
19+
20+
// If the permissions weren't set at all, don't write the file's permissions,
21+
// since the .zip could have been made using a previous version of .NET, which didn't
22+
// include the permissions, or was made on Windows.
23+
if (permissions != 0)
24+
{
25+
Interop.CheckIo(Interop.Sys.FChMod(fs.SafeFileHandle, permissions), fs.Name);
26+
}
27+
}
28+
}
29+
}

src/libraries/System.IO.Compression.ZipFile/src/System/IO/Compression/ZipFileExtensions.ZipArchiveEntry.Extract.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

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

78-
using (Stream fs = new FileStream(destinationFileName, fMode, FileAccess.Write, FileShare.None, bufferSize: 0x1000, useAsync: false))
76+
using (FileStream fs = new FileStream(destinationFileName, fMode, FileAccess.Write, FileShare.None, bufferSize: 0x1000, useAsync: false))
7977
{
8078
using (Stream es = source.Open())
8179
es.CopyTo(fs);
80+
81+
ExtractExternalAttributes(fs, source);
8282
}
8383

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

87+
static partial void ExtractExternalAttributes(FileStream fs, ZipArchiveEntry entry);
88+
8789
internal static void ExtractRelativeToDirectory(this ZipArchiveEntry source, string destinationDirectoryName) =>
8890
ExtractRelativeToDirectory(source, destinationDirectoryName, overwrite: false);
8991

src/libraries/System.IO.Compression.ZipFile/tests/System.IO.Compression.ZipFile.Tests.csproj

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk">
22
<PropertyGroup>
3-
<TargetFrameworks>$(NetCoreAppCurrent)</TargetFrameworks>
3+
<TargetFrameworks>$(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-Unix;$(NetCoreAppCurrent)-Browser</TargetFrameworks>
44
</PropertyGroup>
55
<ItemGroup>
66
<Compile Include="ZipFile.Create.cs" />
@@ -23,6 +23,12 @@
2323
<Compile Include="$(CommonTestPath)System\IO\Compression\ZipTestHelper.cs"
2424
Link="Common\System\IO\Compression\ZipTestHelper.cs" />
2525
</ItemGroup>
26+
<ItemGroup Condition=" '$(TargetsUnix)' == 'true' or '$(TargetsBrowser)' == 'true' ">
27+
<Compile Include="ZipFile.Unix.cs" />
28+
<Compile Include="$(CommonPath)Interop\Unix\Interop.Libraries.cs" Link="Interop\Unix\Interop.Libraries.cs" />
29+
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.ChMod.cs" Link="Interop\Unix\System.Native\Interop.ChMod.cs" />
30+
<Compile Include="$(CommonPath)Interop\Unix\System.Native\Interop.Stat.cs" Link="Interop\Unix\System.Native\Interop.Stat.cs" />
31+
</ItemGroup>
2632
<ItemGroup>
2733
<PackageReference Include="System.IO.Compression.TestData" Version="$(SystemIOCompressionTestDataVersion)" />
2834
</ItemGroup>

src/libraries/System.IO.Compression.ZipFile/tests/ZipFile.Create.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,5 +450,28 @@ private static async Task UpdateArchive(ZipArchive archive, string installFile,
450450
}
451451
}
452452
}
453+
454+
[Fact]
455+
public void CreateSetsExternalAttributesCorrectly()
456+
{
457+
string folderName = zfolder("normal");
458+
string filepath = GetTestFilePath();
459+
ZipFile.CreateFromDirectory(folderName, filepath);
460+
461+
using (ZipArchive archive = ZipFile.Open(filepath, ZipArchiveMode.Read))
462+
{
463+
foreach (ZipArchiveEntry entry in archive.Entries)
464+
{
465+
if (OperatingSystem.IsWindows())
466+
{
467+
Assert.Equal(0, entry.ExternalAttributes);
468+
}
469+
else
470+
{
471+
Assert.NotEqual(0, entry.ExternalAttributes);
472+
}
473+
}
474+
}
475+
}
453476
}
454477
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text;
5+
using Xunit;
6+
7+
namespace System.IO.Compression.Tests
8+
{
9+
public class ZipFile_Unix : ZipFileTestBase
10+
{
11+
[Fact]
12+
public void UnixCreateSetsPermissionsInExternalAttributes()
13+
{
14+
// '7600' tests that S_ISUID, S_ISGID, and S_ISVTX bits get preserved in ExternalAttributes
15+
string[] testPermissions = new[] { "777", "755", "644", "600", "7600" };
16+
17+
using (var tempFolder = new TempDirectory(Path.Combine(GetTestFilePath(), "testFolder")))
18+
{
19+
foreach (string permission in testPermissions)
20+
{
21+
CreateFile(tempFolder.Path, permission);
22+
}
23+
24+
string archivePath = GetTestFilePath();
25+
ZipFile.CreateFromDirectory(tempFolder.Path, archivePath);
26+
27+
using (ZipArchive archive = ZipFile.OpenRead(archivePath))
28+
{
29+
Assert.Equal(5, archive.Entries.Count);
30+
31+
foreach (ZipArchiveEntry entry in archive.Entries)
32+
{
33+
Assert.EndsWith(".txt", entry.Name, StringComparison.Ordinal);
34+
EnsureExternalAttributes(entry.Name.Substring(0, entry.Name.Length - 4), entry);
35+
}
36+
37+
void EnsureExternalAttributes(string permissions, ZipArchiveEntry entry)
38+
{
39+
Assert.Equal(Convert.ToInt32(permissions, 8), (entry.ExternalAttributes >> 16) & 0xFFF);
40+
}
41+
}
42+
43+
// test that round tripping the archive has the same file permissions
44+
using (var extractFolder = new TempDirectory(Path.Combine(GetTestFilePath(), "extract")))
45+
{
46+
ZipFile.ExtractToDirectory(archivePath, extractFolder.Path);
47+
48+
foreach (string permission in testPermissions)
49+
{
50+
string filename = Path.Combine(extractFolder.Path, permission + ".txt");
51+
Assert.True(File.Exists(filename));
52+
53+
EnsureFilePermissions(filename, permission);
54+
}
55+
}
56+
}
57+
}
58+
59+
[Fact]
60+
public void UnixExtractSetsFilePermissionsFromExternalAttributes()
61+
{
62+
// '7600' tests that S_ISUID, S_ISGID, and S_ISVTX bits don't get extracted to file permissions
63+
string[] testPermissions = new[] { "777", "755", "644", "754", "7600" };
64+
byte[] contents = Encoding.UTF8.GetBytes("contents");
65+
66+
string archivePath = GetTestFilePath();
67+
using (FileStream fileStream = new FileStream(archivePath, FileMode.CreateNew))
68+
using (ZipArchive archive = new ZipArchive(fileStream, ZipArchiveMode.Create))
69+
{
70+
foreach (string permission in testPermissions)
71+
{
72+
ZipArchiveEntry entry = archive.CreateEntry(permission + ".txt");
73+
entry.ExternalAttributes = Convert.ToInt32(permission, 8) << 16;
74+
using Stream stream = entry.Open();
75+
stream.Write(contents);
76+
stream.Flush();
77+
}
78+
}
79+
80+
using (var tempFolder = new TempDirectory(GetTestFilePath()))
81+
{
82+
ZipFile.ExtractToDirectory(archivePath, tempFolder.Path);
83+
84+
foreach (string permission in testPermissions)
85+
{
86+
string filename = Path.Combine(tempFolder.Path, permission + ".txt");
87+
Assert.True(File.Exists(filename));
88+
89+
EnsureFilePermissions(filename, permission);
90+
}
91+
}
92+
}
93+
94+
private static void CreateFile(string folderPath, string permissions)
95+
{
96+
string filename = Path.Combine(folderPath, $"{permissions}.txt");
97+
File.WriteAllText(filename, "contents");
98+
99+
Assert.Equal(0, Interop.Sys.ChMod(filename, Convert.ToInt32(permissions, 8)));
100+
}
101+
102+
private static void EnsureFilePermissions(string filename, string permissions)
103+
{
104+
Interop.Sys.FileStatus status;
105+
Assert.Equal(0, Interop.Sys.Stat(filename, out status));
106+
107+
// note that we don't extract S_ISUID, S_ISGID, and S_ISVTX bits,
108+
// so only use the last 3 numbers of permissions to verify the file permissions
109+
permissions = permissions.Length > 3 ? permissions.Substring(permissions.Length - 3) : permissions;
110+
Assert.Equal(Convert.ToInt32(permissions, 8), status.Mode & 0xFFF);
111+
}
112+
113+
[Theory]
114+
[InlineData("sharpziplib.zip", null)] // ExternalAttributes are not set in this .zip, use the system default
115+
[InlineData("Linux_RW_RW_R__.zip", "664")]
116+
[InlineData("Linux_RWXRW_R__.zip", "764")]
117+
[InlineData("OSX_RWXRW_R__.zip", "764")]
118+
public void UnixExtractFilePermissionsCompat(string zipName, string expectedPermissions)
119+
{
120+
expectedPermissions = GetExpectedPermissions(expectedPermissions);
121+
122+
string zipFileName = compat(zipName);
123+
using (var tempFolder = new TempDirectory(GetTestFilePath()))
124+
{
125+
ZipFile.ExtractToDirectory(zipFileName, tempFolder.Path);
126+
127+
using ZipArchive archive = ZipFile.Open(zipFileName, ZipArchiveMode.Read);
128+
foreach (ZipArchiveEntry entry in archive.Entries)
129+
{
130+
string filename = Path.Combine(tempFolder.Path, entry.FullName);
131+
Assert.True(File.Exists(filename), $"File '{filename}' should exist");
132+
133+
EnsureFilePermissions(filename, expectedPermissions);
134+
}
135+
}
136+
}
137+
138+
private static string GetExpectedPermissions(string expectedPermissions)
139+
{
140+
if (string.IsNullOrEmpty(expectedPermissions))
141+
{
142+
// Create a new file, and get its permissions to get the current system default permissions
143+
144+
using (var tempFolder = new TempDirectory())
145+
{
146+
string filename = Path.Combine(tempFolder.Path, Path.GetRandomFileName());
147+
File.WriteAllText(filename, "contents");
148+
149+
Interop.Sys.FileStatus status;
150+
Assert.Equal(0, Interop.Sys.Stat(filename, out status));
151+
152+
expectedPermissions = Convert.ToString(status.Mode & 0xFFF, 8);
153+
}
154+
}
155+
156+
return expectedPermissions;
157+
}
158+
}
159+
}

0 commit comments

Comments
 (0)