From 6ea17f141effa41fce868974398ab513b8352b06 Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Sat, 13 Jun 2026 00:15:03 +0000 Subject: [PATCH] =?UTF-8?q?v1.0.77:=20fix=20WebDAV=20filename=20decode=20?= =?UTF-8?q?=E2=80=94=20literal=20'+'=20(e.g.=206103+.pdf)=20no=20longer=20?= =?UTF-8?q?404s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../data/providers/webdav/WebDavProvider.kt | 36 ++++++++++++++++++- .../data/providers/webdav/WebDavDecodeTest.kt | 34 ++++++++++++++++++ version.properties | 4 +-- 3 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/com/syncflow/data/providers/webdav/WebDavDecodeTest.kt diff --git a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt index ff593ef..d1a6b3f 100644 --- a/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt +++ b/app/src/main/kotlin/com/syncflow/data/providers/webdav/WebDavProvider.kt @@ -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 { """.toRequestBody("application/xml".toMediaType()) } } + +/** + * Percent-decode a single WebDAV path segment from a PROPFIND , 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") +} diff --git a/app/src/test/kotlin/com/syncflow/data/providers/webdav/WebDavDecodeTest.kt b/app/src/test/kotlin/com/syncflow/data/providers/webdav/WebDavDecodeTest.kt new file mode 100644 index 0000000..e5fb2d1 --- /dev/null +++ b/app/src/test/kotlin/com/syncflow/data/providers/webdav/WebDavDecodeTest.kt @@ -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%")) +} diff --git a/version.properties b/version.properties index 97833f2..09613fa 100644 --- a/version.properties +++ b/version.properties @@ -1,2 +1,2 @@ -VERSION_NAME=1.0.76 -VERSION_CODE=76 +VERSION_NAME=1.0.77 +VERSION_CODE=77