sync(chunk 3): the sync engine
Full bidirectional sync, correct but not yet clever: calendar-query with no time-range, calendar-multiget in batches matched against what was asked for, conditional writes, and per-resource quarantine so one bad task cannot stop a collection. - :caldav gains CalendarCollection (list/fetch/create/update/delete + the three-way 412 triage), ETag with its weak flag, vdirsyncer-style resource names, and the RemoteCalendar seam. - CalDavHttp now sends Accept-Encoding: identity and Prefer: handling=strict, the two headers that keep ETags strong and our bytes unrepaired. - :app gains CollectionSyncer (the reconciliation), SyncEngine, SyncStore, QuarantineStore, SyncReport and a working SyncWorker, plus an in-app sync trigger since ContentResolver.requestSync is gated at our targetSdk. - VTimeZones closes a chunk-1 gap: the mapper emitted TZID with no VTIMEZONE to resolve it, which handling=strict turns from a repair into a rejection. /code-review high raised 11 findings, all fixed. The three that mattered: an empty listing swept the whole list (a VTODO comp-filter some servers mishandle is not proof of deletion, so an empty listing now never sweeps); quarantine never covered creates, so a permanently rejected new task was re-PUT forever; and a RELATED-TO deleted on the server was re-uploaded on the next edit. Reasoning recorded in docs/SYNC-PLAN.md. Chunk 2's on-device review is still outstanding; nothing here has been run on a device or against a real server.
This commit is contained in:
@@ -2,15 +2,8 @@ package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
||||
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.DisplayName
|
||||
import at.bitfire.dav4jvm.property.MaxICalendarSize
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.SupportedReportSet
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -38,16 +31,13 @@ class CalDavDiscovery(
|
||||
private val allowCleartext: Boolean = false,
|
||||
) {
|
||||
|
||||
/** Properties requested **by name**, because allprop legitimately omits them. */
|
||||
private val collectionProperties = arrayOf(
|
||||
ResourceType.NAME,
|
||||
DisplayName.NAME,
|
||||
CalendarColor.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
SupportedReportSet.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
MaxICalendarSize.NAME,
|
||||
)
|
||||
/**
|
||||
* 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(
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.caldav
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import at.bitfire.dav4jvm.UrlUtils
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -81,9 +82,39 @@ object CalDavHttp {
|
||||
}
|
||||
|
||||
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) builder.header("Prefer", "handling=strict")
|
||||
chain.proceed(builder.build())
|
||||
}
|
||||
|
||||
private val WRITE_METHODS = setOf("PUT", "POST", "PATCH")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
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(
|
||||
private val httpClient: OkHttpClient,
|
||||
override val url: HttpUrl,
|
||||
) : RemoteCalendar {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/** [batchSize] is a seam for tests; production always uses [MULTIGET_BATCH]. */
|
||||
internal fun fetch(hrefs: List<HttpUrl>, batchSize: Int): Result<FetchResult> =
|
||||
runCatching {
|
||||
val resources = mutableListOf<RemoteResource>()
|
||||
val unsolicited = mutableListOf<HttpUrl>()
|
||||
val seen = mutableSetOf<HttpUrl>()
|
||||
|
||||
hrefs.chunked(batchSize).forEach { batch ->
|
||||
val wanted = batch.associateBy { it.encodedPath }
|
||||
dav.multiget(batch, MIME_ICALENDAR, ICALENDAR_VERSION) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) return@multiget
|
||||
val asked = wanted[response.href.encodedPath]
|
||||
if (asked == null) {
|
||||
unsolicited += response.href
|
||||
return@multiget
|
||||
}
|
||||
seen += asked
|
||||
if (!response.isSuccess()) return@multiget
|
||||
val body = response[CalendarData::class.java]?.iCalendar ?: return@multiget
|
||||
resources += RemoteResource(
|
||||
href = asked,
|
||||
// The tag from *this* response, paired with *this* body.
|
||||
eTag = ETag.from(response[GetETag::class.java]),
|
||||
iCalendar = body,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FetchResult(
|
||||
resources = resources,
|
||||
missing = hrefs.filterNot { it in seen },
|
||||
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 const val NOT_FOUND = 404
|
||||
private const val GONE = 410
|
||||
private const val PRECONDITION_FAILED = 412
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
|
||||
/** 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>,
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -2,7 +2,10 @@ 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
|
||||
@@ -38,6 +41,23 @@ data class TaskCollection(
|
||||
*/
|
||||
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,
|
||||
)
|
||||
|
||||
/** `CS:shared`, which Nextcloud adds alongside `caldav:calendar` on a share. */
|
||||
private val SHARED = Property.Name("http://calendarserver.org/ns/", "shared")
|
||||
|
||||
@@ -67,16 +87,16 @@ object CollectionClassifier {
|
||||
|
||||
return TaskCollection(
|
||||
url = response.href,
|
||||
displayName = response[at.bitfire.dav4jvm.property.DisplayName::class.java]?.displayName,
|
||||
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[at.bitfire.dav4jvm.property.CalendarColor::class.java]?.color,
|
||||
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[at.bitfire.dav4jvm.property.MaxICalendarSize::class.java]?.maxSize,
|
||||
maxResourceSize = response[MaxICalendarSize::class.java]?.maxSize,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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>>
|
||||
|
||||
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,51 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
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 `fetch batches`() {
|
||||
repeat(2) { server.enqueue(multistatus("")) }
|
||||
collection.fetch((1..3).map { href("$it.ics") }, batchSize = 2).getOrThrow()
|
||||
assertThat(server.requestCount).isEqualTo(2)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- 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,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,41 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user