sync(chunk 2a): vendor dav4jvm 2.2.1 as :dav

JitPack-only publishing conflicts with FAIL_ON_PROJECT_REPOS, artifacts are
unsigned and not immutable, and upstream shipped two breaking majors 19 days
apart. 2.2.1 is the last OkHttp release: 3.0.0 moved to Ktor and 4.x needs
Java 21, and SYNC.md's whole auth section is written in OkHttp terms, so 4.x
would have invalidated it. A plain JVM module, not an Android library — the
tree has no Android imports and keeping it that way keeps the floret-kit
extraction a file move.

Four changes from upstream, all in dav/PROVENANCE.md:
- commons-lang3 dropped (one import, one call). The replacement requires the
  whole string to be consumed, as DateUtils did: pattern 1 ends in a literal
  'GMT', so SimpleDateFormat would match "...GMT+02:00" as a prefix and throw
  the offset away.
- HTTP dates were parsed and formatted in the device's local zone — the GMT in
  the format string is a quoted literal and timeZone was never set, so every
  getlastmodified was out by the local UTC offset. Upstream tests dates
  nowhere. The formatter is also no longer shared and mutable.
- dav4jvm#209: permanent redirects now reach the caller via
  DavResource.permanentLocation, cleared per request so it never describes an
  earlier one.
- xpp3 is compileOnly; Android supplies org.xmlpull.v1.

SYNC.md's other claimed defect, "does not follow 303", is not true of 2.2.1 —
pinned by a test rather than removed from the notes.

