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