1+ using System . Buffers ;
12using System . IO . Compression ;
3+ using System . Runtime . Versioning ;
24using System . Text ;
35using ThunderstoreCLI . Configuration ;
46using ThunderstoreCLI . Models ;
@@ -9,16 +11,57 @@ namespace ThunderstoreCLI.Commands;
911
1012public 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 ( )
0 commit comments