diff options
author | Edoardo La Greca | 2025-08-18 21:09:11 +0200 |
---|---|---|
committer | Edoardo La Greca | 2025-08-18 21:09:11 +0200 |
commit | 7341ead2aade10ea1b833e94275277658741883a (patch) | |
tree | 46495f24c54278d50aa0da5046822fbe502f3f14 /lib/src/main/kotlin | |
parent | 1e50cf9c224d03896f176f3718ff80ef1659e9c2 (diff) |
switch to multi-module project structure
Diffstat (limited to 'lib/src/main/kotlin')
26 files changed, 1758 insertions, 0 deletions
diff --git a/lib/src/main/kotlin/Authenticator.kt b/lib/src/main/kotlin/Authenticator.kt new file mode 100644 index 0000000..2d501ae --- /dev/null +++ b/lib/src/main/kotlin/Authenticator.kt @@ -0,0 +1,20 @@ +/** + * The Authenticator interface provides methods for authenticating a user over an established protocol connection. + */ +interface Authenticator { + /** + * Authenticate a user identified by the given [username] and whose authenticity is confirmed by the given [key]. + * The authentication protocol can read and write data within the underlying connection using [readFun] and + * [writeFun]. + * + * @param username The name the user goes by. + * @param key The confirmation of the user's identity and authenticity. It could be any public, private, or derived + * (e.g. mixed with something else or hashed) datum, possibly shared between the server and the client, such as a + * UTF-8-encoded password or a raw public key. + * @param readFun A function to read incoming data from the underlying connection. + * @param writeFun A function to write outgoing data into the underlying connection. + * @throws except.FailedAuthenticationException if the authentication could not be performed. A human-readable + * reason for the failure can be provided if necessary. + */ + fun authenticate(username: String, key: List<UByte>, readFun: () -> List<UByte>, writeFun: (b: List<UByte>) -> Unit) +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/Authenticator9PAnyV2DP9IK.kt b/lib/src/main/kotlin/Authenticator9PAnyV2DP9IK.kt new file mode 100644 index 0000000..a6495b1 --- /dev/null +++ b/lib/src/main/kotlin/Authenticator9PAnyV2DP9IK.kt @@ -0,0 +1,12 @@ +/** + * This class (with an ugly ass name) implements the authentication procedure for the p9any meta-protocol version 2, + * hinting at the usage of dp9ik during negotiation and failing if it's unavailable. + * + * The 9P protocol does not provide a default authentication method. However, since NineKt must work with 9front's + * default authenticated 9P service, it must implement the p9any meta-protocol, preferably version 2. + */ +class Authenticator9PAnyV2DP9IK : Authenticator { + override fun authenticate(username: String, key: List<UByte>, readFun: () -> List<UByte>, writeFun: (List<UByte>) -> Unit) { + + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/Connection.kt b/lib/src/main/kotlin/Connection.kt new file mode 100644 index 0000000..7f2e505 --- /dev/null +++ b/lib/src/main/kotlin/Connection.kt @@ -0,0 +1,319 @@ +import except.MsizeValueTooBigException +import except.RErrorException +import except.UnaccessibleFileException +import except.UnknownVersionException +import net.InMessage +import net.OutMessage +import net.TransportLayer +import java.io.IOException +import java.math.BigInteger + +/** + * This class represents a 9P connection. It provides a practical implementation with networking to the 9P methods + * described in [ProtocolTranslator]. Details about methods related to 9P can be found in [ProtocolTranslator]. Remember + * to disconnect using [disconnect] after use. + * + * Details about network-related topics can be found in [net.TransportLayer] and the implementation of choice. + * + * Details about 9P messages and methods can be found in [ProtocolTranslator]. + * + * @param transLay The networking API backend of choice. + * @throws except.UnresolvableHostException if the host resolution made by [transLay] failed. + */ +class Connection(transLay: TransportLayer) : ProtocolTranslator { + /** + * Networking API. + */ + private val tl: TransportLayer = transLay + + /** + * Tag generator. + */ + private val tagGen = TagGenerator(TagGenerator.TagGenerationMethod.INCREMENTAL, 1u) + + /** + * Maximum size for messages negotiated between the client and the server. + */ + private var maxSize: UInt = 0u + + // 9P constants. + val DEFAULT_VERSION = "9P2000" + val NOTAG = 0.toUShort().inv() + val NOFID = 0.toUInt().inv() + + + /** + * Disconnect from the remote host, + * + * @throws IOException if an I/O error occurred while closing the socket. + */ + fun disconnect() { + this.tl.close() + } + + /** + * Handy function to create an [net.InMessage] instance and check for errors. After successfully using this + * function, it is guaranteed that both no error occurred while reading the incoming message and the message is not + * of type R-error. + * + * It uses [tl] and [maxSize] for instancing the [net.InMessage] class. + * + * @return A pair of: (1) a nullable string (which can be: `null` if no error occurred, empty if an error occurred + * with no message, or non-empty with the error message) and (2) the optional [net.InMessage] instance (null if an + * error occurred). + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + private fun checkedInMessage(reqTag: UShort): InMessage { + val imsg = InMessage(this.tl, this.maxSize, reqTag) + if (imsg.type == NinePMessageType.RERROR) { + imsg.applyField(InMessage.Field("ename", InMessage.Field.Type.STRING, 0u)) + throw RErrorException(imsg.fieldsStr["ename"]) + } + return imsg + } + + override fun version(msize: UInt, version: String) { + val omsg = OutMessage(NinePMessageType.TVERSION, this.NOTAG, listOf("msize", "version"), + mapOf( + "msize" to Pair(BigInteger(msize.toString()), 4u) + ), + mapOf( + "version" to version + ), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + imsg.applySchema(listOf( + InMessage.Field("msize", InMessage.Field.Type.INTEGER, 4u), + InMessage.Field("version", InMessage.Field.Type.STRING, 0u) + )) + val remoteMaxSize = imsg.fieldsInt["msize"]!!.toInt().toUInt() + if (remoteMaxSize > this.maxSize) { + throw MsizeValueTooBigException(msize, remoteMaxSize) + } + this.maxSize = remoteMaxSize + if (!imsg.fieldsStr["version"]!!.startsWith("9P2000")) { + throw UnknownVersionException(imsg.fieldsStr["version"]!!) + } + } + + override fun auth(afid: UInt, uname: String, aname: String) { + } + + override fun flush(oldtag: UShort) { + val omsg = OutMessage(NinePMessageType.TFLUSH, this.tagGen.generate(), listOf("oldtag"), + mapOf( + "oldtag" to Pair(BigInteger(oldtag.toString()), 2u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + } + + override fun attach(fid: UInt, afid: UInt, uname: String, aname: String): QID { + val omsg = OutMessage(NinePMessageType.TATTACH, this.tagGen.generate(), listOf("fid", "afid", "uname", "aname"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u), + "afid" to Pair(BigInteger(afid.toString()), 4u) + ), + mapOf( + "uname" to uname, + "aname" to aname + ), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + + imsg.applyField(InMessage.Field("qid", InMessage.Field.Type.RAW, 13u)) + val qid = QID(imsg.fieldsRaw["qid"]!!.toList()) + return qid + } + + override fun walk(fid: UInt, newfid: UInt, wname: List<String>): List<QID> { + val nwname = wname.size + val omsg = OutMessage(NinePMessageType.TWALK, this.tagGen.generate(), listOf("fid", "newfid", "nwname"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u), + "newfid" to Pair(BigInteger(newfid.toString()), 4u), + "nwname" to Pair(BigInteger(nwname.toString()), 2u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + for (wn in wname) { // write wname elements + this.tl.transmit(OutMessage.convStringToBytes(wn)) + } + val imsg = checkedInMessage(omsg.tag) + + imsg.applyField(InMessage.Field("nwqid", InMessage.Field.Type.INTEGER, 2u)) + val nwqid = imsg.fieldsInt["nwqid"]!!.toInt() + if (nwqid < nwname) { + throw UnaccessibleFileException(wname.slice(0..nwqid)) + } + imsg.applyField(InMessage.Field("qids", InMessage.Field.Type.RAW, (nwqid * 13).toUInt())) + val rawQids = imsg.fieldsRaw["qids"]!! + val qids: MutableList<QID> = mutableListOf() + for (i in 0..<nwqid/13) { + val start = i * 13 + val end = ((i+1) * 13) - 1 + val rawQid = rawQids.slice(start..end) + qids += QID(rawQid) + } + return qids.toList() + } + + override fun open(fid: UInt, mode: FileMode): Pair<QID, UInt> { + val omsg = OutMessage(NinePMessageType.TOPEN, this.tagGen.generate(), listOf("fid", "mode"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u), + "mode" to Pair(BigInteger(mode.toModeByte().toString()), 1u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + imsg.applySchema(listOf( + InMessage.Field("qid", InMessage.Field.Type.RAW, 13u), + InMessage.Field("iounit", InMessage.Field.Type.INTEGER, 4u) + )) + val qid = QID(imsg.fieldsRaw["qid"]!!.toList()) + val iounit = imsg.fieldsInt["iounit"]!!.toInt().toUInt() + return Pair(qid, iounit) + } + + override fun create(fid: UInt, name: String, perm: FilePermissions, mode: FileMode): Pair<QID, UInt> { + val omsg = OutMessage(NinePMessageType.TCREATE, this.tagGen.generate(), listOf("fid", "name", "perm", "mode"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u), + "perm" to Pair(BigInteger(perm.toPermissionInt().toString()), 4u), + "mode" to Pair(BigInteger(mode.toModeByte().toString()), 1u) + ), + mapOf( + "name" to name + ), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + imsg.applySchema(listOf( + InMessage.Field("qid", InMessage.Field.Type.RAW, 13u), + InMessage.Field("iounit", InMessage.Field.Type.INTEGER, 4u) + )) + val qid = QID(imsg.fieldsRaw["qid"]!!.toList()) + val iounit = imsg.fieldsInt["iounit"]!!.toInt().toUInt() + return Pair(qid, iounit) + } + + override fun read(fid: UInt, offset: ULong, count: UInt): Array<UByte> { + val omsg = OutMessage(NinePMessageType.TREAD, this.tagGen.generate(), listOf("fid", "offset", "count"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u), + "offset" to Pair(BigInteger(offset.toString()), 8u), + "count" to Pair(BigInteger(count.toString()), 4u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + imsg.applyField(InMessage.Field("count", InMessage.Field.Type.INTEGER, 4u)) + val count = imsg.fieldsInt["count"]!!.toInt().toUInt() + imsg.applyField(InMessage.Field("data", InMessage.Field.Type.RAW, count)) + + return imsg.fieldsRaw["data"]!! + } + + override fun write(fid: UInt, offset: ULong, count: UInt, data: Iterable<UByte>): UInt { + val data = data.take(count.toInt()).toTypedArray() + val omsg = OutMessage(NinePMessageType.TWRITE, this.tagGen.generate(), listOf("offset", "count", "data"), + mapOf( + "offset" to Pair(BigInteger(offset.toString()), 8u), + "count" to Pair(BigInteger(count.toString()), 4u) + ), + emptyMap(), + mapOf( + "data" to data.toList() + ), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + imsg.applyField(InMessage.Field("count", InMessage.Field.Type.INTEGER, 4u)) + val count = imsg.fieldsInt["count"]!! + + return count.toInt().toUInt() + } + + override fun clunk(fid: UInt) { + val omsg = OutMessage(NinePMessageType.TCLUNK, this.tagGen.generate(), listOf("fid"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + } + + override fun remove(fid: UInt) { + val omsg = OutMessage(NinePMessageType.TREMOVE, this.tagGen.generate(), listOf("fid"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + } + + override fun stat(fid: UInt): Stat { + val omsg = OutMessage(NinePMessageType.TSTAT, this.tagGen.generate(), listOf("fid"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u) + ), + emptyMap(), + emptyMap(), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + imsg.applyField(InMessage.Field("nstat", InMessage.Field.Type.INTEGER, 2u)) + val nstat = imsg.fieldsInt["nstat"]!!.toInt().toUInt() + imsg.applyField(InMessage.Field("stat", InMessage.Field.Type.RAW, nstat)) + val rawStat = imsg.fieldsRaw["stat"]!!.toList() + return Stat(fid, rawStat) + } + + override fun wstat(fid: UInt, stat: Stat) { + val omsg = OutMessage(NinePMessageType.TWSTAT, this.tagGen.generate(), listOf("fid", "stat"), + mapOf( + "fid" to Pair(BigInteger(fid.toString()), 4u) + ), + emptyMap(), + mapOf( + "stat" to stat.toRaw() + ), + this.maxSize + ) + omsg.write(this.tl) + val imsg = checkedInMessage(omsg.tag) + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/FileMode.kt b/lib/src/main/kotlin/FileMode.kt new file mode 100644 index 0000000..81712e6 --- /dev/null +++ b/lib/src/main/kotlin/FileMode.kt @@ -0,0 +1,32 @@ +/** + * The mode in which to open or create a file. For directories, it's illegal for directories to be written, truncated, + * or removed on close. + * + * @param mode The actual mode, as described by the [Mode] enum class. + * @param truncate Set or unset the truncation bit. It requires the write permission. + * @param removeClose Set or unset the "remove on close" bit. It requires the remove permission in the parent directory. + */ +data class FileMode(val mode: Mode, val truncate: Boolean, val removeClose: Boolean) { + enum class Mode(val value: UByte) { + READ(0u), + WRITE(1u), + READ_WRITE(2u), + EXECUTE(3u) + } + + /** + * Turn the mode described by the [FileMode] fields into a mode byte. + */ + fun toModeByte(): UByte { + var byte: UByte = 0u + byte = byte.or(this.mode.value) + if (truncate) { + byte = byte.or(0x10u) + } + if (removeClose) { + byte = byte.or(0x40u) + } + + return byte + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/FilePermissions.kt b/lib/src/main/kotlin/FilePermissions.kt new file mode 100644 index 0000000..84a7944 --- /dev/null +++ b/lib/src/main/kotlin/FilePermissions.kt @@ -0,0 +1,92 @@ +/** + * The permissions of a newly created file. + */ +class FilePermissions { + + /** + * The permissions for the file's owning user. + */ + val userPerms: Permissions + + /** + * The permissions for the file's owning group. + */ + val groupPerms: Permissions + + /** + * The permissions for everyone else. + */ + val elsePerms: Permissions + + /** + * Is the file a directory? If not, it's a regular file. + */ + val isDirectory: Boolean + + private val DMDIR: UInt = 0x80000000u + + /** + * Constructor for file permissions with separate fields. + * + * @param userPerms The permissions for the file's owning user. + * @param groupPerms The permissions for the file's owning group. + * @param elsePerms The permissions for everyone else. + * @param isDirectory Is the file a directory? If not, it's a regular file. + */ + constructor(userPerms: Permissions, groupPerms: Permissions, elsePerms: Permissions, isDirectory: Boolean) { + this.userPerms = userPerms + this.groupPerms = groupPerms + this.elsePerms = elsePerms + this.isDirectory = isDirectory + } + + /** + * Constructor for raw file permission data. Only the first 4 elements are read. + * + * @param raw The raw file permission data. + * @throws IllegalArgumentException if [raw] does not have at least 4 elements. + */ + constructor(raw: List<UByte>) { + require(raw.size >= 4) + val raw = raw.slice(0..4) + val dirValue = raw[0].toUInt().xor(this.DMDIR) + this.isDirectory = dirValue > 0u + this.userPerms = Permissions.fromByte(raw[1]) + this.groupPerms = Permissions.fromByte(raw[2]) + this.elsePerms = Permissions.fromByte(raw[3]) + } + + enum class Permissions(val bits: UByte) { + READ(0x4u), + WRITE(0x2u), + EXECUTE(0x1u), + READ_WRITE(READ.bits.or(WRITE.bits)), + READ_EXECUTE(READ.bits.or(EXECUTE.bits)), + WRITE_EXECUTE(WRITE.bits.or(EXECUTE.bits)), + READ_WRITE_EXECUTE(READ.bits.or(WRITE.bits.or(EXECUTE.bits))); + + companion object { + /** + * Obtain a [Permissions] instance by matching its value. + * + * @throws NoSuchElementException if no such element has the provided value. + */ + fun fromByte(bits: UByte) = Permissions.entries.first { it.bits == bits } + } + } + + /** + * Turn the permissions described by the [FilePermissions] fields into a permission integer (4 bytes). + */ + fun toPermissionInt(): UInt { + val permFileds = listOf(userPerms, groupPerms, elsePerms) + val perms: UInt = 0u + for (i in 0..permFileds.size) { + perms.or(permFileds[i].bits.toUInt().shl(8 * (permFileds.size - 1 - i))) + } + if (isDirectory) { + perms.or(this.DMDIR) + } + return perms + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/IPAddress.kt b/lib/src/main/kotlin/IPAddress.kt new file mode 100644 index 0000000..8eb7414 --- /dev/null +++ b/lib/src/main/kotlin/IPAddress.kt @@ -0,0 +1,73 @@ +/** + * An IP address (v4 or v6). + */ +class IPAddress { + private var address: Array<UByte> + private var is4: Boolean + + /** + * @throws NumberFormatException if the IP address follows neither the IPv4 syntax, nor the IPv6 syntax. + */ + constructor(address: String) { + if (isAddressV4(address)) { + this.is4 = true + } else if (isAddressV6(address)) { + this.is4 = false + } else { + throw NumberFormatException() + } + + val split4 = address.split('.') + val split6 = address.split(':') + val bytes: List<UByte> + if (this.is4) { + bytes = split4.map { it.toUByte(10) } + } else { + val shorts = split6.map { it.toShort(16) } + bytes = shorts.flatMap { + listOf( + it.toInt().and(0xFF00).shr(0x08).toUByte(), + it.toInt().and(0x00FF).toUByte() + ) + } + } + this.address = bytes.toTypedArray() + } + + private fun isAddressV4(address: String): Boolean { + val split4 = address.split('.') + var isOK = split4.size == 4 + isOK = isOK && split4.size == split4.filter { it.matches(Regex("^[0-9]+$")) }.size + return isOK + } + + private fun isAddressV6(address: String): Boolean { + val split6 = address.split(':') + var isOK = split6.size == 8 + isOK = isOK && split6.size == split6.filter { it.contains(Regex("^[0-9][a-f]+$")) }.size + if (isOK) { + return true + } + + // try with "::" + val split6Double = address.split("::") + if (split6Double.size != 2) { + return false + } + val omitted = 8 - split6Double[0].split(':').size - split6Double[1].split(':').size + if (omitted < 0) { + return false + } + return true + } + + override fun toString(): String { + val str: String + if (this.is4) { + str = this.address.joinToString(separator = ".") { it.toString() } + } else { + str = this.address.joinToString(separator = ":") { it.toString() } + } + return str + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/NinePMacros.kt b/lib/src/main/kotlin/NinePMacros.kt new file mode 100644 index 0000000..d5aa83e --- /dev/null +++ b/lib/src/main/kotlin/NinePMacros.kt @@ -0,0 +1,8 @@ +/** + * Handy function that initializes the 9P connection. + */ +fun initNineP(npconn: Connection) { + // TODO: implement +} + +fun readAll(npconn: Connection, )
\ No newline at end of file diff --git a/lib/src/main/kotlin/NinePMessageType.kt b/lib/src/main/kotlin/NinePMessageType.kt new file mode 100644 index 0000000..4c8c60f --- /dev/null +++ b/lib/src/main/kotlin/NinePMessageType.kt @@ -0,0 +1,39 @@ +enum class NinePMessageType(val value: UByte) { + TVERSION(100u), + RVERSION(101u), + TAUTH(102u), + RAUTH(103u), + TATTACH(104u), + RATTACH(105u), + //TERROR(106), <--- illegal + RERROR(107u), + TFLUSH(108u), + RFLUSH(109u), + TWALK(110u), + RWALK(111u), + TOPEN(112u), + ROPEN(113u), + TCREATE(114u), + RCREATE(115u), + TREAD(116u), + RREAD(117u), + TWRITE(118u), + RWRITE(119u), + TCLUNK(120u), + RCLUNK(121u), + TREMOVE(122u), + RREMOVE(123u), + TSTAT(124u), + RSTAT(125u), + TWSTAT(126u), + RWSTAT(127u); + + companion object { + /** + * Obtain a [NinePMessageType] instance by matching its value. + * + * @throws NoSuchElementException if no such element has the provided value. + */ + fun fromByte(value: UByte) = NinePMessageType.entries.first { it.value == value } + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/PathInfo.kt b/lib/src/main/kotlin/PathInfo.kt new file mode 100644 index 0000000..2aee406 --- /dev/null +++ b/lib/src/main/kotlin/PathInfo.kt @@ -0,0 +1,93 @@ +/** + * This class holds all info about paths, their FIDs, and their QIDs. + */ +class PathInfo() { + private val paths: MutableSet<Path> = mutableSetOf() + + /** + * Information about a path. Each FID maps to one path but not all paths have FIDs (injective). On the other hand, + * one QID can be shared with different paths and FIDs. + * + * FIDs are optional because some R-messages only return QIDs for certain files (e.g. walk). + * + * @param path The path. + * @param fid The optional FID associated with the path. + * @param qid The QID associated with the path. + */ + data class Path(val path: List<String>, val fid: UInt?, val qid: QID) + + /** + * Add a path. + */ + fun addPath(path: Path) { + this.paths.add(path) + } + + /** + * Add a QID to an existing FID. Do nothing if the given FID does not exist. + */ + fun addQIDToFID(fid: UInt, qid: QID) { + findByFID(fid)?.qid = qid + } + + /** + * Remove a FID. + */ + fun removeFID(fid: UInt) { + this.paths.removeIf { x -> x.fid == fid } + } + + fun find(predicate: (Path) -> Boolean) = this.paths.find(predicate) + + /** + * Find [Path] object by path. + * + * @param path The path to search for. + * @return A path object if [path] exists, or null otherwise. + */ + fun findByPath(path: List<String>): Path? { + return this.paths.find { x -> x.path == path } + } + + /** + * Find [Path] object by FID. + * + * @param fid The FID to search for. + * @return A path object if [fid] exists, or null otherwise. + */ + fun findByFID(fid: UInt): Path? { + return this.paths.find { x -> x.fid == fid } + } + + /** + * Find [Path] object by QID. + * + * @param qid The path to search for. + * @return A path object if [qid] exists, or null otherwise. + */ + fun findByQID(qid: QID): Path? { + return this.paths.find { x -> x.qid == qid } + } + + /** + * Retrieve all paths + */ + fun getAllPaths(): Set<Path> { + return this.paths.toSet() + } + + /** + * Generate a new FID which is not already in use. + * + * @return The new FID. + * @throws IllegalStateException if there is no available FID. + */ + fun genFID(): UInt { + for (newFid in 0u..UInt.MAX_VALUE) { + if (findByFID(newFid) == null) { + return newFid + } + } + throw IllegalStateException() + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/ProtocolTranslator.kt b/lib/src/main/kotlin/ProtocolTranslator.kt new file mode 100644 index 0000000..fee52f4 --- /dev/null +++ b/lib/src/main/kotlin/ProtocolTranslator.kt @@ -0,0 +1,167 @@ +/* +TODO: + - add arguments to methods + - switch from returned strings to exceptions +*/ + +/** + * The [ProtocolTranslator] interface provides methods that coincide 1:1 with each request type in the 9P protocol. + * Every method that can fail, that is, every request that can receive a response with `Rerror` type instead of the same + * type as itself, returns a non-null `String` that contains the error message received in the response. + * + * Tags and the `msize` value are supposed to be managed internally by the implementing class. + * + * When compared to 9P's formal message descriptions, like those which can be read in Plan 9's manual pages, some of the + * methods might lack parameters. Those which can be inferred from the existing parameters are purposefully omitted. An + * example is [walk], which omits `nwname` because it can be obtained by calculating the size of `wname`. + * + * Trivia: comments for each method are taken from each message type's manual page in section 5. + */ +interface ProtocolTranslator { + /** + * Negotiate protocol version. + * + * This must be the first message sent on the 9P connection and no other requests can be issued until a response has + * been received. + * + * @param msize The maximum length, in bytes, that the client will ever generate or expect to receive in a single + * 9P message. + * @param version Should be "9P2000", which is the only defined value. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + * @throws except.MsizeValueTooBigException if the received `msize` value is bigger than what the client requested. + * @throws except.UnknownVersionException if the version negotiation failed. + */ + fun version(msize: UInt, version: String) + + /** + * Perform authentication. + */ + fun auth(afid: UInt, uname: String, aname: String) + + /** + * Abort a message. + * + * @param oldtag The tag of the message that needs to be purged. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun flush(oldtag: UShort) + + /** + * Establish a connection. + * + * @param fid FID that represents the root directory of the desired file tree. + * @param afid A FID previously established by an auth message. + * @param uname A user identifier. + * @param aname The desired file tree to access. + * @return The QID of the file tree's root. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun attach(fid: UInt, afid: UInt, uname: String, aname: String): QID + + /** + * Descend a directory hierarchy. + * + * @param fid The existing FID. It must represent a directory. + * @param newfid The proposed FID, which is going to be associated with the result of walking the hierarchy. It must + * be not in use, unless it is the same as [fid]. + * @param wname The successive path name elements which describe the file to walk to. + * @return The QID of each path element that has been walked through. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + * @throws except.UnaccessibleFileException if [wname] contains one or more path elements that do not exist. + */ + fun walk(fid: UInt, newfid: UInt, wname: List<String>): List<QID> + + /** + * Prepare an FID for I/O on an existing file. + * + * @param fid The FID of the file to open. + * @param mode The mode in which the file is opened. + * @return A pair of: (1) the returned QID, and (2) a value called `iounit` that indicates, if non-zero, the maximum + * number of bytes that are guaranteed to be read from or written to the file without breaking the I/O transfer into + * multiple 9P messages. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun open(fid: UInt, mode: FileMode): Pair<QID, UInt> + + /** + * Prepare an FID for I/O on a new file. + * + * @param fid The FID of the directory that is going to contain the file. The specified directory requires write + * permission. + * @param name The file name. + * @param perm The permissions of the new file. + * @param mode The open mode after successful creation. + * @return A pair of: (1) the QID of the newly created file, and (2) a value called `iounit` that indicates, if + * non-zero, the maximum number of bytes that are guaranteed to be read from or written to the file without breaking + * the I/O transfer into multiple 9P messages. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun create(fid: UInt, name: String, perm: FilePermissions, mode: FileMode): Pair<QID, UInt> + + /** + * Transfer data from file. Due to the negotiated maximum size of 9P messages, called `msize`, one is supposed to + * call this method multiple times, unless the content is smaller than `msize`. + * + * @return The content read with the call just made. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun read(fid: UInt, offset: ULong, count: UInt): Array<UByte> + + /** + * Transfer data to file. Due to the negotiated maximum size of 9P messages, called `msize`, this method is supposed + * to be called multiple times, unless the content is smaller than `msize`. + * + * @param fid The FID to write to. + * @param offset The distance between the beginning of the file and the first written byte. + * @param data The raw bytes that are going to be written. + * @return The amount of bytes written with the call just made. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun write(fid: UInt, offset: ULong, count: UInt, data: Iterable<UByte>): UInt + + /** + * Forget about a FID. + * + * @param fid The FID to forget. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun clunk(fid: UInt) + + /** + * Remove a file from a server. + * + * @param fid The FID of the file to remove. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun remove(fid: UInt) + + /** + * Inquire file attributes. + * + * @param fid The FID of the file to inquire. + * @return All the file attributes of the file associated with [fid]. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun stat(fid: UInt): Stat + + /** + * Change file attributes. + * + * @param fid The FID of the file to set attributes of. + * @param stat The attributes to set. + * @throws except.InvalidMessageException if the received message is invalid. + * @throws except.RErrorException if the received message is an R-error message. + */ + fun wstat(fid: UInt, stat: Stat) +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/QID.kt b/lib/src/main/kotlin/QID.kt new file mode 100644 index 0000000..4d0bd6a --- /dev/null +++ b/lib/src/main/kotlin/QID.kt @@ -0,0 +1,105 @@ +import net.InMessage +import net.OutMessage +import java.math.BigInteger + +/** + * This class holds information about a single QID. + */ +class QID { + /** + * Does the QID represent a directory? + */ + val isDirectory get() = getIsDirectory() + + /** + * Does the QID represent an append-only file? + */ + val isAppendOnly get() = getIsAppendOnly() + + /** + * Does the QID represent an exclusive-use file? + */ + val isExclusive get() = getIsExclusive() + + /** + * Does the QID represent a temporary file? + */ + val isTemporary get() = getIsTemporary() + + /** + * The QID type. + */ + val type: UByte + + /** + * The QID file version. + */ + val version: UInt + + /** + * The QID path. + */ + val path: ULong + + /** + * Constructor for a QID with separate fields. See the `stat(5)` manual page for more information about [type], + * [version], and [path]. + * + * @param type The QID type. + * @param version The QID file version. + * @param path The QID path. + */ + constructor(type: UByte, version: UInt, path: ULong) { + this.type = type + this.version = version + this.path = path + } + + /** + * Constructor for raw QID data. Only the first 13 elements are read. + * + * @param raw The raw QID data. + * @throws IllegalArgumentException if [raw] does not have at least 13 elements. + */ + constructor(raw: List<UByte>) { + require(raw.size >= 13) + this.type = raw.first() + val rawVersion = raw.slice(1..4) + val rawPath = raw.slice(5..12) + this.version = InMessage.convInteger(rawVersion, 0, rawVersion.size).toInt().toUInt() + this.path = InMessage.convInteger(rawPath, 0, rawPath.size).toLong().toULong() + } + + /** + * Check bit values in [type]. In case of multiple bits, the method returns true if at least one of them is 1. + * + * @param bits A byte whose bits set to 1 are checked. + */ + private fun checkTypeBits(bits: UByte): Boolean { + return this.type.and(bits) != 0u.toUByte() + } + + private fun getIsDirectory(): Boolean { + return checkTypeBits(0x80u) + } + + private fun getIsAppendOnly(): Boolean { + return checkTypeBits(0x40u) + } + + private fun getIsExclusive(): Boolean { + return checkTypeBits(0x20u) + } + + private fun getIsTemporary(): Boolean { + return checkTypeBits(0x04u) + } + + fun toRaw(): List<UByte> { + var bytes: List<UByte> = emptyList() + bytes += this.type + bytes += OutMessage.convIntegerToBytes(BigInteger(this.version.toString()), 4u) + bytes += OutMessage.convIntegerToBytes(BigInteger(this.path.toString()), 8u) + return bytes + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/SizedInteger.kt b/lib/src/main/kotlin/SizedInteger.kt new file mode 100644 index 0000000..48e4e25 --- /dev/null +++ b/lib/src/main/kotlin/SizedInteger.kt @@ -0,0 +1,36 @@ +import java.math.BigInteger + +/** + * [SizedInteger] represents an unsigned integer number of arbitrary yet fixed length. It's useful for storing integers + * got from a connection as message fields. + * + * @param size The size of the field, measured in bytes. + * @param value The value of the field. + * + * @throws IllegalArgumentException if the size required to store the provided value is bigger than the provided size. + */ +class SizedInteger(val size: ULong, value: BigInteger) { + init { + if (!this.sizeOk(size, value)) { + throw IllegalArgumentException() + } + } + + constructor(size: ULong) : this(size, 0.toBigInteger()) + + /** + * @throws IllegalStateException on set if the declared size is not enough for the new value. + */ + var value = value + set(value) { + if (!this.sizeOk(value)) { + throw IllegalStateException() + } + } + + private fun sizeOk(size: ULong, value: BigInteger): Boolean { + val requiredSize = value.toByteArray().size.toUInt() + return requiredSize <= size + } + private fun sizeOk(value: BigInteger): Boolean = sizeOk(this.size, value) +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/Stat.kt b/lib/src/main/kotlin/Stat.kt new file mode 100644 index 0000000..20692c5 --- /dev/null +++ b/lib/src/main/kotlin/Stat.kt @@ -0,0 +1,151 @@ +import net.InMessage +import net.OutMessage +import java.math.BigInteger + +// TODO: add time conversion methods +/** + * File attributes. The `type` and `dev` attributes are ignored since they are for kernel use only. Time is measured in + * seconds, since the epoch (Jan 1 00:00 1970 GMT). + */ +class Stat { + /** + * The FID sent the T-stat message. + */ + val fid: UInt + + /** + * The QID of the file. + */ + val qid: QID + + /** + * Permissions and flags. + */ + val mode: FilePermissions + + /** + * Last acces time. + */ + val atime: UInt + + /** + * Last modification time. + */ + val mtime: UInt + + /** + * The length of the file in bytes. + */ + val length: ULong + + /** + * The file name, which is `/` if the file is the root directory. + */ + val name: String + + /** + * The owner's name. + */ + val uid: String + + /** + * The group's name. + */ + val gid: String + + /** + * The name of the user who last modified the file. + */ + val muid: String + + /** + * Make an instance of [Stat] from each of its fields. + * + * @param fid The FID sent the T-stat message. + * @param qid The QID of the file. + * @param mode Permissions and flags. + * @param atime Last acces time. + * @param mtime Last modification time. + * @param length The length of the file in bytes. + * @param name The file name, which is `/` if the file is the root directory. + * @param uid The owner's name. + * @param gid The group's name. + * @param muid The name of the user who last modified the file. + */ + constructor(fid: UInt, qid: QID, mode: FilePermissions, atime: UInt, mtime: UInt, length: ULong, name: String, uid: String, gid: String, muid: String) { + this.fid = fid + this.qid = qid + this.mode = mode + this.atime = atime + this.mtime = mtime + this.length = length + this.name = name + this.uid = uid + this.gid = gid + this.muid = muid + } + + /** + * Make an instance of [Stat] from raw data. + * + * @param fid The FID of the file associated with the stat instance. + * @param raw The raw stat data. + */ + constructor(fid: UInt, raw: List<UByte>) { + var offset = 0 + val qid = QID(raw.slice(0..<13)) + offset += 13 + val mode = FilePermissions(raw.slice(offset+0..<offset+4)) + offset += 4 + + val intFielSizes = listOf(4, 4, 8) + val intFields: MutableList<BigInteger> = mutableListOf() + for (size in intFielSizes) { + intFields.add(InMessage.convInteger(raw, offset, size)) + offset += size + } + val atime = intFields[0].toInt().toUInt() + val mtime = intFields[1].toInt().toUInt() + val length = intFields[2].toLong().toULong() + + val strAmount = 4 + val strFields: MutableList<String> = mutableListOf() + for (i in 0..strAmount) { + val str = InMessage.convString(raw, offset) + strFields.add(str) + offset += str.length + } + val name = strFields[0] + val uid = strFields[1] + val gid = strFields[2] + val muid = strFields[3] + + this.fid = fid + this.qid = qid + this.mode = mode + this.atime = atime + this.mtime = mtime + this.length = length + this.name = name + this.uid = uid + this.gid = gid + this.muid = muid + } + + /** + * Turn a [Stat] instance into raw data. This leaves out the [fid] field. + */ + fun toRaw(): List<UByte> { + var bytes: List<UByte> = emptyList() + bytes += this.qid.toRaw() + bytes += OutMessage.convIntegerToBytes(BigInteger(this.mode.toPermissionInt().toString()), 4u) + bytes += OutMessage.convIntegerToBytes(BigInteger(this.atime.toString()), 4u) + bytes += OutMessage.convIntegerToBytes(BigInteger(this.mtime.toString()), 4u) + bytes += OutMessage.convIntegerToBytes(BigInteger(this.length.toString()), 8u) + bytes += OutMessage.convStringToBytes(this.name) + bytes += OutMessage.convStringToBytes(this.uid) + bytes += OutMessage.convStringToBytes(this.gid) + bytes += OutMessage.convStringToBytes(this.muid) + return bytes + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/TagGenerator.kt b/lib/src/main/kotlin/TagGenerator.kt new file mode 100644 index 0000000..c386b17 --- /dev/null +++ b/lib/src/main/kotlin/TagGenerator.kt @@ -0,0 +1,63 @@ +import kotlin.random.Random + +/** + * Generate tags for 9P messages. + */ +class TagGenerator(val method: TagGenerationMethod, val initial: UShort) { + + private var current: UShort = initial + private val rng: Random = Random(initial.toInt()) + private val randomReturned: Set<UShort> = emptySet() + + private val generationFunctions: Map<TagGenerationMethod, () -> UShort> = mapOf( + TagGenerationMethod.INCREMENTAL to this::generateIncremental, + TagGenerationMethod.RANDOM to this::generateRandom, + TagGenerationMethod.RANDOM_CHECKED to this::generateRandomChecked + ) + + /** + * How are tags generated? + */ + enum class TagGenerationMethod { + /** + * Return the initial value on the first generation. Increment the value on each generation. + */ + INCREMENTAL, + + /** + * Use the initial value as a seed and generate random values from it. + */ + RANDOM, + + /** + * Same as [RANDOM], but checks are added to avoid generating the same value twice. + */ + RANDOM_CHECKED, + } + + /** + * Generate a new tag. + */ + fun generate(): UShort { + return this.generationFunctions.getValue(method).invoke() + } + + private fun generateIncremental(): UShort { + val tmp = this.current + this.current++ + return tmp + } + + private fun generateRandom(): UShort { + return this.rng.nextBits(16).toUShort() + } + + private fun generateRandomChecked(): UShort { + var v: UShort + do { + v = generateRandom() + } while (v in randomReturned) + randomReturned.plus(v) + return v + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/Utils.kt b/lib/src/main/kotlin/Utils.kt new file mode 100644 index 0000000..45185de --- /dev/null +++ b/lib/src/main/kotlin/Utils.kt @@ -0,0 +1,38 @@ +const val DEFAULT_9P_PORT: UShort = 564u +const val DEFAULT_9P_TLS_PORT: UShort = 17020u + +/** + * The [nineAddressToValues] function translates an address from one of the dial(2) formats into the respective data. + * For the sake of my sanity, the following list highlights differences from the typical dial(2) formats: + * 1. The "network" part is ignored. + * 2. If nothing is specified in the place of "service", the default unencrypted 9P port is used: 564. + * 3. "Service" can only contain port numbers. + * All the rest is the same. + * + * @return A Pair (A, P) containing the host address (A) and the port (P). + * + * @throws IllegalArgumentException If the address is improperly formatted or has invalid values. + * @throws NumberFormatException If the port's value can't be parsed into an [UShort]. + */ +fun nineAddressToValues(nineAddress: String): Pair<String, UShort> { + val parts = nineAddress.split('!', limit = 3) + val address: String + val port: UShort + when (parts.size) { + 1 -> { + address = parts[0] + port = DEFAULT_9P_PORT + } + 2 -> { + address = parts[1] + port = DEFAULT_9P_PORT + } + 3 -> { + address = parts[1] + port = parts[2].toUShort() + } + else -> throw IllegalArgumentException() + } + + return Pair(address, port) +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/FailedAuthenticationException.kt b/lib/src/main/kotlin/except/FailedAuthenticationException.kt new file mode 100644 index 0000000..94935c3 --- /dev/null +++ b/lib/src/main/kotlin/except/FailedAuthenticationException.kt @@ -0,0 +1,8 @@ +package except + +/** + * The authentication with the remote part failed. + * + * @param reason A human-readable reason for which the authentication failed. + */ +class FailedAuthenticationException(val reason: String) : Exception("Authentication with remote host failed: $reason")
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/InvalidMessageException.kt b/lib/src/main/kotlin/except/InvalidMessageException.kt new file mode 100644 index 0000000..02a3cc4 --- /dev/null +++ b/lib/src/main/kotlin/except/InvalidMessageException.kt @@ -0,0 +1,8 @@ +package except + +/** + * The packet that is currently being read is not valid. + * + * @param reason The reason for which the packet is invalid. + */ +class InvalidMessageException(val reason: String) : Exception("Invalid packet: $reason")
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/MsizeValueTooBigException.kt b/lib/src/main/kotlin/except/MsizeValueTooBigException.kt new file mode 100644 index 0000000..f1f6da1 --- /dev/null +++ b/lib/src/main/kotlin/except/MsizeValueTooBigException.kt @@ -0,0 +1,10 @@ +package except + +/** + * This exception is thrown when the `msize` value sent by the server is bigger than that sent by the client during the + * version transaction. + * + * @param maxClientValue The value requested by the client. + * @param receivedValue The value sent by the server. + */ +class MsizeValueTooBigException(val maxClientValue: UInt, val receivedValue: UInt) : Exception("Msize value too big: $receivedValue > $maxClientValue")
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/RErrorException.kt b/lib/src/main/kotlin/except/RErrorException.kt new file mode 100644 index 0000000..c15cbc2 --- /dev/null +++ b/lib/src/main/kotlin/except/RErrorException.kt @@ -0,0 +1,8 @@ +package except + +/** + * This exception represents an error sent by the remote server as an R-error message. + * + * @param message The message sent by the server. + */ +class RErrorException(val rErrorMessage: String?) : Exception("R-error message received: $rErrorMessage")
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/UnaccessibleFileException.kt b/lib/src/main/kotlin/except/UnaccessibleFileException.kt new file mode 100644 index 0000000..07d5d13 --- /dev/null +++ b/lib/src/main/kotlin/except/UnaccessibleFileException.kt @@ -0,0 +1,11 @@ +package except + +/** + * This exception is thrown when the file that the client is trying to open (or walk through, in case of a directory) + * cannot be accessed. + * + * @param path The path, as a list of path elements, that the client tried to access, up to and including the first + * element that cannot be accessed (e.g. if the path the user wants to access is `["usr", "foo", "bar", "zib"]` but + * `bar` does not exist, then [path] must be `["usr", "foo", "bar"]`). + */ +class UnaccessibleFileException(val path: List<String>) : Exception("Could not walk to file ${path.joinToString(separator = "/")}.")
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/UnknownVersionException.kt b/lib/src/main/kotlin/except/UnknownVersionException.kt new file mode 100644 index 0000000..5023bc5 --- /dev/null +++ b/lib/src/main/kotlin/except/UnknownVersionException.kt @@ -0,0 +1,9 @@ +package except + +/** + * This exception is thrown when the remote server sent either an "unknown" version back during the version negotiation + * procedure or a version unknown to this client implementation. + * + * @param version The version sent by the server. + */ +class UnknownVersionException(val version: String) : Exception("Unknown version: $version")
\ No newline at end of file diff --git a/lib/src/main/kotlin/except/UnresolvableHostException.kt b/lib/src/main/kotlin/except/UnresolvableHostException.kt new file mode 100644 index 0000000..19dd3b6 --- /dev/null +++ b/lib/src/main/kotlin/except/UnresolvableHostException.kt @@ -0,0 +1,8 @@ +package except + +/** + * The specified domain, which identifies the host's address, could not be resolved. + * + * @param address The unresolvable address. + */ +class UnresolvableHostException(val address: String) : Exception("Hostname $address unresolvable.")
\ No newline at end of file diff --git a/lib/src/main/kotlin/net/InMessage.kt b/lib/src/main/kotlin/net/InMessage.kt new file mode 100644 index 0000000..771c670 --- /dev/null +++ b/lib/src/main/kotlin/net/InMessage.kt @@ -0,0 +1,167 @@ +package net + +import NinePMessageType +import except.InvalidMessageException +import java.math.BigInteger + +/** + * An incoming 9P message. Upon instancing this class only one message is read, and it's represented in a way similar to + * that of [OutMessage]. This class is supposed to be complementary, and opposite, to [OutMessage]. + * + * @param tl The transport layer API. + * @param maxSize The maximum message size negotiated with the remote part. + * @param reqTag The required tag. + * @throws InvalidMessageException if the message that is currently being read is invalid. + */ +class InMessage(val tl: TransportLayer, maxSize: UInt, val reqTag: UShort) { + /** + * The total size of the message. + */ + val size: UInt + + /** + * The message type. + */ + val type: NinePMessageType + + /** + * The message tag. + */ + val tag: UShort + + /** + * A map of each integer field's name to its value. + */ + var fieldsInt: MutableMap<String, BigInteger> = mutableMapOf() + private set + + /** + * A map of each string field's name to its value. + */ + var fieldsStr: MutableMap<String, String> = mutableMapOf() + private set + + /** + * A map of each raw field's name to its value. + */ + var fieldsRaw: MutableMap<String, Array<UByte>> = mutableMapOf() + private set + + /** + * An ordered collection of raw bytes that still need to be interpreted as values. + */ + private var rawData: List<UByte> + + init { + size = convInteger(this.tl.receiver(), 0, 4).toInt().toUInt() + if (this.size > maxSize) { + throw InvalidMessageException("Size greater than maximum size (${this.size} > ${maxSize}).") + } + try { + this.type = NinePMessageType.fromByte(convInteger(this.tl.receiver(), 0, 1).toInt().toUByte()) + } catch (_: NoSuchElementException) { + throw InvalidMessageException("Invalid 9P message type.") + } + tag = convInteger(this.tl.receiver(), 0, 2).toInt().toUShort() + if (tag != reqTag) { + // TODO: what do we do now? + } + this.rawData = this.tl.receive((size - (4u + 1u + 2u)).toULong()).toList() + } + + /** + * Field of an incoming 9P message. An ordered collection of fields makes a schema. + * + * @param name The field's name. It's typically the same you can find in the manual pages. + * @param type The field's type. + * @param size The field's size in bytes. If the type is [Type.STRING], this parameter is ignored. + */ + data class Field(val name: String, val type: Type, val size: UInt) { + + enum class Type { + INTEGER, + STRING, + RAW + } + } + + /** + * Apply the given field to the raw data and put it in one of [fieldsInt], [fieldsStr], or [fieldsRaw]. Fields must + * be applied strictly in order, as their application is not commutative. + * + * Each time a field is applied, the initial part of raw data that coincides with that field is removed. + * + * @param field The given field. + */ + fun applyField(field: Field) { + val size: Int + when (field.type) { + Field.Type.STRING -> { + val str = convString(this.rawData.toList(), 0) + size = 2 + str.length + this.fieldsStr[field.name] = str + } + Field.Type.INTEGER -> { + size = field.size.toInt() + this.fieldsInt[field.name] = convInteger(this.rawData.toList(), 0, size) + } + Field.Type.RAW -> { + size = field.size.toInt() + this.fieldsRaw[field.name] = this.rawData.take(size).toTypedArray() + } + } + this.rawData = this.rawData.drop(size) + } + + /** + * Apply the given message schema to the raw data and fill [fieldsInt], [fieldsStr], and [fieldsRaw]. + * + * Note: This method could have been avoided by making a giant `when` block in the class constructor. However, I'd + * rather let the caller, which is usually a method that makes a request and reads its response, decide the schema. + * In this way, each method that needs to read a response of a specific type (and there is usually one method per + * response type) declares its own schema, while those which cannot be easily represented by a schema (e.g. `Rwalk`) + * are simply going to be read in a field-by-field fashion. + * + * @param schema The desired ordered collection of fields. + */ + fun applySchema(schema: Iterable<Field>) { + for (field in schema) { + applyField(field) + } + } + + companion object { + /** + * Convert an [len] bytes long unsigned integer number from raw bytes. + * + * In 9P, binary numbers (non-textual) are specified in little-endian order (least significant byte first). + * + * @param len The length of the integer number in bytes. If zero, nothing is read. + * @return the number's value. + * @throws IllegalArgumentException if either [offset] or [len] are negative. + */ + fun convInteger(bytes: Iterable<UByte>, offset: Int, len: Int): BigInteger { + val bytes = bytes.drop(offset).take(len) + var value = 0.toBigInteger() + for (i in 0..<bytes.size) { + value += bytes[i].toInt().toBigInteger().shl(i*8) + } + return value + } + + /** + * Convert a string from raw bytes. + * + * In 9P, strings are represented as a 2-byte integer (the string's size) followed by the actual UTF-8 string. The + * null terminator is forbidden in 9P messages. + * + * @return the string. + * @throws IllegalArgumentException if either [offset] is negative. + */ + fun convString(bytes: Iterable<UByte>, offset: Int): String { + val length = convInteger(bytes, 0, 2).toInt() + val bytes = bytes.drop(offset).take(length) + return String(ByteArray(bytes.size) { i -> bytes[i].toByte() }) + } + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/net/OutMessage.kt b/lib/src/main/kotlin/net/OutMessage.kt new file mode 100644 index 0000000..c9ae879 --- /dev/null +++ b/lib/src/main/kotlin/net/OutMessage.kt @@ -0,0 +1,127 @@ +package net + +import NinePMessageType +import java.math.BigInteger +import kotlin.math.pow + +/** + * An outgoing 9P message with the given type, tag, and fields. The message size is calculated automatically. + * + * Important note: the field names in [fieldValuesInt], [fieldValuesStr], and [fieldValuesRaw] (i.e. the keys of their + * maps) must be mutually exclusive and the union of these two maps' keys must result in a subset of (or a set equal to) + * [fieldNames]. Calling [write] when these conditions are not met throws an exception. + * + * @param type The 9P message type. + * @param tag The tag given to the message. + * @param fieldNames The names of the message fields, in the same order they are expected to be sent. + * @param fieldValuesInt A map of each integer field's name into its value and size in bytes. + * @param fieldValuesStr A map of each string field's name into its value. + * @param fieldValuesRaw A map of each raw field's name into its value. + * @param maxSize The maximum message size. + */ +class OutMessage(val type: NinePMessageType, val tag: UShort, val fieldNames: List<String>, val fieldValuesInt: Map<String, Pair<BigInteger, UInt>>, val fieldValuesStr: Map<String, String>, val fieldValuesRaw: Map<String, List<UByte>>, val maxSize: UInt) { + /** + * Intersection between [fieldNames] and [fieldValuesInt]. In other words: the integer fields that are going to be + * used when writing the message. + */ + private val insecInts = fieldNames.intersect(fieldValuesInt.keys) + + /** + * Intersection between [fieldNames] and [fieldValuesStr]. In other words: the string fields that are going to be + * used when writing the message. + */ + private val insecStrs = fieldNames.intersect(fieldValuesStr.keys) + + /** + * Intersection between [fieldNames] and [fieldValuesRaw]. In other words: the raw fields that are going to be used + * when writing the message. + */ + private val insecRaws = fieldNames.intersect(fieldValuesRaw.keys) + + /** + * Send the message using the given networking API. + * + * @param tl The networking API. + * @throws IllegalArgumentException if [fieldNames], [fieldValuesInt], and [fieldValuesStr] are incoherent or the + * final size of the message exceeds the negotiated value. + */ + fun write(tl: TransportLayer) { + // check that names in fieldNames exist as keys in either fieldValuesInt or fieldValuesStr but not both + require(fieldNames.size == insecInts.size + insecStrs.size + insecRaws.size) + + val totalSize = size() + if (totalSize > this.maxSize) { + throw IllegalArgumentException("Message size exceeded.") + } + writeMessageSizeTypeTag(tl, totalSize, type, tag) + for (field in fieldNames) { + tl.transmit( + if (field in insecInts) { + val valsize = fieldValuesInt[field]!! + convIntegerToBytes(valsize.first, valsize.second) + } else if (field in insecStrs) { + convStringToBytes(fieldValuesStr[field]!!) + } else { + fieldValuesRaw[field]!!.toList() + } + ) + } + } + + /** + * Write the message size and type. + * + * @param tl The networking API. + * @param size The total message size, including the 4 bytes of this parameter and the type's byte. + * @param type The 9P message type as a [NinePMessageType] constant. + * @param tag The 9P message tag. + */ + private fun writeMessageSizeTypeTag(tl: TransportLayer, size: UInt, type: NinePMessageType, tag: UShort) { + var bytes: List<UByte> = emptyList() + bytes += convIntegerToBytes(BigInteger(size.toString()), 4u) + bytes += convIntegerToBytes(BigInteger(type.value.toString()), 1u) + bytes += convIntegerToBytes(BigInteger(tag.toString()), 2u) + tl.transmit(bytes) + } + + /** + * Calculate the expected size of the message. + */ + fun size(): UInt { + return 4u + 1u + 2u + this.insecInts.sumOf { this.fieldValuesInt[it]!!.second } + this.insecStrs.sumOf { 2u + this.fieldValuesStr[it]!!.length.toUInt() } + this.insecRaws.sumOf { this.fieldValuesRaw[it]!!.size.toUInt() } + } + + companion object { + // TODO: Add size that the value is required to fit in + + /** + * Convert an integer number to its byte representation. + * + * In 9P, binary numbers (non-textual) are specified in little-endian order (least significant byte first). + * + * @param value The number's value. + * @param size The number's size in bytes. + */ + fun convIntegerToBytes(value: BigInteger, size: UInt): List<UByte> { + var bytes: List<UByte> = value.toByteArray().toList().map { x -> x.toUByte() } + bytes += List(size.toInt() - bytes.size, {0u}) // add padding for missing bytes + return bytes + } + + /** + * Write a string to the connection. + * + * In 9P, strings are represented as a 2-byte integer (the string's size) followed by the actual UTF-8 string. The + * null terminator is forbidden in 9P messages. + * + * @param value The string. + * @throws IllegalArgumentException if the value of the string's size does not fit into 2 bytes. + */ + fun convStringToBytes(value: String): List<UByte> { + require(value.length <= 2.0.pow(16.0) - 1) + var bytes = convIntegerToBytes(value.length.toBigInteger(), 2u) + bytes += value.toByteArray().toList().map { x -> x.toUByte() } + return bytes + } + } +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/net/TransportLayer.kt b/lib/src/main/kotlin/net/TransportLayer.kt new file mode 100644 index 0000000..90dcd11 --- /dev/null +++ b/lib/src/main/kotlin/net/TransportLayer.kt @@ -0,0 +1,59 @@ +package net + +import java.io.Closeable + +/** + * [TransportLayer] is an interface for network transport-layer operations. A class that implements these methods, once + * instantiated, establishes and manages a connection with a remote endpoint defined by an address and a port and allows + * to send and receive network messages (also called "payloads"). + * + * The address of the remote endpoint can be an IP address (v4 or v6) or a domain name, in which case it is resolved to + * an IP address right before initializing the connection. Every constructor should throw an + * [except.UnresolvableHostException] if the remote address is formatted as an actual domain name, but it cannot be + * resolved (e.g. it doesn't exist, or it contains forbidden characters). + * + * Depending on the specific given implementation, the constructor of this class might throw other exceptions (e.g. the + * [java.net.Socket] constructor in [TransportLayerJavaNet]). + */ +interface TransportLayer : Closeable { + /** + * Close the connection. + */ + abstract override fun close() + + /** + * Transmit a payload. + * + * @throws java.io.IOException if the message could not be correctly transmitted. + */ + fun transmit(payload: Iterable<UByte>) +/* + /** + * Receive a payload until a byte occurs, which marks the end of the message. The byte is discarded after being read + * and is not returned. + * + * If you know both which byte marks the end of the message and the message length, it is advised to use + * [receiveFixed] instead, which is usually more efficient. + * + * @param untilByte The byte that marks the end of the message. + * @return the received payload. + * @throws java.io.IOException if the message could not be correctly received. + */ + abstract fun receiveUntil(untilByte: UByte): Array<UByte> +*/ + /** + * Receive a payload with fixed length. If zero, nothing is read. + * + * @param length The length of the message in bytes. + * @return the received payload. + * @throws java.io.IOException if the message could not be correctly received. + */ + fun receive(length: ULong): Array<UByte> + + /** + * Gives the caller a "receiver" (i.e. an instance of Iterable) from which raw data of any length can be read. + * + * @return The receiver. + */ + fun receiver(): Iterable<UByte> +}
\ No newline at end of file diff --git a/lib/src/main/kotlin/net/TransportLayerJavaNet.kt b/lib/src/main/kotlin/net/TransportLayerJavaNet.kt new file mode 100644 index 0000000..3d2867a --- /dev/null +++ b/lib/src/main/kotlin/net/TransportLayerJavaNet.kt @@ -0,0 +1,95 @@ +package net + +import nineAddressToValues +import java.io.InputStream +import java.io.OutputStream +import java.net.Socket +import kotlin.math.min + +/* +TODO: + - add TLS support +*/ + +/** + * An implementation of [TransportLayer] written using the [java.net] package. + */ +class TransportLayerJavaNet(val address: String, val port: UShort) : TransportLayer { + /** + * The connection's socket. + */ + private val socket: Socket = Socket(this.address, this.port.toInt()) + + /** + * The connection's input stream. + */ + private val inStream: InputStream = this.socket.inputStream + + /** + * The connection's output stream. + */ + private val outStream: OutputStream = this.socket.outputStream + + constructor(fullAddress: String) : this(nineAddressToValues(fullAddress).first, nineAddressToValues(fullAddress).second) + + private class InStreamIterator(val inStream: InputStream) : Iterator<UByte> { + override fun next(): UByte { + return this.inStream.readNBytes(1).first().toUByte() + } + + override fun hasNext(): Boolean { + return this.inStream.available() > 0 + } + } + + override fun close() { + if (this.socket.isClosed) { + return + } + this.socket.close() + } + + override fun transmit(payload: Iterable<UByte>) { + val payload = payload.toList() + val bytes = ByteArray(payload.size, { i -> payload[i].toByte() }) + this.outStream.write(bytes) + } + +/* + override fun receiveUntil(untilByte: UByte): Array<UByte> { + var stop = false + val payload: Array<UByte> = MutableList(0, { 0 }) + while (!stop) { + val b = this.inStream.readNBytes(1)[0] + if (b == untilByte) { + stop = true + continue + } else { + payload.add(b) + } + } + return payload + } +*/ + + override fun receive(length: ULong): Array<UByte> { + var length = length + val intMax = Int.MAX_VALUE.toULong() + val bytes: MutableList<Byte> = MutableList(0) { 0 } + // readNBytes only takes Int values, so it must be called multiple times if the length is greater than Int's + // maximum value + while (length > 0u) { + // the min function ensures that the value passed to readNBytes never exceeds Int's maximum value while also + // switching to the length variable when its value eventually becomes less than Int's maximum value, which + // avoids writing duplicated readNBytes calls in the code + val lenMin = min(length, intMax) + bytes += this.inStream.readNBytes(lenMin.toInt()).toMutableList() + length -= intMax + } + return Array(bytes.size) { i -> bytes[i].toUByte() } + } + + override fun receiver(): Iterable<UByte> { + return Iterable { InStreamIterator(this.inStream) } + } +}
\ No newline at end of file |