Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ data class ConnectionTarget(
const val DEFAULT_HOST = "192.168.1.2"
const val DEFAULT_PORT = 9100
const val DEFAULT_LATENCY_MS = 120
private const val MAX_PAIRING_QUERY_LENGTH = 1024
private const val MAX_HOST_LENGTH = 253
private const val MAX_NAME_LENGTH = 128
private const val MIN_LATENCY_MS = 80
private const val MAX_LATENCY_MS = 200

/** Brackets a bare IPv6 literal so it is safe to embed in a URL authority. */
internal fun bracketForUrl(host: String): String {
Expand All @@ -40,14 +45,30 @@ data class ConnectionTarget(
}

fun fromPairingUri(uri: Uri): ConnectionTarget? {
if (uri.scheme != "shin" || uri.host != "connect") return null
val host = uri.getQueryParameter("host")?.trim().orEmpty()
if (host.isBlank()) return null
val port = uri.getQueryParameter("port")?.toIntOrNull()?.coerceIn(1, 65535) ?: DEFAULT_PORT
val latencyMs = uri.getQueryParameter("latency")?.toIntOrNull()?.coerceIn(80, 200) ?: DEFAULT_LATENCY_MS
val bitrateMbps = uri.getQueryParameter("bitrateMbps")?.toIntOrNull()
?.coerceIn(StreamConfig.MIN_BITRATE_MBPS, StreamConfig.MAX_BITRATE_MBPS)
val name = uri.getQueryParameter("name")?.ifBlank { DEFAULT_NAME } ?: DEFAULT_NAME
val scheme = uri.scheme ?: return null
if (scheme != "shin" && scheme != "openstream") return null
if (uri.encodedAuthority != "connect" || uri.path.orEmpty().isNotEmpty() || uri.fragment != null) return null
if ((uri.encodedQuery?.length ?: 0) > MAX_PAIRING_QUERY_LENGTH) return null

val host = uri.getQueryParameter("host") ?: return null
if (!isValidPairingHost(host)) return null

val rawPort = uri.getQueryParameter("port")
val port = rawPort?.let { raw ->
raw.toIntOrNull()?.takeIf { it in 1..65535 }
} ?: if (rawPort == null) DEFAULT_PORT else return null
val rawLatency = uri.getQueryParameter("latency")
val latencyMs = rawLatency?.let { raw ->
raw.toIntOrNull()?.takeIf { it in MIN_LATENCY_MS..MAX_LATENCY_MS }
} ?: if (rawLatency == null) DEFAULT_LATENCY_MS else return null
val bitrateMbps = uri.getQueryParameter("bitrateMbps")?.let { raw ->
raw.toIntOrNull()?.takeIf {
it in StreamConfig.MIN_BITRATE_MBPS..StreamConfig.MAX_BITRATE_MBPS
} ?: return null
}
val rawName = uri.getQueryParameter("name")
if (rawName != null && (rawName.isBlank() || rawName.length > MAX_NAME_LENGTH)) return null
val name = rawName?.ifBlank { DEFAULT_NAME } ?: DEFAULT_NAME
return ConnectionTarget(
name = name,
host = host,
Expand All @@ -56,5 +77,38 @@ data class ConnectionTarget(
bitrateMbps = bitrateMbps,
)
}

private fun isValidPairingHost(host: String): Boolean {
if (host.isEmpty() || host.length > MAX_HOST_LENGTH) return false
if (host.any { it.isWhitespace() || it.isISOControl() || it in "/?#" }) return false

val ipv6 = when {
host.startsWith('[') && host.endsWith(']') -> host.substring(1, host.length - 1)
'[' in host || ']' in host -> return false
':' in host -> host
else -> null
}
if (ipv6 != null) {
if (':' !in ipv6) return false
return try {
java.net.URI("http://[$ipv6]/").host != null
} catch (_: java.net.URISyntaxException) {
false
}
}

val labels = host.split('.')
if (labels.size == 4 && labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) {
return labels.all { label -> label.toIntOrNull()?.let { it in 0..255 } == true }
}
return labels.all { label ->
label.isNotEmpty() && label.length <= 63 &&
label.first().isAsciiAlphanumeric() && label.last().isAsciiAlphanumeric() &&
label.all { it.isAsciiAlphanumeric() || it == '-' }
}
}

private fun Char.isAsciiAlphanumeric(): Boolean =
this in 'a'..'z' || this in 'A'..'Z' || this in '0'..'9'
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,79 @@
package dev.openstream.app.stream
// android.net.Uri.parse is an unmocked Android stub in local JVM tests. Keep
// this test in android.net so its adapter can call Uri's package-private constructor.
package android.net

import android.os.Parcel
import dev.openstream.app.stream.ConnectionTarget
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

class ConnectionTargetTest {
@Test
fun publishedOpenStreamPairingUriParses() {
val target = ConnectionTarget.fromPairingUri(
TestUri("openstream://connect?host=192.168.1.20&port=9100&latency=120&name=CAM%20A"),
)

assertNotNull(target)
assertEquals(ConnectionTarget("CAM A", "192.168.1.20", 9100, 120), target)
}

@Test
fun currentShinPairingUriRemainsSupported() {
assertEquals(
ConnectionTarget("CAM A", "192.168.1.20", 9100, 120),
ConnectionTarget.fromPairingUri(
TestUri("shin://connect?host=192.168.1.20&port=9100&latency=120&name=CAM%20A"),
),
)
}

@Test
fun pairingUriAcceptsIpv4HostnameAndIpv6Hosts() {
listOf("192.168.1.20", "obs.local", "2001:db8::1", "[2001:db8::1]").forEach { host ->
val target = ConnectionTarget.fromPairingUri(
TestUri("openstream://connect?host=${encodeQueryValue(host)}"),
)
assertEquals(host, target?.host)
}
}

@Test
fun pairingUriRejectsMalformedOrOversizedAuthorityAndInputs() {
val invalidUris = listOf(
"openstream://user@connect?host=192.168.1.20",
"openstream://connect:80?host=192.168.1.20",
"openstream://connect/path?host=192.168.1.20",
"openstream://connect?host=",
"openstream://connect?host=${"h".repeat(254)}",
"openstream://connect?host=bad%20host",
"openstream://connect?host=bad%2Fhost",
"openstream://connect?host=bad%3Fhost",
"openstream://connect?host=bad%23host",
"openstream://connect?host=bad%00host",
"openstream://connect?host=%5Bgg%3A%3A1%5D",
"openstream://connect?host=%5B2001%3Adb8%3A%3A1",
"openstream://connect?host=999.168.1.20",
"openstream://connect?host=192.168.1.20&name=",
"openstream://connect?host=192.168.1.20&name=${"n".repeat(129)}",
"openstream://connect?host=192.168.1.20&x=${"x".repeat(1025)}",
"openstream://connect?host=192.168.1.20&port=0",
"openstream://connect?host=192.168.1.20&port=65536",
"openstream://connect?host=192.168.1.20&port=invalid",
"openstream://connect?host=192.168.1.20&latency=79",
"openstream://connect?host=192.168.1.20&latency=201",
"openstream://connect?host=192.168.1.20&latency=invalid",
)

invalidUris.forEach { raw ->
assertNull(raw, ConnectionTarget.fromPairingUri(TestUri(raw)))
}
}

@Test
fun srtCallerUrlLeavesIpv4AndHostnameUnchanged() {
assertEquals(
Expand Down Expand Up @@ -36,3 +106,39 @@ class ConnectionTargetTest {
)
}
}

private fun encodeQueryValue(value: String): String =
java.net.URLEncoder.encode(value, StandardCharsets.UTF_8)

private class TestUri(private val raw: String) : Uri() {
private val parsed = java.net.URI(raw)
private val query = parsed.rawQuery.orEmpty().split('&').filter(String::isNotEmpty).associate { item ->
val (key, value) = item.split('=', limit = 2).let { it[0] to it.getOrElse(1) { "" } }
URLDecoder.decode(key, StandardCharsets.UTF_8) to URLDecoder.decode(value, StandardCharsets.UTF_8)
}

override fun isHierarchical() = true
override fun isRelative() = false
override fun getScheme() = parsed.scheme
override fun getSchemeSpecificPart() = parsed.rawSchemeSpecificPart
override fun getEncodedSchemeSpecificPart() = parsed.rawSchemeSpecificPart
override fun getAuthority() = parsed.authority
override fun getEncodedAuthority() = parsed.rawAuthority
override fun getUserInfo() = parsed.userInfo
override fun getEncodedUserInfo() = parsed.rawUserInfo
override fun getHost() = parsed.host
override fun getPort() = parsed.port
override fun getPath() = parsed.path
override fun getEncodedPath() = parsed.rawPath
override fun getQuery() = parsed.query
override fun getEncodedQuery() = parsed.rawQuery
override fun getFragment() = parsed.fragment
override fun getEncodedFragment() = parsed.rawFragment
override fun getPathSegments() = parsed.path.orEmpty().split('/').filter(String::isNotEmpty)
override fun getLastPathSegment() = getPathSegments().lastOrNull()
override fun getQueryParameter(key: String) = query[key]
override fun toString() = raw
override fun buildUpon(): Uri.Builder = throw UnsupportedOperationException()
override fun describeContents() = 0
override fun writeToParcel(dest: Parcel, flags: Int) = throw UnsupportedOperationException()
}
9 changes: 8 additions & 1 deletion tests/test_repo_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,14 @@ def test_android_connection_target_builds_srt_caller_url_and_pairing_targets() -
assert "DEFAULT_PORT = 9100" in target
assert "fromDiscoveredDevice" in target
assert "fromPairingUri" in target
assert 'uri.scheme != "shin"' in target
assert 'scheme != "shin" && scheme != "openstream"' in target
assert 'uri.encodedAuthority != "connect"' in target
assert "MAX_PAIRING_QUERY_LENGTH" in target
assert "isValidPairingHost(host)" in target
assert "in 1..65535" in target
assert "MIN_LATENCY_MS..MAX_LATENCY_MS" in target
assert "InetAddress" not in target
assert "getByName" not in target
assert "val stats: StreamStats" in stream_client
assert "AtomicLong" in stream_client
assert "accessUnitsSent.incrementAndGet()" in stream_client
Expand Down
Loading