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