CI names :dav:test explicitly; testDebugUnitTest is Android-only and would
have run none of the vendored suite. lintDebug added to the per-chunk checks
after it caught a literal byte-order mark left by chunk 1.
This commit is contained in:
2026-09-04 17:12:27 +02:00
parent a30114efbb
commit 5a81d3c2f5
88 changed files with 7288 additions and 9 deletions
@@ -0,0 +1,294 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.*
import okhttp3.Response
import okio.Buffer
import okio.ByteString.Companion.toByteString
import java.io.IOException
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
/**
* Handler to manage authentication against a given service (may be limited to one domain).
* There's no domain-based cache, because the same user name and password will be used for
* all requests.
*
* Authentication methods/credentials found to be working will be cached for further requests
* (this is why the interceptor is needed).
*
* Usage: Set as authenticator *and* as network interceptor.
*/
class BasicDigestAuthHandler(
/** Authenticate only against hosts ending with this domain (may be null, which means no restriction) */
val domain: String?,
val username: String,
val password: String,
val insecurePreemptive: Boolean = false
): Authenticator, Interceptor {
companion object {
private const val HEADER_AUTHORIZATION = "Authorization"
// cached digest parameters
var clientNonce = h(UUID.randomUUID().toString())
var nonceCount = AtomicInteger(1)
fun quotedString(s: String) = "\"" + s.replace("\"", "\\\"") + "\""
fun h(data: String) = data.toByteArray().toByteString().md5().hex()
fun h(body: RequestBody): String {
val buffer = Buffer()
body.writeTo(buffer)
return buffer.readByteArray().toByteString().md5().hex()
}
fun kd(secret: String, data: String) = h("$secret:$data")
}
// cached authentication schemes
private var basicAuth: Challenge? = null
private var digestAuth: Challenge? = null
fun authenticateRequest(request: Request, response: Response?): Request? {
domain?.let {
val host = request.url.host
if (!domain.equals(UrlUtils.hostToDomain(host), true)) {
Dav4jvm.log.warning("Not authenticating against $host because it doesn't belong to $domain")
return null
}
}
if (response == null) {
// we're not processing a 401 response
if (basicAuth == null && digestAuth == null && (request.isHttps || insecurePreemptive)) {
Dav4jvm.log.fine("Trying Basic auth preemptively")
basicAuth = Challenge("Basic", "")
}
} else {
// we're processing a 401 response
var newBasicAuth: Challenge? = null
var newDigestAuth: Challenge? = null
for (challenge in response.challenges())
when {
"Basic".equals(challenge.scheme, true) -> {
basicAuth?.let {
Dav4jvm.log.warning("Basic credentials didn't work last time -> aborting")
basicAuth = null
return null
}
newBasicAuth = challenge
}
"Digest".equals(challenge.scheme, true) -> {
if (digestAuth != null && !"true".equals(challenge.authParams["stale"], true)) {
Dav4jvm.log.warning("Digest credentials didn't work last time and server nonce has not expired -> aborting")
digestAuth = null
return null
}
newDigestAuth = challenge
}
}
basicAuth = newBasicAuth
digestAuth = newDigestAuth
}
// we MUST prefer Digest auth [https://tools.ietf.org/html/rfc2617#section-4.6]
when {
digestAuth != null -> {
Dav4jvm.log.fine("Adding Digest authorization request for ${request.url}")
return digestRequest(request, digestAuth)
}
basicAuth != null -> {
Dav4jvm.log.fine("Adding Basic authorization header for ${request.url}")
/* In RFC 2617 (obsolete), there was no encoding for credentials defined, although
one can interpret it as "use ISO-8859-1 encoding". This has been clarified by RFC 7617,
which creates a new charset parameter for WWW-Authenticate, which always must be UTF-8.
So, UTF-8 encoding for credentials is compatible with all RFC 7617 servers and many,
but not all pre-RFC 7617 servers. */
return request.newBuilder()
.header(HEADER_AUTHORIZATION, Credentials.basic(username, password, Charsets.UTF_8))
.build()
}
response != null ->
Dav4jvm.log.warning("No supported authentication scheme")
}
return null
}
fun digestRequest(request: Request, digest: Challenge?): Request? {
if (digest == null)
return null
val realm = digest.authParams["realm"]
val opaque = digest.authParams["opaque"]
val nonce = digest.authParams["nonce"]
val algorithm = Algorithm.determine(digest.authParams["algorithm"])
val qop = Protection.selectFrom(digest.authParams["qop"])
// build response parameters
var response: String? = null
val params = LinkedList<String>()
params.add("username=${quotedString(username)}")
if (realm != null)
params.add("realm=${quotedString(realm)}")
else {
Dav4jvm.log.warning("No realm provided, aborting Digest auth")
return null
}
if (nonce != null)
params.add("nonce=${quotedString(nonce)}")
else {
Dav4jvm.log.warning("No nonce provided, aborting Digest auth")
return null
}
if (opaque != null)
params.add("opaque=${quotedString(opaque)}")
if (algorithm != null)
params.add("algorithm=${quotedString(algorithm.algorithm)}")
val method = request.method
val digestURI = request.url.encodedPath
params.add("uri=${quotedString(digestURI)}")
if (qop != null) {
params.add("qop=${qop.qop}")
params.add("cnonce=${quotedString(clientNonce)}")
val nc = nonceCount.getAndIncrement()
val ncValue = String.format(Locale.ROOT, "%08x", nc)
params.add("nc=$ncValue")
val a1: String? = when (algorithm) {
Algorithm.MD5 ->
"$username:$realm:$password"
Algorithm.MD5_SESSION ->
h("$username:$realm:$password") + ":$nonce:$clientNonce"
else ->
null
}
Dav4jvm.log.finer("A1=$a1")
val a2: String? = when (qop) {
Protection.Auth ->
"$method:$digestURI"
Protection.AuthInt -> {
try {
val body = request.body
"$method:$digestURI:" + (if (body != null) h(body) else h(""))
} catch(e: IOException) {
Dav4jvm.log.warning("Couldn't get entity-body for hash calculation")
null
}
}
}
Dav4jvm.log.finer("A2=$a2")
if (a1 != null && a2 != null)
response = kd(h(a1), "$nonce:$ncValue:$clientNonce:${qop.qop}:${h(a2)}")
} else {
Dav4jvm.log.finer("Using legacy Digest auth")
// legacy (backwards compatibility with RFC 2069)
if (algorithm == Algorithm.MD5) {
val a1 = "$username:$realm:$password"
val a2 = "$method:$digestURI"
response = kd(h(a1), nonce + ":" + h(a2))
}
}
return if (response != null) {
params.add("response=" + quotedString(response))
request.newBuilder()
.header(HEADER_AUTHORIZATION, "Digest " + params.joinToString(", "))
.build()
} else
null
}
private enum class Algorithm(
val algorithm: String
) {
MD5("MD5"),
MD5_SESSION("MD5-sess");
companion object {
fun determine(paramValue: String?): Algorithm? {
return when {
paramValue == null || MD5.algorithm.equals(paramValue, true) ->
MD5
MD5_SESSION.algorithm.equals(paramValue, true) ->
MD5_SESSION
else -> {
Dav4jvm.log.warning("Ignoring unknown hash algorithm: $paramValue")
null
}
}
}
}
}
private enum class Protection(
val qop: String
) { // quality of protection:
Auth("auth"), // authentication only
AuthInt("auth-int"); // authentication with integrity protection
companion object {
fun selectFrom(paramValue: String?): Protection? {
paramValue?.let {
var qopAuth = false
var qopAuthInt = false
for (qop in paramValue.split(","))
when (qop) {
"auth" -> qopAuth = true
"auth-int" -> qopAuthInt = true
}
// prefer auth-int as it provides more protection
if (qopAuthInt)
return AuthInt
else if (qopAuth)
return Auth
}
return null
}
}
}
override fun authenticate(route: Route?, response: Response) =
authenticateRequest(response.request, response)
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (request.header(HEADER_AUTHORIZATION) == null) {
// try to apply cached authentication
val authRequest = authenticateRequest(request, null)
if (authRequest != null)
request = authRequest
}
return chain.proceed(request)
}
}
@@ -0,0 +1,40 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
/**
* Callback for the OPTIONS request.
*/
fun interface CapabilitiesCallback {
fun onCapabilities(davCapabilities: Set<String>, response: okhttp3.Response)
}
/**
* Callback for 207 Multi-Status responses.
*/
fun interface MultiResponseCallback {
/**
* Called for every `<response>` element in the `<multistatus>` body. For instance,
* in response to a `PROPFIND` request, this callback will be called once for every found
* member resource.
*
* @param response the parsed response (including URL)
* @param relation relation of the response to the called resource
*/
fun onResponse(response: Response, relation: Response.HrefRelation)
}
/**
* Callback for HTTP responses.
*/
fun interface ResponseCallback {
/**
* Called for a HTTP response. Typically this is only called for successful/redirect
* responses because HTTP errors throw an exception before this callback is called.
*/
fun onResponse(response: okhttp3.Response)
}
@@ -0,0 +1,15 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import java.util.logging.Logger
object Dav4jvm {
var log = Logger.getLogger("dav4jvm")!!
}
@@ -0,0 +1,141 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.AddressData
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetETag
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
import java.util.logging.Logger
class DavAddressBook @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
log: Logger = Dav4jvm.log
): DavCollection(httpClient, location, log) {
companion object {
val MIME_JCARD = "application/vcard+json".toMediaType()
val MIME_VCARD3_UTF8 = "text/vcard;charset=utf-8".toMediaType()
val MIME_VCARD4 = "text/vcard;version=4.0".toMediaType()
val ADDRESSBOOK_QUERY = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-query")
val ADDRESSBOOK_MULTIGET = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-multiget")
val FILTER = Property.Name(XmlUtils.NS_CARDDAV, "filter")
}
/**
* Sends an addressbook-query REPORT request to the resource.
*
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun addressbookQuery(callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT addressbook-query ((DAV:allprop |
DAV:propname |
DAV:prop)?, filter, limit?)>
<!ELEMENT filter (prop-filter*)>
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
serializer.insertTag(ADDRESSBOOK_QUERY) {
insertTag(PROP) {
insertTag(GetETag.NAME)
}
insertTag(FILTER)
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "1")
.build()).execute()
}.use { response ->
return processMultiStatus(response, callback)
}
}
/**
* Sends an addressbook-multiget REPORT request to the resource.
*
* @param urls list of vCard URLs to be requested
* @param contentType MIME type of requested format; may be "text/vcard" for vCard or
* "application/vcard+json" for jCard. *null*: don't request specific representation type
* @param version vCard version subtype of the requested format. Should only be specified together with a [contentType] of "text/vcard".
* Currently only useful value: "4.0" for vCard 4. *null*: don't request specific version
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT addressbook-multiget ((DAV:allprop |
DAV:propname |
DAV:prop)?,
DAV:href+)>
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
serializer.insertTag(ADDRESSBOOK_MULTIGET) {
insertTag(PROP) {
insertTag(GetContentType.NAME)
insertTag(GetETag.NAME)
insertTag(AddressData.NAME) {
if (contentType != null)
attribute(null, AddressData.CONTENT_TYPE, contentType)
if (version != null)
attribute(null, AddressData.VERSION, version)
}
}
for (url in urls)
insertTag(HREF) {
text(url.encodedPath)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "0") // "The request MUST include a Depth: 0 header [...]"
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
@@ -0,0 +1,178 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CalendarData
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.ScheduleTag
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
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.Logger
class DavCalendar @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
log: Logger = Dav4jvm.log
): DavCollection(httpClient, location, log) {
companion object {
val MIME_ICALENDAR = "text/calendar".toMediaType()
val MIME_ICALENDAR_UTF8 = "text/calendar;charset=utf-8".toMediaType()
val CALENDAR_QUERY = Property.Name(XmlUtils.NS_CALDAV, "calendar-query")
val CALENDAR_MULTIGET = Property.Name(XmlUtils.NS_CALDAV, "calendar-multiget")
val FILTER = Property.Name(XmlUtils.NS_CALDAV, "filter")
val COMP_FILTER = Property.Name(XmlUtils.NS_CALDAV, "comp-filter")
const val COMP_FILTER_NAME = "name"
val TIME_RANGE = Property.Name(XmlUtils.NS_CALDAV, "time-range")
const val TIME_RANGE_START = "start"
const val TIME_RANGE_END = "end"
private val timeFormatUTC = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT)
init {
timeFormatUTC.timeZone = TimeZone.getTimeZone("Etc/UTC")
}
}
/**
* Sends a calendar-query REPORT to the resource.
*
* @param component requested component name (like VEVENT or VTODO)
* @param start time-range filter: start date (optional)
* @param end time-range filter: end date (optional)
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun calendarQuery(component: String, start: Date?, end: Date?, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT calendar-query ((DAV:allprop |
DAV:propname |
DAV:prop)?, filter, timezone?)>
<!ELEMENT filter (comp-filter)>
<!ELEMENT comp-filter (is-not-defined | (time-range?,
prop-filter*, comp-filter*))>
<!ATTLIST comp-filter name CDATA #REQUIRED>
name value: a calendar object or calendar component
type (e.g., VEVENT)
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
serializer.insertTag(CALENDAR_QUERY) {
insertTag(PROP) {
insertTag(GetETag.NAME)
}
insertTag(FILTER) {
insertTag(COMP_FILTER) {
attribute(null, COMP_FILTER_NAME, "VCALENDAR")
insertTag(COMP_FILTER) {
attribute(null, COMP_FILTER_NAME, component)
if (start != null || end != null) {
insertTag(TIME_RANGE) {
if (start != null)
attribute(null, TIME_RANGE_START, timeFormatUTC.format(start))
if (end != null)
attribute(null, TIME_RANGE_END, timeFormatUTC.format(end))
}
}
}
}
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "1")
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
/**
* Sends a calendar-multiget REPORT to the resource. Received responses are sent
* to the callback, whether they are successful (2xx) or not.
*
* @param urls list of iCalendar URLs to be requested
* @param contentType MIME type of requested format; may be "text/calendar" for iCalendar or
* "application/calendar+json" for jCard. *null*: don't request specific representation type
* @param version Version subtype of the requested format, like "2.0" for iCalendar 2. *null*: don't request specific version
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun multiget(urls: List<HttpUrl>, contentType: String? = null, version: String? = null, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT calendar-multiget ((DAV:allprop |
DAV:propname |
DAV:prop)?, DAV:href+)>
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
serializer.insertTag(CALENDAR_MULTIGET) {
insertTag(PROP) {
insertTag(GetContentType.NAME) // to determine the character set
insertTag(GetETag.NAME)
insertTag(ScheduleTag.NAME)
insertTag(CalendarData.NAME) {
if (contentType != null)
attribute(null, CalendarData.CONTENT_TYPE, contentType)
if (version != null)
attribute(null, CalendarData.VERSION, version)
}
}
for (url in urls)
insertTag(HREF) {
serializer.text(url.encodedPath)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
@@ -0,0 +1,123 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.io.StringWriter
import java.util.logging.Logger
/**
* Represents a WebDAV collection.
*/
open class DavCollection @JvmOverloads constructor(
httpClient: OkHttpClient,
location: HttpUrl,
log: Logger = Dav4jvm.log
): DavResource(httpClient, location, log) {
companion object {
val SYNC_COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "sync-collection")
val SYNC_LEVEL = Property.Name(XmlUtils.NS_WEBDAV, "sync-level")
val LIMIT = Property.Name(XmlUtils.NS_WEBDAV, "limit")
val NRESULTS = Property.Name(XmlUtils.NS_WEBDAV, "nresults")
}
/**
* Sends a POST request. Primarily intended to be used with an Add-Member URL (RFC 5995).
*/
@Throws(IOException::class, HttpException::class)
fun post(body: RequestBody, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
followRedirects {
val builder = Request.Builder()
.post(body)
.url(location)
if (ifNoneMatch)
// don't overwrite anything existing
builder.header("If-None-Match", "*")
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a REPORT sync-collection request.
*
* @param syncToken sync-token to be sent with the request
* @param infiniteDepth sync-level to be sent with the request: false = "1", true = "infinite"
* @param limit maximum number of results (may cause truncation)
* @param properties WebDAV properties to be requested
* @param callback called for every WebDAV response XML element in the result
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws java.io.IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error
*/
fun reportChanges(syncToken: String?, infiniteDepth: Boolean, limit: Int?, vararg properties: Property.Name, callback: MultiResponseCallback): List<Property> {
/* <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
<!ELEMENT sync-token CDATA> <!-- Text MUST be a URI -->
<!ELEMENT sync-level CDATA> <!-- Text MUST be either "1" or "infinite" -->
<!ELEMENT limit (nresults) >
<!ELEMENT nresults (#PCDATA)> <!-- only digits -->
<!-- DAV:prop defined in RFC 4918, Section 14.18 -->
*/
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.startDocument("UTF-8", null)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.insertTag(SYNC_COLLECTION) {
insertTag(SyncToken.NAME) {
if (syncToken != null)
text(syncToken)
}
insertTag(SYNC_LEVEL) {
text(if (infiniteDepth) "infinite" else "1")
}
if (limit != null)
insertTag(LIMIT) {
insertTag(NRESULTS) {
text(limit.toString())
}
}
insertTag(PROP) {
for (prop in properties)
insertTag(prop)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("REPORT", writer.toString().toRequestBody(MIME_XML))
.header("Depth", "0")
.build()).execute()
}.use {
return processMultiStatus(it, callback)
}
}
}
@@ -0,0 +1,780 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.XmlUtils.insertTag
import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.exception.*
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.EOFException
import java.io.IOException
import java.io.Reader
import java.io.StringWriter
import java.net.HttpURLConnection
import java.util.logging.Level
import java.util.logging.Logger
import at.bitfire.dav4jvm.Response as DavResponse
/**
* Represents a WebDAV resource at the given location and allows WebDAV
* requests to be performed on this resource.
*
* Requests are executed synchronously (blocking). If no error occurs, the given
* callback will be called. Otherwise, an exception is thrown. *These callbacks
* don't need to close the response.*
*
* To cancel a request, interrupt the thread. This will cause the requests to
* throw `InterruptedException` or `InterruptedIOException`.
*
* @param httpClient [OkHttpClient] to access this object (must not follow redirects)
* @param location location of the WebDAV resource
* @param log will be used for logging
*/
open class DavResource @JvmOverloads constructor(
val httpClient: OkHttpClient,
location: HttpUrl,
val log: Logger = Dav4jvm.log
) {
companion object {
const val MAX_REDIRECTS = 5
const val HTTP_MULTISTATUS = 207
/** 301 and 308 — the two redirects a caller should persist. */
const val HTTP_MOVED_PERM = 301
const val HTTP_PERM_REDIRECT = 308
val MIME_XML = "application/xml; charset=utf-8".toMediaType()
val PROPFIND = Property.Name(XmlUtils.NS_WEBDAV, "propfind")
val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate")
val SET = Property.Name(XmlUtils.NS_WEBDAV, "set")
val REMOVE = Property.Name(XmlUtils.NS_WEBDAV, "remove")
val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop")
val HREF = Property.Name(XmlUtils.NS_WEBDAV, "href")
val XML_SIGNATURE = "<?xml".toByteArray()
/**
* Creates a request body for the PROPPATCH request.
*/
internal fun createProppatchXml(
setProperties: Map<Property.Name, String>,
removeProperties: List<Property.Name>
): String {
// build XML request body
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.setPrefix("d", XmlUtils.NS_WEBDAV)
serializer.startDocument("UTF-8", null)
serializer.insertTag(PROPERTYUPDATE) {
// DAV:set
if (setProperties.isNotEmpty()) {
serializer.insertTag(SET) {
for (prop in setProperties) {
serializer.insertTag(PROP) {
serializer.insertTag(prop.key) {
text(prop.value)
}
}
}
}
}
// DAV:remove
if (removeProperties.isNotEmpty()) {
serializer.insertTag(REMOVE) {
for (prop in removeProperties) {
insertTag(PROP) {
insertTag(prop)
}
}
}
}
}
serializer.endDocument()
return writer.toString()
}
}
/**
* URL of this resource (changes when being redirected by server)
*/
/**
* The location reached through an unbroken chain of **permanent** redirects
* (301, 308), or null if none was followed.
*
* Upstream mutates [location] in place for every redirect and tells the
* caller nothing, so a caller cannot distinguish "this resource has moved,
* store the new URL" from "follow this once". DAVx5 consequently never
* rewrites a stored collection URL after a 301 and re-follows it on every
* single sync — bitfireAT/dav4jvm#209, and `docs/SYNC.md` names persisting
* the new URL ourselves as the fix. Local addition; see dav/PROVENANCE.md
* change 3.
*/
var permanentLocation: HttpUrl? = null
private set
var location: HttpUrl
private set // allow internal modification only (for redirects)
init {
// Don't follow redirects (only useful for GET/POST).
// This means we have to handle 30x responses ourselves.
require(!httpClient.followRedirects) { "httpClient must not follow redirects automatically" }
this.location = location
}
override fun toString() = location.toString()
/**
* Gets the file name of this resource. See [HttpUtils.fileName] for details.
*/
fun fileName() = HttpUtils.fileName(location)
/**
* Sends an OPTIONS request to this resource without HTTP compression (because some servers have
* broken compression for OPTIONS). Doesn't follow redirects.
*
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun options(callback: CapabilitiesCallback) {
httpClient.newCall(Request.Builder()
.method("OPTIONS", null)
.header("Content-Length", "0")
.url(location)
.header("Accept-Encoding", "identity") // disable compression
.build()).execute().use { response ->
checkStatus(response)
callback.onCapabilities(
HttpUtils.listHeader(response, "DAV").map { it.trim() }.toSet(),
response
)
}
}
/**
* Sends a MOVE request to this resource. Follows up to [MAX_REDIRECTS] redirects.
* Updates [location] on success.
*
* @param destination where the resource shall be moved to
* @param forceOverride whether resources are overwritten when they already exist in destination
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun move(destination: HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
val requestBuilder = Request.Builder()
.method("MOVE", null)
.header("Content-Length", "0")
.header("Destination", destination.toString())
if (forceOverride) requestBuilder.header("Overwrite", "F")
followRedirects {
requestBuilder.url(location)
httpClient.newCall(requestBuilder
.build())
.execute()
}.use { response ->
checkStatus(response)
if (response.code == HTTP_MULTISTATUS)
/* Multiple resources were to be affected by the MOVE, but errors on some
of them prevented the operation from taking place.
[_] (RFC 4918 9.9.4. Status Codes for MOVE Method) */
throw HttpException(response)
// update location
location.resolve(response.header("Location") ?: destination.toString())?.let {
location = it
}
callback.onResponse(response)
}
}
/**
* Sends a COPY request for this resource. Follows up to [MAX_REDIRECTS] redirects.
*
* @param destination where the resource shall be copied to
* @param forceOverride whether resources are overwritten when they already exist in destination
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error or HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun copy(destination:HttpUrl, forceOverride: Boolean, callback: ResponseCallback) {
val requestBuilder = Request.Builder()
.method("COPY", null)
.header("Content-Length", "0")
.header("Destination", destination.toString())
if (forceOverride) requestBuilder.header("Overwrite", "F")
followRedirects {
requestBuilder.url(location)
httpClient.newCall(requestBuilder
.build())
.execute()
}.use{ response ->
checkStatus(response)
if (response.code == HTTP_MULTISTATUS)
/* Multiple resources were to be affected by the COPY, but errors on some
of them prevented the operation from taking place.
[_] (RFC 4918 9.8.5. Status Codes for COPY Method) */
throw HttpException(response)
callback.onResponse(response)
}
}
/**
* Sends a MKCOL request to this resource. Follows up to [MAX_REDIRECTS] redirects.
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun mkCol(xmlBody: String?, callback: ResponseCallback) {
val rqBody = xmlBody?.toRequestBody(MIME_XML)
followRedirects {
httpClient.newCall(Request.Builder()
.method("MKCOL", rqBody)
.url(location)
.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a HEAD request to the resource.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
fun head(callback: ResponseCallback) {
followRedirects {
httpClient.newCall(
Request.Builder()
.head()
.url(location)
.build()
).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a GET request to the resource. Sends `Accept-Encoding: identity` to disable
* compression, because compression might change the ETag.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param accept value of `Accept` header (always sent for clarity; use *&#47;* if you don't care)
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Deprecated("Use get(accept, headers, callback) with explicit Accept-Encoding instead")
@Throws(IOException::class, HttpException::class)
fun get(accept: String, callback: ResponseCallback) =
get(accept, Headers.headersOf("Accept-Encoding", "identity"), callback)
/**
* Sends a GET request to the resource. Follows up to [MAX_REDIRECTS] redirects.
*
* Note: Add `Accept-Encoding: identity` to [headers] if you want to disable compression
* (compression might change the returned ETag).
*
* @param accept value of `Accept` header (always sent for clarity; use *&#47;* if you don't care)
* @param headers additional headers to send with the request
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
fun get(accept: String, headers: Headers?, callback: ResponseCallback) {
followRedirects {
val request = Request.Builder()
.get()
.url(location)
if (headers != null)
request.headers(headers)
// always Accept header
request.header("Accept", accept)
httpClient.newCall(request.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a GET request to the resource for a specific byte range. Make sure to check the
* response code: servers may return the whole resource with 200 or partials with 206.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param accept value of `Accept` header (always sent for clarity; use *&#47;* if you don't care)
* @param offset zero-based index of first byte to request
* @param size number of bytes to request
* @param headers additional headers to send with the request
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on high-level errors
*/
@Throws(IOException::class, HttpException::class)
fun getRange(accept: String, offset: Long, size: Int, headers: Headers? = null, callback: ResponseCallback) {
followRedirects {
val request = Request.Builder()
.get()
.url(location)
if (headers != null)
request.headers(headers)
val lastIndex = offset + size - 1
request
.header("Accept", accept)
.header("Range", "bytes=$offset-$lastIndex")
httpClient.newCall(request.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a PUT request to the resource. Follows up to [MAX_REDIRECTS] redirects.
*
* When the server returns an ETag, it is stored in response properties.
*
* @param body new resource body to upload
* @param ifETag value of `If-Match` header to set, or null to omit
* @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit
* @param ifNoneMatch indicates whether `If-None-Match: *` ("don't overwrite anything existing") header shall be sent
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun put(body: RequestBody, ifETag: String? = null, ifScheduleTag: String? = null, ifNoneMatch: Boolean = false, callback: ResponseCallback) {
followRedirects {
val builder = Request.Builder()
.put(body)
.url(location)
if (ifETag != null)
// only overwrite specific version
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
if (ifScheduleTag != null)
// only overwrite specific version
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
if (ifNoneMatch)
// don't overwrite anything existing
builder.header("If-None-Match", "*")
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
callback.onResponse(response)
}
}
/**
* Sends a DELETE request to the resource. Warning: Sending this request to a collection will
* delete the collection with all its contents!
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param ifETag value of `If-Match` header to set, or null to omit
* @param ifScheduleTag value of `If-Schedule-Tag-Match` header to set, or null to omit
* @param callback called with server response unless an exception is thrown
*
* @throws IOException on I/O error
* @throws HttpException on HTTP errors, or when 207 Multi-Status is returned
* (because then there was probably a problem with a member resource)
* @throws DavException on HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class)
fun delete(ifETag: String? = null, ifScheduleTag: String? = null, callback: ResponseCallback) {
followRedirects {
val builder = Request.Builder()
.delete()
.url(location)
if (ifETag != null)
builder.header("If-Match", QuotedStringUtils.asQuotedString(ifETag))
if (ifScheduleTag != null)
builder.header("If-Schedule-Tag-Match", QuotedStringUtils.asQuotedString(ifScheduleTag))
httpClient.newCall(builder.build()).execute()
}.use { response ->
checkStatus(response)
if (response.code == HTTP_MULTISTATUS)
/* If an error occurs deleting a member resource (a resource other than
the resource identified in the Request-URI), then the response can be
a 207 (Multi-Status). […] (RFC 4918 9.6.1. DELETE for Collections) */
throw HttpException(response)
callback.onResponse(response)
}
}
/**
* Sends a PROPFIND request to the resource. Expects and processes a 207 Multi-Status response.
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* @param depth "Depth" header to send (-1 for `infinity`)
* @param reqProp properties to request
* @param callback called for every XML response element in the Multi-Status response
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
*/
@Throws(IOException::class, HttpException::class, DavException::class)
fun propfind(depth: Int, vararg reqProp: Property.Name, callback: MultiResponseCallback) {
// build XML request body
val serializer = XmlUtils.newSerializer()
val writer = StringWriter()
serializer.setOutput(writer)
serializer.setPrefix("", XmlUtils.NS_WEBDAV)
serializer.setPrefix("CAL", XmlUtils.NS_CALDAV)
serializer.setPrefix("CARD", XmlUtils.NS_CARDDAV)
serializer.startDocument("UTF-8", null)
serializer.insertTag(PROPFIND) {
insertTag(PROP) {
for (prop in reqProp)
insertTag(prop)
}
}
serializer.endDocument()
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("PROPFIND", writer.toString().toRequestBody(MIME_XML))
.header("Depth", if (depth >= 0) depth.toString() else "infinity")
.build()).execute()
}.use {
processMultiStatus(it, callback)
}
}
/**
* Sends a PROPPATCH request to the server in order to set and remove properties.
*
* @param setProperties map of properties that shall be set (values currently have to be strings)
* @param removeProperties list of names of properties that shall be removed
* @param callback called for every XML response element in the Multi-Status response
*
* Follows up to [MAX_REDIRECTS] redirects.
*
* Currently expects a 207 Multi-Status response although servers are allowed to
* return other values, too.
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
*/
fun proppatch(
setProperties: Map<Property.Name, String>,
removeProperties: List<Property.Name>,
callback: MultiResponseCallback
) {
followRedirects {
val rqBody = createProppatchXml(setProperties, removeProperties)
httpClient.newCall(
Request.Builder()
.url(location)
.method("PROPPATCH", rqBody.toRequestBody(MIME_XML))
.build()
).execute()
}.use {
// TODO handle not only 207 Multi-Status
// http://www.webdav.org/specs/rfc4918.html#PROPPATCH-status
processMultiStatus(it, callback)
}
}
/**
* Sends a SEARCH request (RFC 5323) with the given body to the server.
*
* Follows up to [MAX_REDIRECTS] redirects. Expects a 207 Multi-Status response.
*
* @param search search request body (XML format, DAV:searchrequest or DAV:query-schema-discovery)
* @param callback called for every XML response element in the Multi-Status response
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like no 207 Multi-Status response) or HTTPS -> HTTP redirect
*/
fun search(search: String, callback: MultiResponseCallback) {
followRedirects {
httpClient.newCall(Request.Builder()
.url(location)
.method("SEARCH", search.toRequestBody(MIME_XML))
.build()).execute()
}.use {
processMultiStatus(it, callback)
}
}
// status handling
/**
* Checks the status from an HTTP response and throws an exception in case of an error.
*
* @throws HttpException in case of an HTTP error
*/
protected fun checkStatus(response: Response) =
checkStatus(response.code, response.message, response)
/**
* Checks the status from an HTTP response and throws an exception in case of an error.
*
* @throws HttpException (with XML error names, if available) in case of an HTTP error
*/
private fun checkStatus(code: Int, message: String?, response: Response?) {
if (code / 100 == 2)
// everything OK
return
throw when (code) {
HttpURLConnection.HTTP_UNAUTHORIZED ->
if (response != null) UnauthorizedException(response) else UnauthorizedException(message)
HttpURLConnection.HTTP_FORBIDDEN ->
if (response != null) ForbiddenException(response) else ForbiddenException(message)
HttpURLConnection.HTTP_NOT_FOUND ->
if (response != null) NotFoundException(response) else NotFoundException(message)
HttpURLConnection.HTTP_CONFLICT ->
if (response != null) ConflictException(response) else ConflictException(message)
HttpURLConnection.HTTP_PRECON_FAILED ->
if (response != null) PreconditionFailedException(response) else PreconditionFailedException(message)
HttpURLConnection.HTTP_UNAVAILABLE ->
if (response != null) ServiceUnavailableException(response) else ServiceUnavailableException(message)
else ->
if (response != null) HttpException(response) else HttpException(code, message)
}
}
/**
* Send a request and follows up to [MAX_REDIRECTS] redirects.
*
* @param sendRequest called to send the request (may be called multiple times)
*
* @return response of the last request (whether it is a redirect or not)
*
* @throws DavException on HTTPS -> HTTP redirect
*/
internal fun followRedirects(sendRequest: () -> Response): Response {
lateinit var response: Response
// Describes the request being made now, not one made earlier through the
// same object. DavResource instances are reused, and a stale value would
// have the caller persist a URL that a later move() already superseded.
permanentLocation = null
// A permanent target is only permanent while every hop so far has been
// permanent: 301 -> 302 means the resource moved to the 301's target and
// is being served from elsewhere temporarily.
var chainStillPermanent = true
for (attempt in 1..MAX_REDIRECTS) {
response = sendRequest()
if (response.isRedirect)
// handle 3xx Redirection
response.use {
val target = it.header("Location")?.let { location.resolve(it) }
if (target != null) {
log.fine("Redirected, new location = $target")
if (location.isHttps && !target.isHttps)
throw DavException("Received redirect from HTTPS to HTTP")
if (chainStillPermanent && (it.code == HTTP_MOVED_PERM || it.code == HTTP_PERM_REDIRECT))
permanentLocation = target
else
chainStillPermanent = false
location = target
} else
throw DavException("Redirected without new Location")
}
else
break
}
return response
}
/**
* Validates a 207 Multi-Status response.
*
* @param response will be checked for Multi-Status response
*
* @throws DavException if the response is not a Multi-Status response
*/
fun assertMultiStatus(response: Response) {
if (response.code != HTTP_MULTISTATUS)
throw DavException("Expected 207 Multi-Status, got ${response.code} ${response.message}", httpResponse = response)
val body = response.body ?:
throw DavException("Received 207 Multi-Status without body", httpResponse = response)
body.contentType()?.let { mimeType ->
if (((mimeType.type != "application" && mimeType.type != "text")) || mimeType.subtype != "xml") {
/* Content-Type is not application/xml or text/xml although that is expected here.
Some broken servers return an XML response with some other MIME type. So we try to see
whether the response is maybe XML although the Content-Type is something else. */
try {
val firstBytes = ByteArray(XML_SIGNATURE.size)
body.source().peek().readFully(firstBytes)
if (XML_SIGNATURE.contentEquals(firstBytes)) {
Dav4jvm.log.warning("Received 207 Multi-Status that seems to be XML but has MIME type $mimeType")
// response is OK, return and do not throw Exception below
return
}
} catch (e: Exception) {
Dav4jvm.log.log(Level.WARNING, "Couldn't scan for XML signature", e)
}
throw DavException("Received non-XML 207 Multi-Status", httpResponse = response)
}
} ?: log.warning("Received 207 Multi-Status without Content-Type, assuming XML")
}
// Multi-Status handling
/**
* Processes a Multi-Status response.
*
* @param response response which is expected to contain a Multi-Status response
* @param callback called for every XML response element in the Multi-Status response
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (for instance, when the response is not a Multi-Status response)
*/
protected fun processMultiStatus(response: Response, callback: MultiResponseCallback): List<Property> {
checkStatus(response)
assertMultiStatus(response)
response.body!!.use {
return processMultiStatus(it.charStream(), callback)
}
}
/**
* Processes a Multi-Status response.
*
* @param reader the Multi-Status response is read from this
* @param callback called for every XML response element in the Multi-Status response
*
* @return list of properties which have been received in the Multi-Status response, but
* are not part of response XML elements (like `sync-token` which is returned as [SyncToken])
*
* @throws IOException on I/O error
* @throws HttpException on HTTP error
* @throws DavException on WebDAV error (like an invalid XML response)
*/
protected fun processMultiStatus(reader: Reader, callback: MultiResponseCallback): List<Property> {
val responseProperties = mutableListOf<Property>()
val parser = XmlUtils.newPullParser()
fun parseMultiStatus(): List<Property> {
// <!ELEMENT multistatus (response*, responsedescription?,
// sync-token?) >
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
DavResponse.RESPONSE ->
at.bitfire.dav4jvm.Response.parse(parser, location, callback)
SyncToken.NAME ->
XmlUtils.readText(parser)?.let {
responseProperties += SyncToken(it)
}
}
eventType = parser.next()
}
return responseProperties
}
try {
parser.setInput(reader)
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
if (parser.propertyName() == DavResponse.MULTISTATUS)
return parseMultiStatus()
// ignore further <multistatus> elements
eventType = parser.next()
}
throw DavException("Multi-Status response didn't contain multistatus XML element")
} catch (e: EOFException) {
throw DavException("Incomplete multistatus XML element", e)
} catch (e: XmlPullParserException) {
throw DavException("Couldn't parse multistatus XML element", e)
}
}
}
@@ -0,0 +1,55 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.dav4jvm
import org.xmlpull.v1.XmlPullParser
import java.io.Serializable
/**
* Represents an XML precondition/postcondition error. Every error has a name, which is the XML element
* name. Subclassed errors may have more specific information available.
*
* At the moment, there is no logic for subclassing errors.
*/
class Error(
val name: Property.Name
): Serializable {
companion object {
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "error")
fun parseError(parser: XmlPullParser): List<Error> {
val names = mutableSetOf<Property.Name>()
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
names += Property.Name(parser.namespace, parser.name)
eventType = parser.next()
}
return names.map { Error(it) }
}
// some pre-defined errors
val NEED_PRIVILEGES = Error(Property.Name(XmlUtils.NS_WEBDAV, "need-privileges"))
val VALID_SYNC_TOKEN = Error(Property.Name(XmlUtils.NS_WEBDAV, "valid-sync-token"))
}
override fun equals(other: Any?) =
(other is Error) && other.name == name
override fun hashCode() = name.hashCode()
}
@@ -0,0 +1,140 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl
import okhttp3.Response
import java.text.ParsePosition
import java.text.SimpleDateFormat
import java.util.*
object HttpUtils {
const val httpDateFormatStr = "EEE, dd MMM yyyy HH:mm:ss 'GMT'"
/**
* RFC 7231 §7.1.1.1: an HTTP-date is always GMT.
*
* ⚠️ Upstream never set this, and the `GMT` in [httpDateFormatStr] is a
* quoted *literal* — so both formatting and parsing silently used the
* device's default zone, putting every `getlastmodified` out by the local
* UTC offset. Upstream's suite does not test date handling at all. Local
* fix; see dav/PROVENANCE.md change 2.
*/
private val GMT: TimeZone = TimeZone.getTimeZone("GMT")
/**
* Built per call rather than shared. `SimpleDateFormat` is mutable and not
* thread-safe, and upstream exposed one instance publicly: two sync workers
* formatting a header concurrently corrupt each other through its `Calendar`,
* and any caller could `setTimeZone` on it and undo the fix above for
* everyone. `:dav` is earmarked for floret-kit, where concurrent workers are
* the normal case. Local change; see dav/PROVENANCE.md change 5.
*/
private fun httpDateFormat() = SimpleDateFormat(httpDateFormatStr, Locale.ROOT).apply {
timeZone = GMT
}
/**
* Gets the resource name (the last segment of the path) from an URL.
* Empty if the resource is the base directory.
*
* * `dir` for `https://example.com/dir/`
* * `file` for `https://example.com/file`
* * `` for `https://example.com` or `https://example.com/`
*
* @return resource name
*/
fun fileName(url: HttpUrl): String {
val pathSegments = url.pathSegments.dropLastWhile { it == "" }
return pathSegments.lastOrNull() ?: ""
}
fun listHeader(response: Response, name: String): Array<String> {
val value = response.headers(name).joinToString(",")
return value.split(',').filter { it.isNotEmpty() }.toTypedArray()
}
/**
* Formats a date for use in HTTP headers.
*
* @param date date to be formatted
* @return date in HTTP-date format
*/
fun formatDate(date: Date): String = httpDateFormat().format(date)
/**
* Parses a HTTP-date.
*
* @param dateStr date with format specified by RFC 7231 section 7.1.1.1
* or in one of the obsolete formats (copied from okhttp internal date-parsing class)
*
* @return date, or null if date could not be parsed
*/
/**
* The formats [parseDate] accepts, in order. RFC 7231 §7.1.1.1 first, then the
* obsolete ones, then the malformed-but-real ones — copied from OkHttp's
* internal date parser, which is where upstream took them from.
*/
private val dateFormats = arrayOf(
httpDateFormatStr,
"EEE, dd MMM yyyy HH:mm:ss zzz", // RFC 822, updated by RFC 1123 with any TZ
"EEEE, dd-MMM-yy HH:mm:ss zzz", // RFC 850, obsoleted by RFC 1036 with any TZ
"EEE MMM d HH:mm:ss yyyy", // ANSI C's asctime() format
// Alternative formats.
"EEE, dd-MMM-yyyy HH:mm:ss z",
"EEE, dd-MMM-yyyy HH-mm-ss z",
"EEE, dd MMM yy HH:mm:ss z",
"EEE dd-MMM-yyyy HH:mm:ss z",
"EEE dd MMM yyyy HH:mm:ss z",
"EEE dd-MMM-yyyy HH-mm-ss z",
"EEE dd-MMM-yy HH:mm:ss z",
"EEE dd MMM yy HH:mm:ss z",
"EEE,dd-MMM-yy HH:mm:ss z",
"EEE,dd-MMM-yyyy HH:mm:ss z",
"EEE, dd-MM-yyyy HH:mm:ss z",
/* RI bug 6641315 claims a cookie of this format was once served by www.yahoo.com */
"EEE MMM d yyyy HH:mm:ss z",
)
/**
* Parses an HTTP-date.
*
* @param dateStr date in the format of RFC 7231 §7.1.1.1, or one of the
* obsolete formats
*
* @return the date, or null if none of the formats matched
*/
fun parseDate(dateStr: String): Date? {
// Upstream used commons-lang3 DateUtils.parseDate for exactly this loop,
// and that import was the library's only use of the dependency. See
// dav/PROVENANCE.md change 1.
for (format in dateFormats) {
val parser = SimpleDateFormat(format, Locale.US).apply {
// GMT is the default for the formats that carry no zone — the
// quoted-literal 'GMT' one and ANSI C's asctime(). The patterns
// with a real `z` still take the zone from the input.
timeZone = GMT
// The obsolete formats carry two-digit years, and lenient parsing
// is what lets "dd MMM yy" resolve at all.
isLenient = true
}
val position = ParsePosition(0)
val parsed = parser.parse(dateStr, position)
// ⚠️ The whole string must be consumed. `DateUtils.parseDate` enforced
// this and `SimpleDateFormat.parse` does not: pattern 1 ends in the
// *literal* 'GMT', so "…07:28:00 GMT+02:00" matches it as a prefix and
// the offset is silently discarded — reintroducing the exact two-hour
// error change 2 exists to fix.
if (parsed != null && position.index == dateStr.length) return parsed
}
return null
}
}
@@ -0,0 +1,69 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.Response.Companion.STATUS
import at.bitfire.dav4jvm.XmlUtils.propertyName
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import org.xmlpull.v1.XmlPullParser
import java.net.ProtocolException
import java.util.*
/**
* Represents a WebDAV propstat XML element.
*
* <!ELEMENT propstat (prop, status, error?, responsedescription?) >
*/
data class PropStat(
val properties: List<Property>,
val status: StatusLine,
val error: List<Error>? = null
) {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "propstat")
private val ASSUMING_OK = StatusLine(Protocol.HTTP_1_1, 200, "Assuming OK")
private val INVALID_STATUS = StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
fun parse(parser: XmlPullParser): PropStat {
val depth = parser.depth
var status: StatusLine? = null
val prop = LinkedList<Property>()
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
DavResource.PROP ->
prop.addAll(Property.parse(parser))
STATUS ->
status = try {
StatusLine.parse(parser.nextText())
} catch (e: ProtocolException) {
// invalid status line, treat as 500 Internal Server Error
INVALID_STATUS
}
}
eventType = parser.next()
}
return PropStat(prop, status ?: ASSUMING_OK)
}
}
fun isSuccess() = status.code/100 == 2
}
@@ -0,0 +1,59 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.Dav4jvm.log
import org.xmlpull.v1.XmlPullParser
import java.io.Serializable
import java.util.*
/**
* Represents a WebDAV property.
*
* Every [Property] must define a static field (use `@JvmStatic`) called `NAME` of type [Property.Name],
* which will be accessed by reflection.
*/
interface Property {
data class Name(
val namespace: String,
val name: String
): Serializable {
override fun toString() = "$namespace:$name"
}
companion object {
fun parse(parser: XmlPullParser): List<Property> {
// <!ELEMENT prop ANY >
val depth = parser.depth
val properties = LinkedList<Property>()
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
val depthBeforeParsing = parser.depth
val name = Property.Name(parser.namespace, parser.name)
val property = PropertyRegistry.create(name, parser)
assert(parser.depth == depthBeforeParsing)
if (property != null) {
properties.add(property)
} else
log.fine("Ignoring unknown property $name")
}
eventType = parser.next()
}
return properties
}
}
}
@@ -0,0 +1,26 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
interface PropertyFactory {
/**
* Name of the Property the factory creates,
* e.g. Property.Name("DAV:", "displayname") if the factory creates DisplayName objects)
*/
fun getName(): Property.Name
/**
* Parses XML of a property and returns its data class.
* @throws XmlPullParserException in case of parsing errors
*/
fun create(parser: XmlPullParser): Property?
}
@@ -0,0 +1,95 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.property.*
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.util.logging.Level
object PropertyRegistry {
private val factories = mutableMapOf<Property.Name, PropertyFactory>()
init {
Dav4jvm.log.info("Registering DAV property factories")
registerDefaultFactories()
}
private fun registerDefaultFactories() {
register(listOf(
AddMember.Factory,
AddressbookDescription.Factory,
AddressbookHomeSet.Factory,
AddressData.Factory,
CalendarColor.Factory,
CalendarData.Factory,
CalendarDescription.Factory,
CalendarHomeSet.Factory,
CalendarProxyReadFor.Factory,
CalendarProxyWriteFor.Factory,
CalendarTimezone.Factory,
CalendarUserAddressSet.Factory,
CreationDate.Factory,
CurrentUserPrincipal.Factory,
CurrentUserPrivilegeSet.Factory,
DisplayName.Factory,
GetContentLength.Factory,
GetContentType.Factory,
GetCTag.Factory,
GetETag.Factory,
GetLastModified.Factory,
GroupMembership.Factory,
MaxICalendarSize.Factory,
MaxVCardSize.Factory,
Owner.Factory,
QuotaAvailableBytes.Factory,
QuotaUsedBytes.Factory,
ResourceType.Factory,
ScheduleTag.Factory,
Source.Factory,
SupportedAddressData.Factory,
SupportedCalendarComponentSet.Factory,
SupportedCalendarData.Factory,
SupportedReportSet.Factory,
SyncToken.Factory
))
}
/**
* Registers a property factory, so that objects for all WebDAV properties which are handled
* by this factory can be created.
*
* @param factory property factory to be registered
*/
fun register(factory: PropertyFactory) {
Dav4jvm.log.fine("Registering ${factory::class.java.name} for ${factory.getName()}")
factories[factory.getName()] = factory
}
/**
* Registers some property factories, so that objects for all WebDAV properties which are handled
* by these factories can be created.
* @param factories property factories to be registered
*/
fun register(factories: Iterable<PropertyFactory>) {
factories.forEach {
register(it)
}
}
fun create(name: Property.Name, parser: XmlPullParser) =
try {
factories[name]?.create(parser)
} catch (e: XmlPullParserException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $name", e)
null
}
}
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
object QuotedStringUtils {
fun asQuotedString(raw: String) =
"\"" + raw.replace("\\" ,"\\\\").replace("\"", "\\\"") + "\""
fun decodeQuotedString(quoted: String): String {
/* quoted-string = ( <"> *(qdtext | quoted-pair ) <"> )
qdtext = <any TEXT except <">>
quoted-pair = "\" CHAR
*/
val len = quoted.length
if (len >= 2 && quoted[0] == '"' && quoted[len-1] == '"') {
val result = StringBuffer(len)
var pos = 1
while (pos < len-1) {
var c = quoted[pos]
if (c == '\\' && pos != len-2)
c = quoted[++pos]
result.append(c)
pos++
}
return result.toString()
} else
return quoted
}
}
@@ -0,0 +1,222 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.Dav4jvm.log
import at.bitfire.dav4jvm.XmlUtils.propertyName
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Protocol
import okhttp3.internal.http.StatusLine
import org.xmlpull.v1.XmlPullParser
import java.net.ProtocolException
/**
* Represents a WebDAV response XML Element.
*
* <!ELEMENT response (href, ((href*, status)|(propstat+)),
* error?, responsedescription? , location?) >
*/
data class Response(
/**
* URL of the requested resource. For instance, if `this` is a result
* of a PROPFIND request, the `requestedUrl` would be the URL where the
* PROPFIND request has been sent to (usually the collection URL).
*/
val requestedUrl: HttpUrl,
/**
* URL of this response (`href` element)
*/
val href: HttpUrl,
/**
* status of this response (`status` XML element)
*/
val status: StatusLine?,
/**
* property/status elements (`propstat` XML elements)
*/
val propstat: List<PropStat>,
/**
* list of precondition/postcondition elements (`error` XML elements)
*/
val error: List<Error>? = null,
/**
* new location of this response (`location` XML element), used for redirects
*/
val newLocation: HttpUrl? = null
) {
enum class HrefRelation {
SELF, MEMBER, OTHER
}
/**
* All properties from propstat elements with empty status or status code 2xx.
*/
val properties: List<Property> by lazy {
if (isSuccess())
propstat.filter { it.isSuccess() }.map { it.properties }.flatten()
else
emptyList()
}
/**
* Convenience method to get a certain property with empty status or status code 2xx
* from the current response.
*/
operator fun<T: Property> get(clazz: Class<T>) =
properties.filterIsInstance(clazz).firstOrNull()
/**
* Returns whether the request was successful.
*
* @return true: no status XML element or status code 2xx; false: otherwise
*/
fun isSuccess() = status == null || status.code/100 == 2
/**
* Returns the name (last path segment) of the resource.
*/
fun hrefName() = HttpUtils.fileName(href)
companion object {
val RESPONSE = Property.Name(XmlUtils.NS_WEBDAV, "response")
val MULTISTATUS = Property.Name(XmlUtils.NS_WEBDAV, "multistatus")
val STATUS = Property.Name(XmlUtils.NS_WEBDAV, "status")
val LOCATION = Property.Name(XmlUtils.NS_WEBDAV, "location")
/**
* Parses an XML response element.
*/
fun parse(parser: XmlPullParser, location: HttpUrl, callback: MultiResponseCallback) {
val depth = parser.depth
var href: HttpUrl? = null
var status: StatusLine? = null
val propStat = mutableListOf<PropStat>()
var error: List<Error>? = null
var newLocation: HttpUrl? = null
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth+1)
when (parser.propertyName()) {
DavResource.HREF -> {
var sHref = parser.nextText()
if (!sHref.startsWith("/")) {
/* According to RFC 4918 8.3 URL Handling, only absolute paths are allowed as relative
URLs. However, some servers reply with relative paths. */
val firstColon = sHref.indexOf(':')
if (firstColon != -1) {
/* There are some servers which return not only relative paths, but relative paths like "a:b.vcf",
which would be interpreted as scheme: "a", scheme-specific part: "b.vcf" normally.
For maximum compatibility, we prefix all relative paths which contain ":" (but not "://"),
with "./" to allow resolving by HttpUrl. */
var hierarchical = false
try {
if (sHref.substring(firstColon, firstColon + 3) == "://")
hierarchical = true
} catch (e: IndexOutOfBoundsException) {
// no "://"
}
if (!hierarchical)
sHref = "./$sHref"
}
}
href = location.resolve(sHref)
}
STATUS ->
status = try {
StatusLine.parse(parser.nextText())
} catch(e: ProtocolException) {
log.warning("Invalid status line, treating as HTTP error 500")
StatusLine(Protocol.HTTP_1_1, 500, "Invalid status line")
}
PropStat.NAME ->
PropStat.parse(parser).let { propStat += it }
Error.NAME ->
error = Error.parseError(parser)
LOCATION ->
newLocation = parser.nextText().toHttpUrlOrNull()
}
eventType = parser.next()
}
if (href == null) {
log.warning("Ignoring XML response element without valid href")
return
}
// if we know this resource is a collection, make sure href has a trailing slash
// (for clarity and resolving relative paths)
propStat.filter { it.isSuccess() }
.map { it.properties }
.filterIsInstance(ResourceType::class.java)
.firstOrNull()
?.let { type ->
if (type.types.contains(ResourceType.COLLECTION))
href = UrlUtils.withTrailingSlash(href!!)
}
//log.log(Level.FINE, "Received properties for $href", if (status != null) status else propStat)
// Which resource does this <response> represent?
val relation = when {
UrlUtils.equals(UrlUtils.omitTrailingSlash(href!!), UrlUtils.omitTrailingSlash(location)) ->
HrefRelation.SELF
else -> {
if (location.scheme == href!!.scheme && location.host == href!!.host && location.port == href!!.port) {
val locationSegments = location.pathSegments
val hrefSegments = href!!.pathSegments
// don't compare trailing slash segment ("")
var nBasePathSegments = locationSegments.size
if (locationSegments[nBasePathSegments-1] == "")
nBasePathSegments--
/* example: locationSegments = [ "davCollection", "" ]
nBasePathSegments = 1
hrefSegments = [ "davCollection", "aMember" ]
*/
var relation = HrefRelation.OTHER
if (hrefSegments.size > nBasePathSegments) {
val sameBasePath = (0 until nBasePathSegments).none { locationSegments[it] != hrefSegments[it] }
if (sameBasePath)
relation = HrefRelation.MEMBER
}
relation
} else
HrefRelation.OTHER
}
}
callback.onResponse(
Response(
location,
href!!,
status,
propStat,
error,
newLocation
),
relation)
}
}
}
@@ -0,0 +1,109 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl
import java.net.URI
import java.net.URISyntaxException
object UrlUtils {
/**
* Compares two URLs in WebDAV context. If two URLs are considered *equal*, both
* represent the same WebDAV resource (e.g. `http://host:80/folder1` and `http://HOST/folder1#somefragment`).
*
* It decodes %xx entities in the path, so `/my@dav` and `/my%40dav` are considered the same.
* This is important to process multi-status responses: some servers serve a multi-status
* response with href `/my@dav` when you request `/my%40dav` and vice versa.
*
* This method does not deal with trailing slashes, so if you want to compare collection URLs,
* make sure they both (don't) have a trailing slash before calling this method, for instance
* with [omitTrailingSlash] or [withTrailingSlash].
*
* @param url1 the first URL to be compared
* @param url2 the second URL to be compared
*
* @return whether [url1] and [url2] (usually) represent the same WebDAV resource
*/
fun equals(url1: HttpUrl, url2: HttpUrl): Boolean {
// if okhttp thinks the two URLs are equal, they're in any case
// (and it's a simple String comparison)
if (url1 == url2)
return true
// drop #fragment parts and convert to URI
val uri1 = url1.newBuilder().fragment(null).build().toUri()
val uri2 = url2.newBuilder().fragment(null).build().toUri()
return try {
val decoded1 = URI(uri1.scheme, uri1.schemeSpecificPart, uri1.fragment)
val decoded2 = URI(uri2.scheme, uri2.schemeSpecificPart, uri2.fragment)
decoded1 == decoded2
} catch (e: URISyntaxException) {
false
}
}
/**
* Gets the first-level domain name (without subdomains) from a host name.
* Also removes trailing dots.
*
* @param host name (e.g. `www.example.com.`)
*
* @return domain name (e.g. `example.com`)
*/
fun hostToDomain(host: String?): String? {
if (host == null)
return null
// remove optional dot at end
val withoutTrailingDot = host.removeSuffix(".")
// split into labels
val labels = withoutTrailingDot.split('.')
return if (labels.size >= 2) {
labels[labels.size - 2] + "." + labels[labels.size - 1]
} else
withoutTrailingDot
}
/**
* Ensures that a given URL doesn't have a trailing slash after member names.
* If the path is the root path (`/`), the slash is preserved.
*
* @param url URL to process (e.g. 'http://host/test1/')
*
* @return URL without trailing slash (except when the path is the root path), e.g. `http://host/test1`
*/
fun omitTrailingSlash(url: HttpUrl): HttpUrl {
val idxLast = url.pathSize - 1
val hasTrailingSlash = url.pathSegments[idxLast] == ""
return if (hasTrailingSlash)
url.newBuilder().removePathSegment(idxLast).build()
else
url
}
/**
* Ensures that a given URL has a trailing slash after member names.
*
* @param url URL to process (e.g. 'http://host/test1')
*
* @return URL with trailing slash, e.g. `http://host/test1/`
*/
fun withTrailingSlash(url: HttpUrl): HttpUrl {
val idxLast = url.pathSize - 1
val hasTrailingSlash = url.pathSegments[idxLast] == ""
return if (hasTrailingSlash)
url
else
url.newBuilder().addPathSegment("").build()
}
}
@@ -0,0 +1,102 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import org.xmlpull.v1.XmlPullParserFactory
import org.xmlpull.v1.XmlSerializer
import java.io.IOException
object XmlUtils {
const val NS_WEBDAV = "DAV:"
const val NS_CALDAV = "urn:ietf:params:xml:ns:caldav"
const val NS_CARDDAV = "urn:ietf:params:xml:ns:carddav"
const val NS_APPLE_ICAL = "http://apple.com/ns/ical/"
const val NS_CALENDARSERVER = "http://calendarserver.org/ns/"
private val factory: XmlPullParserFactory
init {
try {
factory = XmlPullParserFactory.newInstance()
factory.isNamespaceAware = true
} catch (e: XmlPullParserException) {
throw RuntimeException("Couldn't create XmlPullParserFactory", e)
}
}
fun newPullParser() = factory.newPullParser()!!
fun newSerializer() = factory.newSerializer()!!
@Throws(IOException::class, XmlPullParserException::class)
fun processTag(parser: XmlPullParser, name: Property.Name, processor: () -> Unit) {
val depth = parser.depth
var eventType = parser.eventType
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
processor()
eventType = parser.next()
}
}
@Throws(IOException::class, XmlPullParserException::class)
fun readText(parser: XmlPullParser): String? {
var text: String? = null
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.TEXT && parser.depth == depth)
text = parser.text
eventType = parser.next()
}
return text
}
@Throws(IOException::class, XmlPullParserException::class)
fun readTextProperty(parser: XmlPullParser, name: Property.Name): String? {
val depth = parser.depth
var eventType = parser.eventType
var result: String? = null
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
result = parser.nextText()
eventType = parser.next()
}
return result
}
@Throws(IOException::class, XmlPullParserException::class)
fun readTextPropertyList(parser: XmlPullParser, name: Property.Name, list: MutableCollection<String>) {
val depth = parser.depth
var eventType = parser.eventType
while (!((eventType == XmlPullParser.END_TAG || eventType == XmlPullParser.END_DOCUMENT) && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1 && parser.propertyName() == name)
list.add(parser.nextText())
eventType = parser.next()
}
}
fun XmlSerializer.insertTag(name: Property.Name, contentGenerator: XmlSerializer.() -> Unit = {}) {
startTag(name.namespace, name.name)
contentGenerator(this)
endTag(name.namespace, name.name)
}
fun XmlPullParser.propertyName(): Property.Name {
val propNs = namespace
val propName = name
if (propNs == null || propName == null)
throw IllegalStateException("Current event must be START_TAG or END_TAG")
return Property.Name(propNs, propName)
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class ConflictException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_CONFLICT, message)
}
@@ -0,0 +1,144 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Error
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import okhttp3.MediaType
import okhttp3.Response
import okio.Buffer
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.Serializable
import java.lang.Long.min
import java.util.logging.Level
/**
* Signals that an error occurred during a WebDAV-related operation.
*
* This could be a logical error like when a required ETag has not been
* received, but also an explicit HTTP error.
*/
open class DavException @JvmOverloads constructor(
message: String,
ex: Throwable? = null,
/**
* An associated HTTP [Response]. Will be closed after evaluation.
*/
httpResponse: Response? = null
): Exception(message, ex), Serializable {
companion object {
const val MAX_EXCERPT_SIZE = 10*1024 // don't dump more than 20 kB
fun isPlainText(type: MediaType) =
type.type == "text" ||
(type.type == "application" && type.subtype in arrayOf("html", "xml"))
}
var request: String? = null
/**
* Body excerpt of [request] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP request body was textual content and could be read again.
*/
var requestBody: String? = null
private set
val response: String?
/**
* Body excerpt of [response] (up to [MAX_EXCERPT_SIZE] characters). Only available
* if the HTTP response body was textual content.
*/
var responseBody: String? = null
private set
/**
* Precondition/postcondition XML elements which have been found in the XML response.
*/
var errors: List<Error> = listOf()
private set
init {
if (httpResponse != null) {
response = httpResponse.toString()
try {
request = httpResponse.request.toString()
httpResponse.request.body?.let { body ->
body.contentType()?.let { type ->
if (isPlainText(type)) {
val buffer = Buffer()
body.writeTo(buffer)
val baos = ByteArrayOutputStream()
buffer.writeTo(baos, min(buffer.size, MAX_EXCERPT_SIZE.toLong()))
requestBody = baos.toString(type.charset(Charsets.UTF_8)!!.name())
}
}
}
} catch (e: Exception) {
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP request", e)
requestBody = "Couldn't read HTTP request: ${e.message}"
}
try {
// save response body excerpt
if (httpResponse.body?.source() != null) {
// response body has a source
httpResponse.peekBody(MAX_EXCERPT_SIZE.toLong()).let { body ->
body.contentType()?.let { mimeType ->
if (isPlainText(mimeType))
responseBody = body.string()
}
}
httpResponse.body?.use { body ->
body.contentType()?.let {
if (it.type in arrayOf("application", "text") && it.subtype == "xml") {
// look for precondition/postcondition XML elements
try {
val parser = XmlUtils.newPullParser()
parser.setInput(body.charStream())
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.depth == 1)
if (parser.propertyName() == Error.NAME)
errors = Error.parseError(parser)
eventType = parser.next()
}
} catch (e: XmlPullParserException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse XML response", e)
}
}
}
}
}
} catch (e: IOException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't read HTTP response", e)
responseBody = "Couldn't read HTTP response: ${e.message}"
} finally {
httpResponse.body?.close()
}
} else
response = null
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class ForbiddenException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_FORBIDDEN, message)
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class GoneException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_GONE, message)
}
@@ -0,0 +1,29 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
/**
* Signals that a HTTP error was sent by the server.
*/
open class HttpException: DavException {
var code: Int
constructor(response: Response): super(
"HTTP ${response.code} ${response.message}",
httpResponse = response
) {
code = response.code
}
constructor(code: Int, message: String?): super("HTTP $code $message") {
this.code = code
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class NotFoundException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_NOT_FOUND, message)
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class PreconditionFailedException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_PRECON_FAILED, message)
}
@@ -0,0 +1,42 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.HttpUtils
import okhttp3.Response
import java.net.HttpURLConnection
import java.util.*
class ServiceUnavailableException: HttpException {
var retryAfter: Date? = null
constructor(message: String?): super(HttpURLConnection.HTTP_UNAVAILABLE, message)
constructor(response: Response): super(response) {
// Retry-After = "Retry-After" ":" ( HTTP-date | delta-seconds )
// HTTP-date = rfc1123-date | rfc850-date | asctime-date
response.header("Retry-After")?.let { after ->
retryAfter = HttpUtils.parseDate(after) ?:
// not a HTTP-date, must be delta-seconds
try {
val seconds = Integer.parseInt(after)
val cal = Calendar.getInstance()
cal.add(Calendar.SECOND, seconds)
cal.time
} catch (ignored: NumberFormatException) {
Dav4jvm.log.warning("Received Retry-After which was not a HTTP-date nor delta-seconds: $after")
null
}
}
}
}
@@ -0,0 +1,17 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.Response
import java.net.HttpURLConnection
class UnauthorizedException: HttpException {
constructor(response: Response): super(response)
constructor(message: String?): super(HttpURLConnection.HTTP_UNAUTHORIZED, message)
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
/**
* Defined in RFC 5995 3.2.1 DAV:add-member Property (Protected).
*/
data class AddMember(
val href: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "add-member")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
AddMember(XmlUtils.readTextProperty(parser, DavResource.HREF))
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class AddressData(
val card: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "address-data")
// attributes
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT address-data (#PCDATA)>
AddressData(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class AddressbookDescription(
var description: String? = null
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-description")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT addressbook-description (#PCDATA)>
AddressbookDescription(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class AddressbookHomeSet: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "addressbook-home-set")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, AddressbookHomeSet())
}
}
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
import java.util.regex.Pattern
data class CalendarColor(
val color: Int
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color")
private val PATTERN = Pattern.compile("#?(\\p{XDigit}{6})(\\p{XDigit}{2})?")!!
/**
* Converts a WebDAV color from one of these formats:
* #RRGGBB (alpha = 0xFF)
* RRGGBB (alpha = 0xFF)
* #RRGGBBAA
* RRGGBBAA
* to an [Int] with alpha.
*/
@Throws(IllegalArgumentException::class)
fun parseARGBColor(davColor: String): Int {
val m = PATTERN.matcher(davColor)
if (m.find()) {
val color_rgb = Integer.parseInt(m.group(1), 16)
val color_alpha = m.group(2)?.let { Integer.parseInt(m.group(2), 16) and 0xFF } ?: 0xFF
return (color_alpha shl 24) or color_rgb
} else
throw IllegalArgumentException("Couldn't parse color value: $davColor")
}
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CalendarColor? {
XmlUtils.readText(parser)?.let {
try {
return CalendarColor(parseARGBColor(it))
} catch (e: IllegalArgumentException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse color, ignoring", e)
}
}
return null
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CalendarData(
val iCalendar: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
// attributes
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT calendar-data (#PCDATA)>
CalendarData(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,34 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CalendarDescription(
val description: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-description")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT calendar-description (#PCDATA)>
CalendarDescription(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarHomeSet: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-home-set")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarHomeSet())
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarProxyReadFor: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-read-for")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarProxyReadFor())
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarProxyWriteFor: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "calendar-proxy-write-for")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarProxyWriteFor())
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CalendarTimezone(
val vTimeZone: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-timezone")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT calendar-timezone (#PCDATA)>
CalendarTimezone(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class CalendarUserAddressSet: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "calendar-user-address-set")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, CalendarUserAddressSet())
}
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class CreationDate(
var creationDate: String
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "creationdate")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CreationDate? {
XmlUtils.readText(parser)?.let { it ->
return CreationDate(it)
}
return null
}
}
}
@@ -0,0 +1,42 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
// see RFC 5397: WebDAV Current Principal Extension
data class CurrentUserPrincipal(
val href: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-principal")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CurrentUserPrincipal {
// <!ELEMENT current-user-principal (unauthenticated | href)>
var href: String? = null
XmlUtils.processTag(parser, DavResource.HREF) {
href = XmlUtils.readText(parser)
}
return CurrentUserPrincipal(href)
}
}
}
@@ -0,0 +1,88 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import org.xmlpull.v1.XmlPullParser
data class CurrentUserPrivilegeSet(
// not all privileges from RFC 3744 are implemented by now
// feel free to add more if you need them for your project
var mayRead: Boolean = false,
var mayWriteProperties: Boolean = false,
var mayWriteContent: Boolean = false,
var mayBind: Boolean = false,
var mayUnbind: Boolean = false
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "current-user-privilege-set")
val PRIVILEGE = Property.Name(XmlUtils.NS_WEBDAV, "privilege")
val READ = Property.Name(XmlUtils.NS_WEBDAV, "read")
val WRITE = Property.Name(XmlUtils.NS_WEBDAV, "write")
val WRITE_PROPERTIES = Property.Name(XmlUtils.NS_WEBDAV, "write-properties")
val WRITE_CONTENT = Property.Name(XmlUtils.NS_WEBDAV, "write-content")
val BIND = Property.Name(XmlUtils.NS_WEBDAV, "bind")
val UNBIND = Property.Name(XmlUtils.NS_WEBDAV, "unbind")
val ALL = Property.Name(XmlUtils.NS_WEBDAV, "all")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): CurrentUserPrivilegeSet? {
// <!ELEMENT current-user-privilege-set (privilege*)>
// <!ELEMENT privilege ANY>
val privs = CurrentUserPrivilegeSet()
XmlUtils.processTag(parser, PRIVILEGE) {
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1)
when (parser.propertyName()) {
READ ->
privs.mayRead = true
WRITE -> {
privs.mayBind = true
privs.mayUnbind = true
privs.mayWriteProperties = true
privs.mayWriteContent = true
}
WRITE_PROPERTIES ->
privs.mayWriteProperties = true
WRITE_CONTENT ->
privs.mayWriteContent = true
BIND ->
privs.mayBind = true
UNBIND ->
privs.mayUnbind = true
ALL -> {
privs.mayRead = true
privs.mayBind = true
privs.mayUnbind = true
privs.mayWriteProperties = true
privs.mayWriteContent = true
}
}
eventType = parser.next()
}
}
return privs
}
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class DisplayName(
val displayName: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT displayname (#PCDATA) >
DisplayName(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class GetCTag(
val cTag: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "getctag")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
GetCTag(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class GetContentLength(
val contentLength: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontentlength")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): GetContentLength? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return GetContentLength(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.xmlpull.v1.XmlPullParser
data class GetContentType(
val type: MediaType?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getcontenttype")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT getcontenttype (#PCDATA) >
GetContentType(XmlUtils.readText(parser)?.toMediaTypeOrNull())
}
}
@@ -0,0 +1,78 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.QuotedStringUtils
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
/**
* The GetETag property.
*
* Can also be used to parse ETags from HTTP responses – just pass the raw ETag
* header value to the constructor and then use [eTag] and [weak].
*/
class GetETag(
rawETag: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getetag")
fun fromResponse(response: Response) =
response.header("ETag")?.let { GetETag(it) }
}
/**
* The parsed eTag value. May be null if the tag is weak.
*/
val eTag: String?
/**
* If the tag is weak. May be null if the tag passed is null.
*/
val weak: Boolean?
init {
/* entity-tag = [ weak ] opaque-tag
weak = "W/"
opaque-tag = quoted-string
*/
var tag: String? = rawETag
if (tag != null) {
// remove trailing "W/"
if (tag.startsWith("W/") && tag.length >= 3) {
// entity tag is weak
tag = tag.substring(2)
weak = true
} else
weak = false
tag = QuotedStringUtils.decodeQuotedString(tag)
} else
weak = null
eTag = tag
}
override fun toString() = eTag ?: "(null)"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
GetETag(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,40 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.*
import org.xmlpull.v1.XmlPullParser
data class GetLastModified(
var lastModified: Long
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "getlastmodified")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): GetLastModified? {
// <!ELEMENT getlastmodified (#PCDATA) >
XmlUtils.readText(parser)?.let { rawDate ->
val date = HttpUtils.parseDate(rawDate)
if (date != null)
return GetLastModified(date.time)
else
Dav4jvm.log.warning("Couldn't parse Last-Modified date")
}
return null
}
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class GroupMembership: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "group-membership")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, GroupMembership())
}
}
@@ -0,0 +1,36 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.*
abstract class HrefListProperty: Property {
val hrefs = LinkedList<String>()
val href
get() = hrefs.firstOrNull()
override fun toString() = "href=[" + hrefs.joinToString(", ") + "]"
abstract class Factory: PropertyFactory {
fun create(parser: XmlPullParser, list: HrefListProperty): HrefListProperty {
XmlUtils.readTextPropertyList(parser, DavResource.HREF, list.hrefs)
return list
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class MaxICalendarSize(
val maxSize: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "max-resource-size")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): MaxICalendarSize? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return MaxICalendarSize(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class MaxVCardSize(
val maxSize: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "max-resource-size")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): MaxVCardSize? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return MaxVCardSize(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class Owner: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "owner")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, Owner())
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class QuotaAvailableBytes(
val quotaAvailableBytes: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-available-bytes")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): QuotaAvailableBytes? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return QuotaAvailableBytes(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,38 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
import java.util.logging.Level
data class QuotaUsedBytes(
val quotaUsedBytes: Long
) : Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "quota-used-bytes")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): QuotaUsedBytes? {
XmlUtils.readText(parser)?.let { valueStr ->
try {
return QuotaUsedBytes(valueStr.toLong())
} catch(e: NumberFormatException) {
Dav4jvm.log.log(Level.WARNING, "Couldn't parse $NAME: $valueStr", e)
}
}
return null
}
}
}
@@ -0,0 +1,63 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class ResourceType: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype")
val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection") // WebDAV
val PRINCIPAL = Property.Name(XmlUtils.NS_WEBDAV, "principal") // WebDAV ACL
val ADDRESSBOOK = Property.Name(XmlUtils.NS_CARDDAV, "addressbook") // CardDAV
val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar") // CalDAV
val SUBSCRIBED = Property.Name(XmlUtils.NS_CALENDARSERVER, "subscribed")
}
val types = mutableSetOf<Property.Name>()
override fun toString() = "[${types.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): ResourceType? {
val type = ResourceType()
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
// use static objects to allow types.contains()
var typeName = Property.Name(parser.namespace, parser.name)
when (typeName) {
COLLECTION -> typeName = COLLECTION
PRINCIPAL -> typeName = PRINCIPAL
ADDRESSBOOK -> typeName = ADDRESSBOOK
CALENDAR -> typeName = CALENDAR
SUBSCRIBED -> typeName = SUBSCRIBED
}
type.types.add(typeName)
}
eventType = parser.next()
}
assert(parser.depth == depth)
return type
}
}
}
@@ -0,0 +1,45 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.QuotedStringUtils
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.Response
import org.xmlpull.v1.XmlPullParser
class ScheduleTag(
rawScheduleTag: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "schedule-tag")
fun fromResponse(response: Response) =
response.header("Schedule-Tag")?.let { ScheduleTag(it) }
}
/* Value: opaque-tag
opaque-tag = quoted-string
*/
val scheduleTag: String? = rawScheduleTag?.let { QuotedStringUtils.decodeQuotedString(it) }
override fun toString() = scheduleTag ?: "(null)"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
ScheduleTag(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,30 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class Source: HrefListProperty() {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALENDARSERVER, "source")
}
object Factory: HrefListProperty.Factory() {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
create(parser, Source())
}
}
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.util.logging.Level
class SupportedAddressData: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CARDDAV, "supported-address-data")
val ADDRESS_DATA_TYPE = Property.Name(XmlUtils.NS_CARDDAV, "address-data-type")
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
val types = mutableSetOf<MediaType>()
fun hasVCard4() = types.any { "text/vcard; version=4.0".equals(it.toString(), true) }
fun hasJCard() = types.any { "application".equals(it.type, true) && "vcard+json".equals(it.subtype, true) }
override fun toString() = "[${types.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedAddressData? {
val supported = SupportedAddressData()
try {
XmlUtils.processTag(parser, ADDRESS_DATA_TYPE) {
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
var type = contentType
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
}
}
} catch(e: XmlPullParserException) {
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
return null
}
return supported
}
}
}
@@ -0,0 +1,67 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.XmlUtils.propertyName
import org.xmlpull.v1.XmlPullParser
data class SupportedCalendarComponentSet(
var supportsEvents: Boolean,
var supportsTasks: Boolean,
var supportsJournal: Boolean
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
val ALLCOMP = Property.Name(XmlUtils.NS_CALDAV, "allcomp")
val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedCalendarComponentSet? {
/* <!ELEMENT supported-calendar-component-set (comp+)>
<!ELEMENT comp ((allprop | prop*), (allcomp | comp*))>
<!ATTLIST comp name CDATA #REQUIRED>
*/
val components = SupportedCalendarComponentSet(false, false, false)
val depth = parser.depth
var eventType = parser.eventType
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
if (eventType == XmlPullParser.START_TAG && parser.depth == depth + 1) {
when (parser.propertyName()) {
ALLCOMP -> {
components.supportsEvents = true
components.supportsTasks = true
components.supportsJournal = true
}
COMP ->
when (parser.getAttributeValue(null, "name")?.uppercase()) {
"VEVENT" -> components.supportsEvents = true
"VTODO" -> components.supportsTasks = true
"VJOURNAL" -> components.supportsJournal = true
}
}
}
eventType = parser.next()
}
return components
}
}
}
@@ -0,0 +1,64 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Dav4jvm
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.util.logging.Level
class SupportedCalendarData: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-data")
val CALENDAR_DATA_TYPE = Property.Name(XmlUtils.NS_CALDAV, "calendar-data")
const val CONTENT_TYPE = "content-type"
const val VERSION = "version"
}
val types = mutableSetOf<MediaType>()
fun hasJCal() = types.any { "application".equals(it.type, true) && "calendar+json".equals(it.subtype, true) }
override fun toString() = "[${types.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedCalendarData? {
val supported = SupportedCalendarData()
try {
XmlUtils.processTag(parser, CALENDAR_DATA_TYPE) {
parser.getAttributeValue(null, CONTENT_TYPE)?.let { contentType ->
var type = contentType
parser.getAttributeValue(null, VERSION)?.let { version -> type += "; version=$version" }
type.toMediaTypeOrNull()?.let { supported.types.add(it) }
}
}
} catch(e: XmlPullParserException) {
Dav4jvm.log.log(Level.SEVERE, "Couldn't parse <resourcetype>", e)
return null
}
return supported
}
}
}
@@ -0,0 +1,58 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
class SupportedReportSet: Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "supported-report-set")
val SUPPORTED_REPORT = Property.Name(XmlUtils.NS_WEBDAV, "supported-report")
val REPORT = Property.Name(XmlUtils.NS_WEBDAV, "report")
const val SYNC_COLLECTION = "DAV:sync-collection" // collection synchronization (RFC 6578)
}
val reports = mutableSetOf<String>()
override fun toString() = "[${reports.joinToString(", ")}]"
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser): SupportedReportSet? {
/* <!ELEMENT supported-report-set (supported-report*)>
<!ELEMENT supported-report report>
<!ELEMENT report ANY>
*/
val supported = SupportedReportSet()
XmlUtils.processTag(parser, SUPPORTED_REPORT) {
XmlUtils.processTag(parser, REPORT) {
parser.nextTag()
if (parser.eventType == XmlPullParser.TEXT)
supported.reports += parser.text
else if (parser.eventType == XmlPullParser.START_TAG)
supported.reports += "${parser.namespace}${parser.name}"
}
}
return supported
}
}
}
@@ -0,0 +1,33 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.PropertyFactory
import at.bitfire.dav4jvm.XmlUtils
import org.xmlpull.v1.XmlPullParser
data class SyncToken(
val token: String?
): Property {
companion object {
@JvmField
val NAME = Property.Name(XmlUtils.NS_WEBDAV, "sync-token")
}
object Factory: PropertyFactory {
override fun getName() = NAME
override fun create(parser: XmlPullParser) =
// <!ELEMENT sync-token #PCDATA>
SyncToken(XmlUtils.readText(parser))
}
}
@@ -0,0 +1,282 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.Challenge
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.Response.Builder
import org.junit.Assert.*
import org.junit.Test
class BasicDigestAuthHandlerTest {
@Test
fun testBasic() {
var authenticator = BasicDigestAuthHandler(null, "user", "password")
val original = Request.Builder()
.url("http://example.com")
.build()
var response = Builder()
.request(original)
.protocol(Protocol.HTTP_1_1)
.code(401).message("Authentication required")
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\"")
.build()
var request = authenticator.authenticateRequest(original, response)
assertEquals("Basic dXNlcjpwYXNzd29yZA==", request!!.header("Authorization"))
// special characters: always use UTF-8 (and don't crash on RFC 7617 charset header)
authenticator = BasicDigestAuthHandler(null, "username", "paßword")
response = response.newBuilder()
.header("WWW-Authenticate", "Basic realm=\"WallyWorld\",charset=UTF-8")
.build()
request = authenticator.authenticateRequest(original, response)
assertEquals("Basic dXNlcm5hbWU6cGHDn3dvcmQ=", request!!.header("Authorization"))
}
@Test
fun testDigestRFCExample() {
// use cnonce from example
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "Circle Of Life")
BasicDigestAuthHandler.clientNonce = "0a4f113b"
BasicDigestAuthHandler.nonceCount.set(1)
// construct WWW-Authenticate
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "testrealm@host.com"),
Pair("qop", "auth"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"Mufasa\""))
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/dir/index.html\""))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("cnonce=\"0a4f113b\""))
assertTrue(auth.contains("response=\"6629fae49393a05397450978507c4ef1\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testDigestRealWorldExamples() {
var authenticator = BasicDigestAuthHandler(null, "demo", "demo")
BasicDigestAuthHandler.clientNonce = "MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI="
BasicDigestAuthHandler.nonceCount.set(1)
// example 1
var authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth"),
Pair("nonce", "56212407212c8"),
Pair("opaque", "df58bdff8cf60599c939187d0b5c54de")
))
var original = Request.Builder()
.method("PROPFIND", null)
.url("https://demo.group-office.eu/caldav/")
.build()
var request = authenticator.digestRequest(original, authScheme)
var auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"demo\""))
assertTrue(auth.contains("realm=\"Group-Office\""))
assertTrue(auth.contains("nonce=\"56212407212c8\""))
assertTrue(auth.contains("uri=\"/caldav/\""))
assertTrue(auth.contains("cnonce=\"MDI0ZDgxYTNmZDk4MTA1ODM0NDNjNmJjNDllYjQ1ZTI=\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("response=\"de3b3b194d85ddc62537208c9c3637dc\""))
assertTrue(auth.contains("opaque=\"df58bdff8cf60599c939187d0b5c54de\""))
// example 2
authenticator = BasicDigestAuthHandler(null, "test", "test")
authScheme = Challenge("digest", mapOf<String?, String>( // lower case
Pair("nonce", "87c4c2aceed9abf30dd68c71"),
Pair("algorithm", "md5"),
Pair("opaque", "571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw=="),
Pair("realm", "ieddy.ru")
))
original = Request.Builder()
.method("OPTIONS", null)
.url("https://ieddy.ru/")
.build()
request = authenticator.digestRequest(original, authScheme)
auth = request!!.header("Authorization")
assertTrue(auth!!.contains("algorithm=\"MD5\"")) // some servers require it
assertTrue(auth.contains("username=\"test\""))
assertTrue(auth.contains("realm=\"ieddy.ru\""))
assertTrue(auth.contains("nonce=\"87c4c2aceed9abf30dd68c71\""))
assertTrue(auth.contains("uri=\"/\""))
assertFalse(auth.contains("cnonce="))
assertFalse(auth.contains("nc=00000001"))
assertFalse(auth.contains("qop="))
assertTrue(auth.contains("response=\"d42a39f25f80b0d6907286a960ff9c7d\""))
assertTrue(auth.contains("opaque=\"571609eb7058505d35c7bf7288fbbec4-ODdjNGMyYWNlZWQ5YWJmMzBkZDY4YzcxLDAuMC4wLjAsMTQ0NTM3NzE0Nw==\""))
}
@Test
fun testDigestMD5Sess() {
val authenticator = BasicDigestAuthHandler(null, "admin", "12345")
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
BasicDigestAuthHandler.nonceCount.set(1)
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "MD5-sess Example"),
Pair("qop", "auth"),
Pair("algorithm", "MD5-sess"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
/* A1 = h("admin:MD5-sess Example:12345"):dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk =
4eaed818bc587129e73b39c8d3e8425a:dcd98b7102dd2f0e8b11d0f600bfb0c093:hxk1lu63b6c7vhk a994ee9d33e2f077d3a6e13e882f6686
A2 = POST:/plain.txt 1b557703454e1aa1230c5523f54380ed
h("a994ee9d33e2f077d3a6e13e882f6686:dcd98b7102dd2f0e8b11d0f600bfb0c093:00000001:hxk1lu63b6c7vhk:auth:1b557703454e1aa1230c5523f54380ed") =
af2a72145775cfd08c36ad2676e89446
*/
val original = Request.Builder()
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
.url("http://example.com/plain.txt")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"admin\""))
assertTrue(auth.contains("realm=\"MD5-sess Example\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/plain.txt\""))
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth"))
assertTrue(auth.contains("response=\"af2a72145775cfd08c36ad2676e89446\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testDigestMD5AuthInt() {
val authenticator = BasicDigestAuthHandler(null, "admin", "12435")
BasicDigestAuthHandler.clientNonce = "hxk1lu63b6c7vhk"
BasicDigestAuthHandler.nonceCount.set(1)
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "AuthInt Example"),
Pair("qop", "auth-int"),
Pair("nonce", "367sj3265s5"),
Pair("opaque", "87aaxcval4gba36")
))
/* A1 = admin:AuthInt Example:12345 380dc3fc1305127cd2aa81ab68ef3f34
h("PLAIN TEXT") = 20296edbd4c4275fb416b64e4be752f9
A2 = POST:/plain.txt:20296edbd4c4275fb416b64e4be752f9 a71c4c86e18b3993ffc98c6e426fe4b0
h(380dc3fc1305127cd2aa81ab68ef3f34:367sj3265s5:00000001:hxk1lu63b6c7vhk:auth-int:a71c4c86e18b3993ffc98c6e426fe4b0) =
81d07cb3b8d412b34144164124c970cb
*/
val original = Request.Builder()
.method("POST", "PLAIN TEXT".toRequestBody("text/plain".toMediaType()))
.url("http://example.com/plain.txt")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"admin\""))
assertTrue(auth.contains("realm=\"AuthInt Example\""))
assertTrue(auth.contains("nonce=\"367sj3265s5\""))
assertTrue(auth.contains("uri=\"/plain.txt\""))
assertTrue(auth.contains("cnonce=\"hxk1lu63b6c7vhk\""))
assertTrue(auth.contains("nc=00000001"))
assertTrue(auth.contains("qop=auth-int"))
assertTrue(auth.contains("response=\"5ab6822b9d906cc711760a7783b28dca\""))
assertTrue(auth.contains("opaque=\"87aaxcval4gba36\""))
}
@Test
fun testDigestLegacy() {
val authenticator = BasicDigestAuthHandler(null, "Mufasa", "CircleOfLife")
// construct WWW-Authenticate
val authScheme = Challenge("Digest", mapOf<String?, String>(
Pair("realm", "testrealm@host.com"),
Pair("nonce", "dcd98b7102dd2f0e8b11d0f600bfb0c093"),
Pair("opaque", "5ccc069c403ebaf9f0171e9517f40e41")
))
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
val request = authenticator.digestRequest(original, authScheme)
val auth = request!!.header("Authorization")
assertTrue(auth!!.contains("username=\"Mufasa\""))
assertTrue(auth.contains("realm=\"testrealm@host.com\""))
assertTrue(auth.contains("nonce=\"dcd98b7102dd2f0e8b11d0f600bfb0c093\""))
assertTrue(auth.contains("uri=\"/dir/index.html\""))
assertFalse(auth.contains("qop="))
assertFalse(auth.contains("nc="))
assertFalse(auth.contains("cnonce="))
assertTrue(auth.contains("response=\"1949323746fe6a43ef61f9606e7febea\""))
assertTrue(auth.contains("opaque=\"5ccc069c403ebaf9f0171e9517f40e41\""))
}
@Test
fun testIncompleteAuthenticationRequests() {
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
val original = Request.Builder()
.get()
.url("http://www.nowhere.org/dir/index.html")
.build()
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>())))
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office")
))))
assertNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth")
))))
assertNotNull(authenticator.digestRequest(original, Challenge("Digest", mapOf<String?, String>(
Pair("realm", "Group-Office"),
Pair("qop", "auth"),
Pair("nonce", "56212407212c8")
))))
}
@Test
fun testAuthenticateNull() {
val authenticator = BasicDigestAuthHandler(null, "demo", "demo")
// must not crash (route may be null)
val request = Request.Builder()
.get()
.url("http://example.com")
.build()
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_2)
.code(200).message("OK")
.build()
authenticator.authenticate(null, response)
}
}
@@ -0,0 +1,257 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.SyncToken
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.HttpURLConnection
class DavCollectionTest {
private val sampleText = "SAMPLE RESPONSE"
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
private fun sampleUrl() = mockServer.url("/dav/")
@Before
fun startServer() = mockServer.start()
@After
fun stopServer() = mockServer.shutdown()
/**
* Test sample response for an initial sync-collection report from RFC 6578 3.8.
*/
@Test
fun testInitialSyncCollectionReport() {
val url = sampleUrl()
val collection = DavCollection(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
" <D:multistatus xmlns:D=\"DAV:\">\n" +
" <D:response>\n" +
" <D:href\n" +
" >${sampleUrl()}test.doc</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\">\n" +
" <R:BoxType>Box type A</R:BoxType>\n" +
" </R:bigbox>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href\n" +
" >${sampleUrl()}vcard.vcf</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href\n" +
" >${sampleUrl()}calendar.ics</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00003-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <R:bigbox xmlns:R=\"urn:ns.example.com:boxschema\"/>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:sync-token>http://example.com/ns/sync/1234</D:sync-token>\n" +
" </D:multistatus>")
)
var nrCalled = 0
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
when (response.href) {
url.resolve("/dav/test.doc") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00001-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/vcard.vcf") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00002-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/calendar.ics") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00003-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
}
}
assertEquals(3, nrCalled)
assertEquals("http://example.com/ns/sync/1234", result.filterIsInstance(SyncToken::class.java).first().token)
}
/**
* Test sample response for an initial sync-collection report with truncation from RFC 6578 3.10.
*/
@Test
fun testInitialSyncCollectionReportWithTruncation() {
val url = sampleUrl()
val collection = DavCollection(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
" <D:multistatus xmlns:D=\"DAV:\">\n" +
" <D:response>\n" +
" <D:href>${sampleUrl()}test.doc</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00001-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href>${sampleUrl()}vcard.vcf</D:href>\n" +
" <D:propstat>\n" +
" <D:prop>\n" +
" <D:getetag>\"00002-abcd1\"</D:getetag>\n" +
" </D:prop>\n" +
" <D:status>HTTP/1.1 200 OK</D:status>\n" +
" </D:propstat>\n" +
" </D:response>\n" +
" <D:response>\n" +
" <D:href>${sampleUrl()}removed.txt</D:href>\n" +
" <D:status>HTTP/1.1 404 Not Found</D:status>\n" +
" </D:response>" +
" <D:response>\n" +
" <D:href>${sampleUrl()}</D:href>\n" +
" <D:status>HTTP/1.1 507 Insufficient Storage</D:status>\n" +
" <D:error><D:number-of-matches-within-limits/></D:error>\n" +
" </D:response>" +
" <D:sync-token>http://example.com/ns/sync/1233</D:sync-token>\n" +
" </D:multistatus>")
)
var nrCalled = 0
val result = collection.reportChanges(null, false, null, GetETag.NAME) { response, relation ->
when (response.href) {
url.resolve("/dav/test.doc") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00001-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/vcard.vcf") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
val eTag = response[GetETag::class.java]
assertEquals("00002-abcd1", eTag?.eTag)
assertTrue(eTag?.weak == false)
nrCalled++
}
url.resolve("/dav/removed.txt") -> {
assertFalse(response.isSuccess())
assertEquals(404, response.status?.code)
assertEquals(Response.HrefRelation.MEMBER, relation)
nrCalled++
}
url.resolve("/dav/") -> {
assertFalse(response.isSuccess())
assertEquals(507, response.status?.code)
assertEquals(Response.HrefRelation.SELF, relation)
nrCalled++
}
}
}
assertEquals("http://example.com/ns/sync/1233", result.filterIsInstance(SyncToken::class.java).first().token)
assertEquals(4, nrCalled)
}
/**
* Test sample response for a sync-collection report with unsupported limit from RFC 6578 3.12.
*/
@Test
fun testSyncCollectionReportWithUnsupportedLimit() {
val url = sampleUrl()
val collection = DavCollection(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(507)
.setHeader("Content-Type", "text/xml; charset=\"utf-8\"")
.setBody("<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
" <D:error xmlns:D=\"DAV:\">\n" +
" <D:number-of-matches-within-limits/>\n" +
" </D:error>")
)
try {
collection.reportChanges("http://example.com/ns/sync/1232", false, 100, GetETag.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(507, e.code)
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "number-of-matches-within-limits") })
assertEquals(1, e.errors.size)
}
}
@Test
fun testPost() {
val url = sampleUrl()
val dav = DavCollection(httpClient, url)
// 201 Created
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_CREATED))
var called = false
dav.post(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
assertEquals("POST", mockServer.takeRequest().method)
assertEquals(response.request.url, dav.location)
called = true
}
assertTrue(called)
}
}
@@ -0,0 +1,971 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import at.bitfire.dav4jvm.exception.DavException
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.exception.PreconditionFailedException
import at.bitfire.dav4jvm.property.DisplayName
import at.bitfire.dav4jvm.property.GetContentType
import at.bitfire.dav4jvm.property.GetETag
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.net.HttpURLConnection
class DavResourceTest {
private val sampleText = "SAMPLE RESPONSE"
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
@Before
fun startServer() {
mockServer.start()
}
@After
fun stopServer() {
mockServer.shutdown()
}
private fun sampleUrl() = mockServer.url("/dav/")
@Test
fun testCopy() {
val url = sampleUrl()
val destination = url.resolve("test")!!
/* POSITIVE TEST CASES */
// no preconditions, 201 Created, resulted in the creation of a new resource
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_CREATED))
var called = false
DavResource(httpClient, url).let { dav ->
dav.copy(destination, false) {
called = true
}
assertTrue(called)
}
var rq = mockServer.takeRequest()
assertEquals("COPY", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertNull(rq.getHeader("Overwrite"))
// no preconditions, 204 No content, resource successfully copied to a preexisting
// destination resource
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
called = false
DavResource(httpClient, url).let { dav ->
dav.copy(destination, true) {
called = true
}
assertTrue(called)
}
rq = mockServer.takeRequest()
assertEquals("COPY", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertEquals("F", rq.getHeader("Overwrite"))
/* NEGATIVE TEST CASES */
// 207 multi-status (e.g. errors on some of resources affected by
// the COPY prevented the operation from taking place)
mockServer.enqueue(MockResponse()
.setResponseCode(207))
try {
called = false
DavResource(httpClient, url).let { dav ->
dav.copy(destination, false) { called = true }
fail("Expected HttpException")
}
} catch(e: HttpException) {
assertFalse(called)
}
}
@Test
fun testDelete() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/* POSITIVE TEST CASES */
// no preconditions, 204 No Content
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
var called = false
dav.delete {
called = true
}
assertTrue(called)
var rq = mockServer.takeRequest()
assertEquals("DELETE", rq.method)
assertEquals(url.encodedPath, rq.path)
assertNull(rq.getHeader("If-Match"))
// precondition: If-Match / If-Schedule-Tag-Match, 200 OK
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody("Resource has been deleted."))
called = false
dav.delete("DeleteOnlyThisETag", "DeleteOnlyThisScheduleTag") {
called = true
}
assertTrue(called)
rq = mockServer.takeRequest()
assertEquals("\"DeleteOnlyThisETag\"", rq.getHeader("If-Match"))
assertEquals("\"DeleteOnlyThisScheduleTag\"", rq.getHeader("If-Schedule-Tag-Match"))
// 302 Moved Temporarily
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
.setHeader("Location", "/new-location")
)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
called = false
dav.delete(null) {
called = true
}
assertTrue(called)
/* NEGATIVE TEST CASES */
// 207 multi-status (e.g. single resource couldn't be deleted when DELETEing a collection)
mockServer.enqueue(MockResponse()
.setResponseCode(207))
try {
called = false
dav.delete(null) { called = true }
fail("Expected HttpException")
} catch(e: HttpException) {
assertFalse(called)
}
}
@Test
fun testFollowRedirects_302() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
var i = 0
dav.followRedirects {
if (i++ == 0)
okhttp3.Response.Builder()
.protocol(Protocol.HTTP_1_1)
.code(302)
.message("Found")
.header("Location", "http://to.com/")
.request(Request.Builder()
.get()
.url("http://from.com/")
.build())
.body("New location!".toResponseBody())
.build()
else
okhttp3.Response.Builder()
.protocol(Protocol.HTTP_1_1)
.code(204)
.message("No Content")
.request(Request.Builder()
.get()
.url("http://to.com/")
.build())
.build()
}.let { response ->
assertEquals(204, response.code)
assertEquals("http://to.com/".toHttpUrl(), dav.location)
}
}
@Test(expected = DavException::class)
fun testFollowRedirects_HttpsToHttp() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.followRedirects {
okhttp3.Response.Builder()
.protocol(Protocol.HTTP_1_1)
.code(302)
.message("Found")
.header("Location", "http://to.com/")
.request(Request.Builder()
.get()
.url("https://from.com/")
.build())
.body("New location!".toResponseBody())
.build()
}
}
@Test
fun testGet() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/* POSITIVE TEST CASES */
// 200 OK
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setHeader("ETag", "W/\"My Weak ETag\"")
.setHeader("Content-Type", "application/x-test-result")
.setBody(sampleText))
var called = false
dav.get("*/*", null) { response ->
called = true
assertEquals(sampleText, response.body!!.string())
val eTag = GetETag.fromResponse(response)
assertEquals("My Weak ETag", eTag!!.eTag)
assertTrue(eTag.weak!!)
assertEquals("application/x-test-result".toMediaType(), GetContentType(response.body!!.contentType()!!).type)
}
assertTrue(called)
var rq = mockServer.takeRequest()
assertEquals("GET", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals("*/*", rq.getHeader("Accept"))
// 302 Moved Temporarily + 200 OK
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_MOVED_TEMP)
.setHeader("Location", "/target")
.setBody("This resource was moved."))
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setHeader("ETag", "\"StrongETag\"")
.setBody(sampleText))
called = false
dav.get("*/*", null) { response ->
called = true
assertEquals(sampleText, response.body!!.string())
val eTag = GetETag(response.header("ETag"))
assertEquals("StrongETag", eTag.eTag)
assertFalse(eTag.weak!!)
}
assertTrue(called)
mockServer.takeRequest()
rq = mockServer.takeRequest()
assertEquals("GET", rq.method)
assertEquals("/target", rq.path)
// 200 OK without ETag in response
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(sampleText))
called = false
dav.get("*/*", null) { response ->
called = true
assertNull(response.header("ETag"))
}
assertTrue(called)
}
@Test
fun testGetRange_Ok() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_PARTIAL))
var called = false
dav.getRange("*/*", 100, 342) { response ->
assertEquals("bytes=100-441", response.request.header("Range"))
called = true
}
assertTrue(called)
}
@Test
fun testMove() {
val url = sampleUrl()
val destination = url.resolve("test")!!
/* POSITIVE TEST CASES */
// no preconditions, 201 Created, new URL mapping at the destination
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_CREATED))
var called = false
DavResource(httpClient, url).let { dav ->
dav.move(destination, false) {
called = true
}
assertTrue(called)
assertEquals(destination, dav.location)
}
var rq = mockServer.takeRequest()
assertEquals("MOVE", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertNull(rq.getHeader("Overwrite"))
// no preconditions, 204 No content, URL already mapped, overwrite
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
called = false
DavResource(httpClient, url).let { dav ->
dav.move(destination, true) {
called = true
}
assertTrue(called)
assertEquals(destination, dav.location)
}
rq = mockServer.takeRequest()
assertEquals("MOVE", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals(destination.toString(), rq.getHeader("Destination"))
assertEquals("F", rq.getHeader("Overwrite"))
/* NEGATIVE TEST CASES */
// 207 multi-status (e.g. errors on some of resources affected by
// the MOVE prevented the operation from taking place)
mockServer.enqueue(MockResponse()
.setResponseCode(207))
try {
called = false
DavResource(httpClient, url).let { dav ->
dav.move(destination, false) { called = true }
fail("Expected HttpException")
}
} catch(e: HttpException) {
assertFalse(called)
}
}
@Test
fun testOptions() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setHeader("DAV", " 1, 2 ,3,hyperactive-access"))
var called = false
dav.options { davCapabilities, _ ->
called = true
assertTrue(davCapabilities.contains("1"))
assertTrue(davCapabilities.contains("2"))
assertTrue(davCapabilities.contains("3"))
assertTrue(davCapabilities.contains("hyperactive-access"))
}
assertTrue(called)
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
called = false
dav.options { davCapabilities, _ ->
called = true
assertTrue(davCapabilities.isEmpty())
}
assertTrue(called)
}
@Test
fun testPropfindAndMultiStatus() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/*** NEGATIVE TESTS ***/
// test for non-multi-status responses:
// * 500 Internal Server Error
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_INTERNAL_ERROR))
var called = false
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected HttpException")
} catch(e: HttpException) {
assertFalse(called)
}
// * 200 OK (instead of 207 Multi-Status)
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// test for invalid multi-status responses:
// * non-XML response
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "text/html")
.setBody("<html></html>"))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// * malformed XML response
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<malformed-xml>"))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// * response without <multistatus> root element
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<test></test>"))
try {
called = false
dav.propfind(0, ResourceType.NAME) { _, _ -> called = true }
fail("Expected DavException")
} catch(e: DavException) {
assertFalse(called)
}
// * multi-status response with invalid <status> in <response>
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>Invalid Status Line</status>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(500, response.status?.code)
called = true
}
assertTrue(called)
// * multi-status response with <response>/<status> element indicating failure
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>HTTP/1.1 403 Forbidden</status>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(403, response.status?.code)
called = true
}
assertTrue(called)
// * multi-status response with invalid <status> in <propstat>
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" </prop>" +
" <status>Invalid Status Line</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
called = true
assertEquals(Response.HrefRelation.SELF, relation)
assertTrue(response.properties.filterIsInstance(ResourceType::class.java).isEmpty())
}
assertTrue(called)
/*** POSITIVE TESTS ***/
// multi-status response without <response> elements
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'></multistatus>"))
dav.propfind(0, ResourceType.NAME) { _, _ ->
fail("Shouldn't be called")
}
// multi-status response with <response>/<status> element indicating success
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <status>HTTP/1.1 200 OK</status>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME) { response, relation ->
called = true
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(0, response.properties.size)
}
assertTrue(called)
// multi-status response with <response>/<propstat> element
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype></resourcetype>" +
" <displayname>My DAV Collection</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
called = true
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
}
assertTrue(called)
// multi-status response for collection with several members; incomplete (not all <resourcetype>s listed)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>" + url.toString() + "</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" <displayname>My DAV Collection</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>/dav/subcollection</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" <displayname>A Subfolder</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>/dav/uid@host:file</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Absolute path with @ and :</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>relative-uid@host.file</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Relative path with @</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>relative:colon.vcf</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Relative path with colon</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
" <response>" +
" <href>/something-very/else</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Not requested</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
var nrCalled = 0
dav.propfind(1, ResourceType.NAME, DisplayName.NAME) { response, relation ->
when (response.href) {
url.resolve("/dav/") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/subcollection/") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
assertEquals("A Subfolder", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/uid@host:file") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertEquals("Absolute path with @ and :", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/relative-uid@host.file") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertEquals("Relative path with @", response[DisplayName::class.java]?.displayName)
nrCalled++
}
url.resolve("/dav/relative:colon.vcf") -> {
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.MEMBER, relation)
assertEquals("Relative path with colon", response[DisplayName::class.java]?.displayName)
nrCalled++
}
}
}
assertEquals(4, nrCalled)
/*** SPECIAL CASES ***/
// same property is sent as 200 OK and 404 Not Found in same <response> (seen in iCloud)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>" + url.toString() + "</href>" +
" <propstat>" +
" <prop>" +
" <resourcetype><collection/></resourcetype>" +
" <displayname>My DAV Collection</displayname>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" <propstat>" +
" <prop>" +
" <resourcetype/>" +
" </prop>" +
" <status>HTTP/1.1 404 Not Found</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, ResourceType.NAME, DisplayName.NAME) { response, relation ->
called = true
assertTrue(response.isSuccess())
assertEquals(Response.HrefRelation.SELF, relation)
assertEquals(url.resolve("/dav/"), response.href)
assertTrue(response[ResourceType::class.java]!!.types.contains(ResourceType.COLLECTION))
assertEquals("My DAV Collection", response[DisplayName::class.java]?.displayName)
}
assertTrue(called)
// multi-status response with <propstat> that doesn't contain <status> (=> assume 200 OK)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Without Status</displayname>" +
" </prop>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
called = false
dav.propfind(0, DisplayName.NAME) { response, _ ->
called = true
assertEquals(200, response.propstat.first().status.code)
assertEquals("Without Status", response[DisplayName::class.java]?.displayName)
}
assertTrue(called)
}
@Test
fun testProppatch() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
// multi-status response with <response>/<propstat> elements
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:' xmlns:s='sample'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <s:setThis>Some Value</s:setThis>" +
" </prop>" +
" <status>HTTP/1.1 200 OK</status>" +
" </propstat>" +
" <propstat>" +
" <prop>" +
" <s:removeThis/>" +
" </prop>" +
" <status>HTTP/1.1 404 Not Found</status>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
var called = false
dav.proppatch(
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
removeProperties = listOf(Property.Name("sample", "removeThis"))
) { response, hrefRelation ->
called = true
assertEquals(Response.HrefRelation.SELF, hrefRelation)
}
assertTrue(called)
}
@Test
fun testProppatch_createProppatchXml() {
val xml = DavResource.createProppatchXml(
setProperties = mapOf(Pair(Property.Name("sample", "setThis"), "Some Value")),
removeProperties = listOf(Property.Name("sample", "removeThis"))
)
assertEquals("<?xml version=\"1.0\" encoding=\"UTF-8\"?>" +
"<d:propertyupdate xmlns:d=\"DAV:\">" +
"<d:set><d:prop><n1:setThis xmlns:n1=\"sample\">Some Value</n1:setThis></d:prop></d:set>" +
"<d:remove><d:prop><n2:removeThis xmlns:n2=\"sample\" /></d:prop></d:remove>" +
"</d:propertyupdate>", xml)
}
@Test
fun testPut() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
/* POSITIVE TEST CASES */
// no preconditions, 201 Created
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_CREATED)
.setHeader("ETag", "W/\"Weak PUT ETag\""))
var called = false
dav.put(sampleText.toRequestBody("text/plain".toMediaType())) { response ->
called = true
val eTag = GetETag.fromResponse(response)!!
assertEquals("Weak PUT ETag", eTag.eTag)
assertTrue(eTag.weak!!)
assertEquals(response.request.url, dav.location)
}
assertTrue(called)
var rq = mockServer.takeRequest()
assertEquals("PUT", rq.method)
assertEquals(url.encodedPath, rq.path)
assertNull(rq.getHeader("If-Match"))
assertNull(rq.getHeader("If-None-Match"))
// precondition: If-None-Match, 301 Moved Permanently + 204 No Content, no ETag in response
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_MOVED_PERM)
.setHeader("Location", "/target"))
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_NO_CONTENT))
called = false
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), ifNoneMatch = true) { response ->
called = true
assertEquals(url.resolve("/target"), response.request.url)
val eTag = GetETag.fromResponse(response)
assertNull("Weak PUT ETag", eTag?.eTag)
assertNull(eTag?.weak)
}
assertTrue(called)
mockServer.takeRequest()
rq = mockServer.takeRequest()
assertEquals("PUT", rq.method)
assertEquals("*", rq.getHeader("If-None-Match"))
// precondition: If-Match, 412 Precondition Failed
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_PRECON_FAILED))
called = false
try {
dav.put(sampleText.toRequestBody("text/plain".toMediaType()), "ExistingETag") {
called = true
}
fail("Expected PreconditionFailedException")
} catch(_: PreconditionFailedException) {}
assertFalse(called)
rq = mockServer.takeRequest()
assertEquals("\"ExistingETag\"", rq.getHeader("If-Match"))
assertNull(rq.getHeader("If-None-Match"))
}
@Test
fun testSearch() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(207)
.setHeader("Content-Type", "application/xml; charset=utf-8")
.setBody("<multistatus xmlns='DAV:'>" +
" <response>" +
" <href>/dav</href>" +
" <propstat>" +
" <prop>" +
" <displayname>Found something</displayname>" +
" </prop>" +
" </propstat>" +
" </response>" +
"</multistatus>"))
var called = false
dav.search("<TEST/>") { response, hrefRelation, ->
assertEquals(Response.HrefRelation.SELF, hrefRelation)
assertEquals("Found something", response[DisplayName::class.java]?.displayName)
called = true
}
assertTrue(called)
val rq = mockServer.takeRequest()
assertEquals("SEARCH", rq.method)
assertEquals(url.encodedPath, rq.path)
assertEquals("<TEST/>", rq.body.readString(Charsets.UTF_8))
}
/** test helpers **/
@Test(expected = DavException::class)
fun testAssertMultiStatus_NoBody_NoXML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.build())
}
@Test(expected = DavException::class)
fun testAssertMultiStatus_NoBody_XML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "text/xml")
.build())
}
@Test
fun testAssertMultiStatus_NonXML_ButContentIsXML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "application/octet-stream")
.body("<?xml version=\"1.0\"><test/>".toResponseBody())
.build())
}
@Test
fun testAssertMultiStatus_NonXML_ReallyNotXML() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "text/plain")
.body("Some error occurred".toResponseBody())
.build())
}
@Test(expected = DavException::class)
fun testAssertMultiStatus_Not207() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(403).message("Multi-Status")
.addHeader("Content-Type", "application/xml")
.body("".toResponseBody())
.build())
}
@Test
fun testAssertMultiStatus_Ok_ApplicationXml() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "application/xml")
.body("".toResponseBody())
.build())
}
@Test
fun testAssertMultiStatus_Ok_TextXml() {
val dav = DavResource(httpClient, "https://from.com".toHttpUrl())
dav.assertMultiStatus(okhttp3.Response.Builder()
.request(Request.Builder().url(dav.location).build())
.protocol(Protocol.HTTP_1_1)
.code(207).message("Multi-Status")
.addHeader("Content-Type", "text/xml")
.body("".toResponseBody())
.build())
}
}
@@ -0,0 +1,20 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.junit.Assert.assertTrue
import org.junit.Test
class ErrorTest {
@Test
fun testEquals() {
val errors = listOf(Error(Property.Name("DAV:", "valid-sync-token")))
assertTrue(errors.contains(Error.VALID_SYNC_TOKEN))
}
}
@@ -0,0 +1,25 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Test
class HttpUtilsTest {
@Test
fun testFilename() {
assertEquals("", HttpUtils.fileName("https://example.com".toHttpUrl()))
assertEquals("", HttpUtils.fileName("https://example.com/".toHttpUrl()))
assertEquals("file1", HttpUtils.fileName("https://example.com/file1".toHttpUrl()))
assertEquals("dir1", HttpUtils.fileName("https://example.com/dir1/".toHttpUrl()))
assertEquals("file2", HttpUtils.fileName("https://example.com/dir1/file2".toHttpUrl()))
assertEquals("dir2", HttpUtils.fileName("https://example.com/dir1/dir2/".toHttpUrl()))
}
}
@@ -0,0 +1,199 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Locale
import java.util.TimeZone
/**
* Covers what Agendula changed while vendoring, and the two upstream defects
* `docs/SYNC.md` says we would inherit. Everything else in this directory is
* upstream's own suite, unmodified. See `dav/PROVENANCE.md`.
*/
class LocalChangesTest {
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
private val mockServer = MockWebServer()
@Before fun startServer() = mockServer.start()
@After fun stopServer() = mockServer.shutdown()
private fun redirect(code: Int, to: String) = MockResponse()
.setResponseCode(code)
.setHeader("Location", to)
// -- change 3: dav4jvm#209, permanent redirects never reached the caller ---
@Test
fun permanentRedirectIsReportedSoTheCallerCanPersistIt() {
mockServer.enqueue(redirect(301, "/moved/"))
mockServer.enqueue(MockResponse().setResponseCode(200))
val resource = DavResource(httpClient, mockServer.url("/dav/"))
resource.head { }
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
}
@Test
fun a308IsPermanentToo() {
mockServer.enqueue(redirect(308, "/moved/"))
mockServer.enqueue(MockResponse().setResponseCode(200))
val resource = DavResource(httpClient, mockServer.url("/dav/"))
resource.head { }
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
}
@Test
fun aTemporaryRedirectIsNotPersisted() {
mockServer.enqueue(redirect(302, "/elsewhere/"))
mockServer.enqueue(MockResponse().setResponseCode(200))
val resource = DavResource(httpClient, mockServer.url("/dav/"))
resource.head { }
assertNull(resource.permanentLocation)
// The in-flight location still moves, as it must.
assertEquals(mockServer.url("/elsewhere/"), resource.location)
}
@Test
fun permanentLocationDescribesOnlyTheRequestJustMade() {
// DavResource objects are reused. A value left over from an earlier
// request would have the caller persist a URL that a later one replaced,
// and every sync after that 404s.
mockServer.enqueue(redirect(301, "/moved/"))
mockServer.enqueue(MockResponse().setResponseCode(200))
mockServer.enqueue(MockResponse().setResponseCode(200))
val resource = DavResource(httpClient, mockServer.url("/dav/"))
resource.head { }
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
resource.head { }
assertNull(resource.permanentLocation)
}
@Test
fun aTemporaryHopEndsThePermanentChain() {
// 301 -> 302 means "the resource moved to /moved/, and /moved/ is being
// served from /temp/ for now". Persisting /temp/ would be wrong.
mockServer.enqueue(redirect(301, "/moved/"))
mockServer.enqueue(redirect(302, "/temp/"))
mockServer.enqueue(MockResponse().setResponseCode(200))
val resource = DavResource(httpClient, mockServer.url("/dav/"))
resource.head { }
assertEquals(mockServer.url("/moved/"), resource.permanentLocation)
assertEquals(mockServer.url("/temp/"), resource.location)
}
// -- the "303 is not handled" claim, checked rather than assumed -----------
@Test
fun a303IsFollowed() {
// `docs/SYNC.md` lists "handles 301/302/307/308 but not 303" as a defect
// we would inherit. It does not hold for this version: OkHttp's
// Response.isRedirect includes HTTP_SEE_OTHER, and followRedirects re-sends
// the same method, which is what RFC 6764 section 5 asks for in discovery.
mockServer.enqueue(redirect(303, "/other/"))
mockServer.enqueue(MockResponse().setResponseCode(200))
val resource = DavResource(httpClient, mockServer.url("/dav/"))
resource.head { }
assertEquals(mockServer.url("/other/"), resource.location)
}
// -- change 1: commons-lang3 removed from date parsing --------------------
/**
* Runs [block] with a deliberately non-UTC default zone.
*
* The defect change 2 fixes *is* "the formatter used the default zone", so a
* test that does not move the default zone passes identically before and
* after the fix — and CI containers default to UTC. Without this the whole
* change is pinned by nothing.
*/
private fun <T> inNonUtcZone(block: () -> T): T {
val previous = TimeZone.getDefault()
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")) // UTC+05:30
try {
return block()
} finally {
TimeZone.setDefault(previous)
}
}
@Test
fun formatDateEmitsGmtRatherThanLocalTimeLabelledGmt() = inNonUtcZone {
assertEquals(
"Wed, 21 Oct 2015 07:28:00 GMT",
HttpUtils.formatDate(java.util.Date(1445412480000L)),
)
}
@Test
fun parseDateAcceptsTheRfc7231Format() = inNonUtcZone {
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
}
@Test
fun parseDateHonoursAnExplicitOffsetInsteadOfMatchingGmtAsAPrefix() {
// Pattern 1 ends in the *literal* 'GMT', and SimpleDateFormat.parse is
// happy with a prefix — so without a full-consumption check the "+02:00"
// is discarded and the result is two hours out, which is the same class of
// error change 2 set out to remove.
assertEquals(1445405280000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT+02:00")!!.time)
assertEquals(1445430480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT-05:00")!!.time)
}
@Test
fun parseDateRejectsTrailingGarbageRatherThanMatchingAPrefix() {
assertNull(HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT and then some"))
}
@Test
fun parseDateAcceptsTheObsoleteFormats() {
// RFC 850, and ANSI C asctime() — both still emitted in the wild.
assertTrue(HttpUtils.parseDate("Wednesday, 21-Oct-15 07:28:00 GMT") != null)
assertTrue(HttpUtils.parseDate("Wed Oct 21 07:28:00 2015") != null)
}
@Test
fun parseDateReturnsNullRatherThanThrowing() {
assertNull(HttpUtils.parseDate("not a date at all"))
}
@Test
fun parseDateIsIndependentOfTheDefaultLocale() {
// SimpleDateFormat defaults to the platform locale, which would reject
// English month names on a Turkish or German device.
val locale = Locale.getDefault()
val zone = TimeZone.getDefault()
try {
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
TimeZone.setDefault(TimeZone.getTimeZone("Europe/Istanbul"))
assertEquals(1445412480000L, HttpUtils.parseDate("Wed, 21 Oct 2015 07:28:00 GMT")!!.time)
} finally {
Locale.setDefault(locale)
TimeZone.setDefault(zone)
}
}
}
@@ -0,0 +1,34 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.junit.Assert.assertEquals
import org.junit.Test
class QuotedStringUtilsTest {
@Test
fun testAsQuotedString() {
assertEquals("\"\"", QuotedStringUtils.asQuotedString(""))
assertEquals("\"\\\"\"", QuotedStringUtils.asQuotedString("\""))
assertEquals("\"\\\\\"", QuotedStringUtils.asQuotedString("\\"))
}
fun testDecodeQuotedString() {
assertEquals("\"", QuotedStringUtils.decodeQuotedString("\""))
assertEquals("\\", QuotedStringUtils.decodeQuotedString("\"\\\""))
assertEquals("\"test", QuotedStringUtils.decodeQuotedString("\"test"))
assertEquals("test", QuotedStringUtils.decodeQuotedString("test"))
assertEquals("", QuotedStringUtils.decodeQuotedString("\"\""))
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"test\""))
assertEquals("test\\", QuotedStringUtils.decodeQuotedString("\"test\\\""))
assertEquals("test", QuotedStringUtils.decodeQuotedString("\"t\\e\\st\""))
assertEquals("12\"34", QuotedStringUtils.decodeQuotedString("\"12\\\"34\""))
assertEquals("1234\"", QuotedStringUtils.decodeQuotedString("\"1234\\\"\""))
}
}
@@ -0,0 +1,58 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.junit.Assert.*
import org.junit.Test
class UrlUtilsTest {
@Test
fun testEquals() {
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("http://host:80/resource".toHttpUrlOrNull()!!, "http://host/resource".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("https://HOST:443/resource".toHttpUrlOrNull()!!, "https://host/resource".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("https://host:443/my@dav/".toHttpUrlOrNull()!!, "https://host/my%40dav/".toHttpUrlOrNull()!!))
assertTrue(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource#frag1".toHttpUrlOrNull()!!))
// should work, but currently doesn't (see MR #5)
// assertTrue(UrlUtils.equals(HttpUrl.parse("https://host/%5bresource%5d/")!!, HttpUrl.parse("https://host/[resource]/")!!))
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host/resource/".toHttpUrlOrNull()!!))
assertFalse(UrlUtils.equals("http://host/resource".toHttpUrlOrNull()!!, "http://host:81/resource".toHttpUrlOrNull()!!))
}
@Test
fun testHostToDomain() {
assertNull(UrlUtils.hostToDomain(null))
assertEquals("", UrlUtils.hostToDomain("."))
assertEquals("com", UrlUtils.hostToDomain("com"))
assertEquals("com", UrlUtils.hostToDomain("com."))
assertEquals("example.com", UrlUtils.hostToDomain("example.com"))
assertEquals("example.com", UrlUtils.hostToDomain("example.com."))
assertEquals("example.com", UrlUtils.hostToDomain(".example.com"))
assertEquals("example.com", UrlUtils.hostToDomain(".example.com."))
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com"))
assertEquals("example.com", UrlUtils.hostToDomain("host.example.com."))
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com"))
assertEquals("example.com", UrlUtils.hostToDomain("sub.host.example.com."))
}
@Test
fun testOmitTrailingSlash() {
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
assertEquals("http://host/resource".toHttpUrlOrNull()!!, UrlUtils.omitTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
}
@Test
fun testWithTrailingSlash() {
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource".toHttpUrlOrNull()!!))
assertEquals("http://host/resource/".toHttpUrlOrNull()!!, UrlUtils.withTrailingSlash("http://host/resource/".toHttpUrlOrNull()!!))
}
}
@@ -0,0 +1,97 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.xmlpull.v1.XmlPullParser
import java.io.StringReader
class XmlUtilsTest {
@Test
fun testProcessTagRoot() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test></test>"))
// now on START_DOCUMENT [0]
var processed = false
XmlUtils.processTag(parser, Property.Name("", "test")) {
processed = true
}
assertTrue(processed)
}
@Test
fun testProcessTagDepth1() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<root><test></test></root>"))
parser.next() // now on START_TAG <root>
var processed = false
XmlUtils.processTag(parser, Property.Name("", "test")) {
processed = true
}
assertTrue(processed)
}
@Test
fun testReadText() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<root><test>Test 1</test><test><garbage/>Test 2</test></root>"))
parser.next()
parser.next() // now on START_TAG <test>
assertEquals("Test 1", XmlUtils.readText(parser))
assertEquals(XmlPullParser.END_TAG, parser.eventType)
parser.next()
assertEquals("Test 2", XmlUtils.readText(parser))
assertEquals(XmlPullParser.END_TAG, parser.eventType)
}
@Test
fun testReadTextCDATA() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test><![CDATA[Test 1</test><test><garbage/>Test 2]]></test>"))
parser.next() // now on START_TAG <test>
assertEquals("Test 1</test><test><garbage/>Test 2", XmlUtils.readText(parser))
assertEquals(XmlPullParser.END_TAG, parser.eventType)
}
@Test
fun testReadTextPropertyRoot() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<root><entry>Test 1</entry><entry>Test 2</entry></root>"))
parser.next() // now on START_TAG <root>
val entries = mutableListOf<String>()
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
assertEquals("Test 1", entries[0])
assertEquals("Test 2", entries[1])
parser.next() // END_TAG </root>
assertEquals(XmlPullParser.END_DOCUMENT, parser.eventType)
}
@Test
fun testReadTextPropertyListDepth1() {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test><entry>Test 1</entry><entry>Test 2</entry></test>"))
parser.next() // now on START_TAG <test> [1]
val entries = mutableListOf<String>()
XmlUtils.readTextPropertyList(parser, Property.Name("", "entry"), entries)
assertEquals("Test 1", entries[0])
assertEquals("Test 2", entries[1])
assertEquals(XmlPullParser.END_TAG, parser.eventType)
assertEquals("test", parser.name)
}
}
@@ -0,0 +1,176 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import at.bitfire.dav4jvm.property.ResourceType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
class DavExceptionTest {
private val httpClient = OkHttpClient.Builder()
.followRedirects(false)
.build()
private val mockServer = MockWebServer()
private fun sampleUrl() = mockServer.url("/dav/")
@Before
fun startServer() = mockServer.start()
@After
fun stopServer() = mockServer.shutdown()
/**
* Test truncation of a too large plain text request in [DavException].
*/
@Test
fun testRequestLargeTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val builder = StringBuilder()
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE+100) { '*' })
val body = builder.toString()
val e = DavException("Error with large request body", null, Response.Builder()
.request(Request.Builder()
.url("http://example.com")
.post(body.toRequestBody("text/plain".toMediaType()))
.build())
.protocol(Protocol.HTTP_1_1)
.code(204)
.message("No Content")
.build())
assertTrue(e.errors.isEmpty())
assertEquals(
body.substring(0, DavException.MAX_EXCERPT_SIZE),
e.requestBody
)
}
/**
* Test a large HTML response which has a multi-octet UTF-8 character
* exactly at the cut-off position.
*/
@Test
fun testResponseLargeTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val builder = StringBuilder()
builder.append(CharArray(DavException.MAX_EXCERPT_SIZE-1) { '*' })
builder.append("\u03C0") // Pi
val body = builder.toString()
mockServer.enqueue(MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "text/html")
.setBody(body))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 404)
assertTrue(e.errors.isEmpty())
assertEquals(
body.substring(0, DavException.MAX_EXCERPT_SIZE-1),
e.responseBody!!.substring(0, DavException.MAX_EXCERPT_SIZE-1)
)
}
}
@Test
fun testResponseNonTextError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(403)
.setHeader("Content-Type", "application/octet-stream")
.setBody("12345"))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 403)
assertTrue(e.errors.isEmpty())
assertNull(e.responseBody)
}
}
@Test
fun testSerialization() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
mockServer.enqueue(MockResponse()
.setResponseCode(500)
.setHeader("Content-Type", "text/plain")
.setBody("12345"))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected DavException")
} catch (e: DavException) {
val baos = ByteArrayOutputStream()
val oos = ObjectOutputStream(baos)
oos.writeObject(e)
val ois = ObjectInputStream(ByteArrayInputStream(baos.toByteArray()))
val e2 = ois.readObject() as HttpException
assertEquals(500, e2.code)
assertTrue(e2.responseBody!!.contains("12345"))
}
}
/**
* Test precondition XML element (sample from RFC 4918 16)
*/
@Test
fun testXmlError() {
val url = sampleUrl()
val dav = DavResource(httpClient, url)
val body = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>\n" +
"<D:error xmlns:D=\"DAV:\">\n" +
" <D:lock-token-submitted>\n" +
" <D:href>/workspace/webdav/</D:href>\n" +
" </D:lock-token-submitted>\n" +
"</D:error>\n"
mockServer.enqueue(MockResponse()
.setResponseCode(423)
.setHeader("Content-Type", "application/xml; charset=\"utf-8\"")
.setBody(body))
try {
dav.propfind(0, ResourceType.NAME) { _, _ -> }
fail("Expected HttpException")
} catch (e: HttpException) {
assertEquals(e.code, 423)
assertTrue(e.errors.any { it.name == Property.Name(XmlUtils.NS_WEBDAV, "lock-token-submitted") })
assertEquals(body, e.responseBody)
}
}
}
@@ -0,0 +1,43 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertTrue
import org.junit.Test
class HttpExceptionTest {
private val responseMessage = "Unknown error"
@Test
fun testHttpFormatting() {
val request = Request.Builder()
.post("REQUEST\nBODY".toRequestBody("text/something".toMediaType()))
.url("http://example.com")
.build()
val response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(500)
.message(responseMessage)
.body("SERVER\r\nRESPONSE".toResponseBody("text/something-other".toMediaType()))
.build()
val e = HttpException(response)
assertTrue(e.message!!.contains("500"))
assertTrue(e.message!!.contains(responseMessage))
assertTrue(e.requestBody!!.contains("REQUEST\nBODY"))
assertTrue(e.responseBody!!.contains("SERVER\r\nRESPONSE"))
}
}
@@ -0,0 +1,65 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package at.bitfire.dav4jvm.exception
import at.bitfire.dav4jvm.HttpUtils
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import org.junit.Assert.*
import org.junit.Test
import java.util.*
import kotlin.math.abs
class ServiceUnavailableExceptionTest {
val response503 = Response.Builder()
.request(Request.Builder()
.url("http://www.example.com")
.get()
.build())
.protocol(Protocol.HTTP_1_1)
.code(503).message("Try later")
.build()
@Test
fun testRetryAfter_NoTime() {
val e = ServiceUnavailableException(response503)
assertNull(e.retryAfter)
}
@Test
fun testRetryAfter_Seconds() {
val response = response503.newBuilder()
.header("Retry-After", "120")
.build()
val e = ServiceUnavailableException(response)
assertNotNull(e.retryAfter)
assertTrue(withinTimeRange(e.retryAfter!!, 120))
}
@Test
fun testRetryAfter_Date() {
val cal = Calendar.getInstance()
cal.add(Calendar.MINUTE, 30)
val response = response503.newBuilder()
.header("Retry-After", HttpUtils.formatDate(cal.time))
.build()
val e = ServiceUnavailableException(response)
assertNotNull(e.retryAfter)
assertTrue(withinTimeRange(e.retryAfter!!, 30*60))
}
private fun withinTimeRange(d: Date, seconds: Int): Boolean {
val msCheck = d.time
val msShouldBe = Date().time + seconds*1000
// assume max. 5 seconds difference for test running
return abs(msCheck - msShouldBe) < 5000
}
}
@@ -0,0 +1,15 @@
package at.bitfire.dav4jvm.property
import org.junit.Assert.assertEquals
import org.junit.Test
class CalendarDescriptionTest: PropertyTest() {
@Test
fun testCalendarDescription() {
val results = parseProperty("<calendar-description xmlns=\"urn:ietf:params:xml:ns:caldav\">My Calendar</calendar-description>")
val result = results.first() as CalendarDescription
assertEquals("My Calendar", result.description)
}
}
@@ -0,0 +1,48 @@
package at.bitfire.dav4jvm.property
import org.junit.Assert.*
import org.junit.Test
class GetETagTest: PropertyTest() {
@Test
fun testGetETag_NoText() {
val results = parseProperty("<getetag xmlns=\"DAV:\"><invalid/></getetag>")
val getETag = results.first() as GetETag
assertNull(getETag.eTag)
assertNull(getETag.weak)
}
@Test
fun testGetETag_Strong() {
val results = parseProperty("<getetag xmlns=\"DAV:\">\"Correct strong ETag\"</getetag>")
val getETag = results.first() as GetETag
assertEquals("Correct strong ETag", getETag.eTag)
assertFalse(getETag.weak!!)
}
@Test
fun testGetETag_Strong_NoQuotes() {
val results = parseProperty("<getetag xmlns=\"DAV:\">Strong ETag without quotes</getetag>")
val getETag = results.first() as GetETag
assertEquals("Strong ETag without quotes", getETag.eTag)
assertFalse(getETag.weak!!)
}
@Test
fun testGetETag_Weak() {
val results = parseProperty("<getetag xmlns=\"DAV:\">W/\"Correct weak ETag\"</getetag>")
val getETag = results.first() as GetETag
assertEquals("Correct weak ETag", getETag.eTag)
assertTrue(getETag.weak!!)
}
@Test
fun testGetETag_Weak_NoQuotes() {
val results = parseProperty("<getetag xmlns=\"DAV:\">W/Weak ETag without quotes</getetag>")
val getETag = results.first() as GetETag
assertEquals("Weak ETag without quotes", getETag.eTag)
assertTrue(getETag.weak!!)
}
}
@@ -0,0 +1,30 @@
package at.bitfire.dav4jvm.property
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class OwnerTest: PropertyTest() {
@Test
fun testOwner_PlainText() {
val results = parseProperty("<owner xmlns=\"DAV:\">https://example.com</owner>")
val owner = results.first() as Owner
assertNull(owner.href)
}
@Test
fun testOwner_PlainTextAndHref() {
val results = parseProperty("<owner xmlns=\"DAV:\">Principal Name. <href>mailto:owner@example.com</href> (test)</owner>")
val owner = results.first() as Owner
assertEquals("mailto:owner@example.com", owner.href)
}
@Test
fun testOwner_Href() {
val results = parseProperty("<owner xmlns=\"DAV:\"><href>https://example.com</href></owner>")
val owner = results.first() as Owner
assertEquals("https://example.com", owner.href)
}
}
@@ -0,0 +1,20 @@
package at.bitfire.dav4jvm.property
import at.bitfire.dav4jvm.Property
import at.bitfire.dav4jvm.XmlUtils
import java.io.StringReader
open class PropertyTest {
companion object {
fun parseProperty(s: String): List<Property> {
val parser = XmlUtils.newPullParser()
parser.setInput(StringReader("<test>$s</test>"))
parser.nextTag() // move into <test>
return Property.parse(parser)
}
}
}