Skip to content

Commit f56192c

Browse files
Merge pull request #109 from thunderstore-io/fix/archive-permissions
Preserve unix file mode for files copied into package zips
2 parents 1116dc7 + 8aa37ad commit f56192c

File tree

3 files changed

+82
-43
lines changed

3 files changed

+82
-43
lines changed

ThunderstoreCLI.Tests/ThunderstoreCLI.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>net7.0;net6.0</TargetFrameworks>
4+
<TargetFramework>net8.0</TargetFramework>
55
<RollForward>Major</RollForward>
66
<LangVersion>11</LangVersion>
77
<IsPackable>false</IsPackable>

ThunderstoreCLI/Commands/BuildCommand.cs

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
using System.Buffers;
12
using System.IO.Compression;
3+
using System.Runtime.Versioning;
24
using System.Text;
35
using ThunderstoreCLI.Configuration;
46
using ThunderstoreCLI.Models;
@@ -9,16 +11,57 @@ namespace ThunderstoreCLI.Commands;
911

1012
public static class BuildCommand
1113
{
12-
public class ArchivePlan
14+
private abstract class EntryData
1315
{
14-
public Config Config { get; protected set; }
15-
public bool HasWarnings { get; protected set; }
16-
public bool HasErrors { get; protected set; }
16+
private EntryData() { }
1717

18-
protected Dictionary<string, Func<byte[]>> plan;
19-
protected Dictionary<string, string> duplicateMap;
20-
protected HashSet<string> directories;
21-
protected HashSet<string> files;
18+
public sealed class FromFile : EntryData
19+
{
20+
private string _filePath;
21+
22+
public FromFile(string path)
23+
{
24+
_filePath = path;
25+
}
26+
27+
public override Stream GetStream() => File.OpenRead(_filePath);
28+
29+
[UnsupportedOSPlatform("windows")]
30+
public override UnixFileMode GetUnixFileMode() => File.GetUnixFileMode(_filePath);
31+
}
32+
33+
public sealed class FromMemory : EntryData
34+
{
35+
private byte[] _data;
36+
private UnixFileMode _fileMode;
37+
38+
public FromMemory(byte[] data, UnixFileMode fileMode)
39+
{
40+
_data = data;
41+
_fileMode = fileMode;
42+
}
43+
44+
public override Stream GetStream() => new MemoryStream(_data, false);
45+
46+
public override UnixFileMode GetUnixFileMode() => _fileMode;
47+
}
48+
49+
public abstract Stream GetStream();
50+
51+
[UnsupportedOSPlatform("windows")]
52+
public abstract UnixFileMode GetUnixFileMode();
53+
}
54+
55+
private class ArchivePlan
56+
{
57+
public Config Config { get; }
58+
public bool HasWarnings { get; private set; }
59+
public bool HasErrors { get; private set; }
60+
61+
private readonly Dictionary<string, EntryData> plan;
62+
private readonly Dictionary<string, string> duplicateMap;
63+
private readonly HashSet<string> directories;
64+
private readonly HashSet<string> files;
2265

2366
public ArchivePlan(Config config)
2467
{
@@ -29,28 +72,27 @@ public ArchivePlan(Config config)
2972
files = new();
3073
}
3174

32-
public void AddPlan(string path, Func<byte[]> dataGetter)
75+
public void AddPlan(string path, EntryData dataGetter)
3376
{
3477
var key = path.ToLowerInvariant();
3578

3679
var directoryKeys = new HashSet<string>();
3780
var pathParts = key;
38-
var lastSeparatorIndex = pathParts.LastIndexOf("/");
81+
var lastSeparatorIndex = pathParts.LastIndexOf('/');
3982
while (lastSeparatorIndex > 0)
4083
{
4184
pathParts = pathParts.Substring(0, lastSeparatorIndex);
4285
directoryKeys.Add(pathParts);
43-
lastSeparatorIndex = pathParts.LastIndexOf("/");
86+
lastSeparatorIndex = pathParts.LastIndexOf('/');
4487
}
4588

46-
if (duplicateMap.ContainsKey(key))
89+
if (duplicateMap.TryGetValue(key, out var duplicatePath))
4790
{
48-
var duplicatePath = duplicateMap[key];
4991
if (duplicatePath != path)
5092
{
5193
Write.Error(
5294
"Case mismatch!",
53-
$"A file target was added twice to the build with different casing, which is not allowed!",
95+
"A file target was added twice to the build with different casing, which is not allowed!",
5496
$"Previously: {White(Dim($"/{duplicatePath}"))}",
5597
$"Now: {White(Dim($"/{path}"))}"
5698
);
@@ -98,7 +140,7 @@ public void AddPlan(string path, Func<byte[]> dataGetter)
98140
}
99141
}
100142

101-
public Dictionary<string, Func<byte[]>>.Enumerator GetEnumerator()
143+
public Dictionary<string, EntryData>.Enumerator GetEnumerator()
102144
{
103145
return plan.GetEnumerator();
104146
}
@@ -152,9 +194,9 @@ public static int DoBuild(Config config)
152194

153195
Write.Header("Planning for files to include in build");
154196

155-
plan.AddPlan("icon.png", () => File.ReadAllBytes(iconPath));
156-
plan.AddPlan("README.md", () => File.ReadAllBytes(readmePath));
157-
plan.AddPlan("manifest.json", () => Encoding.UTF8.GetBytes(SerializeManifest(config)));
197+
plan.AddPlan("icon.png", new EntryData.FromFile(iconPath));
198+
plan.AddPlan("README.md", new EntryData.FromFile(readmePath));
199+
plan.AddPlan("manifest.json", new EntryData.FromMemory(Encoding.UTF8.GetBytes(SerializeManifest(config)), (UnixFileMode)0b110_110_100)); // rw-rw-r--
158200

159201
if (config.BuildConfig.CopyPaths is not null)
160202
{
@@ -181,21 +223,19 @@ public static int DoBuild(Config config)
181223
{
182224
using (var archive = new ZipArchive(outputFile, ZipArchiveMode.Create))
183225
{
184-
var isWindows = OperatingSystem.IsWindows();
185226
foreach (var entry in plan)
186227
{
187228
Write.Light($"Writing /{entry.Key}");
188229
var archiveEntry = archive.CreateEntry(entry.Key, CompressionLevel.Optimal);
189-
if (!isWindows)
190-
{
191-
// https://github.com/dotnet/runtime/issues/17912#issuecomment-641594638
192-
// modifed solution to use a constant instead of a string conversion
193-
archiveEntry.ExternalAttributes |= 0b110110100 << 16; // rw-rw-r-- permissions
194-
}
195-
using (var writer = new BinaryWriter(archiveEntry.Open()))
230+
if (!OperatingSystem.IsWindows())
196231
{
197-
writer.Write(entry.Value());
232+
// windows-created archives do not use these bits as unix file mode, so we should not set them there
233+
archiveEntry.ExternalAttributes |= (int)entry.Value.GetUnixFileMode() << 16;
198234
}
235+
236+
using var entryStream = archiveEntry.Open();
237+
using var dataStream = entry.Value.GetStream();
238+
dataStream.CopyTo(entryStream);
199239
}
200240
}
201241
}
@@ -214,7 +254,7 @@ public static int DoBuild(Config config)
214254
}
215255
}
216256

217-
public static bool AddPathToArchivePlan(ArchivePlan plan, string sourcePath, string destinationPath)
257+
private static bool AddPathToArchivePlan(ArchivePlan plan, string sourcePath, string destinationPath)
218258
{
219259
var basePath = plan.Config.GetProjectRelativePath(sourcePath);
220260
if (Directory.Exists(basePath))
@@ -226,7 +266,7 @@ public static bool AddPathToArchivePlan(ArchivePlan plan, string sourcePath, str
226266
foreach (string filename in Directory.EnumerateFiles(basePath, "*.*", SearchOption.AllDirectories))
227267
{
228268
var targetPath = FormatArchivePath($"{destDirectory}{filename[(basePath.Length + 1)..]}");
229-
plan.AddPlan(targetPath, () => File.ReadAllBytes(filename));
269+
plan.AddPlan(targetPath, new EntryData.FromFile(filename));
230270
}
231271
return true;
232272
}
@@ -236,13 +276,13 @@ public static bool AddPathToArchivePlan(ArchivePlan plan, string sourcePath, str
236276
{
237277
var filename = Path.GetFileName(basePath);
238278
var targetPath = FormatArchivePath($"{destinationPath}{filename}");
239-
plan.AddPlan(targetPath, () => File.ReadAllBytes(basePath));
279+
plan.AddPlan(targetPath, new EntryData.FromFile(basePath));
240280
return true;
241281
}
242282
else
243283
{
244284
var targetPath = FormatArchivePath(destinationPath);
245-
plan.AddPlan(targetPath, () => File.ReadAllBytes(basePath));
285+
plan.AddPlan(targetPath, new EntryData.FromFile(basePath));
246286
return true;
247287
}
248288
}
@@ -254,16 +294,15 @@ public static bool AddPathToArchivePlan(ArchivePlan plan, string sourcePath, str
254294
}
255295

256296
// For crossplatform compat, Windows is more restrictive
257-
public static char[] GetInvalidFileNameChars() => new char[]
258-
{
297+
private static SearchValues<char> InvalidFileNameChars { get; } = SearchValues.Create(new[] {
259298
'\"', '<', '>', '|', '\0',
260-
(char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10,
261-
(char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20,
262-
(char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30,
263-
(char)31, ':', '*', '?', '\\', '/'
264-
};
299+
(char) 1, (char) 2, (char) 3, (char) 4, (char) 5, (char) 6, (char) 7, (char) 8, (char) 9, (char) 10,
300+
(char) 11, (char) 12, (char) 13, (char) 14, (char) 15, (char) 16, (char) 17, (char) 18, (char) 19, (char) 20,
301+
(char) 21, (char) 22, (char) 23, (char) 24, (char) 25, (char) 26, (char) 27, (char) 28, (char) 29, (char) 30,
302+
(char) 31, ':', '*', '?', '\\', '/'
303+
});
265304

266-
public static string FormatArchivePath(string path, bool validate = true)
305+
private static string FormatArchivePath(string path, bool validate = true)
267306
{
268307
var result = path.Replace('\\', '/');
269308

@@ -283,15 +322,15 @@ public static string FormatArchivePath(string path, bool validate = true)
283322
{
284323
foreach (var entry in result.Split("/"))
285324
{
286-
if (string.IsNullOrWhiteSpace(entry.Replace(".", "")) || entry.IndexOfAny(GetInvalidFileNameChars()) > -1)
325+
if (string.IsNullOrWhiteSpace(entry.Replace(".", "")) || entry.AsSpan().ContainsAny(InvalidFileNameChars))
287326
throw new CommandException($"Invalid path defined for a zip entry. Parsed: {result}, Original: {path}");
288327
}
289328
}
290329

291330
return result;
292331
}
293332

294-
public static string SerializeManifest(Config config)
333+
private static string SerializeManifest(Config config)
295334
{
296335
var dependencies = config.PackageConfig.Dependencies ?? new Dictionary<string, string>();
297336
var manifest = new PackageManifestV1()

ThunderstoreCLI/ThunderstoreCLI.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<OutputType>Exe</OutputType>
5-
<TargetFrameworks>net7.0;net6.0</TargetFrameworks>
5+
<TargetFramework>net8.0</TargetFramework>
66
<LangVersion>11</LangVersion>
77
<RollForward>Major</RollForward>
88
<RootNamespace>ThunderstoreCLI</RootNamespace>

0 commit comments

Comments
 (0)