v1.0.77: fix WebDAV filename decode — literal '+' (e.g. 6103+.pdf) no longer 404s
PROPFIND href segments were decoded with java.net.URLDecoder, which applies the form-encoding rule '+' -> space. A file named '6103+.pdf' was read as '6103 .pdf', so every GET/DELETE requested '6103%20.pdf' and 404'd forever — that one file could never sync. Replaced with decodeWebDavSegment(): decodes %XX only, leaves '+' literal. Covered by WebDavDecodeTest (7 tests).
This commit is contained in:
@@ -224,7 +224,12 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
|||||||
"prop" -> inProp = false
|
"prop" -> inProp = false
|
||||||
"response" -> if (inResponse && href.isNotBlank()) {
|
"response" -> if (inResponse && href.isNotBlank()) {
|
||||||
val rawName = href.trimEnd('/').substringAfterLast('/')
|
val rawName = href.trimEnd('/').substringAfterLast('/')
|
||||||
val name = try { java.net.URLDecoder.decode(rawName, "UTF-8") } catch (_: Exception) { rawName }
|
// Decode %XX escapes but NOT '+' → space. java.net.URLDecoder applies the
|
||||||
|
// form-encoding rule where '+' means space; in a URL *path* a literal '+' is a
|
||||||
|
// real character. That rule turned the file "6103+.pdf" into "6103 .pdf", after
|
||||||
|
// which every GET/DELETE asked for "6103%20.pdf" and 404'd forever — the file
|
||||||
|
// could never sync. decodeWebDavSegment leaves '+' alone.
|
||||||
|
val name = try { decodeWebDavSegment(rawName) } catch (_: Exception) { rawName }
|
||||||
// Guard against path-traversal sequences delivered by a malicious server
|
// Guard against path-traversal sequences delivered by a malicious server
|
||||||
if (name.contains("..") || name.contains('/') || name.contains('\\')) {
|
if (name.contains("..") || name.contains('/') || name.contains('\\')) {
|
||||||
inResponse = false
|
inResponse = false
|
||||||
@@ -254,3 +259,32 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
|
|||||||
</d:propfind>""".toRequestBody("application/xml".toMediaType())
|
</d:propfind>""".toRequestBody("application/xml".toMediaType())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percent-decode a single WebDAV path segment from a PROPFIND <href>, WITHOUT the `+` → space rule
|
||||||
|
* that [java.net.URLDecoder] applies (that rule is for application/x-www-form-urlencoded query
|
||||||
|
* strings, not URL paths). A literal '+' in a path is a real character: the file "6103+.pdf" must
|
||||||
|
* stay "6103+.pdf", not become "6103 .pdf". Only %XX escapes are decoded; the resulting bytes are
|
||||||
|
* reassembled and read as UTF-8. Kept top-level and pure so it unit-tests without the Android runtime.
|
||||||
|
*/
|
||||||
|
internal fun decodeWebDavSegment(raw: String): String {
|
||||||
|
if (raw.indexOf('%') < 0) return raw // fast path: nothing to decode, '+' passes through untouched
|
||||||
|
val out = java.io.ByteArrayOutputStream(raw.length)
|
||||||
|
var i = 0
|
||||||
|
while (i < raw.length) {
|
||||||
|
val c = raw[i]
|
||||||
|
if (c == '%' && i + 2 < raw.length) {
|
||||||
|
val hi = Character.digit(raw[i + 1], 16)
|
||||||
|
val lo = Character.digit(raw[i + 2], 16)
|
||||||
|
if (hi >= 0 && lo >= 0) {
|
||||||
|
out.write((hi shl 4) + lo)
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Literal character (including '+', and any raw non-ASCII) → its own UTF-8 bytes.
|
||||||
|
out.write(c.toString().toByteArray(Charsets.UTF_8))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return out.toString("UTF-8")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.syncflow.data.providers.webdav
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path-segment decoding must not apply the form-encoding `+` → space rule.
|
||||||
|
* Regression coverage for the "6103+.pdf" file that 404'd forever.
|
||||||
|
*/
|
||||||
|
class WebDavDecodeTest {
|
||||||
|
|
||||||
|
@Test fun `literal plus is preserved (the 6103 bug)`() =
|
||||||
|
assertEquals("6103+.pdf", decodeWebDavSegment("6103+.pdf"))
|
||||||
|
|
||||||
|
@Test fun `percent-encoded plus decodes to a plus`() =
|
||||||
|
assertEquals("6103+.pdf", decodeWebDavSegment("6103%2B.pdf"))
|
||||||
|
|
||||||
|
@Test fun `percent-twenty decodes to a space`() =
|
||||||
|
assertEquals("my file.pdf", decodeWebDavSegment("my%20file.pdf"))
|
||||||
|
|
||||||
|
@Test fun `space and plus stay distinct`() {
|
||||||
|
assertEquals("a b", decodeWebDavSegment("a%20b")) // %20 -> space
|
||||||
|
assertEquals("a+b", decodeWebDavSegment("a+b")) // literal + stays +, NOT space
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `utf8 percent sequences decode (persian)`() =
|
||||||
|
assertEquals("عکس.jpg", decodeWebDavSegment("%D8%B9%DA%A9%D8%B3.jpg"))
|
||||||
|
|
||||||
|
@Test fun `plain ascii passes through`() =
|
||||||
|
assertEquals("photo.jpg", decodeWebDavSegment("photo.jpg"))
|
||||||
|
|
||||||
|
@Test fun `trailing bare percent is left literal`() =
|
||||||
|
assertEquals("50%", decodeWebDavSegment("50%"))
|
||||||
|
}
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
VERSION_NAME=1.0.76
|
VERSION_NAME=1.0.77
|
||||||
VERSION_CODE=76
|
VERSION_CODE=77
|
||||||
|
|||||||
Reference in New Issue
Block a user