mirror of
https://github.com/packwiz/packwiz-installer.git
synced 2025-04-18 20:46:30 +02:00
Implement new abstraction for file paths
Once integrated with the rest of the installer, this should fix many directory traversal and path encoding issues
This commit is contained in:
parent
6db8422c87
commit
f4dd4fa866
@ -34,6 +34,7 @@ dependencies {
|
||||
implementation("com.google.code.gson:gson:2.8.9")
|
||||
implementation("com.squareup.okio:okio:3.0.0")
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
implementation("com.squareup.okhttp3:okhttp:4.9.3")
|
||||
}
|
||||
|
||||
application {
|
||||
@ -91,7 +92,13 @@ tasks.register<proguard.gradle.ProGuardTask>("shrinkJar") {
|
||||
keep("class link.infra.packwiz.installer.** { *; }")
|
||||
dontoptimize()
|
||||
dontobfuscate()
|
||||
|
||||
// Used by Okio and OkHttp
|
||||
dontwarn("org.codehaus.mojo.animal_sniffer.*")
|
||||
dontwarn("okhttp3.internal.platform.**")
|
||||
dontwarn("org.conscrypt.**")
|
||||
dontwarn("org.bouncycastle.**")
|
||||
dontwarn("org.openjsse.**")
|
||||
}
|
||||
|
||||
// Used for vscode launch.json
|
||||
|
@ -0,0 +1,70 @@
|
||||
package link.infra.packwiz.installer.request
|
||||
|
||||
import okhttp3.Response
|
||||
import okio.IOException
|
||||
|
||||
sealed class RequestException: Exception {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
/**
|
||||
* Internal errors that should not be shown to the user when the code is correct
|
||||
*/
|
||||
sealed class Internal: RequestException {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
class UnsinkableBase: Internal("Base associated with this path is not a SinkableBase")
|
||||
|
||||
sealed class HTTP: Internal {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
class NoResponseBody: HTTP("HTTP response in onResponse must have a response body")
|
||||
class RequestFailed(cause: IOException): HTTP("HTTP request failed; may have been cancelled", cause)
|
||||
class IllegalState(cause: IllegalStateException): HTTP("Internal fatal HTTP request error", cause)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors indicating that the request is malformed
|
||||
*/
|
||||
sealed class Validation: RequestException {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
// TODO: move out of RequestException?
|
||||
class PathContainsNUL(path: String): Validation("Invalid path; contains NUL bytes: ${path.replace("\u0000", "")}")
|
||||
class PathContainsVolumeLetter(path: String): Validation("Invalid path; contains volume letter: $path")
|
||||
}
|
||||
|
||||
/**
|
||||
* Errors relating to the response from the server
|
||||
*/
|
||||
sealed class Response: RequestException {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
// TODO: fancier way of displaying this?
|
||||
sealed class HTTP: Response {
|
||||
val response: okhttp3.Response
|
||||
|
||||
constructor(response: okhttp3.Response, message: String, cause: Throwable) : super(message, cause) {
|
||||
this.response = response
|
||||
}
|
||||
constructor(response: okhttp3.Response, message: String) : super(message) {
|
||||
this.response = response
|
||||
}
|
||||
|
||||
class ErrorCode(res: okhttp3.Response): HTTP(res, "Non-successful error code from HTTP request: ${res.code}")
|
||||
}
|
||||
|
||||
sealed class File: RequestException {
|
||||
constructor(message: String, cause: Throwable) : super(message, cause)
|
||||
constructor(message: String) : super(message)
|
||||
|
||||
class FileNotFound(file: String): File("File path not found: $file")
|
||||
class Other(cause: Throwable): File("Failed to read file", cause)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
package link.infra.packwiz.installer.target
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.FileSystem
|
||||
|
||||
class ClientHolder {
|
||||
// TODO: timeouts?
|
||||
// TODO: a button to increase timeouts temporarily when retrying?
|
||||
val okHttpClient by lazy { OkHttpClient.Builder().build() }
|
||||
|
||||
val fileSystem = FileSystem.SYSTEM
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package link.infra.packwiz.installer.target.path
|
||||
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import okio.*
|
||||
|
||||
data class FilePathBase(private val path: Path): PackwizPath.Base, PackwizPath.SinkableBase {
|
||||
override fun source(path: String, clientHolder: ClientHolder): BufferedSource {
|
||||
val resolved = this.path.resolve(path, true)
|
||||
try {
|
||||
return clientHolder.fileSystem.source(resolved).buffer()
|
||||
} catch (e: FileNotFoundException) {
|
||||
throw RequestException.Response.File.FileNotFound(resolved.toString())
|
||||
} catch (e: IOException) {
|
||||
throw RequestException.Response.File.Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sink(path: String, clientHolder: ClientHolder): BufferedSink {
|
||||
val resolved = this.path.resolve(path, true)
|
||||
try {
|
||||
return clientHolder.fileSystem.sink(resolved).buffer()
|
||||
} catch (e: FileNotFoundException) {
|
||||
throw RequestException.Response.File.FileNotFound(resolved.toString())
|
||||
} catch (e: IOException) {
|
||||
throw RequestException.Response.File.Other(e)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package link.infra.packwiz.installer.target.path
|
||||
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Request
|
||||
import okio.BufferedSource
|
||||
import okio.IOException
|
||||
|
||||
data class HttpUrlBase(private val url: HttpUrl): PackwizPath.Base {
|
||||
private fun resolve(path: String) = url.newBuilder().addPathSegments(path).build()
|
||||
|
||||
override fun source(path: String, clientHolder: ClientHolder): BufferedSource {
|
||||
val req = Request.Builder()
|
||||
.url(resolve(path))
|
||||
.header("Accept", "application/octet-stream")
|
||||
.header("User-Agent", "packwiz-installer")
|
||||
.get()
|
||||
.build()
|
||||
try {
|
||||
val res = clientHolder.okHttpClient.newCall(req).execute()
|
||||
// Can't use .use since it would close the response body before returning it to the caller
|
||||
try {
|
||||
if (!res.isSuccessful) {
|
||||
throw RequestException.Response.HTTP.ErrorCode(res)
|
||||
}
|
||||
|
||||
val body = res.body ?: throw RequestException.Internal.HTTP.NoResponseBody()
|
||||
return body.source()
|
||||
} catch (e: Exception) {
|
||||
// If an exception is thrown, close the response and rethrow
|
||||
res.close()
|
||||
throw e
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
throw RequestException.Internal.HTTP.RequestFailed(e)
|
||||
} catch (e: IllegalStateException) {
|
||||
throw RequestException.Internal.HTTP.IllegalState(e)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
package link.infra.packwiz.installer.target.path
|
||||
|
||||
import link.infra.packwiz.installer.request.RequestException
|
||||
import link.infra.packwiz.installer.target.ClientHolder
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
|
||||
class PackwizPath(path: String, base: Base) {
|
||||
val path: String
|
||||
val base: Base
|
||||
|
||||
init {
|
||||
this.base = base
|
||||
|
||||
// Check for NUL bytes
|
||||
if (path.contains('\u0000')) { throw RequestException.Validation.PathContainsNUL(path) }
|
||||
// Normalise separator, to prevent differences between Unix/Windows
|
||||
val pathNorm = path.replace('\\', '/')
|
||||
// Split, create new lists for output
|
||||
val split = pathNorm.split('/')
|
||||
val canonicalised = mutableListOf<String>()
|
||||
|
||||
// Backward pass: collapse ".." components, remove "." components and empty components (except an empty component at the end; indicating a folder)
|
||||
var parentComponentCount = 0
|
||||
var first = true
|
||||
for (component in split.asReversed()) {
|
||||
if (first) {
|
||||
first = false
|
||||
if (component == "") {
|
||||
canonicalised += component
|
||||
}
|
||||
}
|
||||
// URL-encoded . is normalised
|
||||
val componentNorm = component.replace("%2e", ".")
|
||||
if (componentNorm == "." || componentNorm == "") {
|
||||
// Do nothing
|
||||
} else if (componentNorm == "..") {
|
||||
parentComponentCount++
|
||||
} else if (parentComponentCount > 0) {
|
||||
parentComponentCount--
|
||||
} else {
|
||||
canonicalised += componentNorm
|
||||
// Don't allow volume letters (allows traversal to the root on Windows)
|
||||
if (componentNorm[0] in 'a'..'z' || componentNorm[0] in 'A'..'Z') {
|
||||
if (componentNorm[1] == ':') {
|
||||
throw RequestException.Validation.PathContainsVolumeLetter(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join path
|
||||
this.path = canonicalised.asReversed().joinToString("/")
|
||||
}
|
||||
|
||||
val folder: Boolean get() = path.endsWith("/")
|
||||
|
||||
fun resolve(path: String): PackwizPath {
|
||||
return if (path.startsWith('/') || path.startsWith('\\')) {
|
||||
// Absolute (but still relative to base of pack)
|
||||
PackwizPath(path, base)
|
||||
} else if (folder) {
|
||||
// File in folder; append
|
||||
PackwizPath(this.path + path, base)
|
||||
} else {
|
||||
// File in parent folder; append with parent component
|
||||
PackwizPath(this.path + "/../" + path, base)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain a BufferedSource for this path
|
||||
* @throws RequestException When resolving the file failed
|
||||
*/
|
||||
fun source(clientHolder: ClientHolder): BufferedSource = base.source(path, clientHolder)
|
||||
|
||||
/**
|
||||
* Obtain a BufferedSink for this path
|
||||
* @throws RequestException.Internal.UnsinkableBase When the base of this path does not have a sink
|
||||
* @throws RequestException When resolving the file failed
|
||||
*/
|
||||
fun sink(clientHolder: ClientHolder): BufferedSink =
|
||||
if (base is SinkableBase) { base.sink(path, clientHolder) } else { throw RequestException.Internal.UnsinkableBase() }
|
||||
|
||||
interface Base {
|
||||
/**
|
||||
* Resolve the given (canonical) path against the base, and get a BufferedSource for this file.
|
||||
* @throws RequestException
|
||||
*/
|
||||
fun source(path: String, clientHolder: ClientHolder): BufferedSource
|
||||
}
|
||||
|
||||
interface SinkableBase: Base {
|
||||
/**
|
||||
* Resolve the given (canonical) path against the base, and get a BufferedSink for this file.
|
||||
* @throws RequestException
|
||||
*/
|
||||
fun sink(path: String, clientHolder: ClientHolder): BufferedSink
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PackwizPath
|
||||
|
||||
if (path != other.path) return false
|
||||
if (base != other.base) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = path.hashCode()
|
||||
result = 31 * result + base.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "base=$base; $path"
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user