Editor/Core/UnityArchive.cs
using System;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
namespace ImportUnityPackage;
public enum UnityAssetKind { Unsupported, Material, Texture, Model, ModelSupport }
public sealed class UnityAsset
{
public string Guid { get; init; }
public string Path { get; init; }
public string Source { get; init; }
public string Metadata { get; init; }
public UnityAssetKind Kind { get; init; }
public bool Selected { get; set; }
UnityModel modelInfo;
public UnityModel ModelInfo => modelInfo ??= UnityModel.Read( this );
}
public record ImportProgress( double Fraction, string Message );
/// <summary>Reads Unity's GUID/asset, GUID/pathname, GUID/asset.meta tar layout.</summary>
public sealed class UnityArchive : IDisposable
{
const long MaxEntryBytes = 2L * 1024 * 1024 * 1024;
const long MaxTotalBytes = 32L * 1024 * 1024 * 1024;
readonly string scratch = System.IO.Path.Combine( System.IO.Path.GetTempPath(), "sbox-unity-" + System.Guid.NewGuid().ToString( "N" ) );
public string FileName { get; private set; }
public List<UnityAsset> Assets { get; } = new();
internal Dictionary<string, string> GuidAliases { get; } = new( StringComparer.OrdinalIgnoreCase );
public List<string> Warnings { get; } = new();
internal static UnityArchive View( string file, IEnumerable<UnityAsset> assets )
{
var archive = new UnityArchive { FileName = file };
archive.Assets.AddRange( assets );
return archive;
}
public static UnityArchive Read( string file, IProgress<ImportProgress> progress, CancellationToken cancel )
{
var package = new UnityArchive { FileName = file };
try
{
Directory.CreateDirectory( package.scratch );
using var input = File.OpenRead( file );
using var gzip = new GZipStream( input, CompressionMode.Decompress );
using var tar = new TarReader( gzip );
var entries = new Dictionary<string, Dictionary<string, string>>( StringComparer.OrdinalIgnoreCase );
long total = 0;
int count = 0;
TarEntry entry;
while ( (entry = tar.GetNextEntry( false )) != null )
{
cancel.ThrowIfCancellationRequested();
if ( ++count > 200000 ) throw new InvalidDataException( "Package has too many archive entries." );
if ( entry.EntryType == TarEntryType.Directory ) continue;
if ( entry.EntryType != TarEntryType.RegularFile && entry.EntryType != TarEntryType.V7RegularFile )
throw new InvalidDataException( "Package contains unsupported links or special archive entries." );
if ( entry.Length > MaxEntryBytes || (total += entry.Length) > MaxTotalBytes )
throw new InvalidDataException( "Package exceeds the 2 GiB per file / 32 GiB extracted size limit." );
var name = entry.Name.Replace( '\\', '/' );
if ( name.StartsWith( "./", StringComparison.Ordinal ) ) name = name[2..];
// Asset Store downloads include a package thumbnail outside the GUID records.
if ( name == ".icon.png" ) continue;
var parts = name.Split( '/' );
if ( parts.Length == 2 && parts[0] == "packagemanagermanifest" && parts[1] is "asset" or "pathname" or "asset.meta" ) continue;
if ( parts.Length != 2 || !Regex.IsMatch( parts[0], "^[0-9a-fA-F]{32}$" ) )
throw new InvalidDataException( $"Invalid Unity archive entry: {entry.Name}" );
if ( parts[1] is not ("asset" or "asset.meta" or "pathname" or "preview.png") ) continue;
if ( parts[1] == "preview.png" ) continue;
if ( parts[1] != "asset" && entry.Length > 16 * 1024 * 1024 )
throw new InvalidDataException( "Package metadata is too large." );
if ( !entries.TryGetValue( parts[0], out var record ) ) entries[parts[0]] = record = new();
if ( record.ContainsKey( parts[1] ) ) throw new InvalidDataException( $"Duplicate archive entry: {name}" );
var target = System.IO.Path.Combine( package.scratch, parts[0] + "-" + parts[1] );
using ( var output = new FileStream( target, FileMode.CreateNew ) )
Copy( entry.DataStream, output, cancel );
record[parts[1]] = target;
progress?.Report( new( (double)input.Position / Math.Max( 1, input.Length ), $"Reading package ({entries.Count:N0} entries)…" ) );
}
var paths = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
foreach ( var (guid, record) in entries )
{
cancel.ThrowIfCancellationRequested();
if ( !record.TryGetValue( "asset", out var source ) ) continue; // Unity folder metadata.
if ( !record.TryGetValue( "pathname", out var pathname ) ) throw new InvalidDataException( $"Asset {guid} has no pathname." );
// Unity writes the pathname on the first line, sometimes followed by a "00" trailer.
var path = SafePath( (File.ReadLines( pathname, Encoding.UTF8 ).FirstOrDefault() ?? "").TrimEnd( '\0' ) );
if ( !paths.Add( path ) ) throw new InvalidDataException( $"Duplicate asset path: {path}" );
var kind = Classify( path );
package.Assets.Add( new UnityAsset { Guid = guid, Path = path, Source = source,
Metadata = record.GetValueOrDefault( "asset.meta" ), Kind = kind,
Selected = kind != UnityAssetKind.Unsupported } );
}
package.Assets.Sort( (a, b) => StringComparer.OrdinalIgnoreCase.Compare( a.Path, b.Path ) );
UnityModel.AssignPrefabMaterials( package );
if ( package.Assets.Count == 0 ) throw new InvalidDataException( "This package contains no file assets." );
return package;
}
catch { package.Dispose(); throw; }
}
public static string SafePath( string path )
{
path = path.Replace( '\\', '/' );
if ( !path.StartsWith( "Assets/", StringComparison.OrdinalIgnoreCase ) )
throw new InvalidDataException( $"Asset path must start with Assets/: {path}" );
path = path[7..];
foreach ( var part in path.Split( '/' ) )
{
var stem = part.Split( '.' )[0];
if ( string.IsNullOrWhiteSpace( part ) || part is "." or ".." || part.EndsWith( '.' ) || part.EndsWith( ' ' ) ||
part.Any( c => c < 32 || "<>:\"|?*".Contains( c ) ) ||
Regex.IsMatch( stem, "^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$", RegexOptions.IgnoreCase ) )
throw new InvalidDataException( $"Unsafe asset path: {path}" );
}
return path;
}
public static UnityAssetKind Classify( string path ) => System.IO.Path.GetExtension( path ).ToLowerInvariant() switch
{
".mat" or ".terrainlayer" => UnityAssetKind.Material,
".png" or ".jpg" or ".jpeg" or ".tga" or ".tif" or ".tiff" or ".exr" or ".psd" => UnityAssetKind.Texture,
".fbx" or ".obj" or ".smd" or ".dmx" or ".vox" => UnityAssetKind.Model,
".mtl" => UnityAssetKind.ModelSupport,
_ => UnityAssetKind.Unsupported
};
internal static void Copy( Stream source, Stream target, CancellationToken cancel )
{
if ( source == null ) return;
var buffer = new byte[128 * 1024];
int read;
while ( (read = source.Read( buffer, 0, buffer.Length )) > 0 )
{
cancel.ThrowIfCancellationRequested();
target.Write( buffer, 0, read );
}
}
public void Dispose()
{
if ( Directory.Exists( scratch ) ) Directory.Delete( scratch, true );
}
}