Skip to content

Commit a6ed21c

Browse files
authored
Rework UrlUtil handle to avoid crashes while converting URI to URL (#6031)
1 parent c2425f6 commit a6ed21c

File tree

2 files changed

+153
-18
lines changed
  • common/src
    • main/kotlin/io/homeassistant/companion/android/util
    • test/kotlin/io/homeassistant/companion/android/util

2 files changed

+153
-18
lines changed

common/src/main/kotlin/io/homeassistant/companion/android/util/UrlUtil.kt

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,34 +44,53 @@ object UrlUtil {
4444
.toString()
4545
}
4646

47+
/**
48+
* Resolves a URL input string against a base URL.
49+
*
50+
* @param base The base URL to resolve relative URLs against. Can be null if input is absolute.
51+
* @param input The URL string to resolve. Supported formats:
52+
* - Absolute URL (http://... or https://...)
53+
* - Relative path to be resolved against base
54+
* - Deep link URL with homeassistant://navigate/ prefix
55+
* @return The resolved URL, the base URL if input is invalid, or null if resolution fails
56+
*/
4757
fun handle(base: URL?, input: String): URL? {
48-
val asURI = try {
49-
URI(input.removePrefix("homeassistant://navigate/"))
58+
val normalizedInput = input.removePrefix("homeassistant://navigate/")
59+
60+
val uri = try {
61+
URI(normalizedInput)
5062
} catch (e: Exception) {
51-
Timber.w("Invalid input, returning base only")
52-
null
63+
Timber.w(e, "Invalid URI input: $normalizedInput")
64+
return base
5365
}
54-
return when {
55-
asURI == null -> {
56-
base
57-
}
5866

67+
return when {
5968
isAbsoluteUrl(input) -> {
60-
asURI.toURL()
69+
uri.runCatching { toURL() }
70+
.onFailure { Timber.w(it, "Failed to convert URI to URL: $normalizedInput") }
71+
.getOrNull()
6172
}
6273

63-
else -> { // Input is relative to base URL
64-
val builder = base
65-
?.toHttpUrlOrNull()
66-
?.newBuilder()
67-
if (!asURI.path.isNullOrBlank()) builder?.addPathSegments(asURI.path.trim().removePrefix("/"))
68-
if (!asURI.query.isNullOrBlank()) builder?.query(asURI.query.trim())
69-
if (!asURI.fragment.isNullOrBlank()) builder?.fragment(asURI.fragment.trim())
70-
builder?.build()?.toUrl()
71-
}
74+
else -> buildRelativeUrl(base, uri)
7275
}
7376
}
7477

78+
private fun buildRelativeUrl(base: URL?, uri: URI): URL? {
79+
val builder = base?.toHttpUrlOrNull()?.newBuilder() ?: return null
80+
81+
return builder.apply {
82+
uri.path?.takeIf { it.isNotBlank() }?.let {
83+
addPathSegments(it.trim().removePrefix("/"))
84+
}
85+
uri.query?.takeIf { it.isNotBlank() }?.let {
86+
query(it.trim())
87+
}
88+
uri.fragment?.takeIf { it.isNotBlank() }?.let {
89+
fragment(it.trim())
90+
}
91+
}.build().toUrl()
92+
}
93+
7594
fun isAbsoluteUrl(it: String?): Boolean {
7695
return Regex("^https?://").containsMatchIn(it.toString())
7796
}

common/src/test/kotlin/io/homeassistant/companion/android/util/UrlUtilTest.kt

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,130 @@ package io.homeassistant.companion.android.util
22

33
import java.net.URL
44
import kotlinx.coroutines.test.runTest
5+
import org.junit.jupiter.api.Assertions.assertEquals
56
import org.junit.jupiter.api.Assertions.assertFalse
7+
import org.junit.jupiter.api.Assertions.assertNotNull
8+
import org.junit.jupiter.api.Assertions.assertNull
69
import org.junit.jupiter.api.Assertions.assertTrue
10+
import org.junit.jupiter.api.BeforeEach
711
import org.junit.jupiter.api.Test
812
import org.junit.jupiter.params.ParameterizedTest
13+
import org.junit.jupiter.params.provider.CsvSource
914
import org.junit.jupiter.params.provider.ValueSource
1015

1116
class UrlUtilTest {
1217

18+
private lateinit var baseUrl: URL
19+
20+
@BeforeEach
21+
fun setUp() {
22+
baseUrl = URL("https://example.com:8123/")
23+
}
24+
25+
@ParameterizedTest
26+
@CsvSource(
27+
value = [
28+
"http://another.com/test,http://another.com/test",
29+
"https://secure.com/path,https://secure.com/path",
30+
"http://another.com:9000/path,http://another.com:9000/path",
31+
],
32+
)
33+
fun `Given absolute URL when calling handle then returns parsed URL`(input: String, expected: String) {
34+
val result = UrlUtil.handle(baseUrl, input)
35+
36+
assertNotNull(result)
37+
assertEquals(expected, result.toString())
38+
}
39+
40+
@ParameterizedTest
41+
@CsvSource(
42+
value = [
43+
"lovelace/default,https://example.com:8123/lovelace/default",
44+
"/lovelace/default,https://example.com:8123/lovelace/default",
45+
"lovelace/default?edit=1,https://example.com:8123/lovelace/default?edit=1",
46+
"lovelace/default#section,https://example.com:8123/lovelace/default#section",
47+
"lovelace/default?edit=1#section,https://example.com:8123/lovelace/default?edit=1#section",
48+
"api/states/light.living_room,https://example.com:8123/api/states/light.living_room",
49+
"lovelace?key=value&other=test,https://example.com:8123/lovelace?key=value&other=test",
50+
"path/with%20encoded%20spaces,https://example.com:8123/path/with%20encoded%20spaces",
51+
],
52+
)
53+
fun `Given relative path when calling handle then returns URL resolved against base`(input: String, expected: String) {
54+
val result = UrlUtil.handle(baseUrl, input)
55+
56+
assertNotNull(result)
57+
assertEquals(expected, result.toString())
58+
}
59+
60+
@Test
61+
fun `Given input with homeassistant navigate prefix and absolute URL when calling handle then treats as relative path without taking care of second host and protocol`() {
62+
val input = "homeassistant://navigate/https://example2.com/path/subpath"
63+
64+
val result = UrlUtil.handle(baseUrl, input)
65+
66+
assertNotNull(result)
67+
assertEquals("https://example.com:8123/path/subpath", result.toString())
68+
}
69+
70+
@Test
71+
fun `Given input with homeassistant navigate prefix and relative path when calling handle then returns resolved URL`() {
72+
val input = "homeassistant://navigate/lovelace/default"
73+
74+
val result = UrlUtil.handle(baseUrl, input)
75+
76+
assertNotNull(result)
77+
assertEquals("https://example.com:8123/lovelace/default", result.toString())
78+
}
79+
80+
@ParameterizedTest
81+
@ValueSource(
82+
strings = [
83+
"",
84+
" ",
85+
],
86+
)
87+
fun `Given empty or whitespace input when calling handle then returns base URL`(input: String) {
88+
val result = UrlUtil.handle(baseUrl, input)
89+
90+
assertNotNull(result)
91+
assertEquals("https://example.com:8123/", result.toString())
92+
}
93+
94+
@Test
95+
fun `Given invalid URI input when calling handle then returns base URL`() {
96+
val input = "not a valid uri with spaces and bad chars <>"
97+
98+
val result = UrlUtil.handle(baseUrl, input)
99+
100+
assertEquals(baseUrl, result)
101+
}
102+
103+
@Test
104+
fun `Given null base and relative path when calling handle then returns null`() {
105+
val input = "lovelace/default"
106+
107+
val result = UrlUtil.handle(null, input)
108+
109+
assertNull(result)
110+
}
111+
112+
@Test
113+
fun `Given valid base url and invalid input URI that cannot be parsed into URL when calling handle then returns null`() {
114+
val input = "http://h:8123None"
115+
116+
assertNull(UrlUtil.handle(baseUrl, input))
117+
}
118+
119+
@Test
120+
fun `Given null base and absolute URL when calling handle then returns parsed URL`() {
121+
val input = "https://example.com/test"
122+
123+
val result = UrlUtil.handle(null, input)
124+
125+
assertNotNull(result)
126+
assertEquals("https://example.com/test", result.toString())
127+
}
128+
13129
@ParameterizedTest
14130
@ValueSource(
15131
strings = [

0 commit comments

Comments
 (0)