1+ using System . Diagnostics . CodeAnalysis ;
2+ using System . Formats . Tar ;
3+ using System . IO . Compression ;
4+
5+ /// <summary>
6+ /// Extracts files from a .unitypackage file.
7+ /// </summary>
8+ public class Extractor
9+ {
10+ /// <summary>
11+ /// The path to the .unitypackage file.
12+ /// </summary>
13+ public required string InputPath { get ; init ; }
14+
15+ /// <summary>
16+ /// The directory to output to.
17+ /// </summary>
18+ public required string OutputDir { get ; init ; }
19+
20+ /// <summary>
21+ /// Extracts a <i>.unitypackage</i> archive.
22+ /// </summary>
23+ public void Extract ( )
24+ {
25+ if ( ! InputPath . EndsWith ( ".unitypackage" , StringComparison . OrdinalIgnoreCase ) )
26+ throw new InvalidOperationException ( "The input file must end with .unitypackage" ) ;
27+
28+ // Add the input file name to the output path.
29+ var outputDir = Path . Combine ( Path . GetFullPath ( OutputDir ) , Path . GetFileNameWithoutExtension ( InputPath ) ) ;
30+ if ( ! Directory . Exists ( outputDir ) )
31+ Directory . CreateDirectory ( outputDir ) ;
32+ outputDir += Path . DirectorySeparatorChar ; // This is so SafeCombine can check the root correctly.
33+
34+ Console . Write ( $ "Extracting '{ InputPath } ' to '{ outputDir } '... ") ;
35+
36+ using var fileStream = File . OpenRead ( InputPath ) ;
37+ using var gzipStream = new GZipStream ( fileStream , CompressionMode . Decompress ) ;
38+ using var tarReader = new TarReader ( gzipStream ) ;
39+ using ( var progress = new ProgressBar ( ) )
40+ {
41+ while ( true )
42+ {
43+ // Read from the TAR file.
44+ var entry = tarReader . GetNextEntry ( ) ;
45+ if ( entry == null )
46+ break ;
47+
48+ // Extract it.
49+ if ( entry . EntryType == TarEntryType . RegularFile )
50+ ProcessTarEntry ( entry , outputDir ) ;
51+
52+ // Report progress.
53+ progress . Report ( ( double ) fileStream . Position / fileStream . Length ) ;
54+ }
55+ }
56+
57+ Console . WriteLine ( "done." ) ;
58+ }
59+
60+ private readonly Dictionary < string , string ? > _guidToAssetPath = [ ] ;
61+
62+ /// <summary>
63+ /// Processes a single file in the .unitypackage file.
64+ /// </summary>
65+ /// <param name="entry"> The TAR entry to process. </param>
66+ /// <param name="outputDir"> The output directory. </param>
67+ private void ProcessTarEntry ( TarEntry entry , string outputDir )
68+ {
69+ string fileName = Path . GetFileName ( entry . Name ) ;
70+ string guid = VerifyNonNull ( Path . GetDirectoryName ( entry . Name ) ) ;
71+ if ( fileName == "asset" )
72+ {
73+ string outputFilePath ;
74+ if ( _guidToAssetPath . TryGetValue ( guid , out var assetPath ) )
75+ {
76+ // If we previously encountered a 'pathname' use that.
77+ VerifyNonNull ( assetPath ) ;
78+ outputFilePath = SafeCombine ( outputDir , assetPath ) ;
79+ CreatePathDirectoriesIfNecessary ( outputFilePath ) ;
80+ }
81+ else
82+ {
83+ // Extract the file and call it '<guid>'.
84+ _guidToAssetPath [ guid ] = null ;
85+ outputFilePath = SafeCombine ( outputDir , guid ) ;
86+ }
87+
88+ // Extract the file.
89+ entry . ExtractToFile ( outputFilePath , overwrite : false ) ;
90+ }
91+ else if ( fileName == "pathname" )
92+ {
93+ VerifyNonNull ( entry . DataStream ) ;
94+
95+ // [ascii path] e.g. Assets/Footstep Sounds/Water and Mud/Water Running 1_10.wav
96+ // ASCII line feed (0xA)
97+ // 00
98+ string assetPath = VerifyNonNull ( new StreamReader ( entry . DataStream , System . Text . Encoding . ASCII ) . ReadLine ( ) ) ;
99+ if ( _guidToAssetPath . TryGetValue ( guid , out var existingAssetPath ) )
100+ {
101+ if ( existingAssetPath != null )
102+ throw new FormatException ( "The format of the file is invalid; is this a valid Unity package file?" ) ;
103+ assetPath = SafeCombine ( outputDir , assetPath ) ;
104+ CreatePathDirectoriesIfNecessary ( assetPath ) ;
105+ File . Move ( SafeCombine ( outputDir , guid ) , assetPath ) ;
106+ }
107+ _guidToAssetPath [ guid ] = assetPath ;
108+ }
109+ }
110+
111+ /// <summary>
112+ /// Throws an exception if the value is null.
113+ /// </summary>
114+ /// <typeparam name="T"></typeparam>
115+ /// <param name="value"> The value to check for null. </param>
116+ /// <returns></returns>
117+ private static T VerifyNonNull < T > ( [ NotNull ] T ? value )
118+ {
119+ if ( value == null )
120+ throw new FormatException ( "The format of the file is invalid; is this a valid Unity package file?" ) ;
121+ return value ;
122+ }
123+
124+ /// <summary>
125+ /// Acts like Path.Combine but checks the resulting path starts with <paramref name="rootDir"/>.
126+ /// </summary>
127+ /// <param name="rootDir"> The root directory. </param>
128+ /// <param name="relativePath"> The relative path to append. </param>
129+ /// <returns> The combined file path. </returns>
130+ private static string SafeCombine ( string rootDir , string relativePath )
131+ {
132+ var result = Path . Combine ( rootDir , relativePath ) ;
133+ if ( ! result . StartsWith ( rootDir , StringComparison . Ordinal ) )
134+ throw new InvalidOperationException ( $ "Invalid path '{ result } '; it should start with '{ rootDir } '.") ;
135+ return result ;
136+ }
137+
138+ private readonly HashSet < string > _createdDirectories = [ ] ;
139+
140+ /// <summary>
141+ /// Creates any directories in the given path, if they don't already exist.
142+ /// </summary>
143+ /// <param name="path"> The file path. </param>
144+ private void CreatePathDirectoriesIfNecessary ( string path )
145+ {
146+ // Get the directory from the path.
147+ var dir = Path . GetDirectoryName ( path ) ;
148+ if ( string . IsNullOrEmpty ( dir ) )
149+ return ;
150+
151+ // Fast cache check.
152+ if ( _createdDirectories . Contains ( dir ) )
153+ return ;
154+
155+ // Create it if it doesn't exist.
156+ if ( ! Directory . Exists ( dir ) )
157+ Directory . CreateDirectory ( dir ) ;
158+
159+ // Add to cache.
160+ _createdDirectories . Add ( dir ) ;
161+ }
162+ }
0 commit comments