Release 1.0.0 (#20)

First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play).

**App**
- CalDAV sync built in, with Agendula's own task store; OpenTasks / tasks.org stay available and can be copied over in Settings → Storage
- repeating tasks, several reminders per task, lists managed in the app, iCalendar import/export, widget and Quick Settings tile
- a list can be kept out of the smart lists (#18) and gets its own notification channel (#17)
- duplicate a task with its subtasks (#16)
- HTML descriptions shown as plain text (#15)
- relative day words in reminder notifications (#14)
- asks for exact-alarm access instead of claiming USE_EXACT_ALARM, and re-arms reminders when that access changes

**Release plumbing**
- floret-kit bumped to v0.4.0; the old pin was never pushed, so a clean clone couldn't check out the submodule. 0.4.0 drops CrashConfig.issueTitle (crash issues are always filed in English)
- prebuilt .so files ship unstripped, so the build no longer depends on whether an NDK is installed; now checked by check_reproducible_release.sh
- official F-Droid recipe in docs/fdroid-official/, to submit to fdroiddata once v1.0.0 is tagged
- Google Play: fastlane uploads the AAB and every locale's What's New after the F-Droid release; a separate listing lane pushes text and graphics from the fastlane tree, which CI now checks against Play's limits
- store listing: title "Agendula: Tasks" in every locale, icon, feature graphic, screenshots and 1.0.0 changelogs in en-US, en-GB, de-DE and pt-BR

crash_report_issue_title is now unused but stays until Weblate removes the translated copies.

Closes #14, closes #15, closes #16, closes #17, closes #18

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
This commit is contained in:
Jean-Luc Makiola
2026-09-24 16:36:49 +02:00
co-authored by makiolaj
parent 7bbdbe60e3
commit ac01993d41
476 changed files with 53728 additions and 2919 deletions
+36
View File
@@ -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,126 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
/**
* Gives an app password back when the account is removed.
*
* ⚠️ Without this, **uninstalling never revokes access**. Nextcloud's Login Flow
* v2 mints a device-specific password that survives the app entirely: it stays
* listed under Settings → Security → Devices & sessions until the user notices
* and deletes it by hand, on an entry named after an app that is no longer
* installed. Minting a credential and then abandoning it is not an acceptable
* end state for a client that asked for one.
*
* Best-effort by design. The account is being removed either way, and a server
* that is unreachable, or was never a Nextcloud, must not block that.
*/
object AppPassword {
/** Nextcloud's OCS endpoint for "delete the password I authenticated with". */
private const val PATH = "ocs/v2.php/core/apppassword"
/**
* Derives the OCS root from a Nextcloud principal URL.
*
* ⚠️ The principal URL is **not** the server root, and appending to it is the
* bug this function exists to prevent: a principal is
* `…/remote.php/dav/principals/users/alice/`, so
* `principal + "ocs/v2.php/…"` produces a path that 404s on every server,
* every time, silently — the revocation reads as "attempted" and does
* nothing at all.
*
* Nextcloud mounts WebDAV under `remote.php`, so everything before that
* segment is the server root, and that is true of a subpath install
* (`https://host/nextcloud/`) as much as of a root one. Falling back to the
* origin is right for a server that does not use `remote.php` — it is not a
* Nextcloud, so the endpoint does not exist there under any path.
*/
fun ocsRootFor(principal: HttpUrl): HttpUrl {
val mount = principal.pathSegments.indexOfFirst { it.equals("remote.php", ignoreCase = true) }
// No `remote.php` means this is not a Nextcloud layout, and guessing a
// prefix from an arbitrary DAV path would aim the DELETE somewhere
// unrelated. The origin is the only defensible answer, and on a server
// without the endpoint it simply 404s.
val prefix = if (mount < 0) emptyList() else principal.pathSegments.take(mount)
return principal.newBuilder()
.encodedPath("/")
.apply {
prefix.filter { it.isNotEmpty() }.forEach { addPathSegment(it) }
// A trailing empty segment keeps this a directory URL, so
// appending the OCS path cannot fuse onto the last segment.
if (prefix.any { it.isNotEmpty() }) addPathSegment("")
}
.build()
}
/**
* ⚠️ The budget is enforced here because nothing above can enforce it.
* `execute()` parks on a socket read, which no coroutine cancellation and no
* `Thread.interrupt` can break — only closing the socket does, which is what
* `callTimeout` does. Wrapping this call in `withTimeoutOrNull` instead
* returns only once the read has finished anyway, so the caller waits out
* the shared client's own budget: 30s *per resolved address*, doubled by the
* authenticator's retry, plus up to 120s of read timeout. Minutes, for a
* step whose own doc says it must not block the removal.
*
* The shared client has a ceiling of its own, but it is sized for a
* multiget of a full batch over a slow link — minutes, where a courtesy
* revocation the user is waiting behind gets seconds.
*
* @return true when the server confirmed the revocation. False means the
* credential may still exist server-side — the caller carries on regardless.
*/
fun revoke(
httpClient: OkHttpClient,
principal: HttpUrl,
timeout: Duration = REVOCATION_TIMEOUT,
): Boolean = revokeAt(httpClient, ocsRootFor(principal), timeout)
/**
* The same revocation, for a caller that already holds the server root.
*
* ⚠️ Do not route such a caller through [revoke]. [ocsRootFor] is
* *principal*-shaped: it looks for `remote.php` and falls back to the bare
* origin when there is none. A login flow hands back a server base, so a
* subpath install's `https://host/nextcloud/` would collapse to
* `https://host/` and the DELETE would 404 on every one of them — attempted,
* and doing nothing, which is the failure [ocsRootFor] exists to prevent.
*/
fun revokeAt(
httpClient: OkHttpClient,
ocsRoot: HttpUrl,
timeout: Duration = REVOCATION_TIMEOUT,
): Boolean = try {
val url = ocsRoot.newBuilder().addPathSegments(PATH).build()
Redirects.follow(
httpClient.newBuilder()
// Shares the pool and dispatcher, so this costs nothing.
.callTimeout(timeout.toJavaDuration())
.build(),
Request.Builder()
.url(url)
.delete()
// ⚠️ Not optional. Without this header Nextcloud answers the OCS
// API with a 401 and a CSRF complaint rather than doing the work,
// which reads exactly like a wrong password.
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build(),
).use { it.isSuccessful }
} catch (_: IOException) {
// callTimeout throws InterruptedIOException, which lands here.
false
} catch (_: IllegalArgumentException) {
false
}
/** What a best-effort courtesy call is worth waiting for. */
val REVOCATION_TIMEOUT: Duration = 5.seconds
}
@@ -0,0 +1,343 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.CalendarHomeSet
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import java.io.IOException
/**
* 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.
*/
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. Any escape hatch has 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.
*
* Owned by [CollectionClassifier], which is what reads them back — a second
* copy here would drift the day one of them is added.
*/
private val collectionProperties = CollectionClassifier.PROPERTIES
/** 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>,
/** Every `calendar-home-set` href, in the order the principal listed them. */
val homeSets: List<HttpUrl>,
/** 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, so the caller can say which one it
* could not reach. A cross-host home set under the same registrable
* domain — iCloud puts the principal on `caldav.icloud.com` and the home
* set on `pNN-caldav.icloud.com` — is already covered, because that is
* the scope `CalDavHttp` gives the credential.
*
* ⚠️ What is *not* covered is a home set on a genuinely different
* registrable domain, which RFC 4791 §6.2.1 allows. Nothing widens the
* allowlist for it and nothing should without the user's say-so: the
* password would be offered to a host named by the first server, and one
* credential scope is what makes that decidable. The add flow reports
* such a host by name instead of pretending the account is empty.
*/
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 cause: Cause, val detail: String) : Outcome
data class Failed(val cause: Cause, val detail: String) : Outcome
/**
* Why discovery ended, in a form the UI can translate.
*
* ⚠️ The UI must render *this*, never [Failed.detail]. A server's own
* words are untranslatable, frequently in a language the user does not
* read, and quite often a bare status line — "HTTP 405 Method Not
* Allowed" tells someone entering their address precisely nothing, and
* bypasses `strings.xml` entirely. [detail] exists for logs and bug
* reports, and is never shown.
*/
enum class Cause {
/** The address is not a URL, or names nothing we can look up. */
NOT_AN_ADDRESS,
/** Reached something, but it does not speak WebDAV at all. */
NOT_A_DAV_SERVER,
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
NO_CALENDAR_SUPPORT,
/** Nothing answered: DNS, connection refused, TLS, timeout. */
UNREACHABLE,
/** The address, or where it redirects, is plain HTTP. */
INSECURE,
/** Signed in, but the account exposes no calendar home. */
NO_CALENDARS,
/** The server answered, and the answer was an error of its own. */
SERVER_ERROR,
}
}
/**
* Classifies a transport or protocol failure for the UI.
*
* ⚠️ **405 is the interesting one.** It is what an ordinary web server
* answers to `PROPFIND`, which makes it the single most likely response to
* someone typing their *website* instead of their CalDAV address — and it
* means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method
* Not Allowed" hands the user a status code where they needed a sentence.
*/
private fun causeOf(error: Throwable): Outcome.Cause = when {
error is HttpException -> when (error.code) {
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
else -> Outcome.Cause.SERVER_ERROR
}
// Everything that never got an answer: DNS, refused, TLS, timeout.
error is IOException -> Outcome.Cause.UNREACHABLE
else -> Outcome.Cause.SERVER_ERROR
}
/**
* 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(
Outcome.Cause.INSECURE,
"\"$input\" is an unencrypted http:// address",
)
}
}
val candidates = ServiceDiscovery.candidatesFor(input, dns)
if (candidates.isEmpty()) {
return Outcome.Failed(Outcome.Cause.NOT_AN_ADDRESS, "no candidates for \"$input\"")
}
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "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(causeOf(error), 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(
Outcome.Cause.NO_CALENDAR_SUPPORT,
"advertises calendar-access but returned no principal",
)
} else {
Outcome.NotCalDav(
Outcome.Cause.NOT_A_DAV_SERVER,
"no DAV:current-user-principal, and no calendar-access in OPTIONS",
)
}
if (davCapabilities.isNotEmpty() && !davCapabilities.contains("calendar-access")) {
return Outcome.NotCalDav(
Outcome.Cause.NO_CALENDAR_SUPPORT,
"OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access",
)
}
val principal = resource.location.resolve(href)
?: return Outcome.Failed(
Outcome.Cause.SERVER_ERROR,
"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.
// ⚠️ Against where the PROPFIND *landed*, not where it was
// aimed. `followRedirects` rewrites `location` in place, and the
// probe above already reads it back for exactly this reason. A
// principal that has permanently moved answers a relative
// `<href>calendars/alice/</href>`, which resolved against the
// pre-redirect URL names a path on the old host — the home-set
// PROPFIND 404s and a perfectly good account reports that it
// holds no calendars.
response[CalendarHomeSet::class.java]?.hrefs?.forEach { raw ->
principalResource.location.resolve(raw)?.let(homeSets::add)
}
}
}
homeSetResult.exceptionOrNull()?.let { error ->
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
}
if (homeSets.isEmpty()) {
return Outcome.Failed(Outcome.Cause.NO_CALENDARS, "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 != principalResource.location.host }
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
if (insecure.isNotEmpty()) {
return Outcome.Failed(
Outcome.Cause.INSECURE,
"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(
Outcome.Cause.NO_CALENDARS,
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
)
}
}
return Outcome.Found(
principal = principal,
collections = collections.values.toList(),
homeSets = homeSets.distinct(),
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()
private const val NOT_FOUND = 404
private const val METHOD_NOT_ALLOWED = 405
private const val NOT_IMPLEMENTED = 501
}
}
@@ -0,0 +1,176 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okio.GzipSource
import okio.buffer
import java.util.concurrent.TimeUnit
/**
* The HTTP clients the CalDAV layer talks through.
*
* ⚠️ `followRedirects(false)` is mandatory, not a preference: `DavResource`
* requires it and asserts on it. Redirects are followed by hand so a
* HTTPS→HTTP downgrade can be refused and a permanent move can be reported to
* the caller — see `dav/PROVENANCE.md` change 3.
*/
object CalDavHttp {
/**
* One shared base client, so every derived client reuses its connection pool
* and dispatcher threads. Building a fresh `OkHttpClient` per probe gives
* each its own pool — every rung of the RFC 6764 ladder reopens TLS, and the
* abandoned clients' idle threads live until GC.
*/
private val shared: OkHttpClient by lazy {
OkHttpClient.Builder()
.followRedirects(false)
// A homelab server on the end of a slow link is normal; a hung socket
// is not. Bounded so a killed worker is the exception, not the rule.
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
// ⚠️ A whole-call ceiling, because the three above are per-attempt:
// a server trickling one byte every 119 seconds satisfies the read
// timeout for ever, and holds a sequential sync for the entire
// WorkManager window while every later collection is skipped. Wide
// enough for a large multiget on a slow homelab link, narrow enough
// that one stalled request cannot eat the run.
.callTimeout(3, TimeUnit.MINUTES)
.build()
}
/** Discovery before we have credentials, and the Nextcloud login flow. */
fun anonymous(userAgent: String): OkHttpClient = base(userAgent).build()
/**
* Authenticated against [origin]'s registrable domain.
*
* Uses the vendored [BasicDigestAuthHandler] rather than a hand-rolled
* interceptor, and it is worth saying why, because a preemptive-Basic
* interceptor is the obvious thing to write and this project wrote one first:
*
* - **It does Digest.** Baïkal defaults to `dav_auth_type = Digest` and OkHttp
* has no Digest support of its own (square/okhttp#205, open for years).
* Baïkal is squarely in the self-hosting audience.
* - **It already sends Basic preemptively over HTTPS**, and only over HTTPS,
* so the extra round trip on every request of a PROPFIND-heavy sync is
* avoided without a second implementation.
* - **It restricts by registrable domain**, which is what a cross-host home
* set needs: iCloud puts the principal on `caldav.icloud.com` and the home
* set on `pNN-caldav.icloud.com`, and an exact-host allowlist refuses the
* second one.
* - **It caches which scheme worked for the lifetime of the client**, so a
* sync that reuses one client pays the 401 challenge once. ⚠️ The cache
* lives on the handler, and this builds a fresh one per call — `discover`,
* `revokeAppPassword` and `sync` each get their own — so a Digest-only
* server pays one challenge per client, not one per process.
*/
fun authenticated(
userAgent: String,
username: String,
password: String,
origin: HttpUrl,
): OkHttpClient {
val handler = BasicDigestAuthHandler(
// ⚠️ The **registrable** domain, not the host, and it must be
// derived exactly as the handler derives it for each request — pass
// "cloud.example.com" where the handler computes "example.com" and
// the credential is withheld from every request. That is every
// self-hosted Nextcloud.
//
// Public-suffix list, not a last-two-labels split: the split scopes
// cloud.example.co.uk to co.uk and 192.168.1.10 to 1.10, offering
// the password preemptively to strangers. `topPrivateDomain()` is
// null for an IP literal or a single-label host, where the exact
// host is the only safe scope.
//
// What this still trusts: two hosts under one registrable domain
// have one owner. That is what a cross-host `calendar-home-set`
// needs — iCloud answers on caldav.icloud.com and serves from
// pNN-caldav.icloud.com — so a breached dav.example.com can still
// claim evil.example.com. Narrowing further costs iCloud.
domain = origin.topPrivateDomain() ?: origin.host,
username = username,
password = password,
// Never over cleartext, challenged or not.
insecureBasic = false,
)
return base(userAgent)
.authenticator(handler)
.addNetworkInterceptor(handler)
.build()
}
private fun base(userAgent: String) = shared.newBuilder()
.addInterceptor(calendarHeaders)
.addInterceptor { chain ->
chain.proceed(
chain.request().newBuilder().header("User-Agent", userAgent).build(),
)
}
/**
* The two headers that decide whether ETags are usable and whether our bytes
* survive the server.
*
* **`Accept-Encoding: identity`.** Setting it by hand disables OkHttp's
* transparent gzip, and that is the point: ⚠️ a compressing intermediary
* *weakens the ETag*. Any gzip-enabled nginx, Cloudflare or Traefik in front
* of an otherwise perfect server turns every strong validator into `W/"…"`,
* and a weak tag cannot be used with `If-Match` (RFC 9110 §13.1.1) — so
* conditional writes stop working for reasons that live in the user's reverse
* proxy rather than in their CalDAV server. Trading some bandwidth for a
* working conditional PUT is the right side of that deal.
*
* **`Prefer: handling=strict` on writes.** sabre-based servers (Baïkal,
* Nextcloud, ownCloud, SabreDAV itself) run vobject's `REPAIR` over anything
* uploaded unless this is set, and `Server::createFile` then deliberately
* withholds the ETag because the stored bytes are no longer ours. Strict
* handling keeps both. RFC 7240 §2 requires an unrecognised preference to be
* ignored, so sending it to every server costs nothing.
*/
private val calendarHeaders = Interceptor { chain ->
val request = chain.request()
val builder = request.newBuilder().header("Accept-Encoding", "identity")
if (request.method in WRITE_METHODS) {
// ⚠️ Appended, not replaced. A caller that set its own `Prefer` —
// `return=minimal`, a `depth-noroot` — would otherwise have it
// silently dropped on every write.
val existing = request.header("Prefer")?.takeIf { it.isNotBlank() }
builder.header("Prefer", listOfNotNull(existing, STRICT_HANDLING).joinToString(", "))
}
gunzip(chain.proceed(builder.build()))
}
/**
* Undoes a compression we asked not to have.
*
* Setting `Accept-Encoding` by hand takes OkHttp's transparent gunzip out of
* the loop — it only decompresses what it asked for itself. ⚠️ A proxy that
* gzips regardless (a misconfigured nginx, an ISP middlebox) then hands raw
* deflate bytes to the iCalendar parser, which reports it as an unreadable
* body and quarantines a perfectly good resource. Honouring the header the
* response actually carries costs nothing when nobody compressed anything.
*/
private fun gunzip(response: Response): Response {
if (!"gzip".equals(response.header("Content-Encoding"), ignoreCase = true)) return response
val body = response.body ?: return response
val source = GzipSource(body.source()).buffer()
return response.newBuilder()
.removeHeader("Content-Encoding")
// Dropped with it: the length describes the compressed bytes and is
// a lie about what the caller now reads.
.removeHeader("Content-Length")
.body(source.asResponseBody(body.contentType(), contentLength = -1))
.build()
}
private val WRITE_METHODS = setOf("PUT", "POST", "PATCH")
private const val STRICT_HANDLING = "handling=strict"
}
@@ -0,0 +1,116 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
/**
* Which CalDAV service or server software an account talks to.
*
* Read off the two things an account already stores, so nothing extra is asked
* of the server and no column has to be added: the **host**, which names a
* hosted service outright, and the **principal URL's path**, whose shape is a
* fingerprint of the software behind it.
*
* [hosted] is what decides how an account is *named* on screen. A hosted
* service's host is boilerplate (`caldav.fastmail.com` for everyone), so its
* brand is the name; self-hosted software runs on the user's own host, which is
* the only thing that tells two of them apart.
*/
enum class CalDavProvider(
val label: String,
val hosted: Boolean,
internal val domains: Set<String> = emptySet(),
internal val principalMarkers: Set<String> = emptySet(),
) {
/**
* ownCloud serves `/remote.php/dav/` too and cannot be told apart from here.
* Nextcloud is the far commoner of the two and the only one the add flow has
* a browser login for, so the mark goes to it — and because a self-hosted
* account is titled by its host, the name "Nextcloud" is never written next
* to an ownCloud server, only its mark.
*/
NEXTCLOUD("Nextcloud", hosted = false, principalMarkers = setOf("/remote.php/dav/")),
BAIKAL("Baïkal", hosted = false, principalMarkers = setOf("/dav.php/")),
DAVICAL("DAViCal", hosted = false, principalMarkers = setOf("/caldav.php/")),
SOGO("SOGo", hosted = false, principalMarkers = setOf("/sogo/dav/")),
FASTMAIL(
"Fastmail",
hosted = true,
domains = setOf("fastmail.com", "fastmail.fm", "messagingengine.com"),
),
ICLOUD("iCloud", hosted = true, domains = setOf("icloud.com", "me.com", "mac.com")),
GOOGLE("Google", hosted = true, domains = setOf("gmail.com", "googlemail.com", "google.com")),
MAILBOX_ORG("mailbox.org", hosted = true, domains = setOf("mailbox.org")),
POSTEO("Posteo", hosted = true, domains = setOf("posteo.de", "posteo.net")),
ZOHO("Zoho", hosted = true, domains = setOf("zoho.com", "zoho.eu")),
YANDEX("Yandex", hosted = true, domains = setOf("yandex.ru", "yandex.com")),
;
/**
* The domain to put in an address hint, for a service the user reaches by
* email address. Null for software they run themselves, whose host is theirs.
*/
val primaryDomain: String? get() = domains.firstOrNull()
companion object {
/**
* The services worth offering by name on a picker, in the order they
* should be offered.
*
* Everything identified by domain, plus Nextcloud — picked for its
* browser sign-in rather than for its host. The rest are self-hosted
* software recognised *after* discovery, from the principal path, so
* naming them here would only add rows that all ask the same question.
*
* A service that cannot be synced at all sorts to the **end**. It is
* still listed — people come looking for Google, and an absent row reads
* as the app being unfinished rather than as Google's own limitation —
* but a dead row belongs under the live ones, not in the middle of them.
* The sort is stable, so everything else keeps declaration order.
*/
val selectable: List<CalDavProvider>
get() = entries.filter { it.domains.isNotEmpty() || it == NEXTCLOUD }
.sortedBy { ServerQuirk.forProvider(it)?.isFatal == true }
/** The service a host belongs to, if it is one we know by name. */
fun forHost(host: String): CalDavProvider? {
val lower = host.lowercase().trimEnd('.')
return entries.firstOrNull { provider ->
provider.domains.any { lower == it || lower.endsWith(".$it") }
}
}
/** The service behind an email address or a typed URL, if any. */
fun forInput(input: String): CalDavProvider? {
val host = ServiceDiscovery.asBaseUrl(input)?.host
?: ServiceDiscovery.domainOf(input)
?: return null
return forHost(host)
}
/**
* The provider behind a principal URL: the host first, because a service
* we know by name is not in doubt, then the path shape.
*/
fun forPrincipal(url: HttpUrl): CalDavProvider? =
forHost(url.host) ?: forPath(url.encodedPath)
private fun forPath(path: String): CalDavProvider? {
val lower = path.lowercase()
return entries.firstOrNull { provider ->
provider.principalMarkers.any { it in lower }
}
}
}
}
@@ -0,0 +1,52 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
/** What one `sync-collection` REPORT came back with. */
sealed interface ChangeSet {
/**
* One page of changes.
*
* A page, not a result: RFC 6578 lets a server truncate and hand back a token
* that resumes where it stopped, so the caller applies this page's bodies,
* stores [token], and only then asks for the next.
*/
data class Page(
val changed: List<RemoteRef>,
val removed: List<HttpUrl>,
/**
* The token to store **after** this page is applied.
*
* ⚠️ Null when the server sent a 207 with no `DAV:sync-token` at all,
* which happens. That is not an error and not a reason to throw — the
* changes in this page are still real. It means the next run has no
* cursor and must reconcile in full.
*/
val token: String?,
/**
* The server stopped early and there is more behind [token].
*
* Signalled by a `507` on the collection's **own** href, which is
* truncation rather than failure — a 507 as the *outer* response status
* means something else entirely.
*/
val truncated: Boolean = false,
) : ChangeSet
/**
* The token is no longer usable and the collection must be reconciled in full.
*
* ⚠️ **Invalidation has no status code.** `403` (sabre), `400` (Google),
* `409` (Radicale) and `412` (Evolution) are all observed in the wild, so the
* only reliable signal is `DAV:valid-sync-token` in the body on **any** 4xx.
* Thunderbird matches on 400 alone and therefore never recovers from the 403
* that most of the self-hosted world emits.
*/
data object TokenInvalid : ChangeSet
/** The server will not do `sync-collection` here. Fall back, do not retry. */
data object Unsupported : ChangeSet
data class Failed(val reason: String) : ChangeSet
}
@@ -0,0 +1,493 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.DavCalendar
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.Error
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CalendarData
import at.bitfire.dav4jvm.property.GetCTag
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
/**
* One remote task collection, as a set of operations rather than a set of HTTP
* calls. Every method here returns an outcome; none of them throw for anything a
* server can legitimately answer.
*
* This class holds **no task-domain type and no Android type**, per the module
* boundary — it deals in `String` bodies of `text/calendar`. Deciding what those
* bodies mean is the engine's job, one module up.
*/
class CalendarCollection(
client: OkHttpClient,
override val url: HttpUrl,
/** Our WebDAV-Push subscription here, named in `Push-Dont-Notify` on writes. */
pushRegistration: HttpUrl? = null,
) : RemoteCalendar {
private val httpClient: OkHttpClient = pushRegistration?.let { registration ->
val header = WebDavPush.dontNotifyHeader(registration)
client.newBuilder()
.addInterceptor { chain ->
val request = chain.request()
chain.proceed(
if (request.method in NOTIFYING_METHODS) {
request.newBuilder().header("Push-Dont-Notify", header).build()
} else {
request
},
)
}
.build()
} ?: client
private val dav get() = DavCalendar(httpClient, url)
/** What a Depth-0 PROPFIND says about the collection right now. */
data class State(
val collection: TaskCollection,
val ctag: String?,
val syncToken: String?,
)
/**
* Re-reads the collection's own properties.
*
* Worth doing on every sync rather than trusting what account-add recorded:
* ⚠️ ACL churn is real — a calendar shared with the user can be revoked, or
* demoted from read-write to read-only, with no notification of any kind. A
* stale "writable" flag turns every upload into a 403 the user cannot act on,
* and a stale "not shared" flag disarms the guard that keeps us from writing
* a mutilated body back over the owner's task.
*/
override fun state(): Result<State> = runCatching {
var found: State? = null
var fromSelf = false
DavResource(httpClient, url).propfind(
0,
*CollectionClassifier.PROPERTIES,
GetCTag.NAME,
SyncToken.NAME,
) { response, relation ->
val isSelf = relation == Response.HrefRelation.SELF
// ⚠️ SELF wins outright and nothing overwrites it. A Depth-0 PROPFIND
// that also reports a member — which some servers send — would
// otherwise have that member classified as the collection's own
// state. The non-SELF branch exists only because a server whose href
// differs from ours by a trailing slash is classified OTHER, and
// refusing those outright breaks it against real deployments.
if (!isSelf && (fromSelf || found != null)) return@propfind
val collection = CollectionClassifier.classify(response) ?: return@propfind
found = State(
collection = collection,
ctag = response[GetCTag::class.java]?.cTag,
syncToken = response[SyncToken::class.java]?.token,
)
if (isSelf) fromSelf = true
}
found ?: throw IOException("$url is no longer a task collection")
}
/**
* Every VTODO resource in the collection, as href + ETag.
*
* ⚠️ **No time-range filter.** DAVx5 omits it for the same reason: some
* servers return nothing at all for a `VTODO` comp-filter that carries one,
* and a task without `DTSTART` or `DUE` is outside every range by
* construction — which is most tasks.
*
* ⚠️ **No `calendar-data` in this REPORT.** A `calendar-query` that asks for
* bodies downloads the whole collection on every listing; worse, the bodies
* would arrive without a matched ETag from the same response. Bodies come
* from [fetch] only.
*/
override fun list(): Result<List<RemoteRef>> = runCatching {
val refs = mutableListOf<RemoteRef>()
dav.calendarQuery(COMPONENT, null, null) { response, relation ->
if (relation != Response.HrefRelation.MEMBER) return@calendarQuery
if (!response.isSuccess()) return@calendarQuery
refs += RemoteRef(response.href, ETag.from(response[GetETag::class.java]))
}
refs
}
/**
* One page of changes since [token] (RFC 6578).
*
* ⚠️ **No `DAV:limit`.** Nextcloud regressed it to a localised HTML error
* page, so asking for a bounded page is how you get an unparseable response
* instead of a bounded one. Truncation is the server's decision, signalled
* back through [ChangeSet.Page.truncated].
*
* ⚠️ **A removal is a `404` at `DAV:response` level**, not inside a
* `propstat`. A client that only reads statuses out of propstats sees every
* deletion as an unremarkable response with no properties and silently keeps
* the row.
*/
override fun changes(token: String?): ChangeSet = try {
val changed = mutableListOf<RemoteRef>()
val removed = mutableListOf<HttpUrl>()
var truncated = false
val properties = DavCollection(httpClient, url).reportChanges(
syncToken = token,
infiniteDepth = false,
limit = null,
GetETag.NAME,
) { response, relation ->
val code = response.status?.code
when {
relation == Response.HrefRelation.SELF ->
// On our own href this is truncation, not a failure.
if (code == INSUFFICIENT_STORAGE) truncated = true
code == NOT_FOUND || code == GONE -> removed += response.href
response.isSuccess() ->
changed += RemoteRef(response.href, ETag.from(response[GetETag::class.java]))
// ⚠️ Neither a success nor a removal — a per-object ACL
// answering 403, a server failing on its own object with 5xx.
// Dropping it reports the resource as *unchanged*, so the row
// keeps whatever it holds until the next full reconciliation
// notices the ETag differs. Carried as changed with no
// validator instead: the multiget then produces a real verdict
// for it, and `fetch` already grades one — the same reasoning
// error, and the same fix, as its twin next door.
else -> changed += RemoteRef(response.href, eTag = null)
}
}
ChangeSet.Page(
changed = changed,
removed = removed,
token = properties.filterIsInstance<SyncToken>().firstOrNull()?.token,
truncated = truncated,
)
} catch (e: HttpException) {
when {
e.code / 100 == 4 && mentionsInvalidToken(e) -> ChangeSet.TokenInvalid
e.code in UNSUPPORTED -> ChangeSet.Unsupported
else -> ChangeSet.Failed("HTTP ${e.code}")
}
} catch (e: DavException) {
ChangeSet.Failed(e.toString())
} catch (e: IOException) {
ChangeSet.Failed(e.toString())
}
/**
* ⚠️ The body, not the status. Every observed invalidation status is also a
* legitimate answer to something else, and every server picks a different
* one — so the element is the signal and the code is noise.
*/
private fun mentionsInvalidToken(e: HttpException): Boolean =
Error.VALID_SYNC_TOKEN in e.errors ||
e.responseBody?.contains(VALID_SYNC_TOKEN_ELEMENT) == true
/**
* Downloads [hrefs] with `calendar-multiget`, in batches.
*
* ⚠️ Responses are **matched against the request**. Real servers reply with
* responses for URLs that were never asked for — a collection's own href, a
* sibling, occasionally something from another collection entirely — and a
* client that indexes the response by position or trusts it wholesale will
* apply one resource's body to another's row.
*/
override fun fetch(hrefs: List<HttpUrl>): Result<FetchResult> = fetch(hrefs, MULTIGET_BATCH)
/**
* What makes two hrefs the same resource.
*
* ⚠️ Not the raw `encodedPath`. The request href is *ours*, written into the
* REPORT body verbatim, and the response href is the server's own spelling
* of it — a server that answers `/my@dav.ics` for a requested `/my%40dav.ics`
* is the reason `UrlUtils.equals` exists at all. Compared raw, that resource
* lands in `unsolicited` *and* in `missing`, its intact body is dropped, and
* three such runs quarantine it out of the download for good, since nothing
* on this side can refund the count.
*
* `pathSegments` is decoded, so both spellings agree. Keeping it a list, not
* a joined string, stops `/a%2Fb` colliding with `/a/b`. The trailing slash
* still distinguishes, exactly as `UrlUtils.equals` refuses to normalise it.
*
* ⚠️ The origin is deliberately *not* part of this key, and is checked
* separately by [answersFor]. The two sides do not share one by
* construction: the REPORT body carries only our paths, so the reply's href
* resolves against the collection's *post-redirect* location, while our
* stored hrefs still carry the origin we had before the redirect. Keying on
* the origin would put every resource of a redirected collection into both
* `unsolicited` and `missing`.
*/
private fun identityOf(url: HttpUrl): List<String> = url.pathSegments
/**
* Whether a reply about [answered] can be the resource we asked for at
* [asked].
*
* Same host as the href we asked for, or as the collection we asked it of —
* the latter covering a redirect we followed. Anything else is a body from
* somewhere we never spoke to, and applying it would write one host's
* content into another host's row.
*/
private fun answersFor(answered: HttpUrl, asked: HttpUrl, collection: HttpUrl): Boolean =
answered.host.equals(asked.host, ignoreCase = true) ||
answered.host.equals(collection.host, ignoreCase = true)
/** [batchSize] is a seam for tests; production always uses [MULTIGET_BATCH]. */
internal fun fetch(hrefs: List<HttpUrl>, batchSize: Int): Result<FetchResult> {
val resources = mutableListOf<RemoteResource>()
val unsolicited = mutableListOf<HttpUrl>()
val failed = mutableListOf<FetchFailure>()
val seen = mutableSetOf<HttpUrl>()
// ⚠️ Per batch, not around the whole loop. A 500 on batch seven of ten
// used to discard the two hundred resources already parsed and answer
// `Result.failure`, so the caller recorded a collection failure and
// re-downloaded everything next run — on a flaky link, for ever. A batch
// that could not be asked is exactly what `missing` means, and the
// per-resource verdicts the engine already applies say the rest.
val batches = hrefs.chunked(batchSize)
for (batch in batches) {
val attempt = runCatching {
val byExactPath = batch.associateBy { it.encodedPath }
// ⚠️ Only identities that name exactly one request href. Two
// spellings of one path — `/my%40dav.ics` and `/my@dav.ics` —
// decode alike but are separate rows here, and silently keeping
// the last would report the other as missing and eventually
// quarantine it. Where we cannot tell them apart, the exact
// spelling is the only honest match.
val byPath = batch.groupBy { identityOf(it) }
.filterValues { it.size == 1 }
.mapValues { (_, matches) -> matches.single() }
val calendar = dav
calendar.multiget(batch, MIME_ICALENDAR, ICALENDAR_VERSION) { response, relation ->
if (relation == Response.HrefRelation.SELF) return@multiget
val candidate = byExactPath[response.href.encodedPath]
?: byPath[identityOf(response.href)]
val asked = candidate
?.takeIf { answersFor(response.href, it, calendar.location) }
if (asked == null) {
unsolicited += response.href
return@multiget
}
// Before the checks below, and deliberately: the server did
// mention this href, so whatever it said, the href is not
// one the server omitted.
seen += asked
if (!response.isSuccess()) {
failed += FetchFailure(asked, response.status?.code ?: 0)
return@multiget
}
val body = response[CalendarData::class.java]?.iCalendar
if (body == null) {
// Success at the response level, no body. The usual shape
// is a per-property refusal — a `propstat` carrying 403
// around `calendar-data` — and `Response.properties` drops
// non-2xx propstats silently, so the verdict has to be
// read back out of them or a deterministic refusal
// arrives looking like a transient blank.
val refusal = response.propstat.firstOrNull { !it.isSuccess() }
failed += FetchFailure(asked, refusal?.status?.code ?: 0)
return@multiget
}
resources += RemoteResource(
href = asked,
// The tag from *this* response, paired with *this* body.
eTag = ETag.from(response[GetETag::class.java]),
iCalendar = body,
)
}
}
// ⚠️ The first batch to fail ends the fetch, but keeps what came
// before it. Carrying on would re-ask a server that has just said it
// cannot answer, and the hrefs left unasked fall out as `missing` —
// which the engine grades as "listed but not returned" and counts,
// rather than acting on as a deletion. A whole run that fails on the
// first batch still answers `failure`, exactly as before.
if (attempt.isFailure) {
if (resources.isEmpty() && failed.isEmpty()) {
return Result.failure(attempt.exceptionOrNull()!!)
}
break
}
}
return Result.success(
FetchResult(
resources = resources,
missing = hrefs.filterNot { it in seen },
// A server that repeats a response element must not spend two
// thirds of the quarantine threshold in one run — nor count
// against a resource it also answered properly.
failed = failed
.filterNot { failure -> resources.any { it.href == failure.href } }
.distinctBy { it.href },
unsolicited = unsolicited,
),
)
}
/**
* Creates a resource at [name] with `If-None-Match: *`.
*
* The conditional is what makes creation safe to retry: without it a
* re-running worker overwrites whatever a previous run put there.
*/
override fun create(name: String, iCalendar: String): PutOutcome {
val href = url.newBuilder().addPathSegment(name).build()
return put(href, iCalendar, ifETag = null, ifNoneMatch = true)
}
/**
* Replaces [href], conditional on [eTag] when one is given.
*
* A null [eTag] means the server has never handed us a strong validator for
* this resource, and a conditional write is therefore impossible rather than
* merely inconvenient. The engine decides whether writing anyway is
* acceptable; this class only carries out the decision.
*/
override fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome =
put(href, iCalendar, ifETag = eTag, ifNoneMatch = false)
private fun put(
href: HttpUrl,
iCalendar: String,
ifETag: String?,
ifNoneMatch: Boolean,
): PutOutcome = try {
var outcome: PutOutcome = PutOutcome.StoredNeedsRefetch(href)
DavResource(httpClient, href).put(
body = iCalendar.toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8),
ifETag = ifETag,
ifNoneMatch = ifNoneMatch,
) { response ->
// The server may have stored the resource somewhere else entirely.
val location = response.header("Location")
?.let { href.resolve(it) }
?: href
val eTag = ETag.parse(response.header("ETag"))
outcome = if (eTag != null && eTag.usable) {
PutOutcome.Stored(location, eTag)
} else {
PutOutcome.StoredNeedsRefetch(location)
}
}
outcome
} catch (e: HttpException) {
classifyPutFailure(href, e, ifNoneMatch)
} catch (e: DavException) {
// Not an IOException: a refused HTTPS->HTTP redirect arrives here, and
// it is a configuration problem rather than a transient one.
PutOutcome.Rejected(0, e.toString())
} catch (e: IOException) {
PutOutcome.Failed(e.toString())
}
/**
* ⚠️ **412 means three different things** and merging any two of them is a
* data-loss bug.
*/
private fun classifyPutFailure(
href: HttpUrl,
e: HttpException,
wasCreate: Boolean,
): PutOutcome = when {
e.code != PRECONDITION_FAILED -> PutOutcome.Rejected(e.code, e.message.orEmpty())
// On create the precondition was `If-None-Match: *`, so 412 says only
// that the name is occupied. It says nothing about by what — the engine
// re-fetches and adopts the resource if the UID matches.
wasCreate -> PutOutcome.NameTaken(href)
// On update the precondition was `If-Match`, and a resource that is gone
// fails it just as one that changed does. RFC 9110 §13.1.1 requires 412
// rather than 404 once the precondition is evaluated, so the two are
// indistinguishable without asking again.
!exists(href) -> PutOutcome.Vanished
else -> PutOutcome.ServerNewer
}
/**
* Deletes [href], conditional on [eTag] when we hold a usable one.
*
* ⚠️ 404 and 410 are **success**. The purpose of a DELETE is for the
* resource to be absent, and it is; treating "already gone" as a failure
* leaves a tombstone that is retried on every sync forever.
*/
override fun delete(href: HttpUrl, eTag: String?): DeleteOutcome = try {
DavResource(httpClient, href).delete(ifETag = eTag) { }
DeleteOutcome.Deleted
} catch (e: HttpException) {
when (e.code) {
NOT_FOUND, GONE -> DeleteOutcome.Deleted
PRECONDITION_FAILED -> if (exists(href)) {
DeleteOutcome.ServerNewer
} else {
DeleteOutcome.Deleted
}
else -> DeleteOutcome.Rejected(e.code, e.message.orEmpty())
}
} catch (e: DavException) {
DeleteOutcome.Rejected(0, e.toString())
} catch (e: IOException) {
DeleteOutcome.Failed(e.toString())
}
/** A HEAD, used only to tell "changed" from "gone" apart after a 412. */
private fun exists(href: HttpUrl): Boolean = try {
DavResource(httpClient, href).head { }
true
} catch (e: HttpException) {
e.code != NOT_FOUND && e.code != GONE
} catch (_: DavException) {
true
} catch (_: IOException) {
// Unknown. Treat as still present: discarding a local edit is
// irreversible, and giving up on this resource until the next run is not.
true
}
companion object {
const val COMPONENT = "VTODO"
/**
* Resources per `calendar-multiget`.
*
* A whole collection in one REPORT is a request body and a response that
* a modest server will refuse outright, and a failure mid-way costs the
* entire batch. DAVx5 settled on the same order of magnitude.
*/
const val MULTIGET_BATCH = 30
private const val MIME_ICALENDAR = "text/calendar"
private const val ICALENDAR_VERSION = "2.0"
private val NOTIFYING_METHODS = setOf("PUT", "DELETE")
private const val NOT_FOUND = 404
private const val GONE = 410
private const val PRECONDITION_FAILED = 412
private const val INSUFFICIENT_STORAGE = 507
/** Matched as a local name, so a server's own namespace prefix is irrelevant. */
private const val VALID_SYNC_TOKEN_ELEMENT = "valid-sync-token"
/**
* The server does not implement the report. Deliberately narrow: 400,
* 403, 409 and 412 are all *invalidation* codes on some server, so
* treating them as "unsupported" would abandon `sync-collection` on a
* collection that merely needs a fresh token.
*/
private val UNSUPPORTED = setOf(405, 415, 501)
}
}
@@ -0,0 +1,158 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.property.GetETag
import okhttp3.HttpUrl
/**
* An entity tag, plus the one bit of it that changes behaviour.
*
* ⚠️ A weak tag is not a slightly worse strong tag — it is unusable for what we
* need one for. RFC 9110 §13.1.1: *"A weak entity-tag cannot be used with
* If-Match."* So a weak tag must never be persisted as a validator; the resource
* is re-fetched instead.
*
* Weak tags are not exotic. On sabre they are the **default** path unless
* `Prefer: handling=strict` is sent, and any gzip-compressing nginx, Cloudflare
* or Traefik in front of the server weakens them regardless of the server
* software — which is why the calendar client asks for `Accept-Encoding:
* identity`.
*/
data class ETag(val value: String, val weak: Boolean) {
/** Whether this tag may be persisted and later sent as `If-Match`. */
val usable: Boolean get() = !weak && value.isNotBlank()
override fun toString() = if (weak) "W/\"$value\"" else "\"$value\""
companion object {
/**
* The tag from a parsed `DAV:getetag` property.
*
* ⚠️ Not [parse]. `GetETag` has *already* stripped the `W/` marker and
* recorded it separately, so feeding its [GetETag.eTag] back through a
* parser reports every weak tag as strong — which is precisely the
* failure the weak flag exists to prevent.
*/
fun from(property: GetETag?): ETag? {
val value = property?.eTag?.takeIf { it.isNotBlank() } ?: return null
return ETag(value, property.weak == true)
}
/** Parses a raw `ETag` **header**; null when absent. */
fun parse(raw: String?): ETag? {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val parsed = GetETag(trimmed)
val value = parsed.eTag?.takeIf { it.isNotBlank() } ?: return null
return ETag(value, parsed.weak == true)
}
}
}
/** A resource as a listing reports it: an href and an ETag, with no body. */
data class RemoteRef(val href: HttpUrl, val eTag: ETag?)
/**
* A resource together with the body it was served with.
*
* ⚠️ [eTag] is the tag that arrived **in the same response as [iCalendar]**, and
* only such a pairing is ever stored. A tag taken from a listing and a body
* taken from a later fetch are not a matched pair, and treating them as one is
* how the Nextcloud shared-calendar landmine detonates: `CalendarObject::get()`
* hands back a body it has stripped while leaving the ETag alone, so an
* unmatched pair lets us `If-Match` a mutilated body over the real one.
*/
data class RemoteResource(val href: HttpUrl, val eTag: ETag?, val iCalendar: String)
/**
* A resource the server answered for, but did not hand over.
*
* @param code the response-level status, or `0` when the server reported
* success and supplied no `calendar-data` — the same "no HTTP judgement to
* report" convention [PutOutcome.Rejected] uses.
*/
data class FetchFailure(val href: HttpUrl, val code: Int)
/** What a multiget actually returned, and what it did not. */
data class FetchResult(
val resources: List<RemoteResource>,
/** Asked for, not answered — the server simply omitted them. */
val missing: List<HttpUrl>,
/**
* Asked for, answered, and refused or empty.
*
* Separate from [missing] because "the server said 403" and "the server said
* nothing" are different facts with different right answers — merging them
* is the same mistake as merging the three meanings of a 412.
*/
val failed: List<FetchFailure>,
/**
* Answered without being asked for.
*
* Real servers do this, so responses are matched against the request rather
* than trusted, and the strays are counted rather than silently applied.
*/
val unsolicited: List<HttpUrl>,
)
/** The outcome of a PUT. Every branch here is a different bug if merged with another. */
sealed interface PutOutcome {
/** Stored, with a strong ETag we can use as the next `If-Match`. */
data class Stored(val href: HttpUrl, val eTag: ETag) : PutOutcome
/**
* Stored, but the ETag was absent or weak.
*
* Not an error and not a conflict: the resource is on the server. It has to
* be re-fetched for a usable validator *and* for the server's canonical
* body, which may not be the bytes we sent.
*/
data class StoredNeedsRefetch(val href: HttpUrl) : PutOutcome
/** 412 against `If-None-Match: *` — something already lives at this name. */
data class NameTaken(val href: HttpUrl) : PutOutcome
/** 412 against `If-Match`, and the resource is still there: the server is newer. */
data object ServerNewer : PutOutcome
/**
* 412 against `If-Match`, and the resource is gone.
*
* Spec-correct and not a conflict at all — a delete on the server racing an
* edit here. RFC 9110 requires 412 rather than 404 when the precondition is
* evaluated, so only a follow-up `HEAD` can tell the two apart.
*/
data object Vanished : PutOutcome
/**
* The server refused this body, and sending it again will not help.
*
* Covers 4xx and 5xx alike: `507` MUST NOT be auto-retried (RFC 4918 §11.5)
* and a contradictory `RRULE`/`EXDATE` pair returns 500 from Nextcloud
* forever, so neither class earns a retry loop.
*/
data class Rejected(val code: Int, val message: String) : PutOutcome
/** The request never got an answer. Transport, not judgement. */
data class Failed(val reason: String) : PutOutcome
}
/** The outcome of a DELETE. */
sealed interface DeleteOutcome {
/**
* Gone from the server.
*
* 404 and 410 count as success: the goal was for the resource not to exist,
* and it does not.
*/
data object Deleted : DeleteOutcome
/** 412: the server's copy changed after we last saw it. */
data object ServerNewer : DeleteOutcome
data class Rejected(val code: Int, val message: String) : DeleteOutcome
data class Failed(val reason: String) : DeleteOutcome
}
@@ -0,0 +1,323 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
/**
* What a server will let us do to the collections themselves.
*
* ⚠️ Asked, never assumed, because the alternative is an affordance that
* 405s: **iCloud is
* extended-MKCOL only** and answers 405 to MKCALENDAR, **Google has neither**
* (and no VTODO either), and **Posteo disables collection creation outright**.
* Offering "new list on this account" to any of them produces a failure the user
* can do nothing about, at the end of a form they have already filled in.
*/
data class CollectionSupport(
/** RFC 4791 §5.3.1. The straightforward route, and what Nextcloud wants. */
val mkCalendar: Boolean,
/** RFC 5689: MKCOL carrying a body that sets `resourcetype`. iCloud's route. */
val extendedMkCol: Boolean,
) {
/** Whether a "new list here" affordance should exist at all. */
val canCreate: Boolean get() = mkCalendar || extendedMkCol
companion object {
/** What an OPTIONS we could not read has to mean: offer nothing. */
val NONE = CollectionSupport(mkCalendar = false, extendedMkCol = false)
}
}
/** How a collection write ended. */
sealed interface CollectionOutcome {
data class Created(val url: HttpUrl) : CollectionOutcome
data object Updated : CollectionOutcome
/**
* The server understood and refused. Terminal: retrying writes nothing and
* the user has to be told.
*
* [code] is for logs and for deciding; the caller owns the wording, the same
* rule [CalDavDiscovery.Outcome.Cause] states.
*/
data class Refused(val code: Int, val reason: String) : CollectionOutcome
/** The server never answered. Worth retrying, unlike [Refused]. */
data class Failed(val reason: String) : CollectionOutcome
/** Neither creation method exists here. Nothing to retry and nothing to fix. */
data object Unsupported : CollectionOutcome
}
/**
* Creating, renaming, recolouring and deleting task collections.
*
* A seam, like [RemoteCalendar]: deciding *what* to do to a collection — refuse
* a read-only one, pick a name that will not collide, roll a local row back when
* the server says no — is the app's business and should not need a server to
* exercise. [DavCollectionAdmin] is the only production implementation.
*/
interface CollectionAdmin {
/** What [homeSet] supports, as an OPTIONS rather than a guess. */
fun support(homeSet: HttpUrl): CollectionSupport
/**
* Makes a task collection under [homeSet].
*
* @param name the path segment to create it at. The caller owns collision
* handling, because only it knows whether a second attempt is wanted.
*/
fun create(
homeSet: HttpUrl,
name: String,
displayName: String,
color: Int?,
support: CollectionSupport,
): CollectionOutcome
/** PROPPATCH: a rename, a recolour, or both. */
fun updateProperties(url: HttpUrl, displayName: String?, color: Int?): CollectionOutcome
/** DELETE. The tasks go with it, on the server and everywhere else. */
fun delete(url: HttpUrl): CollectionOutcome
}
class DavCollectionAdmin(
private val httpClient: OkHttpClient,
) : CollectionAdmin {
/**
* ⚠️ Both headers, and neither on its own is enough.
*
* `Allow` names the methods, which is where MKCALENDAR shows up; `DAV` names
* the compliance classes, which is the only place `extended-mkcol` is ever
* advertised (RFC 5689 §5.1). A server may support extended MKCOL while
* listing plain MKCOL in `Allow` — that is the normal shape — so reading
* `Allow` alone reports iCloud as unable to create anything.
*/
override fun support(homeSet: HttpUrl): CollectionSupport = try {
// No compression: some servers have broken compression for OPTIONS,
// which is why DavResource disables it for the same request.
val request = Request.Builder()
.url(homeSet)
.method("OPTIONS", null)
.header("Content-Length", "0")
.header("Accept-Encoding", "identity")
.build()
Redirects.follow(httpClient, request).use { response ->
if (!response.isSuccessful) return CollectionSupport.NONE
val allow = headerTokens(response.headers("Allow"))
val dav = headerTokens(response.headers("DAV"))
CollectionSupport(
mkCalendar = "MKCALENDAR" in allow,
extendedMkCol = "EXTENDED-MKCOL" in dav,
)
}
} catch (_: IOException) {
// Unknown reads as "no", which costs the user an affordance they can
// reach again on the next attempt — the other way round costs them a
// 405 at the end of a form.
CollectionSupport.NONE
}
/**
* ⚠️ MKCALENDAR first where both exist. It is the method written for this
* job, every server that has it treats it as authoritative, and extended
* MKCOL's `resourcetype` set is refused outright by some servers that
* nonetheless advertise the class.
*/
override fun create(
homeSet: HttpUrl,
name: String,
displayName: String,
color: Int?,
support: CollectionSupport,
): CollectionOutcome {
if (!support.canCreate) return CollectionOutcome.Unsupported
// A trailing slash, always: a collection URL that lacks one is resolved
// against its *parent* by every later `resolve`, so the first resource
// written into it lands one level up.
val url = homeSet.newBuilder().addPathSegment(name).addPathSegment("").build()
val (method, body) = if (support.mkCalendar) {
"MKCALENDAR" to mkCalendarBody(displayName, color)
} else {
"MKCOL" to extendedMkColBody(displayName, color)
}
return send(method, url, body) { CollectionOutcome.Created(url) }
}
override fun updateProperties(
url: HttpUrl,
displayName: String?,
color: Int?,
): CollectionOutcome {
if (displayName == null && color == null) return CollectionOutcome.Updated
return send("PROPPATCH", url, propPatchBody(displayName, color)) {
// ⚠️ The 207 is not read for per-property statuses, deliberately. A
// server that stores the name and refuses the colour answers 207
// with a 403 propstat around `calendar-color` — and there is nothing
// for the user to do about a colour their server will not keep,
// while failing the whole rename over it would be worse. The colour
// is ours locally either way.
CollectionOutcome.Updated
}
}
override fun delete(url: HttpUrl): CollectionOutcome {
val request = Request.Builder().url(url).delete().build()
return execute(request) { response ->
// ⚠️ 404 and 410 are success, exactly as they are for a resource:
// the point of a DELETE is for the thing to be absent, and it is.
when {
response.isSuccessful || response.code == NOT_FOUND || response.code == GONE ->
CollectionOutcome.Updated
else -> CollectionOutcome.Refused(response.code, response.message)
}
}
}
private fun send(
method: String,
url: HttpUrl,
body: String,
onSuccess: () -> CollectionOutcome,
): CollectionOutcome {
val request = Request.Builder()
.url(url)
.method(method, body.toRequestBody(MIME_XML))
.build()
return execute(request) { response ->
if (response.isSuccessful) onSuccess() else {
CollectionOutcome.Refused(response.code, response.message)
}
}
}
private fun execute(
request: Request,
grade: (okhttp3.Response) -> CollectionOutcome,
): CollectionOutcome = try {
Redirects.follow(httpClient, request).use(grade)
} catch (e: IOException) {
CollectionOutcome.Failed(e.message ?: e.toString())
}
/**
* ⚠️ `VTODO` and nothing else.
*
* The component set is the one property here that changes what the
* collection *is*: a calendar created without it is a
* `supported-calendar-component-set` of the server's choosing, which on
* several is events-only — and a task list that refuses tasks is the worst
* possible outcome of "new list".
*/
private fun mkCalendarBody(displayName: String, color: Int?): String = xml { serializer ->
serializer.insertTag(MKCALENDAR) {
insertTag(SET) {
insertTag(PROP) {
insertTag(DISPLAYNAME) { text(displayName) }
insertTag(COMPONENT_SET) {
insertTag(COMP) { attribute(null, "name", COMPONENT) }
}
color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } }
}
}
}
}
/**
* RFC 5689, where the resource type is set by the request rather than by the
* method — which is the whole difference, and the reason iCloud needs this
* one.
*/
private fun extendedMkColBody(displayName: String, color: Int?): String = xml { serializer ->
serializer.insertTag(MKCOL) {
insertTag(SET) {
insertTag(PROP) {
insertTag(RESOURCETYPE) {
insertTag(COLLECTION)
insertTag(CALENDAR)
}
insertTag(DISPLAYNAME) { text(displayName) }
insertTag(COMPONENT_SET) {
insertTag(COMP) { attribute(null, "name", COMPONENT) }
}
color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } }
}
}
}
}
private fun propPatchBody(displayName: String?, color: Int?): String = xml { serializer ->
serializer.insertTag(PROPERTYUPDATE) {
insertTag(SET) {
insertTag(PROP) {
displayName?.let { insertTag(DISPLAYNAME) { text(it) } }
color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } }
}
}
}
}
private fun xml(build: (org.xmlpull.v1.XmlSerializer) -> Unit): String {
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.setPrefix("d", XmlUtils.NS_WEBDAV)
serializer.setPrefix("c", XmlUtils.NS_CALDAV)
serializer.setPrefix("i", XmlUtils.NS_APPLE_ICAL)
serializer.startDocument("UTF-8", null)
build(serializer)
serializer.endDocument()
return writer.toString()
}
/**
* ⚠️ `#RRGGBBAA`, which is what Apple's `calendar-color` is and what
* [at.bitfire.dav4jvm.property.CalendarColor] parses back. Writing the
* packed ARGB `Int` verbatim yields "-65536" for red, which is not a colour
* in any spelling.
*/
private fun hexOf(argb: Int): String = "#%06X%02X".format(argb and 0xFFFFFF, argb ushr 24)
/**
* A comma-separated header, split and normalised.
*
* Repeated headers *and* comma lists, because servers use both spellings for
* `DAV` and for `Allow`.
*/
private fun headerTokens(values: List<String>): Set<String> = values
.flatMap { it.split(',') }
.map { it.trim().uppercase() }
.filter { it.isNotEmpty() }
.toSet()
private companion object {
const val COMPONENT = "VTODO"
const val NOT_FOUND = 404
const val GONE = 410
val MIME_XML = "application/xml; charset=utf-8".toMediaType()
val MKCALENDAR = Property.Name(XmlUtils.NS_CALDAV, "mkcalendar")
val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar")
val COMPONENT_SET = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp")
val MKCOL = Property.Name(XmlUtils.NS_WEBDAV, "mkcol")
val SET = Property.Name(XmlUtils.NS_WEBDAV, "set")
val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop")
val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate")
val DISPLAYNAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname")
val RESOURCETYPE = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype")
val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection")
val CALENDAR_COLOR = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color")
}
}
@@ -0,0 +1,145 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.CalendarColor
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 at.bitfire.dav4jvm.property.push.PushTransports
import at.bitfire.dav4jvm.property.push.Topic
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?,
/** WebDAV-Push over Web Push, when the server offers it for this collection. */
val push: PushSupport? = null,
)
/**
* Decides which collections in a Depth-1 listing can hold tasks.
*
* Both of the rules here are **inversions of the obvious one**, and they are
* 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 {
/**
* The properties [classify] reads, requested **by name**.
*
* `allprop` legitimately omits every one of them (RFC 4791 §5.2.3 for the
* component set, RFC 3744 §3.7 for the privileges), so asking for them by
* name is not an optimisation — it is the only way to get them.
*/
val PROPERTIES = arrayOf(
ResourceType.NAME,
DisplayName.NAME,
CalendarColor.NAME,
SupportedCalendarComponentSet.NAME,
SupportedReportSet.NAME,
CurrentUserPrivilegeSet.NAME,
MaxICalendarSize.NAME,
PushTransports.NAME,
Topic.NAME,
)
/** `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[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[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[MaxICalendarSize::class.java]?.maxSize,
push = pushSupport(response),
)
}
/**
* Push needs both halves: a Web Push transport to subscribe with and a topic
* to recognise the messages by. Either alone is useless, so either alone is
* no support at all.
*/
internal fun pushSupport(response: Response): PushSupport? {
val webPush = response[PushTransports::class.java]?.webPush ?: return null
val topic = response[Topic::class.java]?.topic ?: return null
return PushSupport(topic = topic, vapidPublicKey = webPush.vapidPublicKey)
}
/**
* ⚠️ **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,363 @@
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 an 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,
/**
* The server named an origin outside the one we polled, and we used
* the one we polled instead. Reported so the user can be told which
* setting is wrong, never acted on.
*/
val hostMismatch: HostMismatch? = null,
) : 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 cause: Cause, val reason: String) : PollResult
/**
* Why the flow ended, in a form the UI can translate.
*
* ⚠️ The UI must render *this*, never [Failed.reason]. A server's own
* words are untranslatable, often in a language the user does not read,
* and here they are frequently a bare status line or a JSON parser's
* complaint. [reason] exists for logs, and is never shown — the same rule
* [CalDavDiscovery.Outcome.Cause] states for discovery.
*/
enum class Cause {
/** Answering, but turning away repeated attempts (429). */
RATE_LIMITED,
/** Down on purpose (503). */
MAINTENANCE,
/** The server's own error, or an answer we could not read. */
SERVER_ERROR,
}
}
/**
* @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(FLOW_START_PATH).build())
.header("User-Agent", userAgent)
// OCS-APIRequest is *not* needed: v2 is a Frontpage route, not OCS.
.post(FormBody.Builder().build())
.build()
Redirects.follow(httpClient, request).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 {
Redirects.follow(httpClient, request).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(
PollResult.Cause.SERVER_ERROR,
"server answered 200 with $contentType, not JSON",
)
} else {
parseCredentials(flow, response.body?.string().orEmpty())
}
}
response.code == 429 ->
PollResult.Failed(
PollResult.Cause.RATE_LIMITED,
"the server is rate-limiting this address (429)",
)
response.code == 503 ->
PollResult.Failed(
PollResult.Cause.MAINTENANCE,
"the server is in maintenance mode (503)",
)
else -> PollResult.Failed(
PollResult.Cause.SERVER_ERROR,
"unexpected HTTP ${response.code}",
)
}
}
}.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, 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")
// ⚠️ Coerced, never refused — neither the host nor the scheme may discard
// the credentials. The 200 is returned exactly once (the server deletes
// the row inside poll() before answering), so a throw here burns a live
// app password and leaves it dangling in the user's device list.
val secureServer = reachableOrigin(flow.pollEndpoint, server)
PollResult.Approved(
hostMismatch = substitutedMismatch(flow.pollEndpoint, server, secureServer),
credentials = Credentials(
server = secureServer,
// ⚠️ 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(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) }
/**
* These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` /
* `trusted_proxies`, which are misconfigured on a large fraction of
* self-hosted installs — so they are validated rather than trusted verbatim.
*
* A downgrade to `http` is **fatal here and only here**: this runs *before*
* the user has approved anything, and the login URL is where they type their
* account password. There is nothing to lose by refusing, and everything to
* lose by not.
*/
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")
}
}
/**
* The same downgrade, on the *poll response*, where refusing is the wrong
* answer.
*
* ⚠️ By this point the credential has already been issued, and Nextcloud
* returns it **exactly once** — the row is deleted inside `poll()` before it
* answers. Throwing here does not protect anything: it destroys a live app
* password, leaves one dangling in the user's device list, and sends them
* through the whole flow again. The comment on the host mismatch above says
* precisely this, and the scheme deserves the same treatment.
*
* Coercing is strictly safer than either alternative, because the invariant
* that matters is *what we do next*: we only ever talk to the coerced URL, so
* the credential never travels in cleartext regardless of what the server
* put in the JSON.
*/
internal fun secureOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl =
if (expected.isHttps && !actual.isHttps) {
// ⚠️ The port goes with the scheme. OkHttp only drops a *default*
// port across a scheme change, so `http://cloud.example.com:8080/`
// coerced to https keeps :8080 — a port that almost certainly speaks
// cleartext, and the promised coercion becomes a TLS handshake
// failure. The port that answered our poll is the one known to work,
// and a claimed port emitted alongside a wrong scheme comes from the
// same misconfiguration as the scheme did.
actual.newBuilder().scheme("https").port(expected.port).build()
} else {
actual
}
/**
* The origin to actually talk to, given what the poll response claimed.
*
* ⚠️ `server` and the poll endpoint come out of *different* generators in
* Nextcloud: the poll endpoint honours `overwrite.cli.url`, while `server`
* is built from the approving request's own protocol and Host header, which
* respect `X-Forwarded-*` only once `trusted_proxies` is set. The ordinary
* docker-compose-behind-nginx install therefore answers a perfectly good
* `https://cloud.example.com` poll endpoint with
* `"server": "http://nextcloud:11000"` — a name that does not resolve on the
* phone. Following it means discovery fails, the account is never created,
* and the app password is already spent.
*
* So when the claimed origin is outside the registrable domain we just
* successfully polled, rebuild it entirely from the poll endpoint — scheme,
* host, port *and* base path — which is the one URL empirically known to
* answer. The claimed path is dropped with the claimed host: both come from
* the same generator, and keeping half of a URL we have decided not to trust
* is how a subdirectory install loses its prefix. The endpoint's own prefix
* is whatever precedes `index.php/login/v2/poll`, which is where the flow
* was opened. Coerced, never refused: by this point the credential exists
* and the 200 is spent, so throwing burns a live app password.
*
* This compares one server-emitted origin against another, never against
* what the user typed, so a correctly configured proxy — which emits the
* same origin in both — is untouched. A sibling host under the same
* registrable domain passes through, because that is a real deployment shape
* and the credential is scoped to that domain anyway.
*/
internal fun reachableOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl {
val secure = secureOrigin(expected, actual)
// The same boundary the credential is scoped by; null for an IP literal
// or a single-label host, where only the exact host will do.
val claimed = secure.topPrivateDomain() ?: secure.host
val polled = expected.topPrivateDomain() ?: expected.host
if (claimed.equals(polled, ignoreCase = true)) return secure
return expected.newBuilder()
.encodedPath(baseOf(expected))
.query(null)
.fragment(null)
.build()
}
private companion object {
const val FLOW_START_PATH = "index.php/login/v2"
/** What `start()` appends, plus the poll leg — with and without the front controller. */
val FLOW_TAILS = listOf("index.php/login/v2/poll", "login/v2/poll")
}
/**
* The poll endpoint with the flow's own path removed — the server's web root.
*
* ⚠️ Both spellings. Nextcloud drops `index.php` from generated routes when
* `htaccess.IgnoreFrontController` is on, so a subdirectory install can answer
* `/nc/login/v2/poll` — and matching only the `index.php` form would return
* "/" and lose the `/nc` prefix, which is the exact loss this function exists
* to prevent.
*/
private fun baseOf(pollEndpoint: HttpUrl): String {
val path = pollEndpoint.encodedPath
val tail = FLOW_TAILS.map { path.indexOf(it) }.firstOrNull { it >= 0 } ?: return "/"
return path.take(tail).ifEmpty { "/" }
}
/**
* The mismatch to report, which is only ever one we acted on.
*
* ⚠️ [hostMismatchOf] compares hosts exactly, while [reachableOrigin]
* substitutes only when the *registrable domain* differs. So a server
* answering `nc.example.com` for a poll endpoint on `cloud.example.com` is
* used verbatim and correctly — and reporting that would tell the user we
* replaced an address we did not, blaming a setting that is right. The note
* is sticky, so it would follow them through the rest of the flow.
*/
internal fun substitutedMismatch(
expected: HttpUrl,
claimed: HttpUrl,
used: HttpUrl,
): HostMismatch? =
hostMismatchOf(expected, claimed)?.takeIf { used.host != claimed.host }
/** 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,67 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
/**
* Redirect following for the plain-HTTP corners of this module.
*
* ⚠️ Every client here is built with `followRedirects(false)`, because
* `DavResource` requires it and asserts on it — so the DAV calls follow by hand
* and the two OCS/JSON ones did not follow at all. A Nextcloud that
* canonicalises host or path with a 301 (apex→www, a trailing slash, a proxy)
* therefore turned the login flow's `start` into a bare failure, its `poll` into
* an unexplained SERVER_ERROR *after* the password was minted, and the app
* password revocation into the "attempted, and did nothing" outcome its own
* KDoc exists to prevent.
*
* ⚠️ The method and body are **re-issued as they were**, which is 307/308
* behaviour rather than the classic 301/302 POST→GET rewrite. Both endpoints
* answer 405 to a GET, so rewriting the method would turn one silent failure
* into another; and the bodies here are in-memory forms, so replaying costs
* nothing.
*/
internal object Redirects {
/** The same ceiling `DavResource` uses, for the same reason. */
const val MAX_HOPS = 5
@Throws(IOException::class)
fun follow(client: OkHttpClient, request: Request): Response {
var current = request
repeat(MAX_HOPS) {
val response = client.newCall(current).execute()
if (!response.isRedirect) return response
val target = response.header("Location")?.let { current.url.resolve(it) }
response.close()
if (target == null) throw IOException("redirect with no usable Location from ${current.url}")
current = current.newBuilder().url(secure(current.url, target)).build()
}
throw IOException("more than $MAX_HOPS redirects from ${request.url}")
}
/**
* The downgrade rule `DavResource` already applies, stated once more here.
*
* A downgrade to the *same host* is the single most common misconfiguration
* in this space — a Nextcloud behind a TLS-terminating proxy with no
* `overwriteprotocol` builds every redirect with `http://` — and re-issuing
* over TLS is strictly safer than what we were asked to do. A downgrade to a
* different host has no innocent reading.
*
* ⚠️ The port goes with the scheme: OkHttp only drops a *default* port
* across a scheme change, so a redirect to `http://host:8080` would
* otherwise be re-issued as `https://host:8080`, which almost certainly
* speaks cleartext. The port we were already talking to is the one known to
* answer.
*/
private fun secure(from: HttpUrl, to: HttpUrl): HttpUrl = when {
!from.isHttps || to.isHttps -> to
to.host.equals(from.host, ignoreCase = true) ->
to.newBuilder().scheme("https").port(from.port).build()
else -> throw IOException("refusing a redirect from HTTPS to HTTP at ${to.host}")
}
}
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
/**
* The remote side of one task collection, as the sync engine needs it.
*
* A seam, and a load-bearing one: the reconciliation above this interface is
* where local edits get discarded, tombstones get swept and conflicts get
* resolved, and none of that should need a server — or a network — to exercise.
* [CalendarCollection] is the only production implementation.
*/
interface RemoteCalendar {
val url: HttpUrl
fun state(): Result<CalendarCollection.State>
fun list(): Result<List<RemoteRef>>
/**
* One page of changes since [token], or null for a first run.
*
* Never throws for anything a server can legitimately answer — a rejected
* token is [ChangeSet.TokenInvalid], not an exception.
*/
fun changes(token: String?): ChangeSet
fun fetch(hrefs: List<HttpUrl>): Result<FetchResult>
fun create(name: String, iCalendar: String): PutOutcome
/**
* Replaces [href]. A null [eTag] writes **unconditionally**, which is only
* ever correct when the server offers no usable validator at all — see
* [ETag].
*/
fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome
fun delete(href: HttpUrl, eTag: String?): DeleteOutcome
}
@@ -0,0 +1,85 @@
package de.jeanlucmakiola.caldav
import java.util.UUID
/**
* Filenames for calendar resources.
*
* ⚠️ **An href is not a UID.** A UID is opaque text chosen by whoever created
* the task — it may contain slashes, spaces, percent signs or nothing printable
* at all — while an href is a path segment on someone else's filesystem. Using
* one as the other is the classic CalDAV client bug: it works against the server
* you tested on and produces 400s, 403s or silently truncated names elsewhere.
*
* The character class is vdirsyncer's, and the exclusion of `@` is deliberate:
* plenty of UIDs are email-shaped, and `@` in a path segment is legal but
* routinely mishandled by proxies and by servers that re-parse their own URLs.
*/
object ResourceNames {
/**
* Cap on the basename, in bytes.
*
* Well under the 255 that most filesystems allow, because the server appends
* to what we send — Nextcloud's trashbin renames a deleted resource to
* `<name>-deleted.ics`, and a name that only just fit before deletion does
* not fit afterwards.
*/
const val MAX_BASENAME_BYTES = 200
const val EXTENSION = ".ics"
private val UNSAFE = Regex("[^A-Za-z0-9_.+-]")
/**
* A filename derived from [uid], or a random one when [uid] does not survive
* sanitisation.
*
* The derivation is a convenience for humans reading the collection over
* WebDAV, never an identity: nothing reads the UID back out of an href.
*/
fun forUid(uid: String): String {
val cleaned = UNSAFE.replace(uid, "-")
.trim('-', '.')
.take(MAX_BASENAME_BYTES)
.trimEnd('-', '.')
return if (cleaned.isEmpty() || cleaned.all { it == '-' }) random() else cleaned + EXTENSION
}
/** A fresh name that cannot collide, for the fallback and the 412 retry. */
fun random(): String = UUID.randomUUID().toString() + EXTENSION
/**
* A path segment for a new **collection**, derived from what the user called
* it.
*
* No extension: a collection is a directory, not a file. Derived rather than
* random for the same reason [forUid] is — someone browsing the account over
* WebDAV, or in their server's own web UI, should be able to tell which of
* these is "Shopping" — and, like [forUid], it is a convenience and never an
* identity: the href is what identifies the collection afterwards.
*
* ⚠️ Sanitised to the same class, which matters more here than it does for a
* resource: a display name is typed by a person, in their own language, and
* "Einkäufe 🛒" is an ordinary thing to call a list. What survives may be
* empty, and then a random segment is the honest answer.
*/
fun forCollection(displayName: String): String {
val cleaned = UNSAFE.replace(displayName, "-")
.trim('-', '.')
.take(MAX_BASENAME_BYTES)
.trimEnd('-', '.')
return if (cleaned.isEmpty() || cleaned.all { it == '-' }) randomCollection() else cleaned
}
/**
* A collection segment that cannot collide.
*
* ⚠️ Also the retry, and Nextcloud is why it has to exist rather than being
* a fallback for unprintable names: its trashbin renames a deleted
* collection instead of removing it, so re-creating one under a name that
* was used before answers 403 for ever. A fresh segment is the only way
* back, and the display name is unaffected — two lists may share one.
*/
fun randomCollection(): String = UUID.randomUUID().toString()
}
@@ -0,0 +1,77 @@
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.
*
* The domains themselves live on [CalDavProvider] — one list, so a provider the
* accounts screen can mark is also a provider this can warn about.
*/
enum class ServerQuirk {
/** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */
FASTMAIL_APP_PASSWORD,
/** iCloud: app-specific password, and 2FA must be on to mint one. */
ICLOUD_APP_SPECIFIC_PASSWORD,
/**
* Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own
* documentation says *"Doesn't support VTODO or VJOURNAL data"*. It is
* not a target, so this is a refusal with an explanation rather than
* a 401 the user cannot act on.
*/
GOOGLE_UNSUPPORTED,
/**
* 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,
;
companion object {
/** The quirk implied by an email address or a URL host, if any. */
fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk
fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk
/** What is known about a service the user picked by name rather than typed. */
fun forProvider(provider: CalDavProvider): ServerQuirk? = provider.quirk
fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host)
private val CalDavProvider.quirk: ServerQuirk?
get() = when (this) {
CalDavProvider.FASTMAIL -> FASTMAIL_APP_PASSWORD
CalDavProvider.ICLOUD -> ICLOUD_APP_SPECIFIC_PASSWORD
CalDavProvider.GOOGLE -> GOOGLE_UNSUPPORTED
CalDavProvider.NEXTCLOUD -> NEXTCLOUD_BRUTE_FORCE_PROTECTED
else -> null
}
}
/** True when discovery should not even be attempted. */
val isFatal: Boolean get() = this == GOOGLE_UNSUPPORTED
/**
* True when the quirk is an errand the user can actually run — go to the
* provider, mint an app password, come back — rather than a refusal or a
* note for the engine.
*
* What separates the two is whether there is anything to *do*. Google is a
* dead end and the brute-force note is never shown, so neither earns the
* screen that walks through the steps.
*/
val hasSetupSteps: Boolean
get() = this == FASTMAIL_APP_PASSWORD || this == ICLOUD_APP_SPECIFIC_PASSWORD
}
@@ -0,0 +1,214 @@
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.
* 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 URL is taken as typed and DNS is never consulted — a user who
// gave us an address meant it.
asBaseUrl(trimmed)?.let { typed ->
val candidates = mutableListOf<Candidate>()
// ⚠️ A typed URL is not automatically a *DAV* URL, and the bare
// origin is what people actually type. `https://cloud.example.com`
// is the web UI: PROPFIND on it returns the 405 any web server
// answers, which reads as "not a CalDAV server" about a perfectly
// good one. RFC 6764 §6 exists precisely for this case, so the
// well-known probe has to run here too — returning the typed URL as
// the only candidate is what made a working Nextcloud undiscoverable.
//
// ⚠️ Built through `newBuilder`, not by interpolating `host`, which
// returns an IPv6 literal *without* its brackets — "fd00::1", not
// "[fd00::1]". Pasting that back into a URL yields a string OkHttp
// will not parse, so both candidates would be dropped and a typed
// IPv6 address would report as "not an address". `toString()` also
// drops a default port on its own.
val origin = typed.newBuilder()
.encodedPath("/")
.query(null)
.fragment(null)
.build()
.toString()
if (typed.encodedPath.trim('/').isEmpty()) {
add(candidates, origin, WELL_KNOWN, "typed origin + .well-known")
add(candidates, origin, "/", "typed origin + root")
} else {
// A deep URL may well be the DAV root itself, and PROPFIND on it
// can return principal, home-set and collection in one response.
// The well-known stays as the fallback for a path that was a
// guess.
candidates += Candidate(typed, "base URL as typed")
add(candidates, origin, WELL_KNOWN, "typed host + .well-known")
}
return candidates
}
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 }
// ⚠️ RFC 2782 does not require a target to live inside the domain it
// was queried for, and this ladder is walked over plain UDP DNS with
// no DNSSEC — then walked *again* after a 401, with the
// authenticated client. An out-of-domain target is also one the
// credential would never be offered to, since CalDavHttp scopes it
// to the typed address's registrable domain, so following it can
// only end in a 401 the user cannot act on. Refusing leaves the
// well-known ladder on the typed domain, which is where a correctly
// delegated install answers anyway.
.filter { sharesDomain(it.target, domain) }
.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
}
/**
* Whether an SRV target may stand in for [domain] — the same registrable
* domain, or the exact host where there is none (an IP literal, a
* single-label name, a host that *is* a public suffix).
*/
private fun sharesDomain(target: String, domain: String): Boolean {
val host = target.trimEnd('.').lowercase()
val scope = scopeOf(domain) ?: return host.equals(domain, ignoreCase = true)
return scopeOf(host)?.equals(scope, ignoreCase = true) == true
}
/** The boundary `CalDavHttp` scopes a credential by, derived the same way. */
private fun scopeOf(host: String): String? {
val literal = if (':' in host) "[$host]" else host
val url = "https://$literal".toHttpUrlOrNull() ?: return null
return url.topPrivateDomain() ?: url.host
}
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 server **root** for [input] — the origin, not the DAV path.
*
* Nextcloud's Login Flow v2 lives at `index.php/login/v2` off the root, so it
* needs this rather than a discovered collection URL. A typed base URL is
* taken as typed; an email address becomes `https://<domain>`.
*/
fun serverRootFor(input: String): HttpUrl? {
val trimmed = input.trim()
asBaseUrl(trimmed)?.let { return it }
return domainOf(trimmed)?.let { "https://$it".toHttpUrlOrNull() }
}
/** 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,202 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.DavCollection
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.HttpUtils
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.push.WebDAVPush
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
import java.io.StringReader
import java.io.StringWriter
import java.util.Date
import kotlin.time.Instant
/** What a collection's server offers for WebDAV-Push over Web Push. */
data class PushSupport(
/** Carried by every push message about this collection. */
val topic: String,
/** The server's VAPID key; absent means unauthenticated pushes. */
val vapidPublicKey: String?,
)
/**
* The client side of the WebDAV-Push draft (https://github.com/bitfireAT/webdav-push):
* subscribing, unsubscribing and reading messages. Returns outcomes, never throws.
*/
object WebDavPush {
sealed interface Registration {
/** Accepted. [url] is where it lives; null if the server sent no `Location`. */
data class Registered(val url: HttpUrl?, val expires: Instant) : Registration
/** A definite no — push unavailable here, or our subscription refused. */
data class Refused(val code: Int) : Registration
/** Nothing definite: the network, or a server error worth retrying. */
data class Failed(val reason: String) : Registration
}
/** A decoded push message. */
data class Message(
/** Which resource changed; null only for a VAPID key rotation. */
val topic: String?,
/** The collection's sync token after the change, when the server says. */
val syncToken: String?,
/** The server's VAPID key changed and every subscription must be renewed. */
val keyRotation: Boolean,
)
/**
* Subscribes [endpoint] to changes in [collection] and its members. Registering
* the same endpoint again renews it.
*
* @param publicKey the subscription's p256dh key, base64url.
* @param authSecret the subscription's auth secret, base64url; sent only with [publicKey].
*/
fun register(
client: OkHttpClient,
collection: HttpUrl,
endpoint: String,
publicKey: String?,
authSecret: String?,
expires: Instant,
): Registration = try {
var result: Registration = Registration.Failed("no response")
val body = registerBody(endpoint, publicKey, authSecret, expires).toRequestBody(MIME_XML)
DavCollection(client, collection).post(body) { response ->
val location = response.header("Location")?.let { collection.resolve(it) }
// The server may shorten what we asked for.
val granted = response.header("Expires")
?.let { HttpUtils.parseDate(it) }
?.let { Instant.fromEpochMilliseconds(it.time) }
result = Registration.Registered(location, granted ?: expires)
}
result
} catch (e: HttpException) {
if (e.code / 100 == 4) {
Registration.Refused(e.code)
} else {
Registration.Failed(listOfNotNull("HTTP ${e.code}", serverMessage(e.responseBody)).joinToString(": "))
}
} catch (e: DavException) {
Registration.Failed(e.toString())
} catch (e: IOException) {
Registration.Failed(e.toString())
}
/** The exception text sabre-based servers put in an error body, for the log. */
internal fun serverMessage(body: String?): String? =
body?.let { MESSAGE.find(it)?.groupValues?.get(1)?.trim() }?.takeIf { it.isNotEmpty() }
/** Removes the subscription at [registration]; false only when it may still exist. */
fun unregister(client: OkHttpClient, registration: HttpUrl): Boolean = try {
DavResource(client, registration).delete { }
true
} catch (e: HttpException) {
e.code == NOT_FOUND || e.code == GONE
} catch (_: DavException) {
false
} catch (_: IOException) {
false
}
/** `Push-Dont-Notify` for our own writes, so they do not wake us. */
fun dontNotifyHeader(registration: HttpUrl): String = "\"$registration\""
/** Decodes a decrypted push message, or null when it is not one. */
fun parse(message: String): Message? = try {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader(message))
var result: Message? = null
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT && result == null) {
if (eventType == XmlPullParser.START_TAG && parser.propertyName() == WebDAVPush.PushMessage) {
result = readMessage(parser)
}
eventType = parser.next()
}
result
} catch (_: XmlPullParserException) {
null
} catch (_: IOException) {
null
}
private fun readMessage(parser: XmlPullParser): Message {
var topic: String? = null
var syncToken: String? = null
var keyRotation = false
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG) {
when (parser.propertyName()) {
WebDAVPush.Topic -> topic = XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() }
SYNC_TOKEN -> syncToken = XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() }
// A key rotation names the transports property.
WebDAVPush.Transports -> keyRotation = true
}
}
eventType = parser.next()
}
return Message(topic = topic, syncToken = syncToken, keyRotation = keyRotation && topic == null)
}
internal fun registerBody(
endpoint: String,
publicKey: String?,
authSecret: String?,
expires: Instant,
): String {
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", true)
serializer.setPrefix("", WebDAVPush.NS_WEBDAV_PUSH)
serializer.setPrefix("D", XmlUtils.NS_WEBDAV)
serializer.insertTag(WebDAVPush.PushRegister) {
insertTag(WebDAVPush.Subscription) {
insertTag(WebDAVPush.WebPushSubscription) {
insertTag(WebDAVPush.PushResource) { text(endpoint) }
if (publicKey != null && authSecret != null) {
insertTag(WebDAVPush.ContentEncoding) { text("aes128gcm") }
insertTag(WebDAVPush.SubscriptionPublicKey) {
attribute(null, "type", "p256dh")
text(publicKey)
}
insertTag(WebDAVPush.AuthSecret) { text(authSecret) }
}
}
}
// Depth 1 covers the tasks; property changes are left to the periodic sync.
insertTag(WebDAVPush.Trigger) {
insertTag(WebDAVPush.ContentUpdate) {
insertTag(DEPTH) { text("1") }
}
}
insertTag(WebDAVPush.Expires) {
text(HttpUtils.formatDate(Date(expires.toEpochMilliseconds())))
}
}
serializer.endDocument()
return writer.toString()
}
private val MIME_XML = "application/xml; charset=utf-8".toMediaType()
private val DEPTH = Property.Name(XmlUtils.NS_WEBDAV, "depth")
private val SYNC_TOKEN = Property.Name(XmlUtils.NS_WEBDAV, "sync-token")
private val MESSAGE = Regex("<(?:[A-Za-z0-9]+:)?message>(.*?)</(?:[A-Za-z0-9]+:)?message>", RegexOption.DOT_MATCHES_ALL)
private const val NOT_FOUND = 404
private const val GONE = 410
}
@@ -0,0 +1,124 @@
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 okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.nanoseconds
import kotlin.time.Duration.Companion.seconds
class AppPasswordTest {
private val server = MockWebServer()
private val httpClient = OkHttpClient()
@Before fun start() = server.start()
@After fun stop() = server.shutdown()
@Test fun `a redirected revocation is followed`() {
// Production builds its clients with followRedirects(false), so a server
// that canonicalises host or path leaves the password dangling —
// attempted, and doing nothing.
val noRedirects = OkHttpClient.Builder().followRedirects(false).build()
server.enqueue(
MockResponse().setResponseCode(301)
.setHeader("Location", server.url("/nc/ocs/v2.php/core/apppassword").toString()),
)
server.enqueue(MockResponse().setResponseCode(200))
val revoked = AppPassword.revokeAt(noRedirects, server.url("/"))
assertThat(revoked).isTrue()
server.takeRequest()
val followed = server.takeRequest()
assertThat(followed.method).isEqualTo("DELETE")
assertThat(followed.getHeader("OCS-APIRequest")).isEqualTo("true")
}
@Test fun `the OCS root is derived from a principal, not appended to it`() {
// ⚠️ Appending to the principal produces a path that 404s on every
// server, silently — the revocation looks attempted and does nothing.
val principal = "https://cloud.example.com/remote.php/dav/principals/users/alice/"
assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString())
.isEqualTo("https://cloud.example.com/")
}
@Test fun `a subpath install keeps its subpath`() {
val principal = "https://host/nextcloud/remote.php/dav/principals/users/alice/"
assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString())
.isEqualTo("https://host/nextcloud/")
}
@Test fun `a principal with no remote_php falls back to the origin`() {
val principal = "https://baikal.example.com/dav.php/principals/alice/"
assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString())
.isEqualTo("https://baikal.example.com/")
}
@Test fun `a server root is used verbatim, not treated as a principal`() {
server.enqueue(MockResponse().setResponseCode(200))
val revoked = AppPassword.revokeAt(httpClient, server.url("/nextcloud/"))
// ⚠️ ocsRootFor looks for remote.php and falls back to the bare origin.
// A login flow hands back a server base, so routing one through revoke()
// would collapse /nextcloud/ to / and DELETE a path that 404s on every
// subpath install — attempted, and doing nothing.
assertThat(revoked).isTrue()
assertThat(server.takeRequest().path)
.isEqualTo("/nextcloud/ocs/v2.php/core/apppassword")
}
@Test fun `a server that never answers does not hold the removal`() {
// Accepts the connection and says nothing — the off-VPN homelab shape.
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
val startedAt = System.nanoTime()
val revoked = AppPassword.revoke(
httpClient,
server.url("/remote.php/dav/principals/users/alice/"),
timeout = 200.milliseconds,
)
val elapsed = (System.nanoTime() - startedAt).nanoseconds
// ⚠️ The budget has to live on the call. execute() parks on a socket read
// that neither coroutine cancellation nor Thread.interrupt can break, so
// a timeout imposed from outside returns only once the read has finished
// anyway — after the shared client's own minutes-long budget.
assertThat(revoked).isFalse()
assertThat(elapsed).isLessThan(2.seconds)
}
@Test fun `the default budget is what the removal is willing to wait`() {
assertThat(AppPassword.REVOCATION_TIMEOUT).isEqualTo(5.seconds)
}
@Test fun `the revocation is a DELETE with the OCS header`() {
server.enqueue(MockResponse().setResponseCode(200))
val revoked = AppPassword.revoke(
httpClient,
server.url("/remote.php/dav/principals/users/alice/"),
)
assertThat(revoked).isTrue()
val request = server.takeRequest()
assertThat(request.method).isEqualTo("DELETE")
assertThat(request.path).isEqualTo("/ocs/v2.php/core/apppassword")
// ⚠️ Without this header Nextcloud answers with a CSRF complaint that
// reads exactly like a wrong password.
assertThat(request.getHeader("OCS-APIRequest")).isEqualTo("true")
}
@Test fun `an unreachable server is reported, not thrown`() {
server.enqueue(MockResponse().setResponseCode(404))
assertThat(AppPassword.revoke(httpClient, server.url("/remote.php/dav/"))).isFalse()
}
}
@@ -0,0 +1,386 @@
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 home set is resolved against where the principal actually landed`() {
// ⚠️ followRedirects rewrites the resource's location in place, and a
// relative home-set href means "relative to the answer". Resolved
// against the URL we aimed at, a permanently moved principal names a
// path under the old prefix: the home-set PROPFIND 404s and a perfectly
// good account reports that it holds no calendars.
server.enqueue(
MockResponse().setResponseCode(301)
.setHeader("Location", server.url("/nc/principals/me/").toString()),
)
server.enqueue(homeSetResponse("calendars/me/"))
server.enqueue(listing(collection("/nc/principals/me/calendars/me/tasks/", "<collection/>")))
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Found::class.java)
// Under the prefix the redirect landed on, not the one we aimed at —
// which would have been /principals/me/calendars/me/, a 404.
assertThat((outcome as CalDavDiscovery.Outcome.Found).homeSets.single().encodedPath)
.isEqualTo("/nc/principals/me/calendars/me/")
}
@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)
val failed = outcome as CalDavDiscovery.Outcome.Failed
// The cause is what the UI renders; the detail is for logs only.
assertThat(failed.cause).isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE)
assertThat(failed.detail).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,235 @@
package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.BasicDigestAuthHandler
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Test
class CalDavHttpTest {
private val origin = "https://cloud.example.com/remote.php/dav/".toHttpUrl()
@Test
fun `the auth handler is scoped to the registrable domain, not the host`() {
// ⚠️ The handler derives the registrable domain of each request host and
// compares. Passing the full host means the comparison is
// "cloud.example.com" == "example.com" — never true — and the credential
// is withheld from every single request. That is every self-hosted
// Nextcloud, silently answering 401 forever.
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(handler.domain).isEqualTo("example.com")
}
@Test
fun `so the credential actually reaches the host it was made for`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val authorised = handler.authenticateRequest(
Request.Builder().url("https://cloud.example.com/remote.php/dav/").build(),
null,
)
assertThat(authorised?.header("Authorization")).isNotNull()
}
@Test
fun `and reaches a sibling host in the same domain, which is what iCloud needs`() {
// The principal is on caldav.icloud.com and the home set on
// pNN-caldav.icloud.com. An exact-host allowlist refuses the second.
val client = CalDavHttp.authenticated(
"Agendula",
"user",
"pw",
"https://caldav.icloud.com/".toHttpUrl(),
)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val authorised = handler.authenticateRequest(
Request.Builder().url("https://p42-caldav.icloud.com/1234/calendars/").build(),
null,
)
assertThat(authorised?.header("Authorization")).isNotNull()
}
@Test
fun `a multi-label public suffix is not mistaken for a domain`() {
// ⚠️ A last-two-labels split scopes this to "co.uk" and then offers the
// password preemptively to any host under it — one an attacker can buy a
// certificate for. This is the headline case.
val client = CalDavHttp.authenticated(
"Agendula",
"user",
"pw",
"https://cloud.example.co.uk/dav/".toHttpUrl(),
)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(handler.domain).isEqualTo("example.co.uk")
val stranger = handler.authenticateRequest(
Request.Builder().url("https://attacker.co.uk/dav/").build(),
null,
)
assertThat(stranger).isNull()
val ours = handler.authenticateRequest(
Request.Builder().url("https://cloud.example.co.uk/dav/").build(),
null,
)
assertThat(ours?.header("Authorization")).isNotNull()
}
@Test
fun `a free-subdomain host does not trust its neighbours`() {
// The PSL private section covers the dynamic-DNS providers self-hosters
// actually use, where the neighbour is a stranger with a free account.
val client = CalDavHttp.authenticated(
"Agendula",
"user",
"pw",
"https://myhome.duckdns.org/dav/".toHttpUrl(),
)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(handler.domain).isEqualTo("myhome.duckdns.org")
val neighbour = handler.authenticateRequest(
Request.Builder().url("https://evil.duckdns.org/dav/").build(),
null,
)
assertThat(neighbour).isNull()
}
@Test
fun `a bare address is scoped to itself, not to its last two labels`() {
// ⚠️ topPrivateDomain() is null here, and null means *no restriction* to
// the handler — so the fallback has to be the exact host. A split would
// scope 192.168.1.10 to "1.10".
val client = CalDavHttp.authenticated(
"Agendula",
"user",
"pw",
"https://192.168.1.10/dav/".toHttpUrl(),
)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(handler.domain).isEqualTo("192.168.1.10")
val ours = handler.authenticateRequest(
Request.Builder().url("https://192.168.1.10/dav/").build(),
null,
)
assertThat(ours?.header("Authorization")).isNotNull()
val other = handler.authenticateRequest(
Request.Builder().url("https://10.20.1.10/dav/").build(),
null,
)
assertThat(other).isNull()
}
@Test
fun `a single-label host is scoped to itself`() {
val client = CalDavHttp.authenticated(
"Agendula",
"user",
"pw",
"https://localhost:8443/dav/".toHttpUrl(),
)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(handler.domain).isEqualTo("localhost")
val elsewhere = handler.authenticateRequest(
Request.Builder().url("https://notlocalhost/dav/").build(),
null,
)
assertThat(elsewhere).isNull()
}
@Test
fun `an unrelated domain gets nothing`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(
handler.authenticateRequest(
Request.Builder().url("https://evil.example.org/dav/").build(),
null,
),
).isNull()
}
@Test
fun `cleartext never carries a preemptive credential`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
assertThat(
handler.authenticateRequest(
Request.Builder().url("http://cloud.example.com/dav/").build(),
null,
)?.header("Authorization"),
).isNull()
}
@Test
fun `cleartext carries no credential even when the server asks for one`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val request = Request.Builder().url("http://cloud.example.com/dav/").build()
// ⚠️ Gating only the preemptive path leaves this open: the server just
// has to ask. Basic is the password, in a header every hop can read.
assertThat(handler.authenticateRequest(request, challenge(request, "Basic"))).isNull()
}
@Test
fun `a challenge over TLS is still answered`() {
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
val request = Request.Builder().url("https://cloud.example.com/dav/").build()
val authorised = handler.authenticateRequest(request, challenge(request, "Basic"))
assertThat(authorised?.header("Authorization")).startsWith("Basic")
}
private fun challenge(request: Request, scheme: String) = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(401)
.message("Authentication required")
.header("WWW-Authenticate", "$scheme realm=\"dav\"")
.build()
@Test
fun `the shared client bounds a whole call, not just each read`() {
// ⚠️ readTimeout is per-read, so a server trickling a byte every 119
// seconds satisfies it for ever and holds a sequential sync for the
// entire WorkManager window. The ceiling is wide enough for a multiget
// of a full batch over a slow homelab link and far below that window;
// the revocation, which must return in seconds, still sets its own.
assertThat(CalDavHttp.anonymous("Agendula").callTimeoutMillis)
.isEqualTo(3 * 60 * 1000)
}
@Test
fun `derived clients share one connection pool`() {
// A fresh OkHttpClient per probe gives each its own pool and dispatcher
// threads: every rung of the discovery ladder reopens TLS, and the
// abandoned clients linger until GC.
val a = CalDavHttp.anonymous("Agendula")
val b = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
assertThat(a.connectionPool).isSameInstanceAs(b.connectionPool)
assertThat(a.dispatcher).isSameInstanceAs(b.dispatcher)
}
@Test
fun `redirects are never followed automatically`() {
// DavResource requires it and asserts on it: redirects are followed by
// hand so a HTTPS-to-HTTP downgrade can be refused and a permanent move
// reported.
assertThat(CalDavHttp.anonymous("Agendula").followRedirects).isFalse()
assertThat(CalDavHttp.authenticated("Agendula", "u", "p", origin).followRedirects).isFalse()
}
}
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test
class CalDavProviderTest {
@Test
fun `a hosted service is known by its host`() {
assertThat(CalDavProvider.forInput("me@fastmail.com")).isEqualTo(CalDavProvider.FASTMAIL)
assertThat(CalDavProvider.forHost("caldav.icloud.com")).isEqualTo(CalDavProvider.ICLOUD)
assertThat(CalDavProvider.forHost("dav.mailbox.org")).isEqualTo(CalDavProvider.MAILBOX_ORG)
assertThat(CalDavProvider.forHost("cloud.example.de")).isNull()
}
@Test
fun `self-hosted software is known by its principal path`() {
val nextcloud = "https://cloud.example.de/remote.php/dav/principals/users/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(nextcloud)).isEqualTo(CalDavProvider.NEXTCLOUD)
val baikal = "https://dav.example.de/dav.php/principals/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(baikal)).isEqualTo(CalDavProvider.BAIKAL)
val unknown = "https://dav.example.de/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(unknown)).isNull()
}
@Test
fun `the host wins over the path`() {
// Fastmail's principals sit under a path we know nothing about; the host
// already named the service, so nothing else is consulted.
val url = "https://caldav.fastmail.com/dav/principals/user/me@fastmail.com/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(url)).isEqualTo(CalDavProvider.FASTMAIL)
}
@Test
fun `hosted services are titled by brand, self-hosted ones by host`() {
assertThat(CalDavProvider.FASTMAIL.hosted).isTrue()
assertThat(CalDavProvider.NEXTCLOUD.hosted).isFalse()
}
}
@@ -0,0 +1,184 @@
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
/** RFC 6578. Every case here is a documented failure of a real client. */
class CalendarChangesTest {
private val server = MockWebServer()
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
private lateinit var collection: CalendarCollection
@Before fun start() {
server.start()
collection = CalendarCollection(httpClient, server.url("/dav/tasks/"))
}
@After fun stop() = server.shutdown()
@Test fun `changed and removed are told apart`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/kept.ics</href>
<propstat><prop><getetag>"e1"</getetag></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/gone.ics</href>
<status>HTTP/1.1 404 Not Found</status>
</response>
<sync-token>urn:x:2</sync-token>
""",
),
)
val page = collection.changes("urn:x:1") as ChangeSet.Page
// ⚠️ The deletion status is at DAV:response level, not inside a propstat.
assertThat(page.changed.map { it.href.encodedPath })
.containsExactly("/dav/tasks/kept.ics")
assertThat(page.removed.map { it.encodedPath })
.containsExactly("/dav/tasks/gone.ics")
assertThat(page.token).isEqualTo("urn:x:2")
assertThat(page.truncated).isFalse()
}
@Test fun `a status that is neither success nor removal is carried as a change`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/refused.ics</href>
<status>HTTP/1.1 403 Forbidden</status>
</response>
<sync-token>urn:x:2</sync-token>
""",
),
)
val page = collection.changes("urn:x:1") as ChangeSet.Page
// Dropping it would report the resource as unchanged and leave the row
// stale until the next full reconciliation. With no validator on it the
// multiget is forced, and that is what grades the refusal.
assertThat(page.changed.map { it.href.encodedPath })
.containsExactly("/dav/tasks/refused.ics")
assertThat(page.changed.single().eTag).isNull()
assertThat(page.removed).isEmpty()
}
@Test fun `no DAV limit is ever sent`() {
server.enqueue(multistatus("<sync-token>urn:x:2</sync-token>"))
collection.changes(null)
val body = server.takeRequest().body.readUtf8()
// Nextcloud regressed DAV:limit to a localised HTML error page, so asking
// for a bounded page is how you get an unparseable response.
assertThat(body).doesNotContain("limit")
assertThat(body).contains("sync-level")
}
@Test fun `a 507 on our own href is truncation, not failure`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop><getetag>"e1"</getetag></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/</href>
<status>HTTP/1.1 507 Insufficient Storage</status>
</response>
<sync-token>urn:x:2</sync-token>
""",
),
)
val page = collection.changes("urn:x:1") as ChangeSet.Page
assertThat(page.truncated).isTrue()
assertThat(page.changed).hasSize(1)
assertThat(page.token).isEqualTo("urn:x:2")
}
@Test fun `a 207 with no sync-token is not an error`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop><getetag>"e1"</getetag></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val page = collection.changes("urn:x:1") as ChangeSet.Page
// The changes are real; only the cursor is missing.
assertThat(page.changed).hasSize(1)
assertThat(page.token).isNull()
}
// --------------------------------------------------- token invalidation
@Test fun `invalidation is recognised on every status servers use for it`() {
// ⚠️ 403 sabre, 400 Google, 409 Radicale, 412 Evolution. Thunderbird
// matches 400 alone and never recovers from the 403 most self-hosted
// servers emit.
listOf(400, 403, 409, 412).forEach { code ->
server.enqueue(
MockResponse()
.setResponseCode(code)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"<D:error xmlns:D=\"DAV:\"><D:valid-sync-token/></D:error>",
),
)
assertThat(collection.changes("stale")).isEqualTo(ChangeSet.TokenInvalid)
}
}
@Test fun `invalidation is recognised whatever prefix the server uses`() {
server.enqueue(
MockResponse()
.setResponseCode(403)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<error xmlns=\"DAV:\"><valid-sync-token/></error>"),
)
assertThat(collection.changes("stale")).isEqualTo(ChangeSet.TokenInvalid)
}
@Test fun `a 4xx without the marker is not an invalidation`() {
server.enqueue(MockResponse().setResponseCode(403).setBody("nope"))
// Treating every 403 as invalidation would discard a working token on a
// permissions error and re-download the whole collection.
assertThat(collection.changes("good")).isInstanceOf(ChangeSet.Failed::class.java)
}
@Test fun `an unimplemented report is reported as unsupported`() {
server.enqueue(MockResponse().setResponseCode(501))
assertThat(collection.changes(null)).isEqualTo(ChangeSet.Unsupported)
}
@Test fun `a 507 outer status is a failure rather than truncation`() {
server.enqueue(MockResponse().setResponseCode(507))
assertThat(collection.changes("t")).isInstanceOf(ChangeSet.Failed::class.java)
}
private fun multistatus(body: String) = MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns=\"DAV:\">$body</multistatus>")
}
@@ -0,0 +1,648 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockWebServer
import okio.buffer
import org.junit.After
import org.junit.Before
import org.junit.Test
class CalendarCollectionTest {
private val server = MockWebServer()
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
private lateinit var collection: CalendarCollection
@Before fun start() {
server.start()
collection = CalendarCollection(httpClient, server.url("/dav/tasks/"))
}
@After fun stop() = server.shutdown()
// ------------------------------------------------------------------ list
@Test fun `list reads hrefs and strong etags`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop><getetag>"e1"</getetag></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/two.ics</href>
<propstat><prop><getetag>W/"e2"</getetag></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val refs = collection.list().getOrThrow()
assertThat(refs.map { it.href.encodedPath })
.containsExactly("/dav/tasks/one.ics", "/dav/tasks/two.ics")
assertThat(refs[0].eTag).isEqualTo(ETag("e1", weak = false))
assertThat(refs[1].eTag!!.usable).isFalse()
}
@Test fun `list asks for no calendar-data and no time-range`() {
server.enqueue(multistatus(""))
collection.list().getOrThrow()
val body = server.takeRequest().body.readUtf8()
// Bodies in a listing would download the whole collection every time, and
// would arrive without an ETag matched to them.
assertThat(body).doesNotContain("calendar-data")
// A VTODO without DTSTART or DUE is outside every range by construction,
// and some servers answer a ranged VTODO filter with nothing at all.
assertThat(body).doesNotContain("time-range")
assertThat(body).contains("VTODO")
}
// ----------------------------------------------------------------- fetch
@Test fun `fetch matches responses against the request`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/somebody-elses.ics</href>
<propstat><prop>
<getetag>"e9"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow()
assertThat(result.resources.map { it.href.encodedPath })
.containsExactly("/dav/tasks/one.ics")
// Real servers answer with URLs nobody asked about. Applying one of those
// to a row keyed by another href corrupts the wrong task.
assertThat(result.unsolicited.map { it.encodedPath })
.containsExactly("/dav/tasks/somebody-elses.ics")
assertThat(result.missing.map { it.encodedPath }).containsExactly("/dav/tasks/two.ics")
}
@Test fun `a server that respells the path is still matched`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/my%40dav.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val asked = href("my@dav.ics")
val result = collection.fetch(listOf(asked)).getOrThrow()
// ⚠️ Compared raw, one resource lands in unsolicited *and* missing, its
// body is dropped, and three such runs quarantine it out of the download
// for good — nothing on that side can refund the count.
assertThat(result.resources).hasSize(1)
assertThat(result.missing).isEmpty()
assertThat(result.unsolicited).isEmpty()
assertThat(result.failed).isEmpty()
// The href we asked for, not the server's spelling: apply is keyed on
// the local row's href.
assertThat(result.resources.single().href).isEqualTo(asked)
}
@Test fun `an href stored before a redirect still matches the reply`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
// ⚠️ The REPORT body carries only paths, so the reply resolves against
// the collection we asked — while a stored href can still carry the
// origin we held before a redirect moved us. Keying on the origin would
// put every resource of a redirected collection into both buckets.
val stored = "https://old.example.com/dav/tasks/one.ics".toHttpUrl()
val result = collection.fetch(listOf(stored)).getOrThrow()
assertThat(result.resources.map { it.href }).containsExactly(stored)
assertThat(result.missing).isEmpty()
assertThat(result.unsolicited).isEmpty()
}
@Test fun `the same path on another host is not our resource`() {
server.enqueue(
multistatus(
"""
<response>
<href>https://evil.example.com/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// Matching on the path alone would apply one host's body to another
// host's row.
assertThat(result.resources).isEmpty()
assertThat(result.unsolicited.map { it.host }).containsExactly("evil.example.com")
assertThat(result.missing).hasSize(1)
}
@Test fun `an ambiguous spelling matches nothing rather than the wrong row`() {
// A third spelling: matches neither request exactly, decodes like both.
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/%6Dy@dav.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val plain = href("my@dav.ics")
val encoded = server.url("/dav/tasks/my%40dav.ics")
val result = collection.fetch(listOf(plain, encoded)).getOrThrow()
// ⚠️ Two rows decode alike, so no answer can say which it means. Guessing
// applies one row's body to the other; both are reported instead, and the
// syncer will not spend a quarantine count on a stray of the same path.
assertThat(result.resources).isEmpty()
assertThat(result.unsolicited).hasSize(1)
assertThat(result.missing).containsExactly(plain, encoded)
}
@Test fun `a trailing slash is still a different resource`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics/</href>
<propstat><prop><getetag>"e1"</getetag></prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// Deliberately not normalised, as UrlUtils.equals also refuses to.
assertThat(result.resources).isEmpty()
assertThat(result.missing).hasSize(1)
}
@Test fun `an encoded slash is not the same as a real one`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/a/b.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val asked = server.url("/dav/tasks/a%2Fb.ics")
val result = collection.fetch(listOf(asked)).getOrThrow()
// One segment "a/b" is not two segments "a" and "b" — which is why the
// key stays a list rather than a joined string.
assertThat(result.resources).isEmpty()
assertThat(result.missing).containsExactly(asked)
}
@Test fun `a refused resource is reported as failed, not as missing`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/two.ics</href>
<status>HTTP/1.1 403 Forbidden</status>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow()
// The server did mention two.ics, so it is not missing. Merging the two
// costs the caller the difference between "refused" and "omitted", which
// are not the same fact and do not deserve the same answer.
assertThat(result.missing).isEmpty()
assertThat(result.failed.map { it.href.encodedPath to it.code })
.containsExactly("/dav/tasks/two.ics" to 403)
// One bad member does not cost the batch its good ones.
assertThat(result.resources.map { it.href.encodedPath })
.containsExactly("/dav/tasks/one.ics")
}
@Test fun `a success carrying no calendar-data is reported as failed`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// No HTTP judgement to report: the server said yes and sent nothing.
assertThat(result.failed.map { it.href.encodedPath to it.code })
.containsExactly("/dav/tasks/one.ics" to 0)
assertThat(result.resources).isEmpty()
assertThat(result.missing).isEmpty()
}
@Test fun `a propstat refusal carries its own status, not a blank`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
</prop><status>HTTP/1.1 200 OK</status></propstat>
<propstat><prop>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"/>
</prop><status>HTTP/1.1 403 Forbidden</status></propstat>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// A per-property refusal is the usual shape for "you may not read this
// one object". Response.properties drops non-2xx propstats, so reporting
// 0 here would make a deterministic refusal look like a transient blank.
assertThat(result.failed.map { it.code }).containsExactly(403)
}
@Test fun `a resource the server says is gone carries its status`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/one.ics</href>
<status>HTTP/1.1 404 Not Found</status>
</response>
""",
),
)
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
// The code has to survive the trip: the engine treats 404 and 403 as
// opposite verdicts.
assertThat(result.failed.map { it.code }).containsExactly(404)
assertThat(result.missing).isEmpty()
}
@Test fun `fetch batches`() {
repeat(2) { server.enqueue(multistatus("")) }
collection.fetch((1..3).map { href("$it.ics") }, batchSize = 2).getOrThrow()
assertThat(server.requestCount).isEqualTo(2)
}
@Test fun `a batch that fails keeps the batches that already worked`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/1.ics</href>
<propstat><prop>
<getetag>"e1"</getetag>
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
END:VCALENDAR</C:calendar-data>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
server.enqueue(MockResponse().setResponseCode(500))
val result = collection.fetch((1..3).map { href("$it.ics") }, batchSize = 1).getOrThrow()
// ⚠️ Wrapping the whole loop threw away everything parsed so far and
// answered failure, so the engine recorded a collection failure and
// re-downloaded the lot next run — on a flaky link, for ever.
assertThat(result.resources.map { it.href.encodedPath })
.containsExactly("/dav/tasks/1.ics")
// The rest were never asked, which is exactly what missing means; the
// engine counts those rather than acting on them as deletions.
assertThat(result.missing.map { it.encodedPath })
.containsExactly("/dav/tasks/2.ics", "/dav/tasks/3.ics")
}
@Test fun `a fetch that never got anything still fails`() {
server.enqueue(MockResponse().setResponseCode(500))
assertThat(collection.fetch(listOf(href("one.ics"))).isFailure).isTrue()
}
// ---------------------------------------------------------------- create
@Test fun `create with a strong etag is stored`() {
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"new\""))
val outcome = collection.create("one.ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")
assertThat(outcome).isInstanceOf(PutOutcome.Stored::class.java)
assertThat((outcome as PutOutcome.Stored).eTag).isEqualTo(ETag("new", weak = false))
assertThat(server.takeRequest().getHeader("If-None-Match")).isEqualTo("*")
}
@Test fun `create with a weak etag needs a refetch`() {
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "W/\"new\""))
// A weak tag is worse than none: storing it makes every later write look
// conditional while silently not being one.
assertThat(collection.create("one.ics", "x"))
.isInstanceOf(PutOutcome.StoredNeedsRefetch::class.java)
}
@Test fun `create with no etag needs a refetch`() {
server.enqueue(MockResponse().setResponseCode(201))
assertThat(collection.create("one.ics", "x"))
.isInstanceOf(PutOutcome.StoredNeedsRefetch::class.java)
}
@Test fun `create answered 412 means the name is taken`() {
server.enqueue(MockResponse().setResponseCode(412))
val outcome = collection.create("one.ics", "x")
assertThat(outcome).isInstanceOf(PutOutcome.NameTaken::class.java)
assertThat((outcome as PutOutcome.NameTaken).href.encodedPath)
.isEqualTo("/dav/tasks/one.ics")
}
@Test fun `create answered 415 is rejected and not retried`() {
server.enqueue(MockResponse().setResponseCode(415))
val outcome = collection.create("one.ics", "x")
assertThat(outcome).isInstanceOf(PutOutcome.Rejected::class.java)
assertThat((outcome as PutOutcome.Rejected).code).isEqualTo(415)
}
// ---------------------------------------------------------------- update
@Test fun `update answered 412 with the resource still there is a conflict`() {
server.enqueue(MockResponse().setResponseCode(412))
server.enqueue(MockResponse().setResponseCode(200))
assertThat(collection.update(href("one.ics"), "old", "x"))
.isEqualTo(PutOutcome.ServerNewer)
assertThat(server.takeRequest().getHeader("If-Match")).isEqualTo("\"old\"")
assertThat(server.takeRequest().method).isEqualTo("HEAD")
}
@Test fun `update answered 412 with the resource gone is not a conflict`() {
server.enqueue(MockResponse().setResponseCode(412))
server.enqueue(MockResponse().setResponseCode(404))
// RFC 9110 §13.1.1 requires 412 once the precondition is evaluated, so a
// deleted resource and a changed one are the same status code.
assertThat(collection.update(href("one.ics"), "old", "x"))
.isEqualTo(PutOutcome.Vanished)
}
@Test fun `update without an etag writes unconditionally`() {
server.enqueue(MockResponse().setResponseCode(204).setHeader("ETag", "\"new\""))
collection.update(href("one.ics"), null, "x")
assertThat(server.takeRequest().getHeader("If-Match")).isNull()
}
// ---------------------------------------------------------------- delete
@Test fun `delete counts 404 and 410 as success`() {
server.enqueue(MockResponse().setResponseCode(404))
assertThat(collection.delete(href("one.ics"), "e")).isEqualTo(DeleteOutcome.Deleted)
server.enqueue(MockResponse().setResponseCode(410))
assertThat(collection.delete(href("one.ics"), "e")).isEqualTo(DeleteOutcome.Deleted)
}
@Test fun `delete sends If-Match and reports a conflict`() {
server.enqueue(MockResponse().setResponseCode(412))
server.enqueue(MockResponse().setResponseCode(200))
assertThat(collection.delete(href("one.ics"), "old"))
.isEqualTo(DeleteOutcome.ServerNewer)
assertThat(server.takeRequest().getHeader("If-Match")).isEqualTo("\"old\"")
}
@Test fun `delete answered 403 is rejected`() {
// Nextcloud's trashbin renames a deleted resource, so delete, recreate and
// delete again on the same href answers 403.
server.enqueue(MockResponse().setResponseCode(403))
val outcome = collection.delete(href("one.ics"), null)
assertThat(outcome).isInstanceOf(DeleteOutcome.Rejected::class.java)
assertThat((outcome as DeleteOutcome.Rejected).code).isEqualTo(403)
}
// --------------------------------------------------------------- headers
@Test fun `writes carry Prefer strict and identity encoding`() {
val client = CalDavHttp.anonymous("Agendula test")
val withHeaders = CalendarCollection(client, server.url("/dav/tasks/"))
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e\""))
withHeaders.create("one.ics", "x")
val request = server.takeRequest()
// Without this, sabre runs vobject REPAIR over the body and then withholds
// the ETag because the stored bytes are no longer ours.
assertThat(request.getHeader("Prefer")).isEqualTo("handling=strict")
// Without this, a gzip-compressing proxy weakens every ETag it touches.
assertThat(request.getHeader("Accept-Encoding")).isEqualTo("identity")
}
@Test fun `a caller's own Prefer survives beside ours`() {
val client = CalDavHttp.anonymous("Agendula test")
server.enqueue(MockResponse().setResponseCode(200))
client.newCall(
okhttp3.Request.Builder()
.url(server.url("/dav/tasks/one.ics"))
.header("Prefer", "return=minimal")
.put("x".toRequestBody())
.build(),
).execute().close()
// Replacing it would silently drop whatever the caller asked for.
assertThat(server.takeRequest().getHeader("Prefer"))
.isEqualTo("return=minimal, handling=strict")
}
@Test fun `a body gzipped despite identity is still readable`() {
val client = CalDavHttp.anonymous("Agendula test")
val body = okio.Buffer()
okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") }
server.enqueue(
MockResponse().setResponseCode(200)
.setHeader("Content-Encoding", "gzip")
.setBody(body),
)
val response = client.newCall(
okhttp3.Request.Builder().url(server.url("/dav/tasks/one.ics")).build(),
).execute()
// ⚠️ Asking for identity takes OkHttp's transparent gunzip out of the
// loop, so a proxy that compresses anyway hands raw deflate to the
// iCalendar parser and a good resource is quarantined as unreadable.
assertThat(response.use { it.body!!.string() }).isEqualTo("BEGIN:VCALENDAR")
}
// ----------------------------------------------------------------- state
@Test fun `state re-reads read-only and shared`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/</href>
<propstat><prop>
<resourcetype>
<collection/>
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
<CS:shared xmlns:CS="http://calendarserver.org/ns/"/>
</resourcetype>
<current-user-privilege-set>
<privilege><read/></privilege>
</current-user-privilege-set>
<CS:getctag xmlns:CS="http://calendarserver.org/ns/">ct-1</CS:getctag>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
val state = collection.state().getOrThrow()
assertThat(state.collection.readOnly).isTrue()
assertThat(state.collection.isShared).isTrue()
assertThat(state.ctag).isEqualTo("ct-1")
}
@Test fun `a member reported alongside self never becomes the collection state`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/member.ics</href>
<propstat><prop>
<resourcetype>
<collection/>
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
</resourcetype>
<current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
<response>
<href>/dav/tasks/</href>
<propstat><prop>
<resourcetype>
<collection/>
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
</resourcetype>
<CS:getctag xmlns:CS="http://calendarserver.org/ns/">mine</CS:getctag>
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
// Some servers answer a Depth-0 PROPFIND with a member as well. Taking the
// first classifiable response would read that member's ACL as the
// collection's own — here, read-only when it is not.
val state = collection.state().getOrThrow()
assertThat(state.ctag).isEqualTo("mine")
assertThat(state.collection.readOnly).isFalse()
}
@Test fun `state fails rather than guessing when the collection is no longer one`() {
server.enqueue(
multistatus(
"""
<response>
<href>/dav/tasks/</href>
<propstat><prop><resourcetype><collection/></resourcetype></prop>
<status>HTTP/1.1 200 OK</status></propstat>
</response>
""",
),
)
assertThat(collection.state().isFailure).isTrue()
}
// ----------------------------------------------------------------- setup
private fun href(name: String): HttpUrl = server.url("/dav/tasks/$name")
private fun multistatus(responses: String) = MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns=\"DAV:\">$responses</multistatus>")
}
@@ -0,0 +1,181 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
/**
* The collection-management side, against a server rather than a mock of one.
*
* The three things worth pinning are the three that fail silently: which method
* gets used, whether the request says `VTODO`, and whether a colour goes out in
* a spelling anything can read back.
*/
class CollectionAdminTest {
private lateinit var server: MockWebServer
private lateinit var admin: DavCollectionAdmin
@Before fun setUp() {
server = MockWebServer().apply { start() }
admin = DavCollectionAdmin(CalDavHttp.anonymous("test"))
}
@After fun tearDown() = server.shutdown()
private fun homeSet() = server.url("/dav/calendars/me/")
@Test
fun `MKCALENDAR is read out of Allow and extended MKCOL out of DAV`() {
// The normal shape for an extended-MKCOL server: plain MKCOL in Allow,
// and the class advertised only in DAV. Reading Allow alone reports
// iCloud as unable to create anything at all.
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Allow", "OPTIONS, GET, PROPFIND, MKCOL")
.setHeader("DAV", "1, 2, 3, calendar-access, extended-mkcol"),
)
val support = admin.support(homeSet())
assertThat(support.mkCalendar).isFalse()
assertThat(support.extendedMkCol).isTrue()
assertThat(support.canCreate).isTrue()
}
@Test
fun `a server offering neither offers nothing`() {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Allow", "OPTIONS, GET, PROPFIND")
.setHeader("DAV", "1, 2, calendar-access"),
)
// Posteo disables collection creation and Google has neither. The
// affordance has to be gone rather than answering 405 at the end of a
// form the user has already filled in.
assertThat(admin.support(homeSet()).canCreate).isFalse()
}
@Test
fun `an OPTIONS that never answers reads as no`() {
server.shutdown()
assertThat(admin.support(homeSet())).isEqualTo(CollectionSupport.NONE)
}
@Test
fun `creating asks for VTODO, by MKCALENDAR where it exists`() {
server.enqueue(MockResponse().setResponseCode(201))
val outcome = admin.create(
homeSet = homeSet(),
name = "shopping",
displayName = "Shopping",
color = 0xFF4C6FFF.toInt(),
support = CollectionSupport(mkCalendar = true, extendedMkCol = true),
)
val request = server.takeRequest()
assertThat(request.method).isEqualTo("MKCALENDAR")
// ⚠️ The trailing slash is load-bearing: without it every later
// `resolve` against this URL lands in the parent collection.
assertThat(request.path).isEqualTo("/dav/calendars/me/shopping/")
val body = request.body.readUtf8()
// A calendar created without the component set gets the server's
// default, which on several is events-only — a task list that refuses
// tasks.
assertThat(body).contains("VTODO")
assertThat(body).contains("Shopping")
// #RRGGBBAA, not the packed Int, which prints as "-11702273".
assertThat(body).contains("#4C6FFFFF")
assertThat(outcome).isInstanceOf(CollectionOutcome.Created::class.java)
}
@Test
fun `a server with only extended MKCOL gets a resourcetype instead`() {
server.enqueue(MockResponse().setResponseCode(201))
admin.create(
homeSet = homeSet(),
name = "shopping",
displayName = "Shopping",
color = null,
support = CollectionSupport(mkCalendar = false, extendedMkCol = true),
)
val request = server.takeRequest()
assertThat(request.method).isEqualTo("MKCOL")
// The whole difference between the two: here the request says what the
// collection is, rather than the method.
assertThat(request.body.readUtf8()).contains("resourcetype")
}
@Test
fun `a server with neither is refused before a request is made`() {
val outcome = admin.create(
homeSet = homeSet(),
name = "shopping",
displayName = "Shopping",
color = null,
support = CollectionSupport.NONE,
)
assertThat(outcome).isEqualTo(CollectionOutcome.Unsupported)
assertThat(server.requestCount).isEqualTo(0)
}
@Test
fun `a refusal carries its status rather than becoming a failure`() {
// Nextcloud's trashbin renames a deleted collection instead of removing
// it, so re-creating one under a used name answers 403 for ever. The
// caller retries under a fresh segment, which it can only do if it can
// tell "refused" from "unreachable".
server.enqueue(MockResponse().setResponseCode(403))
val outcome = admin.create(
homeSet = homeSet(),
name = "shopping",
displayName = "Shopping",
color = null,
support = CollectionSupport(mkCalendar = true, extendedMkCol = false),
)
assertThat((outcome as CollectionOutcome.Refused).code).isEqualTo(403)
}
@Test
fun `a rename and a recolour go out as one PROPPATCH`() {
server.enqueue(MockResponse().setResponseCode(207).setBody("<d:multistatus xmlns:d=\"DAV:\"/>"))
val outcome = admin.updateProperties(
url = server.url("/dav/calendars/me/shopping/"),
displayName = "Groceries",
color = 0xFF00FF00.toInt(),
)
val request = server.takeRequest()
val body = request.body.readUtf8()
assertThat(request.method).isEqualTo("PROPPATCH")
// One request, not two: a rename and a recolour are one edit as far as
// the user is concerned, and half of one landing is the worse outcome.
assertThat(body).contains("Groceries")
assertThat(body).contains("#00FF00FF")
assertThat(outcome).isEqualTo(CollectionOutcome.Updated)
}
@Test
fun `a collection that is already gone counts as deleted`() {
server.enqueue(MockResponse().setResponseCode(404))
// The point of a DELETE is for the thing to be absent, and it is.
// Treating "already gone" as a failure leaves a row nothing can remove.
assertThat(admin.delete(server.url("/dav/calendars/me/shopping/")))
.isEqualTo(CollectionOutcome.Updated)
}
}
@@ -0,0 +1,45 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ETagTest {
@Test fun `strong tag is unquoted and usable`() {
val tag = ETag.parse("\"abc123\"")
assertThat(tag).isEqualTo(ETag("abc123", weak = false))
assertThat(tag!!.usable).isTrue()
}
@Test fun `weak tag keeps its value but is never usable`() {
val tag = ETag.parse("W/\"abc123\"")
assertThat(tag).isEqualTo(ETag("abc123", weak = true))
// RFC 9110 §13.1.1 — a weak tag cannot be used with If-Match, so anything
// that treats this as a validator writes unconditionally without saying so.
assertThat(tag!!.usable).isFalse()
}
@Test fun `unquoted tag is accepted`() {
assertThat(ETag.parse("abc123")).isEqualTo(ETag("abc123", weak = false))
}
@Test fun `absent and empty are both null`() {
assertThat(ETag.parse(null)).isNull()
assertThat(ETag.parse("")).isNull()
assertThat(ETag.parse(" ")).isNull()
assertThat(ETag.parse("\"\"")).isNull()
}
@Test fun `a parsed property is not re-parsed`() {
// GetETag has already stripped the marker and recorded it separately, so
// running its value back through parse() reports every weak tag as strong.
val property = at.bitfire.dav4jvm.property.GetETag("W/\"abc\"")
assertThat(ETag.from(property)).isEqualTo(ETag("abc", weak = true))
assertThat(ETag.parse(property.eTag)).isEqualTo(ETag("abc", weak = false))
}
@Test fun `a value of W is not a weak marker`() {
// "W/" needs something after it; the guard is length, not prefix alone.
assertThat(ETag.parse("\"W/\"")).isEqualTo(ETag("W/", weak = false))
}
}
@@ -0,0 +1,399 @@
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()
}
/** As production builds it: DavResource requires no automatic redirects. */
private fun noRedirectFlow() = NextcloudLoginFlow(
OkHttpClient.Builder().followRedirects(false).build(),
userAgent = "Agendula/1.0 (Pixel 8)",
)
@Test
fun `a redirected init is followed, and stays a POST`() {
// ⚠️ Apex to www, a trailing slash, a proxy — Nextcloud canonicalises
// with a 301 and the client cannot follow on its own. The method has to
// survive: this route answers 405 to a GET.
val redirecting = noRedirectFlow()
server.enqueue(
MockResponse().setResponseCode(301)
.setHeader("Location", server.url("/nc/index.php/login/v2").toString()),
)
server.enqueue(
json(
"""
{"poll":{"token":"tok-123","endpoint":"${server.url("/nc/index.php/login/v2/poll")}"},
"login":"${server.url("/nc/index.php/login/v2/flow/abc")}"}
""".trimIndent(),
),
)
val started = redirecting.start(server.url("/"), now = 0).getOrThrow()
assertThat(started.pollToken).isEqualTo("tok-123")
server.takeRequest()
val followed = server.takeRequest()
assertThat(followed.path).isEqualTo("/nc/index.php/login/v2")
assertThat(followed.method).isEqualTo("POST")
}
@Test
fun `a redirected poll is followed rather than read as a server error`() {
// ⚠️ After approval. Reporting SERVER_ERROR here abandons a password the
// server has already minted and can never hand back again.
val redirecting = noRedirectFlow()
val started = NextcloudLoginFlow.Flow(
loginUrl = server.url("/index.php/login/v2/flow/abc"),
pollEndpoint = server.url("/index.php/login/v2/poll"),
pollToken = "tok-123",
deadlineEpochSeconds = 1_200,
)
server.enqueue(
MockResponse().setResponseCode(308)
.setHeader("Location", server.url("/nc/login/v2/poll").toString()),
)
server.enqueue(
json("""{"server":"${server.url("/")}","loginName":"me","appPassword":"secret-app-pw"}"""),
)
val result = redirecting.poll(started, now = 1)
assertThat(result).isInstanceOf(NextcloudLoginFlow.PollResult.Approved::class.java)
assertThat((result as NextcloudLoginFlow.PollResult.Approved).credentials.appPassword)
.isEqualTo("secret-app-pw")
server.takeRequest()
val followed = server.takeRequest()
assertThat(followed.method).isEqualTo("POST")
assertThat(followed.body.readUtf8()).contains("token=tok-123")
}
@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 `an unreachable claimed origin is replaced, and reported`() {
val started = startFlow()
server.enqueue(
json("""{"server":"http://nextcloud:11000/","loginName":"me","appPassword":"pw"}"""),
)
val result = flow.poll(started, now = 1) as NextcloudLoginFlow.PollResult.Approved
// We talk to the host that just answered us, not the one it named.
assertThat(result.credentials.server.host).isEqualTo(server.hostName)
// And the user is told which setting is wrong, rather than being sent
// away with "no task lists" for what is a DNS failure.
assertThat(result.hostMismatch?.actual).isEqualTo("nextcloud")
}
@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")
}
@Test fun `a downgraded server URL in the poll response is coerced, not refused`() {
// ⚠️ The credential has already been issued and the 200 comes exactly
// once — the row is deleted inside poll() before it answers. Throwing
// here destroys a live app password and leaves it dangling in the user's
// device list, for no protection at all.
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val downgraded = "http://cloud.example.com".toHttpUrl()
val coerced = flow.secureOrigin(expected, downgraded)
assertThat(coerced.scheme).isEqualTo("https")
assertThat(coerced.host).isEqualTo("cloud.example.com")
}
@Test fun `a cleartext port does not survive the coercion`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val downgraded = "http://cloud.example.com:8080/".toHttpUrl()
// ⚠️ OkHttp only drops a *default* port across a scheme change, so
// :8080 would be carried into https — a port that almost certainly
// speaks cleartext, turning the promised coercion into a handshake
// failure. The port that answered the poll is the one known to work.
val coerced = flow.secureOrigin(expected, downgraded)
assertThat(coerced.scheme).isEqualTo("https")
assertThat(coerced.port).isEqualTo(443)
}
@Test fun `an already-secure server URL is left alone`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val actual = "https://dav.example.com".toHttpUrl()
// secureOrigin keeps its narrow job: the scheme. Which origin we then
// actually talk to is reachableOrigin's decision.
assertThat(flow.secureOrigin(expected, actual)).isEqualTo(actual)
}
@Test fun `an origin outside the polled domain is replaced by the one that answered`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
// ⚠️ The ordinary reverse-proxied install: the poll endpoint comes from
// overwrite.cli.url and is right, while `server` is built from the
// approving request's own Host header and is an internal name the phone
// cannot resolve. Following it strands the user with a spent password.
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
assertThat(reachable.host).isEqualTo("cloud.example.com")
assertThat(reachable.isHttps).isTrue()
assertThat(reachable.encodedPath).isEqualTo("/")
}
@Test fun `a sibling host is used verbatim and not reported as a mismatch`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val polled = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
val claimed = "https://nc.example.com/".toHttpUrl()
// ⚠️ hostMismatchOf compares hosts exactly; reachableOrigin substitutes
// only across registrable domains. Reporting the raw comparison tells the
// user we replaced an address we in fact used, and blames a correct
// setting — stickily, for the rest of the flow.
val used = flow.reachableOrigin(polled, claimed)
assertThat(used).isEqualTo(claimed)
assertThat(flow.substitutedMismatch(polled, claimed, used)).isNull()
}
@Test fun `a substituted host is reported`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val polled = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
val claimed = "http://nextcloud:11000/".toHttpUrl()
val used = flow.reachableOrigin(polled, claimed)
assertThat(flow.substitutedMismatch(polled, claimed, used)?.actual).isEqualTo("nextcloud")
}
@Test fun `a subdirectory install without index_php keeps its prefix`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
// htaccess.IgnoreFrontController drops index.php from generated routes.
val expected = "https://cloud.example.com/nc/login/v2/poll".toHttpUrl()
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
assertThat(reachable.encodedPath).isEqualTo("/nc/")
}
@Test fun `a sibling host in the same domain is still honoured`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
val actual = "https://dav.example.com/".toHttpUrl()
// A real deployment shape, and the credential is scoped to that
// registrable domain anyway.
assertThat(flow.reachableOrigin(expected, actual)).isEqualTo(actual)
}
@Test fun `a subdirectory install keeps its prefix when the host is replaced`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected =
"https://cloud.example.com/nextcloud/index.php/login/v2/poll".toHttpUrl()
// ⚠️ The container's own webroot is empty, so the claimed URL carries no
// prefix at all. Keeping the claimed path alongside the polled host would
// drop /nextcloud and send discovery to the wrong base — with the app
// password already spent.
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
assertThat(reachable.host).isEqualTo("cloud.example.com")
assertThat(reachable.encodedPath).isEqualTo("/nextcloud/")
}
@Test fun `a webroot install is rebuilt at the root`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/whatever".toHttpUrl())
// The claimed path goes with the claimed host: both came from the
// generator we just decided not to trust.
assertThat(reachable.encodedPath).isEqualTo("/")
}
@Test fun `a single-label host is compared exactly, not by a null domain`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://localhost:8443/index.php/login/v2/poll".toHttpUrl()
// topPrivateDomain() is null for both sides here; falling back to the
// exact host is what keeps a homelab install working.
assertThat(flow.reachableOrigin(expected, "https://localhost:8443/".toHttpUrl()).host)
.isEqualTo("localhost")
assertThat(flow.reachableOrigin(expected, "https://elsewhere/".toHttpUrl()).host)
.isEqualTo("localhost")
}
@Test fun `the login URL is still refused outright when downgraded`() {
// Before approval there is nothing to lose by refusing, and the login URL
// is where the *account* password gets typed.
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val failure = runCatching {
flow.requireSecureOrigin(
"https://cloud.example.com".toHttpUrl(),
"http://cloud.example.com/login".toHttpUrl(),
)
}.exceptionOrNull()
assertThat(failure).isNotNull()
}
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class ResourceNamesTest {
@Test fun `an ordinary uid becomes itself`() {
assertThat(ResourceNames.forUid("a1b2c3")).isEqualTo("a1b2c3.ics")
}
@Test fun `path separators never survive`() {
// The whole point: a UID is opaque text, an href is a path segment.
assertThat(ResourceNames.forUid("../../etc/passwd")).doesNotContain("/")
assertThat(ResourceNames.forUid("a/b")).isEqualTo("a-b.ics")
}
@Test fun `at signs are excluded even though they are legal in a path`() {
assertThat(ResourceNames.forUid("task@example.com")).isEqualTo("task-example.com.ics")
}
@Test fun `the basename is capped`() {
val name = ResourceNames.forUid("x".repeat(500))
assertThat(name.removeSuffix(".ics")).hasLength(ResourceNames.MAX_BASENAME_BYTES)
}
@Test fun `a uid with nothing usable in it falls back to a uuid`() {
val name = ResourceNames.forUid("///")
assertThat(name).endsWith(".ics")
assertThat(name.removeSuffix(".ics")).matches("[0-9a-f-]{36}")
}
@Test fun `dots alone do not become a relative path`() {
assertThat(ResourceNames.forUid("..")).doesNotContain("..")
assertThat(ResourceNames.forUid(".")).endsWith(".ics")
}
@Test fun `random names do not repeat`() {
assertThat(ResourceNames.random()).isNotEqualTo(ResourceNames.random())
}
@Test
fun `a collection name keeps no extension`() {
// A collection is a directory, not a file. `.ics` on the end of one is
// wrong in a way that only shows up when someone looks at the server.
assertThat(ResourceNames.forCollection("Shopping")).isEqualTo("Shopping")
}
@Test
fun `a name that does not survive sanitising gets a random segment`() {
// "Einkäufe 🛒" is an ordinary thing to call a list, and none of it is
// in the safe class. A random segment is the honest answer; the display
// name the user sees is unaffected.
val name = ResourceNames.forCollection("🛒")
assertThat(name).isNotEmpty()
assertThat(name).doesNotContain("🛒")
assertThat(name).doesNotContain(ResourceNames.EXTENSION)
}
@Test
fun `two random collection segments differ`() {
// The retry after Nextcloud's trashbin 403 depends on this: re-creating
// under a segment used before answers 403 for ever.
assertThat(ResourceNames.randomCollection())
.isNotEqualTo(ResourceNames.randomCollection())
}
}
@@ -0,0 +1,180 @@
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.Ignore
import org.junit.Test
/**
* The per-server trap matrix.
*
* Everything reproducible from the protocol alone runs here against
* MockWebServer. The handful that genuinely need a live server are `@Ignore`d
* with the reason spelled out — they are named so they can be turned on by hand,
* not deleted and rediscovered.
*/
class ServerMatrixTest {
private val server = MockWebServer()
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
private lateinit var collection: CalendarCollection
@Before fun start() {
server.start()
collection = CalendarCollection(httpClient, server.url("/dav/tasks/"))
}
@After fun stop() = server.shutdown()
// ------------------------------------------------------------- Nextcloud
@Test fun `Nextcloud per-calendar UID uniqueness is a rejection, not a retry`() {
// 409 no-uid-conflict: another resource in this collection already has
// that UID. Retrying the same body reproduces it exactly.
server.enqueue(
MockResponse().setResponseCode(409)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"<D:error xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">" +
"<C:no-uid-conflict/></D:error>",
),
)
val outcome = collection.create("one.ics", "x")
assertThat(outcome).isInstanceOf(PutOutcome.Rejected::class.java)
assertThat((outcome as PutOutcome.Rejected).code).isEqualTo(409)
}
@Test fun `Nextcloud's trashbin makes a reused href answer 403`() {
// The trashbin renames a deleted resource to `<name>-deleted.ics`, so
// delete, recreate and delete again at the same href returns 403. Task
// apps hit this constantly because they reuse hrefs.
server.enqueue(MockResponse().setResponseCode(403))
val outcome = collection.delete(server.url("/dav/tasks/one.ics"), "e")
// Not "already gone" and not a conflict — a refusal that must be
// quarantined rather than retried forever.
assertThat(outcome).isInstanceOf(DeleteOutcome.Rejected::class.java)
assertThat((outcome as DeleteOutcome.Rejected).code).isEqualTo(403)
}
@Test fun `a shared Nextcloud calendar is recognised as shared`() {
// ⚠️ The whole mutilation guard hangs off this bit. `CalendarObject::get()`
// serves a whitelist-reduced body from a share while leaving the ETag
// untouched, so an engine that thinks the collection is not shared will
// write that reduced body back over the owner's task.
server.enqueue(
MockResponse().setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"""
<multistatus xmlns="DAV:">
<response>
<href>/dav/tasks/</href>
<propstat><prop><resourcetype>
<collection/>
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
<CS:shared xmlns:CS="http://calendarserver.org/ns/"/>
</resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
</multistatus>
""".trimIndent(),
),
)
assertThat(collection.state().getOrThrow().collection.isShared).isTrue()
}
// ------------------------------------------------------------------ SOGo
@Test fun `SOGo's second-granularity token is carried verbatim`() {
// SOGo's tokens are second-granularity integers rather than URIs. Nothing
// may parse, normalise or compare them as anything but opaque text.
server.enqueue(
MockResponse().setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"<multistatus xmlns=\"DAV:\"><sync-token>1730000000</sync-token></multistatus>",
),
)
val page = collection.changes("1729999999") as ChangeSet.Page
assertThat(page.token).isEqualTo("1730000000")
assertThat(server.takeRequest().body.readUtf8()).contains("1729999999")
}
@Test fun `SOGo's main calendar survives classification`() {
// ⚠️ SOGo reports collection + calendar + schedule-outbox together for
// every non-Apple client. The obvious "exclude schedule-outbox" rule
// drops the user's only calendar.
server.enqueue(
MockResponse().setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"""
<multistatus xmlns="DAV:">
<response>
<href>/dav/tasks/</href>
<propstat><prop><resourcetype>
<collection/>
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
<C:schedule-outbox xmlns:C="urn:ietf:params:xml:ns:caldav"/>
</resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
</multistatus>
""".trimIndent(),
),
)
assertThat(collection.state().isSuccess).isTrue()
}
// -------------------------------------------------------------- Radicale
@Test fun `Radicale advertising sync-collection without meaning it degrades`() {
// Radicale advertised the report for years without implementing it, so a
// 501 must fall back rather than fail the collection.
server.enqueue(MockResponse().setResponseCode(501))
assertThat(collection.changes("t")).isEqualTo(ChangeSet.Unsupported)
}
// ---------------------------------------------------- needs a real server
@Ignore(
"Needs a live Baikal on dav_auth_type = Digest. OkHttp has no Digest of " +
"its own (square/okhttp#205), so this exercises the vendored " +
"BasicDigestAuthHandler against a real challenge/nonce cycle, which " +
"MockWebServer cannot reproduce faithfully.",
)
@Test fun `Baikal on Digest authenticates`() = Unit
@Ignore(
"Needs a live Nextcloud with a calendar shared read-write from another " +
"account, holding a CLASS:CONFIDENTIAL task. Verifies that we never " +
"write back the whitelist-reduced body CalendarObject::get() serves " +
"— the single most destructive bug available here, and one no mock " +
"can prove absent because the reduction happens server-side.",
)
@Test fun `a confidential task on a shared Nextcloud calendar survives a round trip`() = Unit
@Ignore(
"Needs a live SOGo. Its ETag is a row-version counter and the body is " +
"regenerated per principal, so the same ETag can accompany different " +
"bytes. Proving we do not silently keep a stale body needs the real " +
"server's regeneration behaviour.",
)
@Test fun `an ETag-unchanged body change on SOGo is detected`() = Unit
@Ignore(
"Needs a live Nextcloud. MKCALENDAR is rate-limited to 10 per hour, " +
"which is the behaviour under test and cannot be mocked usefully.",
)
@Test fun `Nextcloud rate-limits collection creation`() = Unit
}
@@ -0,0 +1,50 @@
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 service that cannot be synced is offered last, not omitted`() {
val offered = CalDavProvider.selectable
// Listed, because people go looking for it and an absent row reads as
// the app being unfinished rather than as Google's own limitation.
assertThat(offered).contains(CalDavProvider.GOOGLE)
assertThat(offered.last()).isEqualTo(CalDavProvider.GOOGLE)
// Everything else keeps declaration order — the sort only moves the
// dead rows, so Nextcloud stays the first thing offered.
assertThat(offered.first()).isEqualTo(CalDavProvider.NEXTCLOUD)
assertThat(offered.none { ServerQuirk.forProvider(it)?.isFatal == true && it != CalDavProvider.GOOGLE })
.isTrue()
}
@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,223 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import org.junit.Test
/**
* The 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"))))
val candidates = urls("https://cloud.example.com/remote.php/dav/", dns)
// The typed path is tried first and the SRV target is never consulted:
// a user who gave us an address meant it.
assertThat(candidates.first()).isEqualTo("https://cloud.example.com/remote.php/dav/")
assertThat(candidates).doesNotContain("https://dav.example.com:8443/")
// The well-known follows as a fallback, because a typed path may have
// been a guess — see `a typed deep URL is tried as typed, first`.
assertThat(candidates)
.containsExactly(
"https://cloud.example.com/remote.php/dav/",
"https://cloud.example.com/.well-known/caldav",
).inOrder()
}
@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 `an SRV target outside the queried domain is refused`() {
// Plain UDP DNS, no DNSSEC, and the ladder is walked again after a 401
// with the authenticated client. The credential is scoped to the typed
// address's registrable domain anyway, so this target could only ever
// answer 401.
val dns = FakeDns(
srv = mapOf(
"_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "dav.attacker.test.")),
),
)
val candidates = urls("me@example.com", dns)
assertThat(candidates).doesNotContain("https://dav.attacker.test/.well-known/caldav")
// The typed domain's own ladder still runs.
assertThat(candidates).contains("https://example.com/.well-known/caldav")
}
@Test
fun `a subdomain SRV target is still followed`() {
val dns = FakeDns(
srv = mapOf(
"_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "caldav.example.com.")),
),
)
assertThat(urls("me@example.com", dns))
.contains("https://caldav.example.com/.well-known/caldav")
}
@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/")
}
// --- a typed URL still gets the RFC 6764 probe -------------------------
@Test fun `a typed bare origin still tries well-known`() {
// ⚠️ The case every user actually types. The origin is the *web UI*, and
// PROPFIND on it returns the 405 any web server answers — which reads as
// "not a CalDAV server" about a working Nextcloud. Found against a real
// server, not by reading.
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com")
.map { it.url.encodedPath }
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
}
@Test fun `a trailing slash is still a bare origin`() {
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/")
.map { it.url.encodedPath }
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
}
@Test fun `a typed deep URL is tried as typed, first`() {
// A deep URL may be the DAV root itself, where one PROPFIND can return
// principal, home-set and collection together.
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/remote.php/dav/")
.map { it.url.encodedPath }
assertThat(paths.first()).isEqualTo("/remote.php/dav/")
// …but the path may have been a guess, so the probe stays as a fallback.
assertThat(paths).contains("/.well-known/caldav")
}
@Test fun `a non-default port survives the origin rebuild`() {
val candidates = ServiceDiscovery.candidatesFor("https://cloud.example.com:8443")
assertThat(candidates.map { it.url.toString() })
.containsExactly(
"https://cloud.example.com:8443/.well-known/caldav",
"https://cloud.example.com:8443/",
).inOrder()
}
@Test fun `an IPv6 literal keeps its brackets through the origin rebuild`() {
// ⚠️ `HttpUrl.host` hands back "fd00::1", not "[fd00::1]", so an origin
// built by interpolating it is a string OkHttp will not parse — both
// candidates were silently dropped and a homelab address reported as
// "not an address".
val candidates = ServiceDiscovery.candidatesFor("https://[fd00::1]:8443")
assertThat(candidates.map { it.url.toString() })
.containsExactly(
"https://[fd00::1]:8443/.well-known/caldav",
"https://[fd00::1]:8443/",
).inOrder()
}
}
@@ -0,0 +1,269 @@
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
import kotlin.time.Instant
class WebDavPushTest {
private val server = MockWebServer()
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
@Before fun start() = server.start()
@After fun stop() = server.shutdown()
private fun collectionUrl() = server.url("/dav/tasks/")
private val requested = Instant.parse("2026-10-01T10:00:00Z")
// ------------------------------------------------------------ discovery
@Test fun `state reads the topic and VAPID key`() {
server.enqueue(stateResponse(push = PUSH_PROPS))
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
assertThat(push).isEqualTo(PushSupport(topic = "O7M1nQ7cKkKTKsoS_j6Z3w", vapidPublicKey = VAPID))
}
@Test fun `state asks for the push properties by name`() {
server.enqueue(stateResponse(push = ""))
CalendarCollection(httpClient, collectionUrl()).state().getOrThrow()
val body = server.takeRequest().body.readUtf8()
assertThat(body).contains("https://bitfire.at/webdav-push")
assertThat(body).contains("transports")
assertThat(body).contains("topic")
}
@Test fun `a topic without a web-push transport is no support`() {
server.enqueue(stateResponse(push = """<P:topic>t1</P:topic>"""))
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
assertThat(push).isNull()
}
@Test fun `a web-push transport without a topic is no support`() {
server.enqueue(stateResponse(push = """<P:transports><P:web-push/></P:transports>"""))
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
assertThat(push).isNull()
}
@Test fun `web-push without a VAPID key is still support`() {
server.enqueue(
stateResponse(push = """<P:transports><P:web-push/></P:transports><P:topic>t1</P:topic>"""),
)
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
assertThat(push).isEqualTo(PushSupport(topic = "t1", vapidPublicKey = null))
}
// ---------------------------------------------------------- registration
@Test fun `register posts the subscription and reads Location and Expires`() {
server.enqueue(
MockResponse()
.setResponseCode(201)
.setHeader("Location", "/dav/subscriptions/io6Efei4ooph")
.setHeader("Expires", "Sat, 03 Oct 2026 07:28:00 GMT"),
)
val outcome = WebDavPush.register(
httpClient, collectionUrl(), "https://up.example.net/abc", "PUBKEY", "AUTH", requested,
)
assertThat(outcome).isEqualTo(
WebDavPush.Registration.Registered(
url = server.url("/dav/subscriptions/io6Efei4ooph"),
expires = Instant.parse("2026-10-03T07:28:00Z"),
),
)
val request = server.takeRequest()
assertThat(request.method).isEqualTo("POST")
assertThat(request.path).isEqualTo("/dav/tasks/")
val body = request.body.readUtf8()
assertThat(body).contains("push-register")
assertThat(body).contains("https://up.example.net/abc")
assertThat(body).contains("aes128gcm")
assertThat(body).contains("PUBKEY")
assertThat(body).contains("AUTH")
assertThat(body).contains("content-update")
assertThat(body).contains("Thu, 01 Oct 2026 10:00:00 GMT")
}
@Test fun `register without Expires keeps the requested date`() {
server.enqueue(MockResponse().setResponseCode(204).setHeader("Location", "/sub/1"))
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", null, null, requested)
assertThat(outcome).isEqualTo(WebDavPush.Registration.Registered(server.url("/sub/1"), requested))
}
@Test fun `register leaves the keys out when there are none`() {
server.enqueue(MockResponse().setResponseCode(204).setHeader("Location", "/sub/1"))
WebDavPush.register(httpClient, collectionUrl(), "https://up/1", null, null, requested)
val body = server.takeRequest().body.readUtf8()
assertThat(body).doesNotContain("content-encoding")
assertThat(body).doesNotContain("subscription-public-key")
}
@Test fun `a 403 is a refusal`() {
server.enqueue(MockResponse().setResponseCode(403))
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested)
assertThat(outcome).isEqualTo(WebDavPush.Registration.Refused(403))
}
@Test fun `a 500 is a failure, not a refusal`() {
server.enqueue(MockResponse().setResponseCode(500))
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested)
assertThat(outcome).isInstanceOf(WebDavPush.Registration.Failed::class.java)
}
@Test fun `a 500 carries sabre's exception message`() {
server.enqueue(
MockResponse().setResponseCode(500)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"""<?xml version="1.0" encoding="utf-8"?>
<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">
<s:exception>TypeError</s:exception>
<s:message>Call to a member function getTimestamp() on bool</s:message>
</d:error>""",
),
)
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested)
assertThat(outcome).isEqualTo(
WebDavPush.Registration.Failed("HTTP 500: Call to a member function getTimestamp() on bool"),
)
}
@Test fun `unregister counts an already expired subscription as removed`() {
server.enqueue(MockResponse().setResponseCode(404))
assertThat(WebDavPush.unregister(httpClient, server.url("/sub/1"))).isTrue()
assertThat(server.takeRequest().method).isEqualTo("DELETE")
}
@Test fun `unregister reports a server error as not removed`() {
server.enqueue(MockResponse().setResponseCode(500))
assertThat(WebDavPush.unregister(httpClient, server.url("/sub/1"))).isFalse()
}
// ---------------------------------------------------------------- writes
@Test fun `writes name our subscription in Push-Dont-Notify, reads do not`() {
val registration = server.url("/sub/1")
val collection = CalendarCollection(httpClient, collectionUrl(), pushRegistration = registration)
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e1\""))
server.enqueue(MockResponse().setResponseCode(204))
server.enqueue(stateResponse(push = ""))
collection.create("a.ics", "BEGIN:VCALENDAR")
collection.delete(collectionUrl().resolve("a.ics")!!, "e1")
collection.state()
assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isEqualTo("\"$registration\"")
assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isEqualTo("\"$registration\"")
assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isNull()
}
// -------------------------------------------------------------- messages
@Test fun `a content update carries its topic and sync token`() {
val message = WebDavPush.parse(
"""
<?xml version="1.0" encoding="utf-8" ?>
<push-message xmlns="https://bitfire.at/webdav-push" xmlns:D="DAV:">
<topic>O7M1nQ7cKkKTKsoS_j6Z3w</topic>
<content-update>
<D:sync-token>http://example.com/sync/10</D:sync-token>
</content-update>
<property-update />
</push-message>
""".trimIndent(),
)
assertThat(message).isEqualTo(
WebDavPush.Message(
topic = "O7M1nQ7cKkKTKsoS_j6Z3w",
syncToken = "http://example.com/sync/10",
keyRotation = false,
),
)
}
@Test fun `a transports update without a topic is a key rotation`() {
val message = WebDavPush.parse(
"""
<?xml version="1.0" encoding="utf-8" ?>
<push-message xmlns="https://bitfire.at/webdav-push" xmlns:D="DAV:">
<property-update>
<D:prop>
<transports />
</D:prop>
</property-update>
</push-message>
""".trimIndent(),
)
assertThat(message).isEqualTo(WebDavPush.Message(topic = null, syncToken = null, keyRotation = true))
}
@Test fun `anything that is not a push message is null`() {
assertThat(WebDavPush.parse("not xml")).isNull()
assertThat(WebDavPush.parse("<multistatus xmlns=\"DAV:\"/>")).isNull()
}
private fun stateResponse(push: String) = MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody(
"""
<multistatus xmlns="DAV:" xmlns:P="https://bitfire.at/webdav-push">
<response>
<href>/dav/tasks/</href>
<propstat><prop>
<resourcetype><collection/><C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/></resourcetype>
$push
</prop><status>HTTP/1.1 200 OK</status></propstat>
</response>
</multistatus>
""".trimIndent(),
)
private companion object {
const val VAPID =
"BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs"
const val PUSH_PROPS = """
<P:transports>
<P:web-push>
<P:vapid-public-key type="p256ecdsa">$VAPID</P:vapid-public-key>
</P:web-push>
</P:transports>
<P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>
<P:supported-triggers>
<P:content-update><depth>1</depth></P:content-update>
</P:supported-triggers>
"""
}
}