mirror of
				https://github.com/packwiz/packwiz-installer.git
				synced 2025-11-04 12:34:31 +01: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:
		@@ -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"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
		Reference in New Issue
	
	Block a user