diff --git a/SteamKit2/SteamKit2/Types/DepotManifest.cs b/SteamKit2/SteamKit2/Types/DepotManifest.cs index 068be87df..d34ba9414 100644 --- a/SteamKit2/SteamKit2/Types/DepotManifest.cs +++ b/SteamKit2/SteamKit2/Types/DepotManifest.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.IO; using System.Text; +using System.Linq; namespace SteamKit2 { @@ -81,6 +82,10 @@ public class FileData /// public string FileName { get; internal set; } /// + /// Gets the name hash of the file. + /// + public byte[] FileNameHash { get; internal set; } + /// /// Gets the chunks that this file is composed of. /// public List Chunks { get; private set; } @@ -98,9 +103,13 @@ public class FileData /// Gets the hash of this file. /// public byte[] FileHash { get; private set; } + /// + /// Gets symlink target of this file. + /// + public string LinkTarget { get; private set; } - internal FileData(string filename, EDepotFileFlag flag, ulong size, byte[] hash, bool encrypted, int numChunks) + internal FileData(string filename, byte[] filenameHash, EDepotFileFlag flag, ulong size, byte[] hash, string linkTarget, bool encrypted, int numChunks) { if (encrypted) { @@ -111,10 +120,12 @@ internal FileData(string filename, EDepotFileFlag flag, ulong size, byte[] hash, this.FileName = filename.Replace(altDirChar, Path.DirectorySeparatorChar); } + this.FileNameHash = filenameHash; this.Flags = flag; this.TotalSize = size; this.FileHash = hash; this.Chunks = new List( numChunks ); + this.LinkTarget = linkTarget; } } @@ -149,6 +160,10 @@ internal FileData(string filename, EDepotFileFlag flag, ulong size, byte[] hash, /// Gets the total compressed size of all files in this depot. /// public ulong TotalCompressedSize { get; private set; } + /// + /// Gets CRC-32 checksum of encrypted manifest payload. + /// + public uint EncryptedCRC { get; private set; } internal DepotManifest(byte[] data) @@ -194,10 +209,53 @@ public bool DecryptFilenames(byte[] encryptionKey) file.FileName = Encoding.UTF8.GetString( filename ).TrimEnd( new char[] { '\0' } ).Replace(altDirChar, Path.DirectorySeparatorChar); } + // Sort file entries alphabetically because that's what Steam does + // TODO: Doesn't match Steam sorting if there are non-ASCII names present + Files.Sort( ( f1, f2 ) => StringComparer.OrdinalIgnoreCase.Compare( f1.FileName, f2.FileName ) ); + FilenamesEncrypted = false; return true; } + /// + /// Serializes depot manifest and saves the output to a file. + /// + /// Output file name. + /// true if serialization was successful; otherwise, false. + public bool SaveToFile( string filename ) + { + using ( var fs = File.Open( filename, FileMode.Create ) ) + using ( var bw = new BinaryWriter( fs ) ) + { + var data = Serialize(); + if ( data != null ) + { + bw.Write( data ); + return true; + } + } + + return false; + } + + /// + /// Loads binary manifest from a file and deserializes it. + /// + /// Input file name. + /// DepotManifest object if deserialization was successful; otherwise, null. + public static DepotManifest? LoadFromFile( string filename ) + { + if ( !File.Exists( filename ) ) + return null; + + using ( var fs = File.Open( filename, FileMode.Open ) ) + using ( var ms = new MemoryStream() ) + { + fs.CopyTo( ms ); + return Deserialize( ms.ToArray() ); + } + } + void InternalDeserialize(byte[] data) { ContentManifestPayload? payload = null; @@ -276,7 +334,7 @@ void ParseBinaryManifest(Steam3Manifest manifest) foreach (var file_mapping in manifest.Mapping) { - FileData filedata = new FileData(file_mapping.FileName!, file_mapping.Flags, file_mapping.TotalSize, file_mapping.HashContent!, FilenamesEncrypted, file_mapping.Chunks!.Length); + FileData filedata = new FileData(file_mapping.FileName!, file_mapping.HashFileName!, file_mapping.Flags, file_mapping.TotalSize, file_mapping.HashContent!, "", FilenamesEncrypted, file_mapping.Chunks!.Length); foreach (var chunk in file_mapping.Chunks) { @@ -293,7 +351,7 @@ void ParseProtobufManifestPayload(ContentManifestPayload payload) foreach (var file_mapping in payload.mappings) { - FileData filedata = new FileData(file_mapping.filename, (EDepotFileFlag)file_mapping.flags, file_mapping.size, file_mapping.sha_content, FilenamesEncrypted, file_mapping.chunks.Count); + FileData filedata = new FileData(file_mapping.filename, file_mapping.sha_filename, (EDepotFileFlag)file_mapping.flags, file_mapping.size, file_mapping.sha_content, file_mapping.linktarget, FilenamesEncrypted, file_mapping.chunks.Count); foreach (var chunk in file_mapping.chunks) { @@ -312,6 +370,116 @@ void ParseProtobufManifestMetadata(ContentManifestMetadata metadata) CreationTime = DateUtils.DateTimeFromUnixTime( metadata.creation_time ); TotalUncompressedSize = metadata.cb_disk_original; TotalCompressedSize = metadata.cb_disk_compressed; + EncryptedCRC = metadata.crc_encrypted; + } + + byte[]? Serialize() + { + DebugLog.Assert( Files != null, nameof( DepotManifest ), "Files was null when attempting to serialize manifest." ); + + var payload = new ContentManifestPayload(); + var uniqueChunks = new List(); + + foreach ( var file in Files ) + { + var protofile = new ContentManifestPayload.FileMapping(); + protofile.filename = file.FileName.Replace( '/', '\\' ); + protofile.size = file.TotalSize; + protofile.flags = (uint)file.Flags; + if ( FilenamesEncrypted ) + { + // Assume the name is unmodified + protofile.sha_filename = file.FileNameHash; + } + else + { + protofile.sha_filename = CryptoHelper.SHAHash( Encoding.UTF8.GetBytes( file.FileName.Replace( '/', '\\' ).ToLower() ) ); + } + protofile.sha_content = file.FileHash; + if ( !string.IsNullOrWhiteSpace( file.LinkTarget ) ) + { + protofile.linktarget = file.LinkTarget; + } + + foreach ( var chunk in file.Chunks ) + { + var protochunk = new ContentManifestPayload.FileMapping.ChunkData(); + protochunk.sha = chunk.ChunkID; + protochunk.crc = BitConverter.ToUInt32( chunk.Checksum!, 0 ); + protochunk.offset = chunk.Offset; + protochunk.cb_original = chunk.UncompressedLength; + protochunk.cb_compressed = chunk.CompressedLength; + + protofile.chunks.Add( protochunk ); + if ( !uniqueChunks.Exists( x => x.SequenceEqual( chunk.ChunkID! ) ) ) + { + uniqueChunks.Add( chunk.ChunkID! ); + } + } + + payload.mappings.Add( protofile ); + } + + var metadata = new ContentManifestMetadata(); + metadata.depot_id = DepotID; + metadata.gid_manifest = ManifestGID; + metadata.creation_time = (uint)DateUtils.DateTimeToUnixTime( CreationTime ); + metadata.filenames_encrypted = FilenamesEncrypted; + metadata.cb_disk_original = TotalUncompressedSize; + metadata.cb_disk_compressed = TotalCompressedSize; + metadata.unique_chunks = (uint)uniqueChunks.Count; + + // Calculate payload CRC + using ( var ms_payload = new MemoryStream() ) + { + Serializer.Serialize( ms_payload, payload ); + + int len = ( int )ms_payload.Length; + byte[] data = new byte[ 4 + len ]; + Buffer.BlockCopy( BitConverter.GetBytes( len ), 0, data, 0, 4 ); + Buffer.BlockCopy( ms_payload.ToArray(), 0, data, 4, len ); + uint crc32 = Crc32.Compute( data ); + + if ( FilenamesEncrypted ) + { + metadata.crc_encrypted = crc32; + } + else + { + metadata.crc_encrypted = EncryptedCRC; + metadata.crc_clear = crc32; + } + } + + using var ms = new MemoryStream(); + using var bw = new BinaryWriter( ms ); + + // Write Protobuf payload + using ( var ms_payload = new MemoryStream() ) + { + Serializer.Serialize( ms_payload, payload ); + bw.Write( DepotManifest.PROTOBUF_PAYLOAD_MAGIC ); + bw.Write( ( int )ms_payload.Length ); + bw.Write( ms_payload.ToArray() ); + } + + // Write Protobuf metadata + using ( var ms_metadata = new MemoryStream() ) + { + Serializer.Serialize( ms_metadata, metadata ); + bw.Write( DepotManifest.PROTOBUF_METADATA_MAGIC ); + bw.Write( ( int )ms_metadata.Length ); + bw.Write( ms_metadata.ToArray() ); + } + + // Write empty signature section + bw.Write( DepotManifest.PROTOBUF_SIGNATURE_MAGIC ); + bw.Write( 0 ); + + // Write EOF marker + bw.Write( DepotManifest.PROTOBUF_ENDOFMANIFEST_MAGIC ); + + return ms.ToArray(); } } }