sync(chunk 2b): CalDAV discovery and auth protocol

New :caldav module — MIT, plain JVM, api-depends on :dav. Separate from the
vendored MPL tree so the licences stay unmixed, and so "no Android types" is a
compile-time guarantee rather than a discipline. Chunk 2 split again: the
Android account layer (Keystore, AccountManager, Custom Tabs, account-add UI)
is 2c, with different verification and an on-device review.

- ServiceDiscovery: the RFC 6764 ladder. SRV priority/weight, TXT path=,
  non-443 ports, "." targets, well-known then root.
- CollectionClassifier: the two filters that are inversions of the obvious
  rule. An absent or empty supported-calendar-component-set means "supports
  everything", and classification is a positive test for CALDAV:calendar on an
  unordered set — excluding schedule-outbox would drop SOGo's main calendar.
- CalDavDiscovery: OPTIONS gate, principal, every home set, Depth-1 by name.
  A failing home set does not fail the account, and every home set failing is
  reported as an error rather than as an account with no lists.
- NextcloudLoginFlow: POST not GET, a User-Agent the user can recognise when
  revoking, 404-means-pending only, both URLs origin-checked, host mismatch
  carried rather than refused (reverse proxies are ordinary).
- PreemptiveBasicInterceptor, ServerQuirks.

dnsjava 3.6.3 (BSD-3) added: Android's DnsResolver is callback-only and cannot
do the TXT path lookup, and JNDI's DNS provider does not exist on Android.
Behind an interface, so every trap is tested with a fake and no network.

:dav gains change 6 — <D:unauthenticated/> is parsed rather than inferred from
a null href, which also fires on a merely non-conformant empty element.

52 tests here, 78 in :dav. SYNC.md's live-probed trap table is executable now.
This commit is contained in:
2026-09-04 17:36:42 +02:00
parent 5a81d3c2f5
commit da42423fe2
20 changed files with 1833 additions and 11 deletions
@@ -10,17 +10,29 @@ import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import org.xmlpull.v1.XmlPullParser
// see RFC 5397: WebDAV Current Principal Extension
data class CurrentUserPrincipal(
val href: String?
val href: String?,
/**
* RFC 5397 §3: the server answered 200 but the request was **not**
* authenticated. Upstream parsed only the `<href>` child, so this arrived
* indistinguishable from a conformant-but-empty element and from a server
* that simply omits the property — and a rejected credential then looks
* like a successful discovery that found nothing. Local addition; see
* dav/PROVENANCE.md change 6.
*/
val unauthenticated: Boolean = false
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal")
val UNAUTHENTICATED = Property.Name(XmlUtils.NS_WEBDAV, "unauthenticated")
}
@@ -30,11 +42,23 @@ data class CurrentUserPrincipal(
override fun create(parser: XmlPullParser): CurrentUserPrincipal {
// <!ELEMENT current-user-principal (unauthenticated | href)>
// One pass over both children: XmlUtils.processTag consumes to the end
// tag, so it cannot be called twice on the same element.
var href: String? = null
XmlUtils.processTag(parser, DavResource.HREF) {
href = XmlUtils.readText(parser)
var unauthenticated = false
val depth = parser.depth
var eventType = parser.eventType
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) &&
parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
DavResource.HREF -> href = XmlUtils.readText(parser)
UNAUTHENTICATED -> unauthenticated = true
}
eventType = parser.next()
}
return CurrentUserPrincipal(href)
return CurrentUserPrincipal(href, unauthenticated)
}
}
@@ -6,11 +6,13 @@
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -121,6 +123,50 @@ class LocalChangesTest {
assertEquals(mockServer.url("/other/"), resource.location)
}
// -- change 6: <D:unauthenticated/> is parsed, not inferred ---------------
private fun principalBody(inner: String) = MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"""
<multistatus xmlns="DAV:"><response><href>/dav/</href>
<propstat><prop><current-user-principal>$inner</current-user-principal></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response></multistatus>
""".trimIndent(),
)
private fun principalOf(inner: String): CurrentUserPrincipal? {
mockServer.enqueue(principalBody(inner))
var found: CurrentUserPrincipal? = null
DavResource(httpClient, mockServer.url("/dav/"))
.propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
found = response[CurrentUserPrincipal::class.java]
}
return found
}
@Test
fun unauthenticatedIsDistinguishableFromAMerelyEmptyElement() {
// RFC 5397 §3: a 200 whose body says the request was not authenticated.
// Upstream parsed only <href>, so this arrived identical to an empty
// element and to a server that omits the property — and a rejected
// credential then looks like a discovery that simply found nothing.
assertTrue(principalOf("<unauthenticated/>")!!.unauthenticated)
val empty = principalOf("")
assertNull(empty!!.href)
assertFalse(empty.unauthenticated)
}
@Test
fun anOrdinaryPrincipalHrefStillParses() {
val principal = principalOf("<href>/principals/me/</href>")
assertEquals("/principals/me/", principal!!.href)
assertFalse(principal.unauthenticated)
}
// -- change 1: commons-lang3 removed from date parsing --------------------
/**