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:
@@ -146,7 +146,7 @@ jobs:
|
|||||||
# the whole safety argument in dav/PROVENANCE.md.
|
# the whole safety argument in dav/PROVENANCE.md.
|
||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
run: ./gradlew testDebugUnitTest :dav:test
|
run: ./gradlew testDebugUnitTest :dav:test :caldav:test
|
||||||
|
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
plugins {
|
||||||
|
// No version — AGP already puts the Kotlin plugin on the build classpath.
|
||||||
|
id("org.jetbrains.kotlin.jvm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our CalDAV protocol layer: discovery, auth, Nextcloud Login Flow v2. MIT, and
|
||||||
|
// deliberately a separate module from the MPL-2.0 vendored `:dav`.
|
||||||
|
//
|
||||||
|
// A plain JVM module, like `:dav`, and for the same two reasons: it enforces "no
|
||||||
|
// Android types" at compile time rather than by discipline, and it lets the whole
|
||||||
|
// protocol layer be tested against MockWebServer on the JVM. Anything that needs
|
||||||
|
// the platform — Keystore, AccountManager, Custom Tabs — belongs in `:app`.
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(project(":dav"))
|
||||||
|
implementation(libs.dnsjava)
|
||||||
|
// Runtime API only (Json.parseToJsonElement) — no @Serializable, so the
|
||||||
|
// serialization compiler plugin is not needed here.
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
compileOnly(libs.xpp3)
|
||||||
|
testImplementation(libs.xpp3)
|
||||||
|
testImplementation(libs.junit4)
|
||||||
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.truth)
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import at.bitfire.dav4jvm.DavResource
|
||||||
|
import at.bitfire.dav4jvm.Response
|
||||||
|
import at.bitfire.dav4jvm.property.CalendarColor
|
||||||
|
import at.bitfire.dav4jvm.property.CalendarHomeSet
|
||||||
|
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
||||||
|
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
|
||||||
|
import at.bitfire.dav4jvm.property.DisplayName
|
||||||
|
import at.bitfire.dav4jvm.property.MaxICalendarSize
|
||||||
|
import at.bitfire.dav4jvm.property.ResourceType
|
||||||
|
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
|
||||||
|
import at.bitfire.dav4jvm.property.SupportedReportSet
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RFC 6764 discovery: from what the user typed to the list of task collections.
|
||||||
|
*
|
||||||
|
* The happy path is five requests. Everything else here is a rule that exists
|
||||||
|
* because a real server broke the obvious implementation — see
|
||||||
|
* `docs/SYNC.md` § *Generic CalDAV discovery*, whose live-probed trap table this
|
||||||
|
* encodes.
|
||||||
|
*/
|
||||||
|
class CalDavDiscovery(
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val dns: DnsResolver = DnsResolver.None,
|
||||||
|
/**
|
||||||
|
* Whether a typed `http://` base URL may be probed.
|
||||||
|
*
|
||||||
|
* Off by default. Credentials are never sent over cleartext — the
|
||||||
|
* interceptor withholds them — so an http:// server would otherwise answer
|
||||||
|
* 401 forever and the user would be told their password was wrong when the
|
||||||
|
* real problem is the scheme. `docs/SYNC.md` requires any escape hatch to be
|
||||||
|
* a narrow, warned, per-account opt-in; this is that switch.
|
||||||
|
*/
|
||||||
|
private val allowCleartext: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Properties requested **by name**, because allprop legitimately omits them. */
|
||||||
|
private val collectionProperties = arrayOf(
|
||||||
|
ResourceType.NAME,
|
||||||
|
DisplayName.NAME,
|
||||||
|
CalendarColor.NAME,
|
||||||
|
SupportedCalendarComponentSet.NAME,
|
||||||
|
SupportedReportSet.NAME,
|
||||||
|
CurrentUserPrivilegeSet.NAME,
|
||||||
|
MaxICalendarSize.NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A home set that could not be listed. One failing must not hide the others. */
|
||||||
|
data class HomeSetFailure(
|
||||||
|
val url: HttpUrl,
|
||||||
|
val reason: String,
|
||||||
|
val needsAuthentication: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Outcome {
|
||||||
|
data class Found(
|
||||||
|
val principal: HttpUrl,
|
||||||
|
val collections: List<TaskCollection>,
|
||||||
|
/** Set when a 301/308 moved us; the caller must persist it. */
|
||||||
|
val movedTo: HttpUrl?,
|
||||||
|
/** Home sets on a different host than the principal. Normative, but worth surfacing. */
|
||||||
|
val crossHostHomeSets: List<HttpUrl>,
|
||||||
|
/** Home sets that could not be read. The rest of the result is still good. */
|
||||||
|
val failedHomeSets: List<HomeSetFailure> = emptyList(),
|
||||||
|
) : Outcome
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server wants credentials. Not a failure — authenticate and retry.
|
||||||
|
*
|
||||||
|
* [hosts] names *which* hosts asked, which the caller needs in order to
|
||||||
|
* widen the credential allowlist. A cross-host home set (iCloud puts the
|
||||||
|
* principal on `caldav.icloud.com` and the home set on
|
||||||
|
* `pNN-caldav.icloud.com`) is otherwise unreachable: the interceptor
|
||||||
|
* withholds the credential from the second host, and without naming it
|
||||||
|
* here the caller can never learn what to allow.
|
||||||
|
*/
|
||||||
|
data class NeedsAuthentication(val hosts: List<String>) : Outcome
|
||||||
|
|
||||||
|
/** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */
|
||||||
|
data object Unauthenticated : Outcome
|
||||||
|
|
||||||
|
data class NotCalDav(val reason: String) : Outcome
|
||||||
|
|
||||||
|
data class Failed(val reason: String) : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks every candidate for [input] and returns the first real result.
|
||||||
|
*
|
||||||
|
* A 401 stops the walk immediately: it means we found a DAV server and simply
|
||||||
|
* have no credentials for it, and continuing down the ladder would replace a
|
||||||
|
* precise "sign in" with a vague "nothing found".
|
||||||
|
*/
|
||||||
|
fun discover(input: String): Outcome {
|
||||||
|
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
||||||
|
if (!typed.isHttps && !allowCleartext) {
|
||||||
|
return Outcome.Failed(
|
||||||
|
"\"$input\" is an unencrypted http:// address. Credentials are never sent " +
|
||||||
|
"over cleartext, so this can only ever answer \"not authorised\".",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val candidates = ServiceDiscovery.candidatesFor(input, dns)
|
||||||
|
if (candidates.isEmpty()) return Outcome.Failed("could not read \"$input\" as an address or URL")
|
||||||
|
|
||||||
|
var lastFailure: Outcome = Outcome.Failed("no candidate answered")
|
||||||
|
for (candidate in candidates) {
|
||||||
|
when (val outcome = probe(candidate.url)) {
|
||||||
|
is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome
|
||||||
|
else -> lastFailure = outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun probe(base: HttpUrl): Outcome {
|
||||||
|
val resource = DavResource(httpClient, base)
|
||||||
|
|
||||||
|
// The OPTIONS gate. `DAV: calendar-access` is what distinguishes a CalDAV
|
||||||
|
// server from any other WebDAV host, and it is what keeps Google's SRV
|
||||||
|
// record — which points at something that answers 405 to PROPFIND — from
|
||||||
|
// looking like a discovery that merely found no calendars.
|
||||||
|
var davCapabilities: Set<String> = emptySet()
|
||||||
|
runCatching { resource.options { capabilities, _ -> davCapabilities = capabilities } }
|
||||||
|
|
||||||
|
var principalHref: String? = null
|
||||||
|
var unauthenticated = false
|
||||||
|
val propfindResult = runCatching {
|
||||||
|
resource.propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||||
|
response[CurrentUserPrincipal::class.java]?.let {
|
||||||
|
principalHref = it.href
|
||||||
|
unauthenticated = unauthenticated || it.unauthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
propfindResult.exceptionOrNull()?.let { error ->
|
||||||
|
// 401 is not a failure — iCloud and Zoho answer it from
|
||||||
|
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
||||||
|
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
|
||||||
|
return Outcome.Failed(error.message ?: error.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
|
||||||
|
// credentials were rejected. Without this check a failed login looks like
|
||||||
|
// a successful discovery that found nothing — the shape of bug report
|
||||||
|
// nobody can act on. The element is parsed explicitly (dav/PROVENANCE.md
|
||||||
|
// change 6) rather than inferred from a null href, which would also fire
|
||||||
|
// for a merely non-conformant empty element.
|
||||||
|
if (unauthenticated) return Outcome.Unauthenticated
|
||||||
|
|
||||||
|
val href = principalHref
|
||||||
|
?: return if (davCapabilities.contains("calendar-access")) {
|
||||||
|
Outcome.Failed("server advertises calendar-access but returned no principal")
|
||||||
|
} else {
|
||||||
|
Outcome.NotCalDav("no DAV:current-user-principal, and no calendar-access in OPTIONS")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (davCapabilities.isNotEmpty() && !davCapabilities.contains("calendar-access")) {
|
||||||
|
return Outcome.NotCalDav("OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access")
|
||||||
|
}
|
||||||
|
|
||||||
|
val principal = resource.location.resolve(href)
|
||||||
|
?: return Outcome.Failed("principal href \"$href\" is not a usable URL")
|
||||||
|
|
||||||
|
return fromPrincipal(principal, movedTo = resource.permanentLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun fromPrincipal(principal: HttpUrl, movedTo: HttpUrl? = null): Outcome {
|
||||||
|
val homeSets = mutableListOf<HttpUrl>()
|
||||||
|
val principalResource = DavResource(httpClient, principal)
|
||||||
|
val homeSetResult = runCatching {
|
||||||
|
principalResource.propfind(0, CalendarHomeSet.NAME) { response, _ ->
|
||||||
|
// ⚠️ Iterate ALL hrefs. Multiple home sets are normative (RFC 4791
|
||||||
|
// §6.2.1's own example) and iCloud depends on it: the principal is
|
||||||
|
// on caldav.icloud.com and the home set on pNN-caldav.icloud.com.
|
||||||
|
response[CalendarHomeSet::class.java]?.hrefs?.forEach { raw ->
|
||||||
|
principal.resolve(raw)?.let(homeSets::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
homeSetResult.exceptionOrNull()?.let { error ->
|
||||||
|
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
||||||
|
return Outcome.Failed(error.message ?: error.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (homeSets.isEmpty()) return Outcome.Failed("principal has no calendar-home-set")
|
||||||
|
|
||||||
|
// Cross-host is legal and required, but never over plain HTTP: the
|
||||||
|
// credentials follow the home set, and a downgrade would send them in the
|
||||||
|
// clear. The caller surfaces the host change to the user.
|
||||||
|
val crossHost = homeSets.filter { it.host != principal.host }
|
||||||
|
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
|
||||||
|
if (insecure.isNotEmpty()) {
|
||||||
|
return Outcome.Failed("calendar-home-set downgrades to HTTP: ${insecure.first()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val collections = linkedMapOf<HttpUrl, TaskCollection>()
|
||||||
|
val failures = mutableListOf<HomeSetFailure>()
|
||||||
|
var anySucceeded = false
|
||||||
|
|
||||||
|
for (homeSet in homeSets.distinct()) {
|
||||||
|
val result = runCatching {
|
||||||
|
DavResource(httpClient, homeSet).propfind(1, *collectionProperties) { response, relation ->
|
||||||
|
if (relation == Response.HrefRelation.SELF) return@propfind
|
||||||
|
CollectionClassifier.classify(response)?.let { collections[it.url] = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.fold(
|
||||||
|
// A failed home set must not fail the account — the same rule the
|
||||||
|
// engine runs on. One broken share must not hide every other list,
|
||||||
|
// and a 401 from a cross-host home set must not tell the user to
|
||||||
|
// sign in again with credentials that just worked.
|
||||||
|
onSuccess = { anySucceeded = true },
|
||||||
|
onFailure = {
|
||||||
|
failures += HomeSetFailure(
|
||||||
|
url = homeSet,
|
||||||
|
reason = it.message ?: it.toString(),
|
||||||
|
needsAuthentication = isUnauthorized(it),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every home set failing is a server problem, not a discovery that found
|
||||||
|
// no lists. Reporting it as success gives the user "connected, no task
|
||||||
|
// lists" for what is actually an error.
|
||||||
|
if (!anySucceeded) {
|
||||||
|
val needAuth = failures.filter { it.needsAuthentication }
|
||||||
|
return if (needAuth.isNotEmpty() && needAuth.size == failures.size) {
|
||||||
|
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
||||||
|
} else {
|
||||||
|
Outcome.Failed(
|
||||||
|
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Outcome.Found(
|
||||||
|
principal = principal,
|
||||||
|
collections = collections.values.toList(),
|
||||||
|
movedTo = movedTo,
|
||||||
|
crossHostHomeSets = crossHost,
|
||||||
|
failedHomeSets = failures,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUnauthorized(error: Throwable): Boolean =
|
||||||
|
error is at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** `https://host/path` → the URL, or null. Convenience for callers. */
|
||||||
|
fun url(value: String): HttpUrl? = value.toHttpUrlOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import at.bitfire.dav4jvm.Property
|
||||||
|
import at.bitfire.dav4jvm.Response
|
||||||
|
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
|
||||||
|
import at.bitfire.dav4jvm.property.ResourceType
|
||||||
|
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
|
||||||
|
import at.bitfire.dav4jvm.property.SupportedReportSet
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
/** A collection that survived classification, and what we know about it. */
|
||||||
|
data class TaskCollection(
|
||||||
|
val url: HttpUrl,
|
||||||
|
val displayName: String?,
|
||||||
|
/** Packed ARGB, the same form `task_lists.color` stores. */
|
||||||
|
val color: Int?,
|
||||||
|
val readOnly: Boolean,
|
||||||
|
/**
|
||||||
|
* A share rather than the user's own collection.
|
||||||
|
*
|
||||||
|
* Worth carrying: Nextcloud **rewrites task bodies on GET from a shared
|
||||||
|
* calendar** — stripping `VALARM`, and reducing `CLASS:CONFIDENTIAL` to a
|
||||||
|
* VEVENT-shaped whitelist — while leaving the ETag untouched. Re-PUTting what
|
||||||
|
* we downloaded destroys the owner's task, so the engine needs to know.
|
||||||
|
*/
|
||||||
|
val isShared: Boolean,
|
||||||
|
val supportsSyncCollection: Boolean,
|
||||||
|
val maxResourceSize: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides which collections in a Depth-1 listing can hold tasks.
|
||||||
|
*
|
||||||
|
* Both of the rules here are **inversions of the obvious one**, and
|
||||||
|
* `docs/SYNC.md` calls them the important part of discovery. Getting either
|
||||||
|
* backwards produces a client that silently finds nothing on a large fraction of
|
||||||
|
* real servers, with no error to diagnose.
|
||||||
|
*/
|
||||||
|
object CollectionClassifier {
|
||||||
|
|
||||||
|
/** `CS:shared`, which Nextcloud adds alongside `caldav:calendar` on a share. */
|
||||||
|
private val SHARED = Property.Name("http://calendarserver.org/ns/", "shared")
|
||||||
|
|
||||||
|
fun classify(response: Response): TaskCollection? {
|
||||||
|
val resourceType = response[ResourceType::class.java] ?: return null
|
||||||
|
|
||||||
|
// ⚠️ Positive test on an *unordered set*, never exclusion and never by
|
||||||
|
// position. Excluding `schedule-outbox` — the obvious rule — drops SOGo's
|
||||||
|
// main personal calendar, which reports collection + calendar +
|
||||||
|
// schedule-outbox simultaneously for every non-Apple client, i.e. for us.
|
||||||
|
// The positive rule also keeps shared calendars (they add CS:shared) and
|
||||||
|
// still drops Nextcloud's nc:deleted-calendar, which deliberately strips
|
||||||
|
// caldav:calendar.
|
||||||
|
if (ResourceType.CALENDAR !in resourceType.types) return null
|
||||||
|
|
||||||
|
if (!supportsTasks(response)) return null
|
||||||
|
|
||||||
|
val privileges = response[CurrentUserPrivilegeSet::class.java]
|
||||||
|
// Absent means writable. RFC 3744 §3.7 lets a server withhold the
|
||||||
|
// property, and DAVx5 defaults to writable for the same reason: assuming
|
||||||
|
// read-only hides collections the user can perfectly well write to.
|
||||||
|
// A 403 on write is handled where it happens.
|
||||||
|
val readOnly = privileges != null &&
|
||||||
|
!privileges.mayWriteContent && !privileges.mayBind
|
||||||
|
|
||||||
|
val reports = response[SupportedReportSet::class.java]
|
||||||
|
|
||||||
|
return TaskCollection(
|
||||||
|
url = response.href,
|
||||||
|
displayName = response[at.bitfire.dav4jvm.property.DisplayName::class.java]?.displayName,
|
||||||
|
// CalendarColor.color is a packed ARGB Int. Calling toString() on it
|
||||||
|
// yields "-65536" for red — an unparseable decimal, not a colour.
|
||||||
|
color = response[at.bitfire.dav4jvm.property.CalendarColor::class.java]?.color,
|
||||||
|
readOnly = readOnly,
|
||||||
|
isShared = SHARED in resourceType.types,
|
||||||
|
// A hint, not a contract — Radicale advertised this for years without
|
||||||
|
// implementing it, so the engine must still degrade gracefully.
|
||||||
|
supportsSyncCollection = reports?.reports?.contains(SupportedReportSet.SYNC_COLLECTION) == true,
|
||||||
|
maxResourceSize = response[at.bitfire.dav4jvm.property.MaxICalendarSize::class.java]?.maxSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ **An absent `supported-calendar-component-set` means "supports
|
||||||
|
* everything", not "supports nothing".**
|
||||||
|
*
|
||||||
|
* RFC 4791 §5.2.3 says the property SHOULD NOT be returned from an allprop
|
||||||
|
* request, so it is legitimately missing unless asked for **by name** — and
|
||||||
|
* even then plenty of servers omit it. Keeping only collections whose set
|
||||||
|
* *includes* VTODO therefore drops every server that does not advertise it.
|
||||||
|
*
|
||||||
|
* The grammar is `(comp+)`; an empty element is non-conformant, and
|
||||||
|
* dav4jvm's parser starts all-`false`, so an empty one arrives
|
||||||
|
* indistinguishable from "no VTODO". Treat that as all too — the cost of
|
||||||
|
* being wrong is an empty collection list, and the cost of the other error is
|
||||||
|
* a task list the user cannot reach.
|
||||||
|
*/
|
||||||
|
internal fun supportsTasks(response: Response): Boolean {
|
||||||
|
val components = response[SupportedCalendarComponentSet::class.java] ?: return true
|
||||||
|
val advertisesNothing = !components.supportsEvents &&
|
||||||
|
!components.supportsTasks &&
|
||||||
|
!components.supportsJournal
|
||||||
|
if (advertisesNothing) return true
|
||||||
|
return components.supportsTasks
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import org.xbill.DNS.Lookup
|
||||||
|
import org.xbill.DNS.SRVRecord
|
||||||
|
import org.xbill.DNS.TXTRecord
|
||||||
|
import org.xbill.DNS.Type
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SRV/TXT lookups over dnsjava.
|
||||||
|
*
|
||||||
|
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
|
||||||
|
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
|
||||||
|
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
|
||||||
|
*
|
||||||
|
* A lookup that fails returns an empty list rather than throwing: DNS being
|
||||||
|
* unavailable means "no SRV record", which is an ordinary and common answer, and
|
||||||
|
* the well-known ladder still has rungs left.
|
||||||
|
*/
|
||||||
|
class DnsJavaResolver : DnsResolver {
|
||||||
|
|
||||||
|
override fun srv(name: String): List<SrvRecord> = runCatching {
|
||||||
|
Lookup(name, Type.SRV).run()
|
||||||
|
.orEmpty()
|
||||||
|
.filterIsInstance<SRVRecord>()
|
||||||
|
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
override fun txt(name: String): List<String> = runCatching {
|
||||||
|
Lookup(name, Type.TXT).run()
|
||||||
|
.orEmpty()
|
||||||
|
.filterIsInstance<TXTRecord>()
|
||||||
|
.flatMap { it.strings }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud Login Flow v2.
|
||||||
|
*
|
||||||
|
* The user approves the app in a browser and the server mints a dedicated **app
|
||||||
|
* password**, so the account password never touches this device and the grant can
|
||||||
|
* be revoked from the server's own settings. It is not deprecated, there is no
|
||||||
|
* v3, and OAuth2 is a worse fit.
|
||||||
|
*
|
||||||
|
* Every rule below is a correction from `docs/SYNC.md`'s audit of the first
|
||||||
|
* draft, and each one turns a diagnosable error back into a diagnosable error.
|
||||||
|
*/
|
||||||
|
class NextcloudLoginFlow(
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
/**
|
||||||
|
* ⚠️ Becomes the **app password's name** in Settings → Security → Devices &
|
||||||
|
* sessions. With OkHttp's default the user sees `okhttp/4.12.0` and cannot
|
||||||
|
* tell what to revoke — which defeats the entire point of the flow.
|
||||||
|
*/
|
||||||
|
private val userAgent: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
/** The 20-minute server-side lifetime (`lifetime = 1200` in `LoginFlowV2Mapper.php`). */
|
||||||
|
val flowLifetimeSeconds = 1200L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A started flow. Persist this **before** launching the browser: the flow
|
||||||
|
* outlives our process, and Custom Tabs return no result when dismissed.
|
||||||
|
*/
|
||||||
|
data class Flow(
|
||||||
|
val loginUrl: HttpUrl,
|
||||||
|
val pollEndpoint: HttpUrl,
|
||||||
|
val pollToken: String,
|
||||||
|
val deadlineEpochSeconds: Long,
|
||||||
|
/**
|
||||||
|
* Set when the server sent us to a different host than the user typed.
|
||||||
|
* **Carried, not thrown**: it is generated from `overwrite.cli.url` /
|
||||||
|
* `overwriteprotocol` / `trusted_proxies` and is legitimate behind a
|
||||||
|
* reverse proxy, which describes a large share of self-hosted installs.
|
||||||
|
* Refusing outright would make Login Flow v2 unusable for them. The UI
|
||||||
|
* confirms it with the user, quoting the cause.
|
||||||
|
*/
|
||||||
|
val hostMismatch: HostMismatch? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Credentials(val server: HttpUrl, val loginName: String, val appPassword: String)
|
||||||
|
|
||||||
|
sealed interface PollResult {
|
||||||
|
data class Approved(val credentials: Credentials) : PollResult
|
||||||
|
/** 404: still waiting. Also what an expired or already-consumed flow returns. */
|
||||||
|
data object Pending : PollResult
|
||||||
|
data class Expired(val reason: String) : PollResult
|
||||||
|
data class Failed(val reason: String) : PollResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param server the base URL the user typed
|
||||||
|
* @param now epoch seconds, for the locally tracked deadline
|
||||||
|
*/
|
||||||
|
fun start(server: HttpUrl, now: Long): Result<Flow> = runCatching {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(server.newBuilder().addPathSegments("index.php/login/v2").build())
|
||||||
|
.header("User-Agent", userAgent)
|
||||||
|
// OCS-APIRequest is *not* needed: v2 is a Frontpage route, not OCS.
|
||||||
|
.post(FormBody.Builder().build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) error("login flow init failed: HTTP ${response.code}")
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
|
val poll = root["poll"]?.jsonObject ?: error("no poll object in login flow response")
|
||||||
|
|
||||||
|
val endpointRaw = poll["endpoint"]?.jsonPrimitive?.content
|
||||||
|
?: error("no poll endpoint")
|
||||||
|
val endpoint = endpointRaw.toHttpUrlOrNull() ?: error("poll endpoint is not a URL")
|
||||||
|
requireSecureOrigin(server, endpoint)
|
||||||
|
|
||||||
|
val loginUrl = root["login"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
||||||
|
?: error("no login URL")
|
||||||
|
// ⚠️ The login URL is where the user types their **account** password,
|
||||||
|
// in a browser. Validating only the poll endpoint leaves the more
|
||||||
|
// dangerous of the two unchecked: a misconfigured or hostile server —
|
||||||
|
// or a MITM on a typed http:// base — could point the browser at any
|
||||||
|
// origin and harvest it.
|
||||||
|
requireSecureOrigin(server, loginUrl)
|
||||||
|
|
||||||
|
Flow(
|
||||||
|
loginUrl = loginUrl,
|
||||||
|
pollEndpoint = endpoint,
|
||||||
|
pollToken = poll["token"]?.jsonPrimitive?.content ?: error("no poll token"),
|
||||||
|
deadlineEpochSeconds = now + flowLifetimeSeconds,
|
||||||
|
hostMismatch = hostMismatchOf(server, endpoint) ?: hostMismatchOf(server, loginUrl),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One poll. The 200 is returned **exactly once** — the server deletes the row
|
||||||
|
* inside `poll()` before returning — so the caller must persist the result
|
||||||
|
* immediately.
|
||||||
|
*/
|
||||||
|
fun poll(flow: Flow, now: Long): PollResult {
|
||||||
|
if (now > flow.deadlineEpochSeconds) {
|
||||||
|
// 404 is also what an expired flow returns, so the deadline is tracked
|
||||||
|
// locally or "expired" is indistinguishable from "still waiting".
|
||||||
|
return PollResult.Expired("the 20-minute approval window has passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(flow.pollEndpoint)
|
||||||
|
.header("User-Agent", userAgent)
|
||||||
|
// ⚠️ POST, form-encoded. A GET gets 405.
|
||||||
|
.post(FormBody.Builder().add("token", flow.pollToken).build())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
when {
|
||||||
|
// ⚠️ 404 means pending, and *only* 404 does. "Anything that
|
||||||
|
// isn't 200 is pending" swallows 429 (brute-force protection),
|
||||||
|
// 503 (maintenance), a Cloudflare challenge page and every
|
||||||
|
// DNS/TLS failure — turning a diagnosable error into a
|
||||||
|
// twenty-minute spinner.
|
||||||
|
response.code == 404 -> PollResult.Pending
|
||||||
|
|
||||||
|
response.code == 200 -> {
|
||||||
|
val contentType = response.header("Content-Type").orEmpty()
|
||||||
|
// A Cloudflare challenge is a 200 carrying HTML.
|
||||||
|
if (!contentType.contains("application/json", ignoreCase = true)) {
|
||||||
|
PollResult.Failed("server answered 200 with $contentType, not JSON")
|
||||||
|
} else {
|
||||||
|
parseCredentials(flow, response.body?.string().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.code == 429 ->
|
||||||
|
PollResult.Failed("the server is rate-limiting this address (429)")
|
||||||
|
|
||||||
|
response.code == 503 ->
|
||||||
|
PollResult.Failed("the server is in maintenance mode (503)")
|
||||||
|
|
||||||
|
else -> PollResult.Failed("unexpected HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCredentials(flow: Flow, body: String): PollResult = runCatching {
|
||||||
|
val root = json.parseToJsonElement(body).jsonObject
|
||||||
|
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
||||||
|
?: error("no server URL in poll response")
|
||||||
|
// Scheme only. A host mismatch here must never discard the credentials:
|
||||||
|
// the 200 is returned exactly once — the server deletes the row inside
|
||||||
|
// poll() before returning — so throwing would burn the app password and
|
||||||
|
// force the user through the whole flow again.
|
||||||
|
requireSecureOrigin(flow.pollEndpoint, server)
|
||||||
|
PollResult.Approved(
|
||||||
|
Credentials(
|
||||||
|
server = server,
|
||||||
|
// ⚠️ loginName is what the user typed — possibly an email, an
|
||||||
|
// LDAP-derived value, or the right name in the wrong case. It is
|
||||||
|
// the Basic auth username and nothing else. Interpolating it into
|
||||||
|
// `remote.php/dav/calendars/<loginName>/` is the classic "logged
|
||||||
|
// in but no calendars" bug; the principal comes from discovery.
|
||||||
|
loginName = root["loginName"]?.jsonPrimitive?.content ?: error("no loginName"),
|
||||||
|
appPassword = root["appPassword"]?.jsonPrimitive?.content ?: error("no appPassword"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The endpoint is generated from `overwrite.cli.url` / `overwriteprotocol` /
|
||||||
|
* `trusted_proxies`, which are misconfigured on a large fraction of
|
||||||
|
* self-hosted installs — so it is validated rather than trusted verbatim.
|
||||||
|
*
|
||||||
|
* A downgrade to `http` is refused outright: the poll token is exchanged for a
|
||||||
|
* long-lived app password, which makes it a credential-grade secret.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A downgrade to `http` is **fatal**. The poll token is exchanged for a
|
||||||
|
* long-lived app password and the login URL takes the account password, so
|
||||||
|
* both are credential-grade.
|
||||||
|
*/
|
||||||
|
internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) {
|
||||||
|
if (expected.isHttps && !actual.isHttps) {
|
||||||
|
error("the server returned an http:// URL (${actual.host}) for an https:// server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A different host than the user typed — reported, not refused. */
|
||||||
|
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
|
||||||
|
if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null
|
||||||
|
|
||||||
|
/** The server pointed the flow at a different host than the user typed. */
|
||||||
|
data class HostMismatch(val expected: String, val actual: String) {
|
||||||
|
val message: String
|
||||||
|
get() = "the server sent us to \"$actual\" but you typed \"$expected\" — " +
|
||||||
|
"its overwrite.cli.url is probably wrong"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends Basic credentials up front, rather than waiting to be challenged.
|
||||||
|
*
|
||||||
|
* OkHttp's `Authenticator` is **reactive only**: it fires after a 401, which
|
||||||
|
* costs an extra round trip on every request of a PROPFIND-heavy sync — and it
|
||||||
|
* never fires at all on servers that answer 403 or 404 without a challenge.
|
||||||
|
*
|
||||||
|
* Two guards, and neither is optional. The credential goes out **only over
|
||||||
|
* HTTPS**, and **only to the account's own origin** — a home set may legally live
|
||||||
|
* on another host, and OkHttp deliberately strips `Authorization` across a
|
||||||
|
* redirect, so re-attaching it is something we must do knowingly, per host, after
|
||||||
|
* validating the target. Never blanket.
|
||||||
|
*/
|
||||||
|
class PreemptiveBasicInterceptor(
|
||||||
|
private val username: String,
|
||||||
|
private val password: String,
|
||||||
|
/** Hosts this credential may be sent to. */
|
||||||
|
private val allowedHosts: Set<String>,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
constructor(username: String, password: String, origin: HttpUrl) :
|
||||||
|
this(username, password, setOf(origin.host))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lowercased once. OkHttp already lower-cases and punycodes `url.host`, so an
|
||||||
|
* IDN written in Unicode here would never match — callers pass the host from
|
||||||
|
* an [HttpUrl], which is already in that form.
|
||||||
|
*/
|
||||||
|
private val hosts = allowedHosts.map { it.lowercase() }.toSet()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val url = request.url
|
||||||
|
|
||||||
|
val allowed = url.isHttps && url.host in hosts
|
||||||
|
if (!allowed || request.header("Authorization") != null) {
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain.proceed(
|
||||||
|
request.newBuilder()
|
||||||
|
// ⚠️ UTF-8, not OkHttp's ISO-8859-1 default. A password with ä, ö
|
||||||
|
// or ß is otherwise sent as different bytes than the server
|
||||||
|
// expects — coming back as a 401 the user reads as "wrong
|
||||||
|
// password". The vendored BasicDigestAuthHandler does the same.
|
||||||
|
.header("Authorization", Credentials.basic(username, password, Charsets.UTF_8))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What we know about a server before talking to it.
|
||||||
|
*
|
||||||
|
* This exists for one reason: *"wrong password" that is actually "you used your
|
||||||
|
* account password"* is the single most common support ticket any CalDAV client
|
||||||
|
* inherits. Detecting it at account-add time by domain turns a dead end into one
|
||||||
|
* sentence of instruction.
|
||||||
|
*/
|
||||||
|
enum class ServerQuirk(val domains: Set<String>) {
|
||||||
|
|
||||||
|
/** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */
|
||||||
|
FASTMAIL_APP_PASSWORD(setOf("fastmail.com", "fastmail.fm", "messagingengine.com")),
|
||||||
|
|
||||||
|
/** iCloud: app-specific password, and 2FA must be on to mint one. */
|
||||||
|
ICLOUD_APP_SPECIFIC_PASSWORD(setOf("icloud.com", "me.com", "mac.com")),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own
|
||||||
|
* documentation says *"Doesn't support VTODO or VJOURNAL data"*. `SYNC.md`
|
||||||
|
* drops it as a target, so this is a refusal with an explanation rather than
|
||||||
|
* a 401 the user cannot act on.
|
||||||
|
*/
|
||||||
|
GOOGLE_UNSUPPORTED(setOf("gmail.com", "googlemail.com", "google.com")),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nextcloud's brute-force protection throttles then 429s **per source IP**, so
|
||||||
|
* a retry loop on a dead app password takes down the user's other Nextcloud
|
||||||
|
* clients on that network. Not domain-detectable; set when a server identifies
|
||||||
|
* itself. Kept here so the engine has one place to ask.
|
||||||
|
*/
|
||||||
|
NEXTCLOUD_BRUTE_FORCE_PROTECTED(emptySet()),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
/** The quirk implied by an email address or a URL host, if any. */
|
||||||
|
fun forInput(input: String): ServerQuirk? {
|
||||||
|
val host = ServiceDiscovery.asBaseUrl(input)?.host
|
||||||
|
?: ServiceDiscovery.domainOf(input)
|
||||||
|
?: return null
|
||||||
|
return forHost(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forHost(host: String): ServerQuirk? {
|
||||||
|
val lower = host.lowercase().trimEnd('.')
|
||||||
|
return entries.firstOrNull { quirk ->
|
||||||
|
quirk.domains.any { lower == it || lower.endsWith(".$it") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when discovery should not even be attempted. */
|
||||||
|
val isFatal: Boolean get() = this == GOOGLE_UNSUPPORTED
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
|
||||||
|
/** One `_caldavs._tcp` SRV record. */
|
||||||
|
data class SrvRecord(
|
||||||
|
val priority: Int,
|
||||||
|
val weight: Int,
|
||||||
|
val port: Int,
|
||||||
|
val target: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
|
||||||
|
* without a network, and so the resolver can be swapped per platform.
|
||||||
|
*/
|
||||||
|
interface DnsResolver {
|
||||||
|
fun srv(name: String): List<SrvRecord>
|
||||||
|
fun txt(name: String): List<String>
|
||||||
|
|
||||||
|
/** For the base-URL path, where DNS is never consulted. */
|
||||||
|
object None : DnsResolver {
|
||||||
|
override fun srv(name: String) = emptyList<SrvRecord>()
|
||||||
|
override fun txt(name: String) = emptyList<String>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns what the user typed into the ordered list of URLs worth probing.
|
||||||
|
*
|
||||||
|
* This is RFC 6764 §6 (`SRV` + `TXT`) followed by the well-known ladder, and it
|
||||||
|
* is where `docs/SYNC.md`'s live-probed trap table lives. Every rule below cost
|
||||||
|
* somebody a support ticket:
|
||||||
|
*
|
||||||
|
* - **`TXT path=` is not optional.** Posteo publishes `path=/`, GMX and Web.de
|
||||||
|
* `path=/begenda/dav/users/`. Skipping the TXT lookup lands both on the wrong
|
||||||
|
* path and discovery finds nothing.
|
||||||
|
* - **The SRV port is not 443.** Posteo is SRV-only on **8443**, and its
|
||||||
|
* `/.well-known/caldav` 404s. Hardcoding the port fails it outright.
|
||||||
|
* - **A `.` target means "explicitly unavailable"** (RFC 2782), not "no record".
|
||||||
|
* `_caldav._tcp.fastmail.com` and `runbox.com` both answer `0 0 0 .`.
|
||||||
|
* - **`/` is the last rung**, after the TXT path and `/.well-known/caldav`. The
|
||||||
|
* draft omitted it.
|
||||||
|
* - **Priority and weight are honoured**, lowest priority first.
|
||||||
|
*/
|
||||||
|
object ServiceDiscovery {
|
||||||
|
|
||||||
|
private const val SRV_SERVICE = "_caldavs._tcp"
|
||||||
|
private const val WELL_KNOWN = "/.well-known/caldav"
|
||||||
|
|
||||||
|
/** A URL to probe, and where it came from — the "why" a failure report needs. */
|
||||||
|
data class Candidate(val url: HttpUrl, val origin: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hosts whose SRV record leads somewhere that is not a DAV server.
|
||||||
|
*
|
||||||
|
* Google publishes a valid `_caldavs._tcp` record pointing at
|
||||||
|
* `calendar.google.com`, which answers **405** to PROPFIND. A strict RFC 6764
|
||||||
|
* client follows it into a dead end for every `@gmail.com` address. Google is
|
||||||
|
* out of scope anyway — it supports neither VTODO nor MKCALENDAR — so this is
|
||||||
|
* caught at the front rather than surfaced as a baffling 405.
|
||||||
|
*/
|
||||||
|
private val SRV_DEAD_ENDS = setOf("calendar.google.com")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param input what the user typed: an email address, a `mailto:` URI, or a
|
||||||
|
* base URL
|
||||||
|
*/
|
||||||
|
fun candidatesFor(input: String, dns: DnsResolver = DnsResolver.None): List<Candidate> {
|
||||||
|
val trimmed = input.trim()
|
||||||
|
if (trimmed.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// A typed base URL is used as typed. PROPFIND on it can return principal,
|
||||||
|
// home-set and collection in one response, so DNS is never consulted.
|
||||||
|
asBaseUrl(trimmed)?.let { return listOf(Candidate(it, "base URL as typed")) }
|
||||||
|
|
||||||
|
val domain = domainOf(trimmed) ?: return emptyList()
|
||||||
|
val candidates = mutableListOf<Candidate>()
|
||||||
|
|
||||||
|
val srv = dns.srv("$SRV_SERVICE.$domain")
|
||||||
|
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
|
||||||
|
// DNS names are case-insensitive and dnsjava returns the wire case, so
|
||||||
|
// "Calendar.Google.com." would otherwise walk straight past the guard.
|
||||||
|
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
||||||
|
.sortedWith(compareBy({ it.priority }, { -it.weight }))
|
||||||
|
|
||||||
|
// The TXT path applies to the SRV target, and to the bare domain when
|
||||||
|
// there is no SRV record — that is the GMX/Web.de shape.
|
||||||
|
val txtPath = dns.txt("$SRV_SERVICE.$domain")
|
||||||
|
.firstNotNullOfOrNull { record ->
|
||||||
|
record.split(' ', ';')
|
||||||
|
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
||||||
|
?.substringAfter('=')
|
||||||
|
?.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val origins = srv.map { record ->
|
||||||
|
val host = record.target.trimEnd('.').lowercase()
|
||||||
|
// Port 443 is the default and stays implicit; anything else is
|
||||||
|
// explicit, which is the whole point of Posteo's 8443.
|
||||||
|
val port = if (record.port == 443) "" else ":${record.port}"
|
||||||
|
"https://$host$port" to "SRV $host$port"
|
||||||
|
}.ifEmpty {
|
||||||
|
listOf("https://$domain" to "domain as typed")
|
||||||
|
}
|
||||||
|
|
||||||
|
for ((origin, label) in origins) {
|
||||||
|
if (txtPath != null) {
|
||||||
|
add(candidates, origin, txtPath, "$label + TXT path=$txtPath")
|
||||||
|
}
|
||||||
|
add(candidates, origin, WELL_KNOWN, "$label + .well-known")
|
||||||
|
add(candidates, origin, "/", "$label + root")
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun add(into: MutableList<Candidate>, origin: String, path: String, label: String) {
|
||||||
|
val url = (origin.trimEnd('/') + "/" + path.trimStart('/')).toHttpUrlOrNull() ?: return
|
||||||
|
if (into.none { it.url == url }) into += Candidate(url, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The input as a base URL, or null if it is an address rather than a URL. */
|
||||||
|
internal fun asBaseUrl(input: String): HttpUrl? {
|
||||||
|
if (!input.startsWith("http://", ignoreCase = true) &&
|
||||||
|
!input.startsWith("https://", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return input.toHttpUrlOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
|
||||||
|
internal fun domainOf(input: String): String? {
|
||||||
|
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||||
|
val domain = withoutScheme.substringAfterLast('@').trim().trimEnd('.')
|
||||||
|
return domain.takeIf { it.isNotEmpty() && it.contains('.') && !it.contains('/') }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CalDavDiscoveryTest {
|
||||||
|
|
||||||
|
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||||
|
private val server = MockWebServer()
|
||||||
|
private lateinit var discovery: CalDavDiscovery
|
||||||
|
|
||||||
|
@Before fun start() {
|
||||||
|
server.start()
|
||||||
|
discovery = CalDavDiscovery(httpClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
private fun options(dav: String = "1, 2, 3, calendar-access") =
|
||||||
|
MockResponse().setResponseCode(200).setHeader("DAV", dav)
|
||||||
|
|
||||||
|
private fun multistatus(body: String) = MockResponse()
|
||||||
|
.setResponseCode(207)
|
||||||
|
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
.setBody(body)
|
||||||
|
|
||||||
|
private fun principalResponse(href: String?) = multistatus(
|
||||||
|
"""
|
||||||
|
<multistatus xmlns="DAV:">
|
||||||
|
<response>
|
||||||
|
<href>/dav/</href>
|
||||||
|
<propstat><prop><current-user-principal>
|
||||||
|
${href?.let { "<href>$it</href>" } ?: "<unauthenticated/>"}
|
||||||
|
</current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||||
|
</response>
|
||||||
|
</multistatus>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun homeSetResponse(vararg hrefs: String) = multistatus(
|
||||||
|
"""
|
||||||
|
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<response>
|
||||||
|
<href>/principals/me/</href>
|
||||||
|
<propstat><prop><CAL:calendar-home-set>
|
||||||
|
${hrefs.joinToString("\n") { "<href>$it</href>" }}
|
||||||
|
</CAL:calendar-home-set></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||||
|
</response>
|
||||||
|
</multistatus>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One `<response>` for a collection in a Depth-1 listing. */
|
||||||
|
private fun collection(
|
||||||
|
href: String,
|
||||||
|
resourceTypes: String,
|
||||||
|
componentSet: String? = null,
|
||||||
|
displayName: String = "Tasks",
|
||||||
|
extraProps: String = "",
|
||||||
|
) = """
|
||||||
|
<response>
|
||||||
|
<href>$href</href>
|
||||||
|
<propstat><prop>
|
||||||
|
<resourcetype>$resourceTypes</resourcetype>
|
||||||
|
<displayname>$displayName</displayname>
|
||||||
|
${componentSet ?: ""}
|
||||||
|
$extraProps
|
||||||
|
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||||
|
</response>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private fun listing(vararg responses: String) = multistatus(
|
||||||
|
"""
|
||||||
|
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav"
|
||||||
|
xmlns:CS="http://calendarserver.org/ns/"
|
||||||
|
xmlns:nc="http://nextcloud.com/ns">
|
||||||
|
<response><href>/dav/calendars/me/</href>
|
||||||
|
<propstat><prop><resourcetype><collection/></resourcetype></prop>
|
||||||
|
<status>HTTP/1.1 200 OK</status></propstat></response>
|
||||||
|
${responses.joinToString("\n")}
|
||||||
|
</multistatus>
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun discoverCollections(listingBody: MockResponse): List<TaskCollection> {
|
||||||
|
server.enqueue(homeSetResponse("/dav/calendars/me/"))
|
||||||
|
server.enqueue(listingBody)
|
||||||
|
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Found::class.java)
|
||||||
|
return (outcome as CalDavDiscovery.Outcome.Found).collections
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ principal
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 200 carrying unauthenticated is a failed login, not an empty result`() {
|
||||||
|
// RFC 5397 section 3. Without this check a rejected credential looks like
|
||||||
|
// a successful discovery that happened to find nothing — which is the
|
||||||
|
// shape of bug report nobody can act on.
|
||||||
|
server.enqueue(options())
|
||||||
|
server.enqueue(principalResponse(null))
|
||||||
|
|
||||||
|
assertThat(discovery.probe(server.url("/dav/")))
|
||||||
|
.isEqualTo(CalDavDiscovery.Outcome.Unauthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 401 means sign in, not failure`() {
|
||||||
|
// iCloud and Zoho answer 401 from /.well-known/caldav — the endpoint *is*
|
||||||
|
// the DAV root and wants auth. RFC-legal, and must not end the walk.
|
||||||
|
server.enqueue(options())
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
|
||||||
|
assertThat(discovery.probe(server.url("/dav/")))
|
||||||
|
.isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a WebDAV server without calendar-access is not a CalDAV server`() {
|
||||||
|
server.enqueue(options(dav = "1, 2, 3"))
|
||||||
|
server.enqueue(principalResponse("/principals/me/"))
|
||||||
|
|
||||||
|
val outcome = discovery.probe(server.url("/dav/"))
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NotCalDav::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ home sets
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every calendar-home-set href is followed, not just the first`() {
|
||||||
|
// Multiple home sets are normative (RFC 4791 section 6.2.1's own example)
|
||||||
|
// and iCloud depends on it.
|
||||||
|
server.enqueue(homeSetResponse("/dav/one/", "/dav/two/"))
|
||||||
|
server.enqueue(listing(collection("/dav/one/tasks/", "<collection/><CAL:calendar/>")))
|
||||||
|
server.enqueue(listing(collection("/dav/two/more/", "<collection/><CAL:calendar/>")))
|
||||||
|
|
||||||
|
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||||
|
val found = outcome as CalDavDiscovery.Outcome.Found
|
||||||
|
assertThat(found.collections.map { it.url.encodedPath })
|
||||||
|
.containsExactly("/dav/one/tasks/", "/dav/two/more/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 401 on one home set does not invalidate the whole account`() {
|
||||||
|
// The iCloud shape: principal on one host, home set on another. The
|
||||||
|
// interceptor withholds the credential from the second host by design, so
|
||||||
|
// a 401 there must not tell the user to sign in again with credentials
|
||||||
|
// that just worked.
|
||||||
|
server.enqueue(homeSetResponse("/dav/one/", "/dav/two/"))
|
||||||
|
server.enqueue(listing(collection("/dav/one/tasks/", "<collection/><CAL:calendar/>")))
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
|
||||||
|
val found = discovery.fromPrincipal(server.url("/principals/me/")) as CalDavDiscovery.Outcome.Found
|
||||||
|
assertThat(found.collections.map { it.url.encodedPath }).containsExactly("/dav/one/tasks/")
|
||||||
|
assertThat(found.failedHomeSets).hasSize(1)
|
||||||
|
assertThat(found.failedHomeSets.single().needsAuthentication).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every home set failing is a server error, not an empty account`() {
|
||||||
|
// Returning Found with no collections says "connected, no task lists" for
|
||||||
|
// what is actually a 500 — a bug report nobody can act on.
|
||||||
|
server.enqueue(homeSetResponse("/dav/one/"))
|
||||||
|
server.enqueue(MockResponse().setResponseCode(500))
|
||||||
|
|
||||||
|
assertThat(discovery.fromPrincipal(server.url("/principals/me/")))
|
||||||
|
.isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `when every home set wants credentials, it names the hosts to allow`() {
|
||||||
|
server.enqueue(homeSetResponse("/dav/one/"))
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401))
|
||||||
|
|
||||||
|
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||||
|
// Without the host, the caller can never widen the credential allowlist —
|
||||||
|
// the cross-host home set stays permanently unreachable.
|
||||||
|
assertThat((outcome as CalDavDiscovery.Outcome.NeedsAuthentication).hosts)
|
||||||
|
.containsExactly(server.hostName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a typed http URL is refused with the scheme as the reason`() {
|
||||||
|
// Credentials are never sent over cleartext, so this could only ever
|
||||||
|
// answer "not authorised" — which the user reads as a wrong password.
|
||||||
|
val outcome = discovery.discover("http://cloud.example.com/dav/")
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
||||||
|
assertThat((outcome as CalDavDiscovery.Outcome.Failed).reason).contains("http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a colour comes back as packed ARGB, not as a decimal string`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection(
|
||||||
|
"/dav/calendars/me/tasks/",
|
||||||
|
"<collection/><CAL:calendar/>",
|
||||||
|
extraProps = """<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#FF0000FF</x1:calendar-color>""",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections.single().color).isEqualTo(0xFFFF0000.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a share is flagged, because Nextcloud rewrites bodies fetched from one`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection("/dav/calendars/me/shared/", "<collection/><CAL:calendar/><CS:shared/>"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections.single().isShared).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a principal with no home set is a failure worth naming`() {
|
||||||
|
server.enqueue(homeSetResponse())
|
||||||
|
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||||
|
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------- the two filters
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an absent supported-calendar-component-set means supports everything`() {
|
||||||
|
// RFC 4791 section 5.2.3 says it SHOULD NOT come back from allprop, so it
|
||||||
|
// is legitimately missing. Requiring VTODO to be listed silently drops
|
||||||
|
// every server that does not advertise it.
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(collection("/dav/calendars/me/tasks/", "<collection/><CAL:calendar/>")),
|
||||||
|
)
|
||||||
|
assertThat(collections.map { it.url.encodedPath }).containsExactly("/dav/calendars/me/tasks/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty component set is treated as everything, not as nothing`() {
|
||||||
|
// The grammar is (comp+) so this is non-conformant, and dav4jvm's parser
|
||||||
|
// starts all-false — arriving indistinguishable from "no VTODO".
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection(
|
||||||
|
"/dav/calendars/me/tasks/",
|
||||||
|
"<collection/><CAL:calendar/>",
|
||||||
|
componentSet = "<CAL:supported-calendar-component-set/>",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a VEVENT-only collection is excluded`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection(
|
||||||
|
"/dav/calendars/me/events/",
|
||||||
|
"<collection/><CAL:calendar/>",
|
||||||
|
componentSet = """<CAL:supported-calendar-component-set><CAL:comp name="VEVENT"/></CAL:supported-calendar-component-set>""",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SOGo's personal calendar survives, because the test is positive not exclusionary`() {
|
||||||
|
// SOGo reports collection + calendar + schedule-outbox simultaneously for
|
||||||
|
// every non-Apple client, i.e. for us. Excluding schedule-outbox — the
|
||||||
|
// obvious rule — would drop the user's main calendar.
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection(
|
||||||
|
"/SOGo/dav/me/Calendar/personal/",
|
||||||
|
"<collection/><CAL:calendar/><CAL:schedule-outbox/>",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared calendar is kept`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection("/dav/calendars/me/shared/", "<collection/><CAL:calendar/><CS:shared/>"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections).hasSize(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Nextcloud's trashed calendar is dropped, because it strips caldav-calendar`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection("/dav/calendars/me/deleted/", "<collection/><nc:deleted-calendar/>"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `inboxes, outboxes and notification collections are not task lists`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection("/dav/calendars/me/inbox/", "<collection/><CAL:schedule-inbox/>"),
|
||||||
|
collection("/dav/calendars/me/outbox/", "<collection/><CAL:schedule-outbox/>"),
|
||||||
|
collection("/dav/calendars/me/notifications/", "<collection/><CS:notification/>"),
|
||||||
|
collection("/dav/calendars/me/tasks/", "<collection/><CAL:calendar/>"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections.map { it.url.encodedPath }).containsExactly("/dav/calendars/me/tasks/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- privileges
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an absent privilege set means writable`() {
|
||||||
|
// RFC 3744 section 3.7 lets a server withhold it, and assuming read-only
|
||||||
|
// hides collections the user can perfectly well write to.
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(collection("/dav/calendars/me/tasks/", "<collection/><CAL:calendar/>")),
|
||||||
|
)
|
||||||
|
assertThat(collections.single().readOnly).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read-only share is reported as read-only`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection(
|
||||||
|
"/dav/calendars/me/shared/",
|
||||||
|
"<collection/><CAL:calendar/><CS:shared/>",
|
||||||
|
extraProps = "<current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections.single().readOnly).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sync-collection support is reported when advertised`() {
|
||||||
|
val collections = discoverCollections(
|
||||||
|
listing(
|
||||||
|
collection(
|
||||||
|
"/dav/calendars/me/tasks/",
|
||||||
|
"<collection/><CAL:calendar/>",
|
||||||
|
extraProps = "<supported-report-set><supported-report><report><sync-collection/></report></supported-report></supported-report-set>",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(collections.single().supportsSyncCollection).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class NextcloudLoginFlowTest {
|
||||||
|
|
||||||
|
private val server = MockWebServer()
|
||||||
|
private lateinit var flow: NextcloudLoginFlow
|
||||||
|
|
||||||
|
@Before fun start() {
|
||||||
|
server.start()
|
||||||
|
flow = NextcloudLoginFlow(OkHttpClient(), userAgent = "Agendula/1.0 (Pixel 8)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@After fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
private fun json(body: String) = MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json; charset=utf-8")
|
||||||
|
.setBody(body)
|
||||||
|
|
||||||
|
private fun startFlow(): NextcloudLoginFlow.Flow {
|
||||||
|
server.enqueue(
|
||||||
|
json(
|
||||||
|
"""
|
||||||
|
{"poll":{"token":"tok-123","endpoint":"${server.url("/index.php/login/v2/poll")}"},
|
||||||
|
"login":"${server.url("/index.php/login/v2/flow/abc")}"}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return flow.start(server.url("/"), now = 0).getOrThrow()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `init posts, and carries a User-Agent the user can recognise`() {
|
||||||
|
startFlow()
|
||||||
|
val request = server.takeRequest()
|
||||||
|
assertThat(request.method).isEqualTo("POST")
|
||||||
|
// The User-Agent becomes the app password's *name* in Settings → Security
|
||||||
|
// → Devices & sessions. With OkHttp's default the user sees
|
||||||
|
// "okhttp/4.12.0" and cannot tell what to revoke.
|
||||||
|
assertThat(request.getHeader("User-Agent")).isEqualTo("Agendula/1.0 (Pixel 8)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `polling is a POST - a GET gets 405`() {
|
||||||
|
val started = startFlow()
|
||||||
|
server.enqueue(MockResponse().setResponseCode(404))
|
||||||
|
flow.poll(started, now = 1)
|
||||||
|
|
||||||
|
server.takeRequest() // the init request
|
||||||
|
val poll = server.takeRequest()
|
||||||
|
assertThat(poll.method).isEqualTo("POST")
|
||||||
|
assertThat(poll.body.readUtf8()).contains("token=tok-123")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `404 means pending`() {
|
||||||
|
val started = startFlow()
|
||||||
|
server.enqueue(MockResponse().setResponseCode(404))
|
||||||
|
assertThat(flow.poll(started, now = 1)).isEqualTo(NextcloudLoginFlow.PollResult.Pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `approval yields the app password, and loginName is kept as a username only`() {
|
||||||
|
val started = startFlow()
|
||||||
|
server.enqueue(
|
||||||
|
json("""{"server":"${server.url("/")}","loginName":"Me@Example.COM","appPassword":"secret-app-pw"}"""),
|
||||||
|
)
|
||||||
|
val result = flow.poll(started, now = 1)
|
||||||
|
assertThat(result).isInstanceOf(NextcloudLoginFlow.PollResult.Approved::class.java)
|
||||||
|
val credentials = (result as NextcloudLoginFlow.PollResult.Approved).credentials
|
||||||
|
assertThat(credentials.loginName).isEqualTo("Me@Example.COM")
|
||||||
|
assertThat(credentials.appPassword).isEqualTo("secret-app-pw")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `429 is rate limiting, not pending`() {
|
||||||
|
// "Anything that isn't 200 is pending" turns Nextcloud's brute-force
|
||||||
|
// protection into a twenty-minute spinner.
|
||||||
|
val started = startFlow()
|
||||||
|
server.enqueue(MockResponse().setResponseCode(429))
|
||||||
|
assertThat(flow.poll(started, now = 1))
|
||||||
|
.isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `503 is maintenance, not pending`() {
|
||||||
|
val started = startFlow()
|
||||||
|
server.enqueue(MockResponse().setResponseCode(503))
|
||||||
|
assertThat(flow.poll(started, now = 1))
|
||||||
|
.isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a 200 that is not JSON is a captive portal, not an approval`() {
|
||||||
|
// A Cloudflare challenge is a 200 carrying HTML.
|
||||||
|
val started = startFlow()
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "text/html")
|
||||||
|
.setBody("<html>Checking your browser…</html>"),
|
||||||
|
)
|
||||||
|
assertThat(flow.poll(started, now = 1))
|
||||||
|
.isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the deadline is tracked locally, because 404 also means expired`() {
|
||||||
|
val started = startFlow()
|
||||||
|
assertThat(started.deadlineEpochSeconds).isEqualTo(1200L)
|
||||||
|
// No response enqueued: an expired flow must not even reach the network.
|
||||||
|
assertThat(flow.poll(started, now = 1201))
|
||||||
|
.isInstanceOf(NextcloudLoginFlow.PollResult.Expired::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a URL that downgrades to http is refused`() {
|
||||||
|
// The poll token is exchanged for a long-lived app password, and the login
|
||||||
|
// URL takes the account password — both are credential-grade.
|
||||||
|
val https = "https://cloud.example.com/".toHttpUrl()
|
||||||
|
val http = "http://cloud.example.com/poll".toHttpUrl()
|
||||||
|
val failure = runCatching { flow.requireSecureOrigin(https, http) }.exceptionOrNull()
|
||||||
|
assertThat(failure).isNotNull()
|
||||||
|
assertThat(failure!!.message).contains("http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the browser login URL gets the same scheme check as the poll endpoint`() {
|
||||||
|
// It is where the user types their *account* password, so leaving it
|
||||||
|
// unchecked is the more dangerous of the two omissions. `start` applies
|
||||||
|
// requireSecureOrigin to both; this asserts the rule itself, because
|
||||||
|
// reaching the branch over the wire would need a TLS MockWebServer and a
|
||||||
|
// test that merely fails to connect would pass for the wrong reason.
|
||||||
|
val typed = "https://cloud.example.com/".toHttpUrl()
|
||||||
|
val harvester = "http://evil.example.com/harvest".toHttpUrl()
|
||||||
|
val failure = runCatching { flow.requireSecureOrigin(typed, harvester) }.exceptionOrNull()
|
||||||
|
assertThat(failure).isNotNull()
|
||||||
|
assertThat(failure!!.message).contains("evil.example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a host change is carried, not refused - reverse proxies are legitimate`() {
|
||||||
|
// overwrite.cli.url pointing somewhere other than what the user typed is
|
||||||
|
// ordinary on self-hosted installs. Throwing would make the flow unusable
|
||||||
|
// for them; the UI confirms it instead.
|
||||||
|
server.enqueue(
|
||||||
|
json(
|
||||||
|
"""
|
||||||
|
{"poll":{"token":"t","endpoint":"https://cloud.example.com/poll"},
|
||||||
|
"login":"https://cloud.example.com/flow"}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val started = flow.start(server.url("/"), now = 0)
|
||||||
|
assertThat(started.isSuccess).isTrue()
|
||||||
|
val mismatch = started.getOrThrow().hostMismatch
|
||||||
|
assertThat(mismatch).isNotNull()
|
||||||
|
assertThat(mismatch!!.actual).isEqualTo("cloud.example.com")
|
||||||
|
assertThat(mismatch.message).contains("overwrite.cli.url")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class PreemptiveBasicInterceptorTest {
|
||||||
|
|
||||||
|
private val server = MockWebServer()
|
||||||
|
|
||||||
|
@Before fun start() = server.start()
|
||||||
|
@After fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
private fun clientFor(allowedHosts: Set<String>) = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(PreemptiveBasicInterceptor("user", "pw", allowedHosts))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun authHeaderOf(client: OkHttpClient): String? {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(200))
|
||||||
|
client.newCall(Request.Builder().url(server.url("/dav/")).build()).execute().close()
|
||||||
|
return server.takeRequest().getHeader("Authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `plain HTTP never carries the credential, whatever the host list says`() {
|
||||||
|
// MockWebServer is HTTP, so this also documents why the happy path below
|
||||||
|
// is tested through the interceptor directly rather than over the wire.
|
||||||
|
assertThat(authHeaderOf(clientFor(setOf(server.hostName)))).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a host outside the account's origin gets nothing`() {
|
||||||
|
assertThat(authHeaderOf(clientFor(setOf("someone-else.example.com")))).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the credential is attached up front on an allowed HTTPS host`() {
|
||||||
|
// OkHttp's Authenticator is reactive-only: it costs an extra round trip on
|
||||||
|
// every request of a PROPFIND-heavy sync, and never fires at all against a
|
||||||
|
// server that answers 403 or 404 without a challenge.
|
||||||
|
val interceptor = PreemptiveBasicInterceptor("user", "pw", setOf("cloud.example.com"))
|
||||||
|
val request = Request.Builder().url("https://cloud.example.com/dav/").build()
|
||||||
|
val chain = FakeChain(request)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(chain.proceeded!!.header("Authorization")).isEqualTo("Basic dXNlcjpwdw==")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an existing Authorization header is never overwritten`() {
|
||||||
|
val interceptor = PreemptiveBasicInterceptor("user", "pw", setOf("cloud.example.com"))
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://cloud.example.com/dav/")
|
||||||
|
.header("Authorization", "Digest something")
|
||||||
|
.build()
|
||||||
|
val chain = FakeChain(request)
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(chain.proceeded!!.header("Authorization")).isEqualTo("Digest something")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cross-host home set does not silently receive the credential`() {
|
||||||
|
// OkHttp strips Authorization across hosts on purpose. Re-attaching it is
|
||||||
|
// something we do knowingly, per validated host — never blanket.
|
||||||
|
val interceptor = PreemptiveBasicInterceptor("user", "pw", setOf("caldav.icloud.com"))
|
||||||
|
val chain = FakeChain(Request.Builder().url("https://p42-caldav.icloud.com/dav/").build())
|
||||||
|
|
||||||
|
interceptor.intercept(chain)
|
||||||
|
|
||||||
|
assertThat(chain.proceeded!!.header("Authorization")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeChain(private val request: Request) : okhttp3.Interceptor.Chain {
|
||||||
|
var proceeded: Request? = null
|
||||||
|
override fun request() = request
|
||||||
|
override fun proceed(request: Request): okhttp3.Response {
|
||||||
|
proceeded = request
|
||||||
|
return okhttp3.Response.Builder()
|
||||||
|
.request(request)
|
||||||
|
.protocol(okhttp3.Protocol.HTTP_1_1)
|
||||||
|
.code(200)
|
||||||
|
.message("OK")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
override fun connection() = null
|
||||||
|
override fun call() = throw UnsupportedOperationException()
|
||||||
|
override fun connectTimeoutMillis() = 0
|
||||||
|
override fun withConnectTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit) = this
|
||||||
|
override fun readTimeoutMillis() = 0
|
||||||
|
override fun withReadTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit) = this
|
||||||
|
override fun writeTimeoutMillis() = 0
|
||||||
|
override fun withWriteTimeout(timeout: Int, unit: java.util.concurrent.TimeUnit) = this
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ServerQuirksTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the three providers whose real error is not wrong password`() {
|
||||||
|
assertThat(ServerQuirk.forInput("me@fastmail.com"))
|
||||||
|
.isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD)
|
||||||
|
assertThat(ServerQuirk.forInput("me@icloud.com"))
|
||||||
|
.isEqualTo(ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD)
|
||||||
|
assertThat(ServerQuirk.forInput("me@gmail.com"))
|
||||||
|
.isEqualTo(ServerQuirk.GOOGLE_UNSUPPORTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Google is fatal - it supports neither VTODO nor MKCALENDAR`() {
|
||||||
|
assertThat(ServerQuirk.GOOGLE_UNSUPPORTED.isFatal).isTrue()
|
||||||
|
assertThat(ServerQuirk.FASTMAIL_APP_PASSWORD.isFatal).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `subdomains count, and a lookalike domain does not`() {
|
||||||
|
assertThat(ServerQuirk.forHost("mail.icloud.com")).isEqualTo(ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD)
|
||||||
|
assertThat(ServerQuirk.forHost("noticloud.com")).isNull()
|
||||||
|
assertThat(ServerQuirk.forHost("icloud.com.example.org")).isNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a self-hosted server has no quirk`() {
|
||||||
|
assertThat(ServerQuirk.forInput("https://cloud.example.de/remote.php/dav/")).isNull()
|
||||||
|
assertThat(ServerQuirk.forInput("me@example.de")).isNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package de.jeanlucmakiola.caldav
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `docs/SYNC.md`'s discovery trap table, made executable. Every case here was
|
||||||
|
* live-probed against a real provider; each one breaks the obvious
|
||||||
|
* implementation.
|
||||||
|
*/
|
||||||
|
class ServiceDiscoveryTest {
|
||||||
|
|
||||||
|
private class FakeDns(
|
||||||
|
val srv: Map<String, List<SrvRecord>> = emptyMap(),
|
||||||
|
val txt: Map<String, List<String>> = emptyMap(),
|
||||||
|
) : DnsResolver {
|
||||||
|
override fun srv(name: String) = srv[name].orEmpty()
|
||||||
|
override fun txt(name: String) = txt[name].orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
||||||
|
ServiceDiscovery.candidatesFor(input, dns).map { it.url.toString() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a typed base URL is used as typed and never triggers DNS`() {
|
||||||
|
val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 8443, "dav.example.com"))))
|
||||||
|
assertThat(urls("https://cloud.example.com/remote.php/dav/", dns))
|
||||||
|
.containsExactly("https://cloud.example.com/remote.php/dav/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the well-known ladder ends at the root, not at well-known`() {
|
||||||
|
// The draft stopped at /.well-known/caldav; a server that 404s there and
|
||||||
|
// serves DAV from / would be undiscoverable.
|
||||||
|
assertThat(urls("me@example.com")).containsExactly(
|
||||||
|
"https://example.com/.well-known/caldav",
|
||||||
|
"https://example.com/",
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Posteo is SRV-only on port 8443, and its well-known 404s`() {
|
||||||
|
val dns = FakeDns(
|
||||||
|
srv = mapOf("_caldavs._tcp.posteo.de" to listOf(SrvRecord(0, 0, 8443, "posteo.de."))),
|
||||||
|
txt = mapOf("_caldavs._tcp.posteo.de" to listOf("path=/")),
|
||||||
|
)
|
||||||
|
// Hardcoding 443 fails Posteo outright.
|
||||||
|
assertThat(urls("me@posteo.de", dns)).containsExactly(
|
||||||
|
"https://posteo.de:8443/",
|
||||||
|
"https://posteo.de:8443/.well-known/caldav",
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `GMX publishes a TXT path that discovery cannot skip`() {
|
||||||
|
val dns = FakeDns(
|
||||||
|
txt = mapOf("_caldavs._tcp.gmx.net" to listOf("path=/begenda/dav/users/")),
|
||||||
|
)
|
||||||
|
// Skip the TXT lookup and GMX lands on the wrong path, finding nothing.
|
||||||
|
assertThat(urls("me@gmx.net", dns)).contains("https://gmx.net/begenda/dav/users/")
|
||||||
|
assertThat(urls("me@gmx.net", dns).first()).isEqualTo("https://gmx.net/begenda/dav/users/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a null SRV target means explicitly unavailable, not no record`() {
|
||||||
|
// RFC 2782. _caldav._tcp.fastmail.com and runbox.com both answer `0 0 0 .`
|
||||||
|
val dns = FakeDns(srv = mapOf("_caldavs._tcp.runbox.com" to listOf(SrvRecord(0, 0, 0, "."))))
|
||||||
|
assertThat(urls("me@runbox.com", dns)).containsExactly(
|
||||||
|
"https://runbox.com/.well-known/caldav",
|
||||||
|
"https://runbox.com/",
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Google's SRV record points at something that is not a DAV server`() {
|
||||||
|
// calendar.google.com answers 405 to PROPFIND. A strict RFC 6764 client
|
||||||
|
// follows it into a dead end for every @gmail.com address.
|
||||||
|
val dns = FakeDns(
|
||||||
|
srv = mapOf("_caldavs._tcp.gmail.com" to listOf(SrvRecord(5, 0, 443, "calendar.google.com."))),
|
||||||
|
)
|
||||||
|
assertThat(urls("me@gmail.com", dns)).doesNotContain("https://calendar.google.com/")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SRV priority wins, then weight`() {
|
||||||
|
val dns = FakeDns(
|
||||||
|
srv = mapOf(
|
||||||
|
"_caldavs._tcp.example.com" to listOf(
|
||||||
|
SrvRecord(priority = 20, weight = 0, port = 443, target = "backup.example.com"),
|
||||||
|
SrvRecord(priority = 10, weight = 5, port = 443, target = "low.example.com"),
|
||||||
|
SrvRecord(priority = 10, weight = 90, port = 443, target = "main.example.com"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertThat(urls("me@example.com", dns).first()).startsWith("https://main.example.com/")
|
||||||
|
assertThat(urls("me@example.com", dns)).containsAtLeast(
|
||||||
|
"https://main.example.com/.well-known/caldav",
|
||||||
|
"https://low.example.com/.well-known/caldav",
|
||||||
|
"https://backup.example.com/.well-known/caldav",
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `port 443 stays implicit so URLs compare equal`() {
|
||||||
|
val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "dav.example.com"))))
|
||||||
|
assertThat(urls("me@example.com", dns).first()).isEqualTo("https://dav.example.com/.well-known/caldav")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mailto and a bare domain both resolve`() {
|
||||||
|
assertThat(ServiceDiscovery.domainOf("mailto:me@example.com")).isEqualTo("example.com")
|
||||||
|
assertThat(ServiceDiscovery.domainOf("me@example.com")).isEqualTo("example.com")
|
||||||
|
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `something that is neither an address nor a URL yields nothing`() {
|
||||||
|
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
|
||||||
|
assertThat(ServiceDiscovery.candidatesFor("")).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `every candidate carries where it came from`() {
|
||||||
|
val dns = FakeDns(txt = mapOf("_caldavs._tcp.gmx.net" to listOf("path=/begenda/dav/users/")))
|
||||||
|
assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin })
|
||||||
|
.contains("domain as typed + TXT path=/begenda/dav/users/")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,6 +107,20 @@ through its `Calendar`, and any caller could `setTimeZone` on it and undo change
|
|||||||
for everyone else. It is now private and built per call. Nothing else in the tree
|
for everyone else. It is now private and built per call. Nothing else in the tree
|
||||||
referenced it.
|
referenced it.
|
||||||
|
|
||||||
|
### 6. `<D:unauthenticated/>` is parsed instead of inferred
|
||||||
|
|
||||||
|
`CurrentUserPrincipal.Factory` read only the `<href>` child, so an
|
||||||
|
unauthenticated body (RFC 5397 §3 — a **200** whose content means the request was
|
||||||
|
not authenticated) arrived as "property present, href null" — identical to a
|
||||||
|
conformant-but-empty element, and to a server that omits the property entirely.
|
||||||
|
A caller inferring rejection from the null href therefore also fires on merely
|
||||||
|
non-conformant servers, and on a request that carried no credential at all.
|
||||||
|
|
||||||
|
The factory now makes one pass over both children (`XmlUtils.processTag` consumes
|
||||||
|
to the end tag and so cannot be called twice) and reports `unauthenticated`
|
||||||
|
explicitly. Without it, a rejected credential is indistinguishable from a
|
||||||
|
successful discovery that found nothing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build integration
|
## Build integration
|
||||||
|
|||||||
@@ -10,17 +10,29 @@ import at.bitfire.dav4jvm.DavResource
|
|||||||
import at.bitfire.dav4jvm.Property
|
import at.bitfire.dav4jvm.Property
|
||||||
import at.bitfire.dav4jvm.PropertyFactory
|
import at.bitfire.dav4jvm.PropertyFactory
|
||||||
import at.bitfire.dav4jvm.XmlUtils
|
import at.bitfire.dav4jvm.XmlUtils
|
||||||
|
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
|
||||||
// see RFC 5397: WebDAV Current Principal Extension
|
// see RFC 5397: WebDAV Current Principal Extension
|
||||||
|
|
||||||
data class CurrentUserPrincipal(
|
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 {
|
): Property {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@JvmField
|
@JvmField
|
||||||
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal")
|
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 {
|
override fun create(parser: XmlPullParser): CurrentUserPrincipal {
|
||||||
// <!ELEMENT current-user-principal (unauthenticated | href)>
|
// <!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
|
var href: String? = null
|
||||||
XmlUtils.processTag(parser, DavResource.HREF) {
|
var unauthenticated = false
|
||||||
href = XmlUtils.readText(parser)
|
|
||||||
|
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
|
package at.bitfire.dav4jvm
|
||||||
|
|
||||||
|
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
@@ -121,6 +123,50 @@ class LocalChangesTest {
|
|||||||
assertEquals(mockServer.url("/other/"), resource.location)
|
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 --------------------
|
// -- change 1: commons-lang3 removed from date parsing --------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+33
-6
@@ -57,7 +57,8 @@ Nextcloud/Radicale/Baïkal in chunk 5.
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle |
|
| 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle |
|
||||||
| 2a | **Vendored DAV foundation** | nothing | Vendor a tree we can actually maintain, and know which of its defects are real |
|
| 2a | **Vendored DAV foundation** | nothing | Vendor a tree we can actually maintain, and know which of its defects are real |
|
||||||
| 2b | **Auth, discovery, account** | 2a | Never send a credential into an unvalidated redirect chain |
|
| 2b | **Discovery and auth protocol** | 2a | The two collection filters are inversions of the obvious rule |
|
||||||
|
| 2c | **The Android account layer** | 2b | Never send a credential into an unvalidated redirect chain |
|
||||||
| 3 | **Engine core** — read, write, conflicts | 1, 2 | A failed resource must not fail the collection |
|
| 3 | **Engine core** — read, write, conflicts | 1, 2 | A failed resource must not fail the collection |
|
||||||
| 4 | **Incremental sync + scheduling** | 3 | Never persist a sync token before the bodies it covers are applied |
|
| 4 | **Incremental sync + scheduling** | 3 | Never persist a sync token before the bodies it covers are applied |
|
||||||
| 5 | **Hardening, compliance, real servers** | 1–4 | Ship no licence violation, and no retry loop on a dead app password |
|
| 5 | **Hardening, compliance, real servers** | 1–4 | Ship no licence violation, and no retry loop on a dead app password |
|
||||||
@@ -200,7 +201,32 @@ runtime dependency are gone, and `:app` links against the module.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Chunk 2b — auth, discovery, account
|
## Chunk 2b — the discovery and auth protocol
|
||||||
|
|
||||||
|
⚠️ **Split again while building it.** The seam is the platform: everything here
|
||||||
|
is protocol, testable on a JVM against MockWebServer, and none of it touches
|
||||||
|
Android. Keystore, `AccountManager`, Custom Tabs and the account-add UI are a
|
||||||
|
different kind of work with a different kind of verification — including the
|
||||||
|
on-device review — so they are chunk 2c.
|
||||||
|
|
||||||
|
Lands in a new `:caldav` module: MIT, plain JVM, `api`-depending on `:dav`. A
|
||||||
|
separate module from the vendored MPL tree keeps the licences unmixed and makes
|
||||||
|
"no Android types" a compile-time guarantee rather than a discipline.
|
||||||
|
|
||||||
|
**One dependency added:** `dnsjava` 3.6.3 (BSD-3) for the SRV/TXT half of
|
||||||
|
RFC 6764. Android's own `DnsResolver` is callback-only and cannot do the
|
||||||
|
`TXT path=` lookup, and JNDI's DNS provider does not exist on Android at all.
|
||||||
|
It is what DAVx5 uses. Behind a `DnsResolver` interface so the pipeline is
|
||||||
|
testable without a network.
|
||||||
|
|
||||||
|
**Done when:** the trap table is executable — Posteo's SRV-only 8443, GMX's
|
||||||
|
`TXT path=`, null SRV targets, Google's dead-end record, SOGo's triple
|
||||||
|
resourcetype, Nextcloud's trashed calendar, the absent/empty component set, and
|
||||||
|
`<D:unauthenticated/>` as a 200 that means failure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2c — the Android account layer
|
||||||
|
|
||||||
**Goal:** the app can add a CalDAV account and list its VTODO collections. No
|
**Goal:** the app can add a CalDAV account and list its VTODO collections. No
|
||||||
syncing yet.
|
syncing yet.
|
||||||
@@ -466,10 +492,11 @@ attribution screen ships, and a `releaseTest` build syncs on a real device.
|
|||||||
## Definition of done, every chunk
|
## Definition of done, every chunk
|
||||||
|
|
||||||
1. It builds: `./gradlew :app:assembleDebug`.
|
1. It builds: `./gradlew :app:assembleDebug`.
|
||||||
2. Unit tests pass: `./gradlew :app:testDebugUnitTest :dav:test`. ⚠️ Name
|
2. Unit tests pass:
|
||||||
`:dav:test` explicitly — `testDebugUnitTest` is an Android-variant task and
|
`./gradlew :app:testDebugUnitTest :dav:test :caldav:test`. ⚠️ Name the plain
|
||||||
does not exist on a plain JVM module, so the vendored suite would otherwise be
|
JVM modules explicitly — `testDebugUnitTest` is an Android-variant task and
|
||||||
compiled by nobody and run by nobody.
|
does not exist on them, so their suites would otherwise be compiled by nobody
|
||||||
|
and run by nobody. Add each new module here **and** to CI.
|
||||||
3. Lint passes: `./gradlew :app:lintDebug`. Added after chunk 1 shipped a literal
|
3. Lint passes: `./gradlew :app:lintDebug`. Added after chunk 1 shipped a literal
|
||||||
byte-order mark that builds and tests both accepted and CI's lint step would
|
byte-order mark that builds and tests both accepted and CI's lint step would
|
||||||
have rejected.
|
have rejected.
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ okhttp = "4.12.0"
|
|||||||
# testImplementation and never reaches the APK.
|
# testImplementation and never reaches the APK.
|
||||||
xpp3 = "1.1.6"
|
xpp3 = "1.1.6"
|
||||||
|
|
||||||
|
# --- :caldav (our CalDAV protocol layer) -------------------------------------
|
||||||
|
# SRV/TXT lookups for RFC 6764 discovery. Android's resolver exposes no SRV API
|
||||||
|
# below API 29's DnsResolver (which is callback-only and cannot do TXT paths),
|
||||||
|
# and JNDI's DNS provider does not exist on Android at all. dnsjava is what DAVx5
|
||||||
|
# uses. BSD-3 — it goes on the attribution screen with the rest.
|
||||||
|
dnsjava = "3.6.3"
|
||||||
|
|
||||||
# --- :provider (vendored dmfs task provider) ---------------------------------
|
# --- :provider (vendored dmfs task provider) ---------------------------------
|
||||||
# Versions the upstream 1.4.2 source was written against. These are its runtime
|
# Versions the upstream 1.4.2 source was written against. These are its runtime
|
||||||
# dependencies, not ours — nothing above the data layer touches them, and they
|
# dependencies, not ours — nothing above the data layer touches them, and they
|
||||||
@@ -140,6 +147,9 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
|
|||||||
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||||
xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
|
xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
|
||||||
|
|
||||||
|
# :caldav — RFC 6764 service discovery
|
||||||
|
dnsjava = { group = "dnsjava", name = "dnsjava", version.ref = "dnsjava" }
|
||||||
|
|
||||||
# :provider — vendored dmfs task provider (see provider/PROVENANCE.md)
|
# :provider — vendored dmfs task provider (see provider/PROVENANCE.md)
|
||||||
dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" }
|
dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" }
|
||||||
dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" }
|
dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" }
|
||||||
|
|||||||
@@ -27,4 +27,5 @@ dependencyResolutionManagement {
|
|||||||
rootProject.name = "Agendula"
|
rootProject.name = "Agendula"
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":dav")
|
include(":dav")
|
||||||
|
include(":caldav")
|
||||||
includeBuild("floret-kit")
|
includeBuild("floret-kit")
|
||||||
|
|||||||
Reference in New Issue
Block a user