v1.0.77: fix WebDAV filename decode — literal '+' (e.g. 6103+.pdf) no longer 404s
Build & Release APK / build (push) Successful in 13m10s

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:
2026-06-13 00:15:03 +00:00
parent 019ba930d3
commit 6ea17f141e
3 changed files with 71 additions and 3 deletions
@@ -224,7 +224,12 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
"prop" -> inProp = false
"response" -> if (inResponse && href.isNotBlank()) {
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
if (name.contains("..") || name.contains('/') || name.contains('\\')) {
inResponse = false
@@ -254,3 +259,32 @@ open class WebDavProvider(protected val account: CloudAccount) : CloudProvider {
</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%"))
}