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
+4 -1
View File
@@ -141,9 +141,12 @@ jobs:
if: steps.scope.outputs.code == 'true'
run: ./gradlew lintDebug
# :dav is a plain JVM module, so it has no testDebugUnitTest — naming only
# that task would compile the vendored suite and run none of it, which is
# the whole safety argument in dav/PROVENANCE.md.
- name: Unit tests
if: steps.scope.outputs.code == 'true'
run: ./gradlew testDebugUnitTest
run: ./gradlew testDebugUnitTest :dav:test
- name: Assemble debug APK
if: steps.scope.outputs.code == 'true'
+9
View File
@@ -182,6 +182,15 @@ dependencies {
// API surface, so it isn't declared separately.
implementation(libs.dmfs.lib.recur)
// Vendored dav4jvm — the CalDAV protocol layer. See dav/PROVENANCE.md.
implementation(project(":dav"))
// :dav gets org.xmlpull.v1 from the Android framework at runtime and declares
// xpp3 compileOnly, which is not transitive. Unit tests run on a plain JVM
// with no framework, and android.jar's stub factory returns null under
// isReturnDefaultValues — so anything touching XmlUtils would NPE without a
// real implementation here.
testImplementation(libs.xpp3)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
@@ -15,6 +15,13 @@ package de.jeanlucmakiola.agendula.domain.ical
*/
object ICalParser {
/**
* Written as an escape, not as the character itself: a literal BOM anywhere
* but byte 0 of a file is what Android Lint's `ByteOrderMark` check exists to
* catch, and it fails the build.
*/
private const val BOM = "\uFEFF"
fun parse(text: String): ICalComponent =
parseAll(text).firstOrNull() ?: throw ICalParseException("no component found")
@@ -67,7 +74,7 @@ object ICalParser {
*/
internal fun unfold(text: String): List<String> {
val lines = mutableListOf<StringBuilder>()
val normalised = text.removePrefix("")
val normalised = text.removePrefix(BOM)
for (raw in normalised.split("\r\n", "\n", "\r")) {
if (raw.isEmpty()) continue
val continuation = raw[0] == ' ' || raw[0] == '\t'
@@ -35,7 +35,7 @@ class ICalParserTest {
@Test
fun `a leading BOM is stripped rather than glued onto the first name`() {
val component = ICalParser.parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n")
val component = ICalParser.parse("\uFEFFBEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n")
assertThat(component.name).isEqualTo("VTODO")
}
}
+373
View File
@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
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/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
+143
View File
@@ -0,0 +1,143 @@
# `:dav` — vendored dav4jvm
**Upstream:** [bitfireAT/dav4jvm](https://github.com/bitfireAT/dav4jvm),
tag **2.2.1**, commit `f434c9d19b322228916c106beaebd8634b85ddb1`.
**Licence:** MPL-2.0 (`dav/LICENSE`, verbatim). Every file keeps its upstream
header, as §3.4 requires. Agendula's own code is MIT and unaffected — MPL is
file-level copyleft, which is why this lives in its own module rather than
inside `:app`.
62 source files, ~4,200 lines, plus upstream's 15 test classes.
---
## Why vendored, and why this version
`docs/SYNC-PLAN.md` decision 1 settled *vendor rather than depend*. dav4jvm is
published on **JitPack only**, which conflicts with our `FAIL_ON_PROJECT_REPOS`
policy, does not sign artifacts, and rebuilds on demand — so a coordinate is not
immutable. Upstream also shipped two breaking majors nineteen days apart
(3.0.0 on 2026-07-08, 4.0.0 on 2026-07-27).
**2.2.1 is the last OkHttp release.** 3.0.0 deleted the OkHttp package for Ktor,
and 4.x additionally requires **Java 21** bytecode while we target 17 across
`:app` and all of floret-kit. Taking 4.x would mean Ktor (~2.45 MB), the wrong
`guava` flavour, and a Java-target migration — and it would invalidate the whole
auth design in `docs/SYNC.md`, which is written in OkHttp terms throughout
(preemptive Basic via an `Interceptor`, OkHttp stripping `Authorization` on
cross-host redirects, `BasicDigestAuthHandler` because OkHttp has no Digest).
Vendoring at 2.2.1 costs us upstream's later work and makes us responsible for
this tree. What it buys: our own Java target, no JitPack, no Ktor tail, no xpp3
in the APK, and the freedom to fix the defects below rather than route around
them.
**This is a plain JVM module, not an Android library.** The upstream tree has
zero Android imports and must not gain any: `docs/SYNC.md` earmarks this layer
for floret-kit's `core-dav`, and Calendula needs the same primitives, so
extraction should stay a file move.
---
## Changes from upstream
Upstream's own test suite is vendored with the code and passes unmodified —
that is what makes these changes safe to make. Our additions live in
`LocalChangesTest`; every other test file is upstream's, untouched.
### 1. `commons-lang3` removed
`HttpUtils.kt` imported `org.apache.commons.lang3.time.DateUtils` for exactly one
call — `DateUtils.parseDate(str, locale, patterns…)`, a loop over format strings.
Replaced with that loop. The dependency is gone; the format list is byte-for-byte
upstream's, comments included.
⚠️ **The loop is not the whole of what `DateUtils` did.** It parsed with a
`ParsePosition` and rejected a pattern unless the *entire* string was consumed;
`SimpleDateFormat.parse(String)` accepts a prefix. Pattern 1 ends in the quoted
**literal** `'GMT'`, so `"Wed, 21 Oct 2015 07:28:00 GMT+02:00"` matches it as a
prefix and the offset is silently discarded — a two-hour error, and precisely the
failure change 2 exists to remove. The replacement requires full consumption.
### 2. ⚠️ HTTP dates were parsed and formatted in the device's local zone
**An upstream defect, not a porting artefact.** `httpDateFormatStr` is
`"EEE, dd MMM yyyy HH:mm:ss 'GMT'"` — the `GMT` is a **quoted literal**, so
`SimpleDateFormat` neither reads nor writes a zone from it, and
`httpDateFormat` never had `timeZone` set. So `formatDate` emitted local time
labelled `GMT`, and `parseDate` read `07:28:00 GMT` as 07:28 *local* — every
`getlastmodified` out by the device's UTC offset, in whichever direction the
user happens to live.
Upstream's `HttpUtilsTest` covers only `fileName()` and never touches dates,
which is why this survived. Fixed by forcing GMT on the formatter and on every
parse attempt; patterns carrying a real `z` still take the zone from the input,
as they must. Covered by `LocalChangesTest`.
### 3. Permanent redirects now reach the caller (`dav4jvm#209`)
`followRedirects` mutated `location` in place for every 3xx and told the caller
nothing, so a caller could not 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 sync;
`docs/SYNC.md` names persisting the new URL ourselves as the fix.
Added `DavResource.permanentLocation`, set only along an unbroken chain of 301 /
308. A temporary hop ends the chain — `301 → 302` means the resource moved to the
301's target and is being served elsewhere *for now*, so persisting the 302's
target would be wrong. `location` still moves for every redirect, unchanged.
It is **cleared at the start of every request**, so it describes the request just
made and never 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.
### 4. `xpp3` is compile-time only
Upstream declares `org.ogce:xpp3` as `api`. Android ships `org.xmlpull.v1` in the
framework, so the 371 KB jar is `compileOnly` here and never reaches the APK. The
unit tests run on a plain JVM, which has no framework, so they get the real
implementation via `testImplementation`.
### 5. `httpDateFormat` is no longer a shared mutable formatter
Upstream exposed a single public `SimpleDateFormat`. It is mutable and not
thread-safe: two workers formatting a header concurrently corrupt each other
through its `Calendar`, and any caller could `setTimeZone` on it and undo change 2
for everyone else. It is now private and built per call. Nothing else in the tree
referenced it.
---
## Build integration
- `:dav`'s tests are a **plain JVM `test` task**. CI runs `testDebugUnitTest`,
which exists only on Android variants, so `.forgejo/workflows/ci.yaml` names
`:dav:test` explicitly. Without that the vendored suite is compiled by nobody
and run by nobody, and the safety argument above is void.
- `:app` declares `testImplementation(libs.xpp3)`. `compileOnly` is not
transitive, `:app`'s unit tests run on a plain JVM with no framework, and
`isReturnDefaultValues = true` makes android.jar's stub factory return `null`
so anything touching `XmlUtils` would fail with an unrelated-looking NPE.
---
## What was *not* a defect
`docs/SYNC.md` lists two dav4jvm defects we would inherit. Only one of them
exists in this version:
- **"Handles 301/302/307/308 but not 303"** — does not hold for 2.2.1.
`followRedirects` gates on OkHttp's `Response.isRedirect`, which includes
`HTTP_SEE_OTHER`, and it re-sends the same method, which is what RFC 6764 §5
asks for during discovery. Pinned by a test so a future resync cannot lose it
silently.
- **`dav4jvm#209`** — real, and fixed above as change 3.
---
## Resyncing
Fetch the new tag, diff against `f434c9d`, reapply changes 14, run
`./gradlew :dav:test`. If upstream's suite fails, the port is wrong — that is the
entire reason it is vendored alongside the code.
+36
View File
@@ -0,0 +1,36 @@
plugins {
// No version: AGP already puts the Kotlin plugin on the build classpath, and
// asking for one here fails resolution ("already on the classpath with an
// unknown version"). The version is the catalogue's `kotlin`, via AGP.
id("org.jetbrains.kotlin.jvm")
}
// A plain JVM library on purpose: the vendored tree has no Android imports and
// must not gain any. That keeps it portable — `docs/SYNC.md` earmarks this layer
// for floret-kit's `core-dav`, and Calendula needs the same primitives.
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
api(libs.okhttp)
// Android supplies org.xmlpull.v1 in the framework, so the 371 KB xpp3 jar is
// a compile-time stand-in only and never reaches the APK. The unit tests run
// on a plain JVM, which has no framework, so they get the real thing.
compileOnly(libs.xpp3)
testImplementation(libs.xpp3)
// Upstream's own suite, vendored with the code. It is JUnit 4 while :app is
// JUnit 5; rewriting it would forfeit the regression coverage that makes
// vendoring safe, which is the same call provider/PROVENANCE.md made.
testImplementation(libs.junit4)
testImplementation(libs.okhttp.mockwebserver)
}
@@ -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)
}
}
}
+42 -6
View File
@@ -56,7 +56,8 @@ Nextcloud/Radicale/Baïkal in chunk 5.
| # | Chunk | Rests on | The thing it must not get wrong |
|---|---|---|---|
| 1 | **Mapper** — VTODO ↔ Room | nothing | Unknown properties survive a read-modify-write cycle |
| 2 | **Auth, discovery, account** | 1 (nothing hard) | Never send a credential into an unvalidated redirect chain |
| 2a | **Vendored DAV foundation** | nothing | Vendor a tree we can actually maintain, and know which of its defects are real |
| 2b | **Auth, discovery, account** | 2a | Never send a credential into an unvalidated redirect chain |
| 3 | **Engine core** — read, write, conflicts | 1, 2 | A failed resource must not fail the collection |
| 4 | **Incremental sync + scheduling** | 3 | Never persist a sync token before the bodies it covers are applied |
| 5 | **Hardening, compliance, real servers** | 14 | Ship no licence violation, and no retry loop on a dead app password |
@@ -172,7 +173,34 @@ and no fixture loses an unknown property.
---
## Chunk 2 — auth, discovery, account
## Chunk 2athe vendored DAV foundation
⚠️ **Split out of chunk 2 while building it.** Vendoring is a self-contained
piece with its own verification — upstream's suite has to pass unmodified before
anything is built on top — and bundling it with discovery and auth would have
produced one commit nobody could review.
**Version:** dav4jvm **2.2.1**, the last OkHttp release. 3.0.0 deleted the OkHttp
package for Ktor and 4.x requires Java 21, and `SYNC.md`'s entire auth section is
written in OkHttp terms — preemptive Basic via an `Interceptor`, OkHttp stripping
`Authorization` on cross-host redirects, `BasicDigestAuthHandler` because OkHttp
has no Digest. Taking 4.x would have invalidated all of it. A plain JVM module,
not an Android library: the tree has zero Android imports and must keep it that
way, or the floret-kit extraction stops being a file move.
Full record in [`dav/PROVENANCE.md`](../dav/PROVENANCE.md). Of the two defects
`SYNC.md` said we would inherit, **one was not real** — 2.2.1 does follow 303 —
and the other (`dav4jvm#209`, permanent redirects never reaching the caller) is
fixed. A third, which the audit did not have, was found by writing the tests
upstream never wrote: **HTTP dates were parsed and formatted in the device's
local zone**, putting every `getlastmodified` out by the local UTC offset.
**Done when:** upstream's 63 tests pass unmodified, `commons-lang3` and the xpp3
runtime dependency are gone, and `:app` links against the module.
---
## Chunk 2b — auth, discovery, account
**Goal:** the app can add a CalDAV account and list its VTODO collections. No
syncing yet.
@@ -438,10 +466,18 @@ attribution screen ships, and a `releaseTest` build syncs on a real device.
## Definition of done, every chunk
1. It builds: `./gradlew :app:assembleDebug`.
2. Unit tests pass: `./gradlew :app:testDebugUnitTest`.
3. `/code-review high` on the chunk's diff, with the findings either fixed or
answered in the commit message.
4. One commit per chunk, referencing the chunk number. No release, no tag.
2. Unit tests pass: `./gradlew :app:testDebugUnitTest :dav:test`. ⚠️ Name
`:dav:test` explicitly — `testDebugUnitTest` is an Android-variant task and
does not exist on a plain JVM module, so the vendored suite would otherwise be
compiled by nobody and run by nobody.
3. Lint passes: `./gradlew :app:lintDebug`. Added after chunk 1 shipped a literal
byte-order mark that builds and tests both accepted and CI's lint step would
have rejected.
4. `/code-review high` on the chunk's diff, with the findings either fixed or
answered in the commit message. For a chunk that vendors code, scope the
review to the files we wrote — reviewing thousands of lines of verbatim
upstream is noise, and its defects belong in `PROVENANCE.md`.
5. One commit per chunk, referencing the chunk number. No release, no tag.
Chunk 2 additionally does not count as done until the account-add UI has had an
**on-device review with an explicit go-ahead**`CLAUDE.md`'s rule, and this is
+15
View File
@@ -33,6 +33,16 @@ androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1"
# --- :dav (vendored dav4jvm) -------------------------------------------------
# dav4jvm 2.2.1 is the last OkHttp release; 3.0.0 deleted the OkHttp package for
# Ktor and 4.x additionally requires Java 21 bytecode, which we do not target.
# See dav/PROVENANCE.md. OkHttp 4.12.0 is what the vendored tree was written
# against and what DAVx5 ships.
okhttp = "4.12.0"
# org.xmlpull.v1 ships in the Android framework, so xpp3 is compileOnly +
# testImplementation and never reaches the APK.
xpp3 = "1.1.6"
# --- :provider (vendored dmfs task provider) ---------------------------------
# Versions the upstream 1.4.2 source was written against. These are its runtime
# dependencies, not ours — nothing above the data layer touches them, and they
@@ -125,6 +135,11 @@ androidx-glance-material3 = { group = "androidx.glance", name = "glance-material
# Android tests - GrantPermissionRule
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" }
# :dav — vendored dav4jvm (see dav/PROVENANCE.md)
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
xpp3 = { group = "org.ogce", name = "xpp3", version.ref = "xpp3" }
# :provider — vendored dmfs task provider (see provider/PROVENANCE.md)
dmfs-jems = { group = "org.dmfs", name = "jems", version.ref = "dmfsJems" }
dmfs-jems-testing = { group = "org.dmfs", name = "jems-testing", version.ref = "dmfsJems" }
+1
View File
@@ -26,4 +26,5 @@ dependencyResolutionManagement {
rootProject.name = "Agendula"
include(":app")
include(":dav")
includeBuild("floret-kit")