diff --git a/.run/javasteam [formatKotlin].run.xml b/.run/javasteam [formatKotlin].run.xml new file mode 100644 index 00000000..2ffae927 --- /dev/null +++ b/.run/javasteam [formatKotlin].run.xml @@ -0,0 +1,24 @@ + + + + + + + true + true + false + false + + + \ No newline at end of file diff --git a/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt b/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt index 47819f0c..86d51deb 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt @@ -533,9 +533,7 @@ class ContentDownloader(val steamClient: SteamClient) { val chunkID = Strings.toHex(chunk.chunkID) - val chunkInfo = ChunkData(chunk) - - var outputChunkData = ByteArray(chunkInfo.uncompressedLength) + var outputChunkData = ByteArray(chunk.uncompressedLength) var writtenBytes = 0 do { @@ -544,10 +542,10 @@ class ContentDownloader(val steamClient: SteamClient) { try { connection = cdnPool.getConnection().await() - outputChunkData = ByteArray(chunkInfo.uncompressedLength) + outputChunkData = ByteArray(chunk.uncompressedLength) writtenBytes = cdnPool.cdnClient.downloadDepotChunk( depotId = depot.depotId, - chunk = chunkInfo, + chunk = chunk, server = connection!!, destination = outputChunkData, depotKey = depot.depotKey, @@ -587,7 +585,7 @@ class ContentDownloader(val steamClient: SteamClient) { fileStreamData.fileStream = randomAccessFile.channel } - fileStreamData.fileStream?.position(chunkInfo.offset) + fileStreamData.fileStream?.position(chunk.offset) fileStreamData.fileStream?.write(ByteBuffer.wrap(outputChunkData, 0, writtenBytes)) } finally { fileStreamData.fileLock.release() @@ -715,7 +713,7 @@ class ContentDownloader(val steamClient: SteamClient) { throw CancellationException("Unable to download manifest $manifestId for depot $depotId") } - val newProtoManifest = DepotManifest(depotManifest) + val newProtoManifest = depotManifest steamClient.configuration.depotManifestProvider.updateManifest(newProtoManifest) return@async newProtoManifest diff --git a/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/FileManifestProvider.kt b/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/FileManifestProvider.kt index ea0f6370..ec7201b6 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/FileManifestProvider.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/FileManifestProvider.kt @@ -4,6 +4,7 @@ import `in`.dragonbra.javasteam.types.DepotManifest import `in`.dragonbra.javasteam.util.compat.readNBytesCompat import `in`.dragonbra.javasteam.util.log.LogManager import `in`.dragonbra.javasteam.util.log.Logger +import `in`.dragonbra.javasteam.util.stream.MemoryStream import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException @@ -190,7 +191,11 @@ class FileManifestProvider(private val file: Path) : IManifestProvider { } } // add manifest as uncompressed data - zipUncompressed(zip, getEntryName(manifest.depotID, manifest.manifestGID), manifest.toByteArray()) + MemoryStream().use { ms -> + manifest.serialize(ms.asOutputStream()) + + zipUncompressed(zip, getEntryName(manifest.depotID, manifest.manifestGID), ms.toByteArray()) + } } // save all data to the file try { diff --git a/src/main/java/in/dragonbra/javasteam/types/ChunkData.kt b/src/main/java/in/dragonbra/javasteam/types/ChunkData.kt index 576ff903..3be2db1a 100644 --- a/src/main/java/in/dragonbra/javasteam/types/ChunkData.kt +++ b/src/main/java/in/dragonbra/javasteam/types/ChunkData.kt @@ -1,55 +1,22 @@ -package `in`.dragonbra.javasteam.types - -/** - * Represents a single chunk within a file. - */ -class ChunkData { - - /** - * Gets or sets the SHA-1 hash chunk id. - */ - val chunkID: ByteArray? - - /** - * Gets or sets the expected Adler32 checksum of this chunk. - */ - val checksum: Int - - /** - * Gets or sets the chunk offset. - */ - val offset: Long - - /** - * Gets or sets the compressed length of this chunk. - */ - val compressedLength: Int - - /** - * Gets or sets the decompressed length of this chunk. - */ - val uncompressedLength: Int - - @JvmOverloads - constructor( - chunkID: ByteArray? = null, - checksum: Int = 0, - offset: Long = 0L, - compressedLength: Int = 0, - uncompressedLength: Int = 0, - ) { - this.chunkID = chunkID - this.checksum = checksum - this.offset = offset - this.compressedLength = compressedLength - this.uncompressedLength = uncompressedLength - } - - constructor(chunkData: ChunkData) { - chunkID = chunkData.chunkID - checksum = chunkData.checksum - offset = chunkData.offset - compressedLength = chunkData.compressedLength - uncompressedLength = chunkData.uncompressedLength - } -} +package `in`.dragonbra.javasteam.types + +/** + * Represents a single chunk within a file. + * + * @constructor Initializes a new instance of the [ChunkData] class. + * @constructor Initializes a new instance of the [ChunkData] class with specified values. + * + * @param chunkID Gets or sets the SHA-1 hash chunk id. + * @param checksum Gets or sets the expected Adler32 checksum of this chunk. + * @param offset Gets or sets the chunk offset. + * @param compressedLength Gets or sets the compressed length of this chunk. + * @param uncompressedLength Gets or sets the decompressed length of this chunk. + */ +@Suppress("ArrayInDataClass") +data class ChunkData( + var chunkID: ByteArray? = null, + var checksum: Int = 0, + var offset: Long = 0, + var compressedLength: Int = 0, + var uncompressedLength: Int = 0, +) diff --git a/src/main/java/in/dragonbra/javasteam/types/DepotManifest.kt b/src/main/java/in/dragonbra/javasteam/types/DepotManifest.kt index a34cb018..66da7f59 100644 --- a/src/main/java/in/dragonbra/javasteam/types/DepotManifest.kt +++ b/src/main/java/in/dragonbra/javasteam/types/DepotManifest.kt @@ -13,70 +13,71 @@ import `in`.dragonbra.javasteam.util.log.Logger import `in`.dragonbra.javasteam.util.stream.BinaryReader import `in`.dragonbra.javasteam.util.stream.BinaryWriter import `in`.dragonbra.javasteam.util.stream.MemoryStream -import java.io.ByteArrayOutputStream import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.io.InputStream import java.io.OutputStream -import java.nio.ByteBuffer import java.time.Instant -import java.util.Base64 -import java.util.Date +import java.util.* import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +import kotlin.NoSuchElementException +import kotlin.collections.ArrayList +import kotlin.collections.HashSet /** * Represents a Steam3 depot manifest. */ -@Suppress("MemberVisibilityCanBePrivate", "SpellCheckingInspection") +@Suppress("unused") class DepotManifest { companion object { - const val PROTOBUF_PAYLOAD_MAGIC = 0x71F617D0 - const val PROTOBUF_METADATA_MAGIC = 0x1F4812BE - const val PROTOBUF_SIGNATURE_MAGIC = 0x1B81B817 - const val PROTOBUF_ENDOFMANIFEST_MAGIC = 0x32C415AB - private val logger: Logger = LogManager.getLogger(DepotManifest::class.java) + private const val PROTOBUF_PAYLOAD_MAGIC: Int = 0x71F617D0 + private const val PROTOBUF_METADATA_MAGIC: Int = 0x1F4812BE + private const val PROTOBUF_SIGNATURE_MAGIC: Int = 0x1B81B817 + private const val PROTOBUF_ENDOFMANIFEST_MAGIC: Int = 0x32C415AB + /** * Initializes a new instance of the [DepotManifest] class. * Depot manifests may come from the Steam CDN or from Steam/depotcache/ manifest files. - * @param stream Raw depot manifest stream to deserialize. - * @exception NoSuchElementException Thrown if the given data is not something recognizable. + * @param stream Raw depot manifest stream to deserialize. */ - fun deserialize(stream: InputStream): DepotManifest = deserialize(stream.readBytes()) + @JvmStatic + fun deserialize(stream: InputStream): DepotManifest { + val manifest = DepotManifest() + manifest.internalDeserialize(stream) + return manifest + } /** * Initializes a new instance of the [DepotManifest] class. * Depot manifests may come from the Steam CDN or from Steam/depotcache/ manifest files. * @param data Raw depot manifest data to deserialize. - * @exception NoSuchElementException Thrown if the given data is not something recognizable. */ - fun deserialize(data: ByteArray): DepotManifest = MemoryStream(data).use { ms -> - val manifest = DepotManifest() - manifest.internalDeserialize(ms) - manifest + @JvmStatic + fun deserialize(data: ByteArray): DepotManifest { + val ms = MemoryStream(data) + val manifest = deserialize(ms) + ms.close() + return manifest } /** * Loads binary manifest from a file and deserializes it. * @param filename Input file name. - * @return [Pair]<[DepotManifest], [ByteArray]> object where the first value is the depot manifest - * and the second is the checksum if the deserialization was successful or else the values will be null. - * @exception NoSuchElementException Thrown if the given data is not something recognizable. + * @return [DepotManifest] object if deserialization was successful; otherwise, **null**. */ - @Suppress("unused") + @JvmStatic fun loadFromFile(filename: String): DepotManifest? { val file = File(filename) if (!file.exists()) { return null } - return FileInputStream(file).use { fs -> - deserialize(fs) + return file.inputStream().use { fileStream -> + deserialize(fileStream) } } } @@ -84,130 +85,135 @@ class DepotManifest { /** * Gets the list of files within this manifest. */ - val files: MutableList + var files: ArrayList = arrayListOf() /** * Gets a value indicating whether filenames within this depot are encrypted. + * @return **true** if the filenames are encrypted; otherwise, false. */ var filenamesEncrypted: Boolean = false - private set /** * Gets the depot id. */ var depotID: Int = 0 - private set /** * Gets the manifest id. */ - var manifestGID: Long = 0 - private set + var manifestGID: Long = 0L /** * Gets the depot creation time. */ var creationTime: Date = Date() - private set /** * Gets the total uncompressed size of all files in this depot. */ - var totalUncompressedSize: Long = 0 - private set + var totalUncompressedSize: Long = 0L /** * Gets the total compressed size of all files in this depot. */ - var totalCompressedSize: Long = 0 - private set + var totalCompressedSize: Long = 0L /** * Gets CRC-32 checksum of encrypted manifest payload. */ var encryptedCRC: Int = 0 - private set - - constructor() { - files = mutableListOf() - filenamesEncrypted = false - depotID = 0 - manifestGID = 0 - creationTime = Date() - totalUncompressedSize = 0 - totalCompressedSize = 0 - encryptedCRC = 0 - } - - constructor(manifest: DepotManifest) { - files = manifest.files.map { FileData(it) }.toMutableList() - filenamesEncrypted = manifest.filenamesEncrypted - depotID = manifest.depotID - manifestGID = manifest.manifestGID - creationTime = manifest.creationTime - totalUncompressedSize = manifest.totalUncompressedSize - totalCompressedSize = manifest.totalCompressedSize - encryptedCRC = manifest.encryptedCRC - } /** * Attempts to decrypt file names with the given encryption key. * @param encryptionKey The encryption key. - * @return `true` if the file names were successfully decrypted; otherwise `false`. + * @return **true** if the file names were successfully decrypted; otherwise, **false**. */ fun decryptFilenames(encryptionKey: ByteArray): Boolean { if (!filenamesEncrypted) { return true } - assert(encryptionKey.size == 32) { "Decrypt filenames used with non 32 byte key!" } + require(encryptionKey.size == 32) { "Decrypt filnames used with non 32 byte key!" } // This was originally copy-pasted in the SteamKit2 source from CryptoHelper.SymmetricDecrypt to avoid allocating Aes instance for every filename val ecbCipher = Cipher.getInstance("AES/ECB/NoPadding", CryptoHelper.SEC_PROV) val aes = Cipher.getInstance("AES/CBC/PKCS7Padding", CryptoHelper.SEC_PROV) val secretKey = SecretKeySpec(encryptionKey, "AES") - var iv: ByteArray + + val iv = ByteArray(16) + var filenameLength: Int + var bufferDecoded = ByteArray(256) + var bufferDecrypted = ByteArray(256) try { - for (file in files) { - val decoded = Base64.getUrlDecoder().decode( - file.fileName - .replace('+', '-') - .replace('/', '_') - .replace("\n", "") - .replace("\r", "") - .replace(" ", "") - ) + files.forEach { file -> + var decodedLength = file.fileName.length / 4 * 3 // This may be higher due to padding + + // Majority of filenames are short, even when they are encrypted and base64 encoded, + // so this resize will be hit *very* rarely + if (decodedLength > bufferDecoded.size) { + bufferDecoded = ByteArray(decodedLength) + bufferDecrypted = ByteArray(decodedLength) + } + + val decoder = Base64.getUrlDecoder() + decodedLength = try { + val tempBytes = decoder.decode( + file.fileName + .replace('+', '-') + .replace('/', '_') + .replace("\n", "") + .replace("\r", "") + .replace(" ", "") + ) + if (tempBytes.size <= bufferDecoded.size) { + tempBytes.copyInto(bufferDecoded) + tempBytes.size + } else { + // Buffer too small + throw IllegalArgumentException("Buffer too small") + } + } catch (e: Exception) { + logger.error("Failed to base64 decode the filename: ${e.message}", e) + return false + } - val bufferDecrypted: ByteArray try { - // Extract IV from the first 16 bytes + // Get a slice of the decoded buffer up to decodedLength + val encryptedFilename = bufferDecoded.copyOfRange(0, decodedLength) + + // Decrypt the IV portion (first 16 bytes) using ECB mode ecbCipher.init(Cipher.DECRYPT_MODE, secretKey) - iv = ecbCipher.doFinal(decoded, 0, 16) + ecbCipher.doFinal(encryptedFilename, 0, iv.size, iv, 0) - // Decrypt filename - aes.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(iv)) - bufferDecrypted = aes.doFinal(decoded, iv.size, decoded.size - iv.size) + // Decrypt the rest using CBC mode with the IV we just decrypted + val ivSpec = IvParameterSpec(iv) + aes.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) + + // Decrypt the remaining data after the IV + val remainingData = encryptedFilename.copyOfRange(iv.size, encryptedFilename.size) + filenameLength = aes.doFinal(remainingData, 0, remainingData.size, bufferDecrypted, 0) } catch (e: Exception) { - logger.error("Failed to decrypt the filename: $e") + logger.error("Failed to decrypt the filename.", e) return false } // Trim the ending null byte, safe for UTF-8 - val filenameLength = bufferDecrypted.size - if ( - bufferDecrypted.isNotEmpty() && - bufferDecrypted[bufferDecrypted.size - 1] == 0.toByte() - ) { - 1 - } else { - 0 + if (filenameLength > 0 && bufferDecrypted[filenameLength - 1] == 0.toByte()) { + filenameLength-- + } + + for (i in 0 until filenameLength) { + if (bufferDecrypted[i] == '\\'.code.toByte()) { + bufferDecrypted[i] = File.separatorChar.code.toByte() + } } file.fileName = String(bufferDecrypted, 0, filenameLength, Charsets.UTF_8) - .replace('\\', File.separatorChar) } - } catch (e: Exception) { - logger.error("Failed to decrypt filenames: $e") + } finally { + bufferDecoded.fill(0) + bufferDecrypted.fill(0) } // Sort file entries alphabetically because that's what Steam does @@ -222,15 +228,14 @@ class DepotManifest { * Serializes depot manifest and saves the output to a file. * @param filename Output file name. */ - @Suppress("unused") fun saveToFile(filename: String) { - FileOutputStream(File(filename)).use { fs -> - serialize(fs) + File(filename).outputStream().use { fileStream -> + serialize(fileStream) } } @OptIn(ExperimentalStdlibApi::class) - internal fun internalDeserialize(stream: MemoryStream) { + private fun internalDeserialize(stream: InputStream) { var payload: ContentManifestPayload? = null var metadata: ContentManifestMetadata? = null var signature: ContentManifestSignature? = null @@ -245,13 +250,18 @@ class DepotManifest { when (magic) { Steam3Manifest.MAGIC -> { - val binaryManifest = Steam3Manifest.deserialize(br) + val binaryManifest = Steam3Manifest() + binaryManifest.deserialize(br) parseBinaryManifest(binaryManifest) val marker = br.readInt() if (marker != magic) { throw NoSuchElementException("Unable to find end of message marker for depot manifest") } + + // This is an intentional return because v4 manifest does not have the separate sections, + // and it will be parsed by ParseBinaryManifest. If we get here, the entire buffer has been already processed. + return } PROTOBUF_PAYLOAD_MAGIC -> { @@ -269,7 +279,9 @@ class DepotManifest { signature = ContentManifestSignature.parseFrom(stream.readNBytesCompat(signatureLength)) } - else -> throw NoSuchElementException("Unrecognized magic value ${magic.toHexString(HexFormat.Default)} in depot manifest.") + else -> { + throw NoSuchElementException("Unrecognized magic value ${magic.toHexString()} in depot manifest.") + } } } } @@ -282,73 +294,74 @@ class DepotManifest { } } - internal fun parseBinaryManifest(manifest: Steam3Manifest) { - files.clear() + private fun parseBinaryManifest(manifest: Steam3Manifest) { + files = ArrayList(manifest.mapping.size) filenamesEncrypted = manifest.areFileNamesEncrypted depotID = manifest.depotID manifestGID = manifest.manifestGID creationTime = manifest.creationTime totalUncompressedSize = manifest.totalUncompressedSize totalCompressedSize = manifest.totalCompressedSize + encryptedCRC = manifest.encryptedCRC - for (fileMapping in manifest.fileMapping) { - val fileData = FileData( - fileName = fileMapping.fileName, - fileNameHash = fileMapping.hashFileName, - flags = fileMapping.flags, - totalSize = fileMapping.totalSize, - fileHash = fileMapping.hashContent, + manifest.mapping.forEach { fileMapping -> + val filedata = FileData( + filename = fileMapping.fileName!!, + filenameHash = fileMapping.hashFileName!!, + flag = fileMapping.flags, + size = fileMapping.totalSize, + hash = fileMapping.hashContent!!, linkTarget = "", - encrypted = filenamesEncrypted + encrypted = filenamesEncrypted, + numChunks = fileMapping.chunks.size ) - for (chunk in fileMapping.chunks) { - fileData.chunks.add( - ChunkData( - chunkID = chunk.chunkGID, - checksum = chunk.checksum, - offset = chunk.offset, - compressedLength = chunk.compressedSize, - uncompressedLength = chunk.decompressedSize - ) + fileMapping.chunks.forEach { chunk -> + val chunkData = ChunkData( + chunkID = chunk.chunkGID!!, + checksum = chunk.checksum, + offset = chunk.offset, + compressedLength = chunk.compressedSize, + uncompressedLength = chunk.decompressedSize, ) + filedata.chunks.add(chunkData) } - files.add(fileData) + files.add(filedata) } } - internal fun parseProtobufManifestPayload(payload: ContentManifestPayload) { - files.clear() + private fun parseProtobufManifestPayload(payload: ContentManifestPayload) { + files = ArrayList(payload.mappingsCount) - for (fileMapping in payload.mappingsList) { - val fileData = FileData( - fileName = fileMapping.filename, - fileNameHash = fileMapping.shaFilename.toByteArray(), - flags = EDepotFileFlag.from(fileMapping.flags), - totalSize = fileMapping.size, - fileHash = fileMapping.shaContent.toByteArray(), + payload.mappingsList.forEach { fileMapping -> + val filedata = FileData( + filename = fileMapping.filename, + filenameHash = fileMapping.shaFilename.toByteArray(), + flag = EDepotFileFlag.from(fileMapping.flags), + size = fileMapping.size, + hash = fileMapping.shaContent.toByteArray(), linkTarget = fileMapping.linktarget, - encrypted = filenamesEncrypted + encrypted = filenamesEncrypted, + numChunks = fileMapping.chunksList.size, ) - for (chunk in fileMapping.chunksList) { - fileData.chunks.add( - ChunkData( - chunkID = chunk.sha.toByteArray(), - checksum = chunk.crc, - offset = chunk.offset, - compressedLength = chunk.cbCompressed, - uncompressedLength = chunk.cbOriginal - ) + fileMapping.chunksList.forEach { chunk -> + val chunkData = ChunkData( + chunkID = chunk.sha.toByteArray(), + checksum = chunk.crc, + offset = chunk.offset, + compressedLength = chunk.cbCompressed, + uncompressedLength = chunk.cbOriginal ) + filedata.chunks.add(chunkData) } - files.add(fileData) + files.add(filedata) } } - internal fun parseProtobufManifestMetadata(metadata: ContentManifestMetadata) { + private fun parseProtobufManifestMetadata(metadata: ContentManifestMetadata) { filenamesEncrypted = metadata.filenamesEncrypted depotID = metadata.depotId manifestGID = metadata.gidManifest @@ -361,123 +374,130 @@ class DepotManifest { /** * Serializes the depot manifest into the provided output stream. * @param output The stream to which the serialized depot manifest will be written. - * @return A pair object containing the amount of bytes written and the checksum of the manifest - */ - fun serialize(output: OutputStream): Int { - val manifestBytes = toByteArray() - output.write(manifestBytes) - return manifestBytes.size - } - - /** - * Serializes the depot manifest into a byte array. */ - fun toByteArray(): ByteArray { + fun serialize(output: OutputStream) { val payload = ContentManifestPayload.newBuilder() - val uniqueChunks = hashSetOf() + val uniqueChunks = object : HashSet() { + // This acts like "ChunkIdComparer" + private val items = mutableListOf() + + override fun add(element: ByteArray): Boolean { + if (contains(element)) return false + items.add(element) + return true + } + + override fun contains(element: ByteArray): Boolean = items.any { it.contentEquals(element) } + + override fun iterator(): MutableIterator = items.iterator() + + override val size: Int + get() = items.size + } - for (file in files) { - val protoFile = ContentManifestPayload.FileMapping.newBuilder() - protoFile.setSize(file.totalSize) - protoFile.setFlags(EDepotFileFlag.code(file.flags)) + files.forEach { file -> + val protofile = ContentManifestPayload.FileMapping.newBuilder().apply { + this.size = file.totalSize + this.flags = EDepotFileFlag.code(file.flags) + } if (filenamesEncrypted) { // Assume the name is unmodified - protoFile.setFilename(file.fileName) - protoFile.setShaFilename(ByteString.copyFrom(file.fileNameHash)) + protofile.filename = file.fileName + protofile.shaFilename = ByteString.copyFrom(file.fileNameHash) } else { - protoFile.setFilename(file.fileName.replace('/', '\\')) - protoFile.setShaFilename( - ByteString.copyFrom( - CryptoHelper.shaHash( - file.fileName - .replace('/', '\\') - .lowercase() - .toByteArray(Charsets.UTF_8) - ) + protofile.filename = file.fileName.replace('/', '\\') + protofile.shaFilename = ByteString.copyFrom( + CryptoHelper.shaHash( + file.fileName + .replace('/', '\\') + .lowercase(Locale.getDefault()) + .toByteArray(Charsets.UTF_8) ) ) } - protoFile.setShaContent(ByteString.copyFrom(file.fileHash)) + protofile.shaContent = ByteString.copyFrom(file.fileHash) - if (file.linkTarget.isNotBlank()) { - protoFile.linktarget = file.linkTarget + if (!file.linkTarget.isNullOrBlank()) { + protofile.linktarget = file.linkTarget } - for (chunk in file.chunks) { - val protoChunk = ContentManifestPayload.FileMapping.ChunkData.newBuilder().apply { - sha = ByteString.copyFrom(chunk.chunkID) - crc = chunk.checksum - offset = chunk.offset - cbOriginal = chunk.uncompressedLength - cbCompressed = chunk.compressedLength - } + file.chunks.forEach { chunk -> + val protochunk = ContentManifestPayload.FileMapping.ChunkData.newBuilder().apply { + this.sha = ByteString.copyFrom(chunk.chunkID) + this.crc = chunk.checksum + this.offset = chunk.offset + this.cbOriginal = chunk.uncompressedLength + this.cbCompressed = chunk.compressedLength + }.build() - protoFile.addChunks(protoChunk) + protofile.addChunks(protochunk) uniqueChunks.add(chunk.chunkID!!) } - payload.addMappings(protoFile) + payload.addMappings(protofile.build()) } val metadata = ContentManifestMetadata.newBuilder().apply { this.depotId = depotID this.gidManifest = manifestGID - this.creationTime = this@DepotManifest.creationTime.toInstant().epochSecond.toInt() - this.filenamesEncrypted = filenamesEncrypted + this.creationTime = (this@DepotManifest.creationTime.time / 1000).toInt() + this.filenamesEncrypted = this@DepotManifest.filenamesEncrypted this.cbDiskOriginal = totalUncompressedSize this.cbDiskCompressed = totalCompressedSize this.uniqueChunks = uniqueChunks.size } // Calculate payload CRC - val payloadData = payload.build().toByteArray() - val len = payloadData.size - val data = ByteArray(Int.SIZE_BYTES + len) + val msPayload = MemoryStream() + payload.build().writeTo(msPayload.asOutputStream()) + + val len = msPayload.length.toInt() + val data = ByteArray(4 + len) - System.arraycopy(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(len).array(), 0, data, 0, 4) - System.arraycopy(payloadData, 0, data, 4, len) + // Alternative of BitConverter.GetBytes(len) + val lenBytes = ByteArray(4) + lenBytes[0] = (len and 0xFF).toByte() + lenBytes[1] = ((len shr 8) and 0xFF).toByte() + lenBytes[2] = ((len shr 16) and 0xFF).toByte() + lenBytes[3] = ((len shr 24) and 0xFF).toByte() - val crc32 = Utils.crc32(payloadData).toInt() + System.arraycopy(lenBytes, 0, data, 0, 4) + System.arraycopy(msPayload.toByteArray(), 0, data, 4, len) + val crc32 = Utils.crc32(data).toInt() + + msPayload.close() if (filenamesEncrypted) { - metadata.setCrcEncrypted(crc32) - metadata.setCrcClear(0) + metadata.crcEncrypted = crc32 + metadata.crcClear = 0 } else { - metadata.setCrcEncrypted(encryptedCRC) - metadata.setCrcClear(crc32) + metadata.crcEncrypted = encryptedCRC + metadata.crcClear = crc32 } - // Write the manifest to the stream and return the checksum - return ByteArrayOutputStream().use { bw -> - BinaryWriter(bw).use { writer -> - // Write Protobuf payload - writer.writeInt(PROTOBUF_PAYLOAD_MAGIC) - writer.writeInt(payloadData.size) - writer.write(payloadData, 0, payloadData.size) - - // Write Protobuf metadata - val metadataData = metadata.build().toByteArray() - writer.writeInt(PROTOBUF_METADATA_MAGIC) - writer.writeInt(metadataData.size) - writer.write(metadataData, 0, metadataData.size) - - // Write empty signature section - writer.writeInt(PROTOBUF_SIGNATURE_MAGIC) - writer.writeInt(0) - - // Write EOF marker - writer.writeInt(PROTOBUF_ENDOFMANIFEST_MAGIC) - } + val bw = BinaryWriter(output) - bw.toByteArray() - } - } + // Write Protobuf payload + val payloadBytes = payload.build().toByteArray() + bw.writeInt(PROTOBUF_PAYLOAD_MAGIC) + bw.writeInt(payloadBytes.size) + bw.write(payloadBytes) - /** - * Calculates the checksum of the depot manifest. - */ - @Suppress("unused") - fun calculateChecksum(): ByteArray = CryptoHelper.shaHash(toByteArray()) + // Write Protobuf metadata + val metadataBytes = metadata.build().toByteArray() + bw.writeInt(PROTOBUF_METADATA_MAGIC) + bw.writeInt(metadataBytes.size) + bw.write(metadataBytes) + + // Write empty signature section + bw.writeInt(PROTOBUF_SIGNATURE_MAGIC) + bw.writeInt(0) + + // Write EOF marker + bw.writeInt(PROTOBUF_ENDOFMANIFEST_MAGIC) + + bw.close() + } } diff --git a/src/main/java/in/dragonbra/javasteam/types/FileData.kt b/src/main/java/in/dragonbra/javasteam/types/FileData.kt index c23488d4..53a7c2f4 100644 --- a/src/main/java/in/dragonbra/javasteam/types/FileData.kt +++ b/src/main/java/in/dragonbra/javasteam/types/FileData.kt @@ -1,80 +1,51 @@ -package `in`.dragonbra.javasteam.types - -import `in`.dragonbra.javasteam.enums.EDepotFileFlag -import java.io.File -import java.util.EnumSet - -/** - * Represents a single file within a manifest. - */ -class FileData { - - /** - * Gets the name of the file. - */ - var fileName: String - internal set - - /** - * Gets SHA-1 hash of this file's name. - */ - val fileNameHash: ByteArray - - /** - * Gets the chunks that this file is composed of. - */ - val chunks: MutableList - - /** - * Gets the file flags - */ - val flags: EnumSet - - /** - * Gets the total size of this file. - */ - val totalSize: Long - - /** - * Gets SHA-1 hash of this file. - */ - val fileHash: ByteArray - - /** - * Gets symlink target of this file. - */ - val linkTarget: String - - constructor( - fileName: String, - fileNameHash: ByteArray, - chunks: MutableList = mutableListOf(), - flags: EnumSet, - totalSize: Long, - fileHash: ByteArray, - linkTarget: String, - encrypted: Boolean, - ) { - if (encrypted) { - this.fileName = fileName - } else { - this.fileName = fileName.replace('\\', File.separatorChar) - } - this.fileNameHash = fileNameHash - this.chunks = chunks - this.flags = flags - this.totalSize = totalSize - this.fileHash = fileHash - this.linkTarget = linkTarget - } - - constructor(fileData: FileData) { - fileName = fileData.fileName - fileNameHash = fileData.fileNameHash - chunks = fileData.chunks.map { ChunkData(it) }.toMutableList() - flags = fileData.flags - totalSize = fileData.totalSize - fileHash = fileData.fileHash - linkTarget = fileData.linkTarget - } -} +package `in`.dragonbra.javasteam.types + +import `in`.dragonbra.javasteam.enums.EDepotFileFlag +import java.io.File +import java.util.EnumSet + +/** + * Represents a single file within a manifest. + * + * @constructor Initializes a new instance of the [FileData] class. + * + * @param fileName Gets the name of the file. + * @param fileNameHash Gets SHA-1 hash of this file's name. + * @param chunks Gets the chunks that this file is composed of. + * @param flags Gets the file flags + * @param totalSize Gets the total size of this file. + * @param fileHash Gets SHA-1 hash of this file. + * @param linkTarget Gets symlink target of this file. + */ +@Suppress("ArrayInDataClass") +data class FileData( + var fileName: String = "", + var fileNameHash: ByteArray = byteArrayOf(), + var chunks: MutableList = mutableListOf(), + var flags: EnumSet = EnumSet.noneOf(EDepotFileFlag::class.java), + var totalSize: Long = 0, + var fileHash: ByteArray = byteArrayOf(), + var linkTarget: String? = null, +) { + /** + * Initializes a new instance of the [FileData] class with specified values. + */ + constructor( + filename: String, + filenameHash: ByteArray, + flag: EnumSet, + size: Long, + hash: ByteArray, + linkTarget: String, + encrypted: Boolean, + numChunks: Int, + ) : this( + fileName = if (encrypted) filename else filename.replace('\\', File.separatorChar), + fileNameHash = filenameHash, + chunks = ArrayList(numChunks), + flags = flag, + totalSize = size, + fileHash = hash, + linkTarget = linkTarget, + ) +} diff --git a/src/main/java/in/dragonbra/javasteam/types/Steam3Manifest.kt b/src/main/java/in/dragonbra/javasteam/types/Steam3Manifest.kt index 745ea5b3..63377a44 100644 --- a/src/main/java/in/dragonbra/javasteam/types/Steam3Manifest.kt +++ b/src/main/java/in/dragonbra/javasteam/types/Steam3Manifest.kt @@ -1,130 +1,118 @@ -@file:Suppress("unused") - -package `in`.dragonbra.javasteam.types - -import `in`.dragonbra.javasteam.enums.EDepotFileFlag -import `in`.dragonbra.javasteam.util.compat.readNBytesCompat -import `in`.dragonbra.javasteam.util.stream.BinaryReader -import java.time.Instant -import java.util.Date -import java.util.EnumSet - -/** - * Represents the binary Steam3 manifest format. - */ -class Steam3Manifest( -// val magic: Int, - val version: Int, - val depotID: Int, - val manifestGID: Long, - val creationTime: Date, - val areFileNamesEncrypted: Boolean, - val totalUncompressedSize: Long, - val totalCompressedSize: Long, - val chunkCount: Int, - val fileEntryCount: Int, - val fileMappingSize: Int, - val encryptedCRC: Int, - val decryptedCRC: Int, - val flags: Int, - val fileMapping: List, -) { - - class FileMapping( - val fileName: String, - val totalSize: Long, - val flags: EnumSet, - val hashFileName: ByteArray, - val hashContent: ByteArray, - val numChunks: Int, - val chunks: Array, - ) { - - class Chunk( - val chunkGID: ByteArray, // sha1 hash for this chunk - val checksum: Int, - val offset: Long, - val decompressedSize: Int, - val compressedSize: Int, - ) { - companion object { - internal fun deserialize(ds: BinaryReader): Chunk = Chunk( - chunkGID = ds.readNBytesCompat(20), - checksum = ds.readInt(), - offset = ds.readLong(), - decompressedSize = ds.readInt(), - compressedSize = ds.readInt() - ) - } - } - - companion object { - internal fun deserialize(ds: BinaryReader): FileMapping { - val fileName = ds.readNullTermString(Charsets.UTF_8) - val totalSize = ds.readLong() - val flags = EDepotFileFlag.from(ds.readInt()) - val hashContent = ds.readNBytesCompat(20) - val hashFileName = ds.readNBytesCompat(20) - val numChunks = ds.readInt() - - return FileMapping( - fileName = fileName, - totalSize = totalSize, - flags = flags, - hashContent = hashContent, - hashFileName = hashFileName, - numChunks = numChunks, - chunks = Array(numChunks) { Chunk.deserialize(ds) } - ) - } - } - } - - companion object { - const val MAGIC: Int = 0x16349781 - const val CURRENT_VERSION: Int = 4 - - internal fun deserialize(ds: BinaryReader): Steam3Manifest { - // The magic is verified by DepotManifest.InternalDeserialize, not checked here to avoid seeking - val version = ds.readInt() - val depotID = ds.readInt() - val manifestGID = ds.readLong() - val creationTime = Date.from(Instant.ofEpochSecond(ds.readInt().toLong())) - val areFileNamesEncrypted = ds.readInt() != 0 - val totalUncompressedSize = ds.readLong() - val totalCompressedSize = ds.readLong() - val chunkCount = ds.readInt() - val fileEntryCount = ds.readInt() - val fileMappingSize = ds.readInt() - val encryptedCRC = ds.readInt() - val decryptedCRC = ds.readInt() - val flags = ds.readInt() - - val fileMapping = mutableListOf() - var size = fileMappingSize - - while (size > 0) { - val start = ds.position - fileMapping.add(FileMapping.deserialize(ds)) - size -= ds.position - start - } - - return Steam3Manifest( - version = version, - depotID = depotID, - manifestGID = manifestGID, - creationTime = creationTime, - areFileNamesEncrypted = areFileNamesEncrypted, - totalUncompressedSize = totalUncompressedSize, - totalCompressedSize = totalCompressedSize, - chunkCount = chunkCount, - fileEntryCount = fileEntryCount, - fileMappingSize = fileMappingSize, - fileMapping = fileMapping, - encryptedCRC = encryptedCRC, - decryptedCRC = decryptedCRC, - flags = flags, - ) - } - } -} +package `in`.dragonbra.javasteam.types + +import `in`.dragonbra.javasteam.enums.EDepotFileFlag +import `in`.dragonbra.javasteam.util.stream.BinaryReader +import java.nio.charset.StandardCharsets +import java.util.Date +import java.util.EnumSet + +/** + * Represents the binary Steam3 manifest format. + */ +@Suppress("unused", "MemberVisibilityCanBePrivate") +class Steam3Manifest { + + companion object { + const val MAGIC: Int = 0x16349781 + private const val CURRENT_VERSION: Int = 4 + } + + @Suppress("MemberVisibilityCanBePrivate") + class FileMapping { + class Chunk { + var chunkGID: ByteArray? = null // sha1 hash for this chunk + var checksum: Int = 0 + var offset: Long = 0L + var decompressedSize: Int = 0 + var compressedSize: Int = 0 + + internal fun deserialize(ds: BinaryReader) { + chunkGID = ds.readBytes(20) + checksum = ds.readInt() + offset = ds.readLong() + decompressedSize = ds.readInt() + compressedSize = ds.readInt() + } + } + + var fileName: String? = null + var totalSize: Long = 0 + var flags: EnumSet = EnumSet.noneOf(EDepotFileFlag::class.java) + var hashFileName: ByteArray? = null + var hashContent: ByteArray? = null + var numChunks: Int = 0 + var chunks: Array = arrayOf() + private set + + internal fun deserialize(ds: BinaryReader) { + fileName = ds.readNullTermString(StandardCharsets.UTF_8) + totalSize = ds.readLong() + flags = EDepotFileFlag.from(ds.readInt()) + hashContent = ds.readBytes(20) + hashFileName = ds.readBytes(20) + numChunks = ds.readInt() + chunks = Array(numChunks) { Chunk() } + + for (x in chunks.indices) { + chunks[x].deserialize(ds) + } + } + } + + var magic: Int = 0 + var version: Int = 0 + var depotID: Int = 0 + var manifestGID: Long = 0 + var creationTime: Date = Date() + var areFileNamesEncrypted: Boolean = false + var totalUncompressedSize: Long = 0 + var totalCompressedSize: Long = 0 + var chunkCount: Int = 0 + var fileEntryCount: Int = 0 + var fileMappingSize: Int = 0 + var encryptedCRC: Int = 0 + var decryptedCRC: Int = 0 + var flags: Int = 0 + var mapping: MutableList = mutableListOf() + + internal fun deserialize(ds: BinaryReader) { + // The magic is verified by DepotManifest.InternalDeserialize, not checked here to avoid seeking + // magic = ds.readInt() + // if (magic != MAGIC) { + // throw IOException("data is not a valid steam3 manifest: incorrect magic.") + // } + + version = ds.readInt() + + if (version != CURRENT_VERSION) { + throw NotImplementedError("Only version $CURRENT_VERSION is supported.") + } + + depotID = ds.readInt() + manifestGID = ds.readLong() + creationTime = Date(ds.readInt() * 1000L) + areFileNamesEncrypted = ds.readInt() != 0 + totalUncompressedSize = ds.readLong() + totalCompressedSize = ds.readLong() + chunkCount = ds.readInt() + fileEntryCount = ds.readInt() + fileMappingSize = ds.readInt() + + mapping = ArrayList(fileMappingSize) + + encryptedCRC = ds.readInt() + decryptedCRC = ds.readInt() + flags = ds.readInt() + + var i = fileMappingSize + while (i > 0) { + val start = ds.position.toLong() + + val fileMapping = FileMapping() + fileMapping.deserialize(ds) + mapping.add(fileMapping) + + i -= (ds.position - start.toInt()) + } + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java b/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java index f3c687d5..52cda7f8 100644 --- a/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java +++ b/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java @@ -1,6 +1,7 @@ package in.dragonbra.javasteam.util.stream; import com.google.protobuf.ByteString; +import org.jetbrains.annotations.NotNull; import java.io.Closeable; import java.io.InputStream; @@ -379,6 +380,11 @@ public byte[] toByteArray() { return ret; } + @Override + public byte @NotNull [] readAllBytes() { + return toByteArray(); + } + /** * Get an OutputStream that will write to this MemoryStream, at the current position. * diff --git a/src/test/java/in/dragonbra/javasteam/types/DepotManifestTest.java b/src/test/java/in/dragonbra/javasteam/types/DepotManifestTest.java new file mode 100644 index 00000000..2384672e --- /dev/null +++ b/src/test/java/in/dragonbra/javasteam/types/DepotManifestTest.java @@ -0,0 +1,232 @@ +package in.dragonbra.javasteam.types; + +import in.dragonbra.javasteam.TestBase; +import in.dragonbra.javasteam.enums.EDepotFileFlag; +import in.dragonbra.javasteam.util.Strings; +import in.dragonbra.javasteam.util.crypto.CryptoHelper; +import in.dragonbra.javasteam.util.stream.MemoryStream; +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.file.Path; +import java.security.NoSuchAlgorithmException; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Arrays; + +public class DepotManifestTest extends TestBase { + + private static final byte[] DEPOT_440_DECRYPTION_KEY = new byte[]{ + (byte) 0x44, (byte) 0xCE, (byte) 0x5C, (byte) 0x52, (byte) 0x97, (byte) 0xA4, (byte) 0x15, (byte) 0xA1, + (byte) 0xA6, (byte) 0xF6, (byte) 0x9C, (byte) 0x85, (byte) 0x60, (byte) 0x37, (byte) 0xA5, (byte) 0xA2, + (byte) 0xFD, (byte) 0xD8, (byte) 0x2C, (byte) 0xD4, (byte) 0x74, (byte) 0xFA, (byte) 0x65, (byte) 0x9E, + (byte) 0xDF, (byte) 0xB4, (byte) 0xD5, (byte) 0x9B, (byte) 0x2A, (byte) 0xBC, (byte) 0x55, (byte) 0xFC + }; + + + @Test + public void parsesAndDecryptsManifestVersion4() { + try (var stream = getClass().getResourceAsStream("/depot/depot_440_1118032470228587934_v4.manifest"); + var ms = new MemoryStream() + ) { + Assertions.assertNotNull(stream); + + stream.transferTo(ms.asOutputStream()); + var manifestData = ms.toByteArray(); + + var depotManifest = DepotManifest.deserialize(manifestData); + + Assertions.assertTrue(depotManifest.getFilenamesEncrypted()); + Assertions.assertEquals(1195249848L, depotManifest.getEncryptedCRC()); + + var result = depotManifest.decryptFilenames(DEPOT_440_DECRYPTION_KEY); + Assertions.assertTrue(result); + + testDecryptedManifest(depotManifest); + } catch (Exception e) { + Assertions.fail(e); + } + } + + @Test + public void parsesAndDecryptsManifest() throws IOException, NoSuchAlgorithmException { + try (var stream = getClass().getResourceAsStream("/depot/depot_440_1118032470228587934.manifest"); + var ms = new MemoryStream() + ) { + Assertions.assertNotNull(stream); + + stream.transferTo(ms.asOutputStream()); + + var manifestData = ms.toByteArray(); + + var depotManifest = DepotManifest.deserialize(manifestData); + + Assertions.assertTrue(depotManifest.getFilenamesEncrypted()); + Assertions.assertEquals(1606273976L, depotManifest.getEncryptedCRC()); + + var result = depotManifest.decryptFilenames(DEPOT_440_DECRYPTION_KEY); + Assertions.assertTrue(result); + + testDecryptedManifest(depotManifest); + } + } + + @Test + public void parsesDecryptedManifest() throws IOException, NoSuchAlgorithmException { + try (var stream = getClass().getResourceAsStream("/depot/depot_440_1118032470228587934_decrypted.manifest"); + var ms = new MemoryStream() + ) { + Assertions.assertNotNull(stream); + + stream.transferTo(ms.asOutputStream()); + + var manifestData = ms.toByteArray(); + + var depotManifest = DepotManifest.deserialize(manifestData); + + testDecryptedManifest(depotManifest); + } + } + + @Test + public void roundtripSerializesManifestEncryptedManifest() { + try (var stream = getClass().getResourceAsStream("/depot/depot_440_1118032470228587934.manifest"); + var ms = new MemoryStream() + ) { + Assertions.assertNotNull(stream); + + IOUtils.copy(stream, ms.asOutputStream()); + stream.close(); + + var manifestData = ms.toByteArray(); + + DepotManifest depotManifest = DepotManifest.deserialize(manifestData); + + var actualStream = new ByteArrayOutputStream(); + depotManifest.serialize(actualStream); + + var actual = actualStream.toByteArray(); + + // We are unable to write signatures, so validate everything except for the signature + var signature = new byte[]{0x17, (byte) 0xB8, (byte) 0x81, 0x1B}; + + int actualOffset = indexOf(actual, signature); // DepotManifest.PROTOBUF_SIGNATURE_MAGIC + int expectedOffset = indexOf(manifestData, signature); + + Assertions.assertTrue(actualOffset > 0); + Assertions.assertTrue(expectedOffset > 0); + + Assertions.assertArrayEquals( + Arrays.copyOfRange(manifestData, 0, expectedOffset), + Arrays.copyOfRange(actual, 0, actualOffset)); + + int expectedSignatureLength = ByteBuffer.wrap(manifestData, expectedOffset + 4, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); + int actualSignatureLength = ByteBuffer.wrap(actual, actualOffset + 4, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); + + Assertions.assertEquals(131, expectedSignatureLength); + Assertions.assertEquals(0, actualSignatureLength); + + Assertions.assertArrayEquals( + Arrays.copyOfRange(manifestData, expectedOffset + expectedSignatureLength + 8, manifestData.length), + Arrays.copyOfRange(actual, actualOffset + 8, actual.length) + ); + } catch (IOException e) { + Assertions.fail(e); + } + } + + // C# Test "RoundtripSerializesManifestByteIndentical" + @Test + public void roundtripSerializesManifestByteIdentical() throws IOException { + try (var stream = getClass().getResourceAsStream("/depot/depot_440_1118032470228587934_decrypted.manifest"); + var ms = new MemoryStream() + ) { + Assertions.assertNotNull(stream); + + stream.transferTo(ms.asOutputStream()); + + var manifestData = ms.toByteArray(); + ms.close(); + + DepotManifest depotManifest = DepotManifest.deserialize(manifestData); + + var actualStream = new MemoryStream(); + depotManifest.serialize(actualStream.asOutputStream()); + + var actual = actualStream.toByteArray(); + actualStream.close(); + + Assertions.assertArrayEquals(manifestData, actual); + } + } + + private void testDecryptedManifest(DepotManifest depotManifest) throws NoSuchAlgorithmException { + Assertions.assertFalse(depotManifest.getFilenamesEncrypted()); + Assertions.assertEquals(440L, depotManifest.getDepotID()); + Assertions.assertEquals(1118032470228587934L, depotManifest.getManifestGID()); + Assertions.assertEquals(825745L, depotManifest.getTotalUncompressedSize()); + Assertions.assertEquals(43168L, depotManifest.getTotalCompressedSize()); + Assertions.assertEquals(7, depotManifest.getFiles().size()); + Assertions.assertEquals( + ZonedDateTime.of(2013, 4, 17, 20, 39, 24, 0, ZoneOffset.UTC).toInstant(), + depotManifest.getCreationTime().toInstant() + ); + + Assertions.assertEquals(Path.of("bin", "dxsupport.cfg").toString(), depotManifest.getFiles().get(0).getFileName()); + Assertions.assertEquals(Path.of("bin", "dxsupport.csv").toString(), depotManifest.getFiles().get(1).getFileName()); + Assertions.assertEquals(Path.of("bin", "dxsupport_episodic.cfg").toString(), depotManifest.getFiles().get(2).getFileName()); + Assertions.assertEquals(Path.of("bin", "dxsupport_sp.cfg").toString(), depotManifest.getFiles().get(3).getFileName()); + Assertions.assertEquals(Path.of("bin", "vidcfg.bin").toString(), depotManifest.getFiles().get(4).getFileName()); + Assertions.assertEquals(Path.of("hl2", "media", "startupvids.txt").toString(), depotManifest.getFiles().get(5).getFileName()); + Assertions.assertEquals(Path.of("tf", "media", "startupvids.txt").toString(), depotManifest.getFiles().get(6).getFileName()); + + Assertions.assertEquals(EDepotFileFlag.from(0), depotManifest.getFiles().get(0).getFlags()); + Assertions.assertEquals(398709L, depotManifest.getFiles().get(0).getTotalSize()); + Assertions.assertArrayEquals( + Strings.decodeHex("BAC8E2657470B2EB70D6DDCD6C07004BE8738697"), + depotManifest.getFiles().get(2).getFileHash() + ); + + for (var file : depotManifest.getFiles()) { + Assertions.assertArrayEquals( + file.getFileNameHash(), + CryptoHelper.shaHash(file.getFileName().replace('/', '\\').getBytes()) + ); + Assertions.assertNotNull(file.getLinkTarget()); + Assertions.assertEquals(1, file.getChunks().size()); + } + + var chunk = depotManifest.getFiles().get(6).getChunks().get(0); + Assertions.assertEquals(963249608L, chunk.getChecksum()); + Assertions.assertEquals(144L, chunk.getCompressedLength()); + Assertions.assertEquals(17L, chunk.getUncompressedLength()); + Assertions.assertEquals(0L, chunk.getOffset()); + Assertions.assertArrayEquals( + Strings.decodeHex("94020BDE145A521EDEC9A9424E7A90FD042481E9"), + chunk.getChunkID() + ); + } + + private int indexOf(byte[] array, byte[] target) { + if (array == null || target == null || array.length < target.length) { + return -1; + } + + outer: + for (int i = 0; i <= array.length - target.length; i++) { + for (int j = 0; j < target.length; j++) { + if (array[i + j] != target[j]) { + continue outer; + } + } + return i; + } + + return -1; + } +} diff --git a/src/test/resources/depot/depot_440_1118032470228587934.manifest b/src/test/resources/depot/depot_440_1118032470228587934.manifest new file mode 100644 index 00000000..da6770ec Binary files /dev/null and b/src/test/resources/depot/depot_440_1118032470228587934.manifest differ diff --git a/src/test/resources/depot/depot_440_1118032470228587934_decrypted.manifest b/src/test/resources/depot/depot_440_1118032470228587934_decrypted.manifest new file mode 100644 index 00000000..0edcb8e9 Binary files /dev/null and b/src/test/resources/depot/depot_440_1118032470228587934_decrypted.manifest differ diff --git a/src/test/resources/depot/depot_440_1118032470228587934_v4.manifest b/src/test/resources/depot/depot_440_1118032470228587934_v4.manifest new file mode 100644 index 00000000..7fb01739 Binary files /dev/null and b/src/test/resources/depot/depot_440_1118032470228587934_v4.manifest differ