Significant rewrite to use 4koma, OkHttp, PackwizPath; fixing several issues

Github release plugin, Kotlin, Okio and OkHttp updated
Toml4j removed and replaced with 4koma - improves null safety, immutability, TOML compliance
kotlin-result removed (I don't think it was used anyway)
SpaceSafeURI replaced with PackwizPath which handles special characters much better (fixes #5)
Fixed directory traversal issues
Hashing system rewritten for better type safety and cleaner code
Download mode changed to use an enum
Request system completely rewritten; now uses OkHttp for HTTP requests (fixes #36, fixes #5)
Added request interceptor which retries requests (fixes #4)
Removed: support for extracting packs from zip files (and Github zipballs)
Cleaner exceptions; more improvements pending (in particular showing request failure causes)
Improved speed of cancelling in-progress downloads
Better support for installing from local files (no file: URI required; though it is still supported)
Various code cleanup changes
This commit is contained in:
comp500
2022-07-10 01:44:35 +01:00
parent 02b01b90d7
commit 66bc4c3e29
52 changed files with 877 additions and 1140 deletions

View File

@@ -0,0 +1,19 @@
package link.infra.packwiz.installer.metadata
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
enum class DownloadMode {
URL,
CURSEFORGE;
companion object {
fun mapper() = tomlMapper {
decoder { it: TomlValue.String -> when (it.value) {
"", "url" -> URL
"metadata:curseforge" -> CURSEFORGE
else -> throw Exception("Unsupported download mode ${it.value}")
} }
}
}
}

View File

@@ -17,7 +17,7 @@ class EfficientBooleanAdapter : TypeAdapter<Boolean?>() {
}
@Throws(IOException::class)
override fun read(reader: JsonReader): Boolean? {
override fun read(reader: JsonReader): Boolean {
if (reader.peek() == JsonToken.NULL) {
reader.nextNull()
return false

View File

@@ -1,100 +1,97 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.SerializedName
import com.moandjiezana.toml.Toml
import cc.ekblad.toml.decode
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHasher
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.util.delegateTransitive
import okio.Source
import okio.buffer
import java.io.InputStreamReader
import java.nio.file.Paths
class IndexFile {
@SerializedName("hash-format")
var hashFormat: String = "sha-256"
var files: MutableList<File> = ArrayList()
class File {
var file: SpaceSafeURI? = null
@SerializedName("hash-format")
var hashFormat: String? = null
var hash: String? = null
var alias: SpaceSafeURI? = null
var metafile = false
var preserve = false
@Transient
data class IndexFile(
val hashFormat: HashFormat<*>,
val files: List<File> = listOf()
) {
data class File(
val file: PackwizPath<*>,
private val hashFormat: HashFormat<*>? = null,
val hash: String,
val alias: PackwizPath<*>?,
val metafile: Boolean = false,
val preserve: Boolean = false,
) {
var linkedFile: ModFile? = null
@Transient
var linkedFileURI: SpaceSafeURI? = null
fun hashFormat(index: IndexFile) = hashFormat ?: index.hashFormat
@Throws(Exception::class)
fun getHashObj(index: IndexFile): Hash<*> {
// TODO: more specific exceptions?
return hashFormat(index).fromString(hash)
}
@Throws(Exception::class)
fun downloadMeta(parentIndexFile: IndexFile, indexUri: SpaceSafeURI?) {
fun downloadMeta(index: IndexFile, clientHolder: ClientHolder) {
if (!metafile) {
return
}
if (hashFormat?.length ?: 0 == 0) {
hashFormat = parentIndexFile.hashFormat
}
// TODO: throw a proper exception instead of allowing NPE?
val fileHash = getHash(hashFormat!!, hash!!)
linkedFileURI = getNewLoc(indexUri, file)
val src = getFileSource(linkedFileURI!!)
val fileStream = getHasher(hashFormat!!).getHashingSource(src)
linkedFile = Toml().read(InputStreamReader(fileStream.buffer().inputStream(), "UTF-8")).to(ModFile::class.java)
if (!fileStream.hashIsEqual(fileHash)) {
val fileHash = getHashObj(index)
val src = file.source(clientHolder)
val fileStream = hashFormat(index).source(src)
linkedFile = ModFile.mapper(file).decode<ModFile>(fileStream.buffer().inputStream())
if (fileHash != fileStream.hash) {
// TODO: propagate details about hash, and show better error!
throw Exception("Invalid mod file hash")
}
}
@Throws(Exception::class)
fun getSource(indexUri: SpaceSafeURI?): Source {
fun getSource(clientHolder: ClientHolder): Source {
return if (metafile) {
if (linkedFile == null) {
throw Exception("Linked file doesn't exist!")
}
linkedFile!!.getSource(linkedFileURI)
linkedFile!!.getSource(clientHolder)
} else {
val newLoc = getNewLoc(indexUri, file) ?: throw Exception("Index file URI is invalid")
getFileSource(newLoc)
file.source(clientHolder)
}
}
@Throws(Exception::class)
fun getHashObj(): Hash {
if (hash == null) { // TODO: should these be more specific exceptions (e.g. IndexFileException?!)
throw Exception("Index file doesn't have a hash")
}
if (hashFormat == null) {
throw Exception("Index file doesn't have a hash format")
}
return getHash(hashFormat!!, hash!!)
}
// TODO: throw some kind of exception?
val name: String
get() {
if (metafile) {
return linkedFile?.name ?: linkedFile?.filename ?:
file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
return linkedFile?.name ?: file.filename
}
return file?.run { Paths.get(path ?: return "Invalid file").fileName.toString() } ?: "Invalid file"
return file.filename
}
// TODO: URIs are bad
val destURI: SpaceSafeURI?
val destURI: PackwizPath<*>
get() {
if (alias != null) {
return alias
}
return if (metafile && linkedFile != null) {
linkedFile?.filename?.let { file?.resolve(it) }
return if (metafile) {
linkedFile!!.filename
} else {
file
}
}
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<File>("hash-format" to "hashFormat")
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
}
}
}
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<IndexFile>("hash-format" to "hashFormat")
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegateTransitive<File>(File.mapper(base))
}
}
}

View File

@@ -3,23 +3,23 @@ package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.PackwizFilePath
class ManifestFile {
var packFileHash: Hash? = null
var indexFileHash: Hash? = null
var cachedFiles: MutableMap<SpaceSafeURI, File> = HashMap()
var packFileHash: Hash<*>? = null
var indexFileHash: Hash<*>? = null
var cachedFiles: MutableMap<PackwizFilePath, File> = HashMap()
// If the side changes, EVERYTHING invalidates. FUN!!!
var cachedSide = Side.CLIENT
// TODO: switch to Kotlin-friendly JSON/TOML libs?
class File {
@Transient
var revert: File? = null
private set
var hash: Hash? = null
var linkedFileHash: Hash? = null
var cachedLocation: String? = null
var hash: Hash<*>? = null
var linkedFileHash: Hash<*>? = null
var cachedLocation: PackwizFilePath? = null
@JsonAdapter(EfficientBooleanAdapter::class)
var isOptional = false

View File

@@ -1,74 +1,96 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import com.google.gson.annotations.SerializedName
import cc.ekblad.toml.delegate
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.curseforge.UpdateData
import link.infra.packwiz.installer.metadata.curseforge.UpdateDeserializer
import link.infra.packwiz.installer.metadata.hash.Hash
import link.infra.packwiz.installer.metadata.hash.HashUtils.getHash
import link.infra.packwiz.installer.request.HandlerManager.getFileSource
import link.infra.packwiz.installer.request.HandlerManager.getNewLoc
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.Side
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.util.delegateTransitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okio.Source
import kotlin.reflect.KType
class ModFile {
var name: String? = null
var filename: String? = null
var side: Side? = null
var download: Download? = null
data class ModFile(
val name: String,
val filename: PackwizPath<*>,
val side: Side = Side.BOTH,
val download: Download,
val update: Map<String, UpdateData> = mapOf(),
val option: Option = Option(false)
) {
data class Download(
val url: PackwizPath<*>?,
val hashFormat: HashFormat<*>,
val hash: String,
val mode: DownloadMode = DownloadMode.URL
) {
companion object {
fun mapper() = tomlMapper {
decoder<TomlValue.String, PackwizPath<*>> { it -> HttpUrlPath(it.value.toHttpUrl()) }
mapping<Download>("hash-format" to "hashFormat")
class Download {
var url: SpaceSafeURI? = null
@SerializedName("hash-format")
var hashFormat: String? = null
var hash: String? = null
var mode: String? = null
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
delegate<DownloadMode>(DownloadMode.mapper())
}
}
}
@JsonAdapter(UpdateDeserializer::class)
var update: Map<String, UpdateData>? = null
var option: Option? = null
@Transient
val resolvedUpdateData = mutableMapOf<String, SpaceSafeURI>()
val resolvedUpdateData = mutableMapOf<String, PackwizPath<*>>()
class Option {
var optional = false
var description: String? = null
@SerializedName("default")
var defaultValue = false
data class Option(
val optional: Boolean,
val description: String = "",
val defaultValue: Boolean = false
) {
companion object {
fun mapper() = tomlMapper {
mapping<Option>("default" to "defaultValue")
}
}
}
@Throws(Exception::class)
fun getSource(baseLoc: SpaceSafeURI?): Source {
download?.let {
if (it.mode == null || it.mode == "" || it.mode == "url") {
if (it.url == null) {
throw Exception("Metadata file doesn't have a download URI")
}
val newLoc = getNewLoc(baseLoc, it.url) ?: throw Exception("Metadata file URI is invalid")
return getFileSource(newLoc)
} else if (it.mode == "metadata:curseforge") {
fun getSource(clientHolder: ClientHolder): Source {
return when (download.mode) {
DownloadMode.URL -> {
(download.url ?: throw Exception("No download URL provided")).source(clientHolder)
}
DownloadMode.CURSEFORGE -> {
if (!resolvedUpdateData.contains("curseforge")) {
throw Exception("Metadata file specifies CurseForge mode, but is missing metadata")
}
return getFileSource(resolvedUpdateData["curseforge"]!!)
} else {
throw Exception("Unsupported download mode " + it.mode)
return resolvedUpdateData["curseforge"]!!.source(clientHolder)
}
} ?: throw Exception("Metadata file doesn't have download")
}
}
@get:Throws(Exception::class)
val hash: Hash
get() {
download?.let {
return getHash(
it.hashFormat ?: throw Exception("Metadata file doesn't have a hash format"),
it.hash ?: throw Exception("Metadata file doesn't have a hash")
)
} ?: throw Exception("Metadata file doesn't have download")
}
val hash: Hash<*>
get() = download.hashFormat.fromString(download.hash)
val isOptional: Boolean get() = option?.optional ?: false
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
delegateTransitive<Option>(Option.mapper())
delegateTransitive<Download>(Download.mapper())
delegateTransitive<Side>(Side.mapper())
val updateDataMapper = UpdateData.mapper()
decoder { type: KType, it: TomlValue.Map ->
if (type.arguments[1].type?.classifier == UpdateData::class) {
updateDataMapper.decode<Map<String, UpdateData>>(it)
} else {
pass()
}
}
}
}
}

View File

@@ -1,17 +1,37 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.SerializedName
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.HashFormat
import link.infra.packwiz.installer.target.path.PackwizPath
import link.infra.packwiz.installer.util.delegateTransitive
class PackFile {
var name: String? = null
var index: IndexFileLoc? = null
class IndexFileLoc {
var file: SpaceSafeURI? = null
@SerializedName("hash-format")
var hashFormat: String? = null
var hash: String? = null
data class PackFile(
val name: String,
val packFormat: PackFormat = PackFormat.DEFAULT,
val index: IndexFileLoc,
val versions: Map<String, String> = mapOf()
) {
data class IndexFileLoc(
val file: PackwizPath<*>,
val hashFormat: HashFormat<*>,
val hash: String,
) {
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<IndexFileLoc>("hash-format" to "hashFormat")
delegateTransitive<PackwizPath<*>>(PackwizPath.mapperRelativeTo(base))
delegateTransitive<HashFormat<*>>(HashFormat.mapper())
}
}
}
var versions: Map<String, String>? = null
companion object {
fun mapper(base: PackwizPath<*>) = tomlMapper {
mapping<PackFile>("pack-format" to "packFormat")
decoder { it: TomlValue.String -> PackFormat(it.value) }
encoder { it: PackFormat -> TomlValue.String(it.format) }
delegateTransitive<IndexFileLoc>(IndexFileLoc.mapper(base))
}
}
}

View File

@@ -0,0 +1,10 @@
package link.infra.packwiz.installer.metadata
@JvmInline
value class PackFormat(val format: String) {
companion object {
val DEFAULT = PackFormat("packwiz:1.0.0")
}
// TODO: implement validation, errors for too new / invalid versions
}

View File

@@ -1,61 +0,0 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.annotations.JsonAdapter
import java.io.Serializable
import java.net.MalformedURLException
import java.net.URI
import java.net.URISyntaxException
import java.net.URL
// The world's worst URI wrapper
@JsonAdapter(SpaceSafeURIParser::class)
class SpaceSafeURI : Comparable<SpaceSafeURI>, Serializable {
private val u: URI
@Throws(URISyntaxException::class)
constructor(str: String) {
u = URI(str.replace(" ", "%20"))
}
constructor(uri: URI) {
u = uri
}
@Throws(URISyntaxException::class)
constructor(scheme: String?, authority: String?, path: String?, query: String?, fragment: String?) { // TODO: do all components need to be replaced?
u = URI(
scheme?.replace(" ", "%20"),
authority?.replace(" ", "%20"),
path?.replace(" ", "%20"),
query?.replace(" ", "%20"),
fragment?.replace(" ", "%20")
)
}
val path: String? get() = u.path?.replace("%20", " ")
override fun toString(): String = u.toString().replace("%20", " ")
fun resolve(path: String): SpaceSafeURI = SpaceSafeURI(u.resolve(path.replace(" ", "%20")))
fun resolve(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.resolve(loc.u))
fun relativize(loc: SpaceSafeURI): SpaceSafeURI = SpaceSafeURI(u.relativize(loc.u))
override fun equals(other: Any?): Boolean {
return if (other is SpaceSafeURI) {
u == other.u
} else false
}
override fun hashCode() = u.hashCode()
override fun compareTo(other: SpaceSafeURI): Int = u.compareTo(other.u)
val scheme: String? get() = u.scheme
val authority: String? get() = u.authority
val host: String? get() = u.host
@Throws(MalformedURLException::class)
fun toURL(): URL = u.toURL()
}

View File

@@ -1,25 +0,0 @@
package link.infra.packwiz.installer.metadata
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import java.lang.reflect.Type
import java.net.URISyntaxException
/**
* This class encodes spaces before parsing the URI, so the URI can actually be
* parsed.
*/
internal class SpaceSafeURIParser : JsonDeserializer<SpaceSafeURI> {
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): SpaceSafeURI {
return try {
SpaceSafeURI(json.asString)
} catch (e: URISyntaxException) {
throw JsonParseException("Failed to parse URI", e)
}
}
// TODO: replace this with a better solution?
}

View File

@@ -4,15 +4,18 @@ import com.google.gson.Gson
import com.google.gson.JsonIOException
import com.google.gson.JsonSyntaxException
import link.infra.packwiz.installer.metadata.IndexFile
import link.infra.packwiz.installer.metadata.SpaceSafeURI
import link.infra.packwiz.installer.target.ClientHolder
import link.infra.packwiz.installer.target.path.HttpUrlPath
import link.infra.packwiz.installer.target.path.PackwizFilePath
import link.infra.packwiz.installer.ui.data.ExceptionDetails
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.internal.closeQuietly
import okio.ByteString.Companion.decodeBase64
import java.nio.charset.StandardCharsets
import kotlin.io.path.absolute
private class GetFilesRequest(val fileIds: List<Int>)
private class GetModsRequest(val modIds: List<Int>)
@@ -21,7 +24,7 @@ private class GetFilesResponse {
class CfFile {
var id = 0
var modId = 0
var downloadUrl: SpaceSafeURI? = null
var downloadUrl: String? = null
}
val data = mutableListOf<CfFile>()
}
@@ -43,25 +46,17 @@ private const val APIServer = "api.curseforge.com"
private val APIKey = "JDJhJDEwJHNBWVhqblU1N0EzSmpzcmJYM3JVdk92UWk2NHBLS3BnQ2VpbGc1TUM1UGNKL0RYTmlGWWxh".decodeBase64()!!
.string(StandardCharsets.UTF_8)
private val clientHolder = ClientHolder()
// TODO: switch to PackwizPath stuff and OkHttp in old code
@Throws(JsonSyntaxException::class, JsonIOException::class)
fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
fun resolveCfMetadata(mods: List<IndexFile.File>, packFolder: PackwizFilePath, clientHolder: ClientHolder): List<ExceptionDetails> {
val failures = mutableListOf<ExceptionDetails>()
val fileIdMap = mutableMapOf<Int, IndexFile.File>()
for (mod in mods) {
if (mod.linkedFile!!.update == null) {
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no update section")))
if (!mod.linkedFile!!.update.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue
}
if (!mod.linkedFile!!.update!!.contains("curseforge")) {
failures.add(ExceptionDetails(mod.linkedFile!!.name ?: mod.linkedFile!!.filename!!, Exception("Failed to resolve CurseForge metadata: no CurseForge update section")))
continue
}
fileIdMap[(mod.linkedFile!!.update!!["curseforge"] as CurseForgeUpdateData).fileId] = mod
fileIdMap[(mod.linkedFile!!.update["curseforge"] as CurseForgeUpdateData).fileId] = mod
}
val reqData = GetFilesRequest(fileIdMap.keys.toList())
@@ -93,7 +88,13 @@ fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
manualDownloadMods[file.modId] = Pair(fileIdMap[file.id]!!, file.id)
continue
}
fileIdMap[file.id]!!.linkedFile!!.resolvedUpdateData["curseforge"] = file.downloadUrl!!
try {
fileIdMap[file.id]!!.linkedFile!!.resolvedUpdateData["curseforge"] =
HttpUrlPath(file.downloadUrl!!.toHttpUrl())
} catch (e: IllegalArgumentException) {
failures.add(ExceptionDetails(file.id.toString(),
Exception("Failed to parse URL: ${file.downloadUrl} for ID ${file.id}, Project ID ${file.modId}", e)))
}
}
if (manualDownloadMods.isNotEmpty()) {
@@ -124,7 +125,7 @@ fun resolveCfMetadata(mods: List<IndexFile.File>): List<ExceptionDetails> {
val modFile = manualDownloadMods[mod.id]!!
failures.add(ExceptionDetails(mod.name, Exception("This mod is excluded from the CurseForge API and must be downloaded manually.\n" +
"Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI}")))
"Please go to ${mod.links?.websiteUrl}/files/${modFile.second} and save this file to ${modFile.first.destURI.rebase(packFolder).nioPath.absolute()}")))
}
}

View File

@@ -1,10 +1,14 @@
package link.infra.packwiz.installer.metadata.curseforge
import com.google.gson.annotations.SerializedName
import cc.ekblad.toml.tomlMapper
class CurseForgeUpdateData: UpdateData {
@SerializedName("file-id")
var fileId = 0
@SerializedName("project-id")
var projectId = 0
data class CurseForgeUpdateData(
val fileId: Int,
val projectId: Int,
): UpdateData {
companion object {
fun mapper() = tomlMapper {
mapping<CurseForgeUpdateData>("file-id" to "fileId", "project-id" to "projectId")
}
}
}

View File

@@ -1,3 +1,17 @@
package link.infra.packwiz.installer.metadata.curseforge
interface UpdateData
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
interface UpdateData {
companion object {
fun mapper() = tomlMapper {
val cfMapper = CurseForgeUpdateData.mapper()
decoder { it: TomlValue.Map ->
if (it.properties.contains("curseforge")) {
mapOf("curseforge" to cfMapper.decode<CurseForgeUpdateData>(it.properties["curseforge"]!!))
} else { mapOf() }
}
}
}
}

View File

@@ -1,10 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.ForwardingSource
import okio.Source
abstract class GeneralHashingSource(delegate: Source) : ForwardingSource(delegate) {
abstract val hash: Hash
fun hashIsEqual(compareTo: Any) = compareTo == hash
}

View File

@@ -1,20 +1,55 @@
package link.infra.packwiz.installer.metadata.hash
import com.google.gson.*
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
import okio.ByteString
import okio.ByteString.Companion.decodeHex
import okio.ForwardingSource
import okio.HashingSource
import okio.Source
import java.lang.reflect.Type
abstract class Hash {
protected abstract val stringValue: String
protected abstract val type: String
data class Hash<T>(val type: HashFormat<T>, val value: T) {
interface Encoding<T> {
fun encodeToString(value: T): String
fun decodeFromString(str: String): T
class TypeHandler : JsonDeserializer<Hash>, JsonSerializer<Hash> {
override fun serialize(src: Hash, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = JsonObject().apply {
add("type", JsonPrimitive(src.type))
add("value", JsonPrimitive(src.stringValue))
object Hex: Encoding<ByteString> {
override fun encodeToString(value: ByteString) = value.hex()
override fun decodeFromString(str: String) = str.decodeHex()
}
object UInt: Encoding<kotlin.UInt> {
override fun encodeToString(value: kotlin.UInt) = value.toString()
override fun decodeFromString(str: String) = str.toUInt()
}
}
fun interface SourceProvider<T> {
fun source(type: HashFormat<T>, delegate: Source): HasherSource<T>
companion object {
fun fromOkio(provider: ((Source) -> HashingSource)): SourceProvider<ByteString> {
return SourceProvider { type, delegate ->
val delegateHashing = provider.invoke(delegate)
object : ForwardingSource(delegateHashing), HasherSource<ByteString> {
override val hash: Hash<ByteString> by lazy(LazyThreadSafetyMode.NONE) { Hash(type, delegateHashing.hash) }
}
}
}
}
}
class TypeHandler : JsonDeserializer<Hash<*>>, JsonSerializer<Hash<*>> {
override fun serialize(src: Hash<*>, typeOfSrc: Type, context: JsonSerializationContext): JsonElement = JsonObject().apply {
add("type", JsonPrimitive(src.type.formatName))
// Local function for generics
fun <T> addValue(src: Hash<T>) = add("value", JsonPrimitive(src.type.encodeToString(src.value)))
addValue(src)
}
@Throws(JsonParseException::class)
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash {
override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Hash<*> {
val obj = json.asJsonObject
val type: String
val value: String
@@ -25,7 +60,7 @@ abstract class Hash {
throw JsonParseException("Invalid hash JSON data")
}
return try {
HashUtils.getHash(type, value)
(HashFormat.fromName(type) ?: throw JsonParseException("Unknown hash type $type")).fromString(value)
} catch (e: Exception) {
throw JsonParseException("Failed to create hash object", e)
}

View File

@@ -0,0 +1,39 @@
package link.infra.packwiz.installer.metadata.hash
import cc.ekblad.toml.model.TomlValue
import cc.ekblad.toml.tomlMapper
import link.infra.packwiz.installer.metadata.hash.Hash.Encoding
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider
import link.infra.packwiz.installer.metadata.hash.Hash.SourceProvider.Companion.fromOkio
import okio.ByteString
import okio.Source
import okio.HashingSource.Companion as OkHashes
sealed class HashFormat<T>(val formatName: String): Encoding<T>, SourceProvider<T> {
object SHA1: HashFormat<ByteString>("sha1"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha1)
object SHA256: HashFormat<ByteString>("sha256"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha256)
object SHA512: HashFormat<ByteString>("sha512"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::sha512)
object MD5: HashFormat<ByteString>("md5"),
Encoding<ByteString> by Encoding.Hex, SourceProvider<ByteString> by fromOkio(OkHashes::md5)
object MURMUR2: HashFormat<UInt>("murmur2"),
Encoding<UInt> by Encoding.UInt, SourceProvider<UInt> by SourceProvider(::Murmur2HasherSource)
fun source(delegate: Source): HasherSource<T> = source(this, delegate)
fun fromString(str: String) = Hash(this, decodeFromString(str))
override fun toString() = formatName
companion object {
// lazy used to prevent initialisation issues!
private val values by lazy { listOf(SHA1, SHA256, SHA512, MD5, MURMUR2) }
fun fromName(formatName: String) = values.find { formatName == it.formatName }
fun mapper() = tomlMapper {
// TODO: better exception?
decoder { it: TomlValue.String -> fromName(it.value) ?: throw Exception("Hash format ${it.value} not supported") }
encoder { it: HashFormat<*> -> TomlValue.String(it.formatName) }
}
}
}

View File

@@ -1,22 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
object HashUtils {
private val hashTypeConversion: Map<String, IHasher> = mapOf(
"sha256" to HashingSourceHasher("sha256"),
"sha512" to HashingSourceHasher("sha512"),
"murmur2" to Murmur2Hasher(),
"sha1" to HashingSourceHasher("sha1")
)
@JvmStatic
@Throws(Exception::class)
fun getHasher(type: String): IHasher {
return hashTypeConversion[type] ?: throw Exception("Hash type not supported: $type")
}
@JvmStatic
@Throws(Exception::class)
fun getHash(type: String, value: String): Hash {
return hashTypeConversion[type]?.getHash(value) ?: throw Exception("Hash type not supported: $type")
}
}

View File

@@ -0,0 +1,7 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Source
interface HasherSource<T>: Source {
val hash: Hash<T>
}

View File

@@ -1,46 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.HashingSource
import okio.Source
class HashingSourceHasher internal constructor(private val type: String) : IHasher {
// i love naming things
private inner class HashingSourceGeneralHashingSource(val delegateHashing: HashingSource) : GeneralHashingSource(delegateHashing) {
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
HashingSourceHash(delegateHashing.hash.hex())
}
}
// this some funky inner class stuff
// each of these classes is specific to the instance of the HasherHashingSource
// therefore HashingSourceHashes from different parent instances will be not instanceof each other
private inner class HashingSourceHash(val value: String) : Hash() {
override val stringValue get() = value
override fun equals(other: Any?): Boolean {
if (other !is HashingSourceHash) {
return false
}
return stringValue.equals(other.stringValue, ignoreCase = true)
}
override fun toString(): String = "$type: $stringValue"
override fun hashCode(): Int = value.hashCode()
override val type: String get() = this@HashingSourceHasher.type
}
override fun getHashingSource(delegate: Source): GeneralHashingSource {
when (type) {
"md5" -> return HashingSourceGeneralHashingSource(HashingSource.md5(delegate))
"sha256" -> return HashingSourceGeneralHashingSource(HashingSource.sha256(delegate))
"sha512" -> return HashingSourceGeneralHashingSource(HashingSource.sha512(delegate))
"sha1" -> return HashingSourceGeneralHashingSource(HashingSource.sha1(delegate))
}
throw RuntimeException("Invalid hash type provided")
}
override fun getHash(value: String): Hash {
return HashingSourceHash(value)
}
}

View File

@@ -1,8 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Source
interface IHasher {
fun getHashingSource(delegate: Source): GeneralHashingSource
fun getHash(value: String): Hash
}

View File

@@ -1,91 +0,0 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Buffer
import okio.Source
import java.io.IOException
class Murmur2Hasher : IHasher {
private inner class Murmur2GeneralHashingSource(delegate: Source) : GeneralHashingSource(delegate) {
val internalBuffer = Buffer()
val tempBuffer = Buffer()
override val hash: Hash by lazy(LazyThreadSafetyMode.NONE) {
val data = internalBuffer.readByteArray()
Murmur2Hash(Murmur2Lib.hash32(data, data.size, 1))
}
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val out = delegate.read(tempBuffer, byteCount)
if (out > -1) {
sink.write(tempBuffer.clone(), out)
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
}
return out
}
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
// private fun computeNormalizedArray(input: ByteArray): ByteArray {
// val output = ByteArray(input.size)
// var index = 0
// for (b in input) {
// when (b) {
// 9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
// else -> {
// output[index] = b
// index++
// }
// }
// }
// val outputTrimmed = ByteArray(index)
// System.arraycopy(output, 0, outputTrimmed, 0, index)
// return outputTrimmed
// }
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
var index = 0
val arr = input.readByteArray()
for (b in arr) {
when (b) {
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
else -> {
arr[index] = b
index++
}
}
}
output.write(arr, 0, index)
}
}
private class Murmur2Hash : Hash {
val value: Int
constructor(value: String) {
// Parsing as long then casting to int converts values gt int max value but lt uint max value
// into negatives. I presume this is how the murmur2 code handles this.
this.value = value.toLong().toInt()
}
constructor(value: Int) {
this.value = value
}
override val stringValue get() = value.toString()
override val type get() = "murmur2"
override fun equals(other: Any?): Boolean {
if (other !is Murmur2Hash) {
return false
}
return value == other.value
}
override fun toString(): String = "murmur2: $value"
override fun hashCode(): Int = value
}
override fun getHashingSource(delegate: Source): GeneralHashingSource = Murmur2GeneralHashingSource(delegate)
override fun getHash(value: String): Hash = Murmur2Hash(value)
}

View File

@@ -0,0 +1,43 @@
package link.infra.packwiz.installer.metadata.hash
import okio.Buffer
import okio.ForwardingSource
import okio.Source
import java.io.IOException
class Murmur2HasherSource(type: HashFormat<UInt>, delegate: Source) : ForwardingSource(delegate), HasherSource<UInt> {
private val internalBuffer = Buffer()
private val tempBuffer = Buffer()
override val hash: Hash<UInt> by lazy(LazyThreadSafetyMode.NONE) {
// TODO: remove internal buffering?
val data = internalBuffer.readByteArray()
Hash(type, Murmur2Lib.hash32(data, data.size, 1).toUInt())
}
@Throws(IOException::class)
override fun read(sink: Buffer, byteCount: Long): Long {
val out = delegate.read(tempBuffer, byteCount)
if (out > -1) {
sink.write(tempBuffer.clone(), out)
computeNormalizedBufferFaster(tempBuffer, internalBuffer)
}
return out
}
// Credit to https://github.com/modmuss50/CAV2/blob/master/murmur.go
private fun computeNormalizedBufferFaster(input: Buffer, output: Buffer) {
var index = 0
val arr = input.readByteArray()
for (b in arr) {
when (b) {
9.toByte(), 10.toByte(), 13.toByte(), 32.toByte() -> {}
else -> {
arr[index] = b
index++
}
}
}
output.write(arr, 0, index)
}
}